Skip to content

Commit

Permalink
Merge pull request #117 from game-node-app/dev
Browse files Browse the repository at this point in the history
Dev
  • Loading branch information
Lamarcke authored Sep 20, 2024
2 parents 859a33a + cf2ed8b commit 1db70fb
Show file tree
Hide file tree
Showing 10 changed files with 335 additions and 8 deletions.
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,12 @@
"@tabler/icons-react": "^3.1.0",
"@tanstack/react-query": "^5.29.0",
"@tiptap/extension-link": "^2.2.6",
"@tiptap/extension-mention": "^2.7.1",
"@tiptap/extension-placeholder": "^2.6.6",
"@tiptap/pm": "^2.2.6",
"@tiptap/react": "^2.2.6",
"@tiptap/starter-kit": "^2.2.6",
"@tiptap/suggestion": "^2.7.1",
"axios": "^1.6.8",
"bad-words": "^3.0.4",
"dayjs": "^1.11.10",
Expand Down
25 changes: 21 additions & 4 deletions src/components/game/info/review/editor/GameInfoReviewEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import React, { useMemo, useRef } from "react";
import { Box, BoxComponentProps } from "@mantine/core";
import { useEditor } from "@tiptap/react";
import React, {
forwardRef,
MutableRefObject,
useEffect,
useMemo,
useRef,
} from "react";
import { Box, BoxComponentProps, Menu } from "@mantine/core";
import { JSONContent, ReactRenderer, useEditor } from "@tiptap/react";
import { StarterKit } from "@tiptap/starter-kit";
import { RichTextEditor, RichTextEditorProps } from "@mantine/tiptap";
import useReviewForUserId from "@/components/review/hooks/useReviewForUserIdAndGameId";
import useUserId from "@/components/auth/hooks/useUserId";
import { Placeholder } from "@tiptap/extension-placeholder";
import getEditorMentionConfig from "@/components/general/editor/util/getEditorMentionConfig";
import { Editor } from "@tiptap/core";

interface IGameInfoReviewEditorProps extends BoxComponentProps {
editorRef?: MutableRefObject<Editor | null>;
gameId: number;
onBlur: (html: string) => void;
}
Expand All @@ -16,11 +25,13 @@ export const DEFAULT_REVIEW_EDITOR_EXTENSIONS = [
StarterKit,
Placeholder.configure({
placeholder:
"Review content. Leave empty to create a score-only review.",
"Review content. Leave empty to create a score-only review. Use '@' to mention other users.",
}),
getEditorMentionConfig(),
];

const GameInfoReviewEditor = ({
editorRef,
gameId,
onBlur,
}: IGameInfoReviewEditorProps) => {
Expand All @@ -42,6 +53,12 @@ const GameInfoReviewEditor = ({
[previousContent],
);

useEffect(() => {
if (editor != null && editorRef) {
editorRef.current = editor;
}
}, [editor, editorRef]);

if (!editor) return null;

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ import ReviewListItem from "@/components/review/view/ReviewListItem";
import { useOwnCollectionEntryForGameId } from "@/components/collection/collection-entry/hooks/useOwnCollectionEntryForGameId";
import { IconX } from "@tabler/icons-react";
import GameRating from "@/components/general/input/GameRating";
import { Editor } from "@tiptap/core";
import { JSONContent } from "@tiptap/react";
import getEditorMentions from "@/components/general/editor/util/getEditorMentions";

const processMentions = (jsonContent: JSONContent) => {};

const ReviewFormSchema = z.object({
rating: z.number().min(0).max(5).default(5),
Expand All @@ -46,6 +51,7 @@ interface IGameInfoReviewEditorViewProps {
const GameInfoReviewEditorView = ({
gameId,
}: IGameInfoReviewEditorViewProps) => {
const editorRef = useRef<Editor | null>(null);
const [isEditMode, setIsEditMode] = useState<boolean>(true);
const hasSetEditMode = useRef<boolean>(false);

Expand All @@ -65,9 +71,13 @@ const GameInfoReviewEditorView = ({

const reviewMutation = useMutation({
mutationFn: async (data: TReviewFormValues) => {
const mentionedUserIds = getEditorMentions(
editorRef.current!.getJSON(),
);
await ReviewsService.reviewsControllerCreateOrUpdate({
...data,
gameId: gameId,
mentionedUserIds: mentionedUserIds,
});
},
onSuccess: () => {
Expand Down Expand Up @@ -130,8 +140,12 @@ const GameInfoReviewEditorView = ({
return (
<form className={"w-full h-full"} onSubmit={handleSubmit(onSubmit)}>
<GameInfoReviewEditor
editorRef={editorRef}
gameId={gameId}
onBlur={(v) => setValue("content", v)}
onBlur={(html) => {
setValue("content", html);
getEditorMentions(editorRef.current!.getJSON());
}}
/>
<Break />
<Group mt={"md"} justify={"space-between"}>
Expand Down
118 changes: 118 additions & 0 deletions src/components/general/editor/EditorMentionList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useState,
} from "react";
import { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
import { List, Paper } from "@mantine/core";

export type SuggestionListRef = {
// For convenience using this SuggestionList from within the
// mentionSuggestionOptions, we'll match the signature of SuggestionOptions's
// `onKeyDown` returned in its `render` function
onKeyDown: NonNullable<
ReturnType<
NonNullable<SuggestionOptions<string>["render"]>
>["onKeyDown"]
>;
};

export interface MentionSuggestion {
id: string;
label: string;
}

const EditorMentionList = forwardRef<
SuggestionListRef,
SuggestionProps<MentionSuggestion>
>((props, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);

const selectItem = (index: number) => {
const item = props.items[index];

if (item) {
props.command(item);
}
};

const upHandler = () => {
setSelectedIndex(
(selectedIndex + props.items.length - 1) % props.items.length,
);
};

const downHandler = () => {
setSelectedIndex((selectedIndex + 1) % props.items.length);
};

const enterHandler = () => {
selectItem(selectedIndex);
};

useEffect(() => setSelectedIndex(0), [props.items]);

// @ts-ignore
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: any) => {
if (event.key === "ArrowUp") {
upHandler();
return true;
}

if (event.key === "ArrowDown") {
downHandler();
return true;
}

if (event.key === "Enter") {
enterHandler();
return true;
}

return false;
},
}));

return (
<Paper>
<List>
{props.items?.map((item, index) => {
return (
<List.Item
key={item.id}
onClick={() => selectItem(index)}
className={
index === selectedIndex
? "bg-brand-5 rounded-xs px-1"
: undefined
}
>
{item.label}
</List.Item>
);
})}
</List>
</Paper>
// <div className="dropdown-menu">
// {props.items.length ? (
// props.items.map((item, index) => (
// <button
// className={index === selectedIndex ? "is-selected" : ""}
// key={index}
// onClick={() => selectItem(index)}
// >
// {item.label}
// </button>
// ))
// ) : (
// <div className="item">No result</div>
// )}
// </div>
);
});

EditorMentionList.displayName = "EditorMentionList";

export default EditorMentionList;
114 changes: 114 additions & 0 deletions src/components/general/editor/util/getEditorMentionConfig.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { Mention } from "@tiptap/extension-mention";
import { ReactRenderer } from "@tiptap/react";
import EditorMentionList, {
MentionSuggestion,
SuggestionListRef,
} from "@/components/general/editor/EditorMentionList";
import tippy, { type Instance as TippyInstance } from "tippy.js";
import { SearchService } from "@/wrapper/search";
import { Profile, ProfileService } from "@/wrapper/server";

export default function getEditorMentionConfig() {
// Poor man's cache
let profiles: Profile[] | undefined = undefined;

return Mention.configure({
HTMLAttributes: {
class: "mention",
},
suggestion: {
items: async ({ query }): Promise<MentionSuggestion[]> => {
try {
if (profiles == undefined) {
profiles =
await ProfileService.profileControllerFindAll();
}

return profiles
.map((profile) => ({
id: profile.userId,
label: profile.username,
}))
.filter((item) => {
return item.label
.toLowerCase()
.startsWith(query.toLowerCase());
})
.slice(0, 5);
} catch (err) {
console.error(err);
}

return [];
},
render: () => {
let component: ReactRenderer<SuggestionListRef> | undefined =
undefined;
let popup: TippyInstance | undefined;

return {
onStart: (props) => {
component = new ReactRenderer(EditorMentionList, {
props,
editor: props.editor,
});

if (!props.clientRect) {
return;
}

popup = tippy("body", {
getReferenceClientRect:
props.clientRect as () => DOMRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
})[0];
},

onUpdate(props) {
component?.updateProps(props);

if (!props.clientRect) {
return;
}

popup?.setProps({
getReferenceClientRect:
props.clientRect as () => DOMRect,
});
},

onKeyDown(props) {
if (props.event.key === "Escape") {
popup?.hide();

return true;
}
if (component?.ref?.onKeyDown == undefined) {
return false;
}

return component?.ref?.onKeyDown(props);
},

onExit() {
popup?.destroy();
component?.destroy();

// Remove references to the old popup and component upon destruction/exit.
// (This should prevent redundant calls to `popup.destroy()`, which Tippy
// warns in the console is a sign of a memory leak, as the `suggestion`
// plugin seems to call `onExit` both when a suggestion menu is closed after
// a user chooses an option, *and* when the editor itself is destroyed.)
popup = undefined;
component = undefined;
},
};
},
},
});
}
39 changes: 39 additions & 0 deletions src/components/general/editor/util/getEditorMentions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { JSONContent } from "@tiptap/react";

function getMentionNodes(item: JSONContent[] | JSONContent): JSONContent[] {
const mentionNodes: JSONContent[] = [];

if (Array.isArray(item)) {
for (const node of item) {
if (node.type === "mention") {
mentionNodes.push(node);
} else if (Array.isArray(node.content)) {
mentionNodes.push(...getMentionNodes(node.content));
}
}
} else if (!Array.isArray(item) && Array.isArray(item.content)) {
mentionNodes.push(...getMentionNodes(item.content));
}

return mentionNodes;
}

/**
* Returns the list of possible user ids mentioned in a editor's content.
*/
export default function getEditorMentions(jsonContent: JSONContent) {
const mentionedUsers: string[] = [];
if (!jsonContent.content) {
return [];
}

const mentionNodes = getMentionNodes(jsonContent);

for (const node of mentionNodes) {
if (node && node.attrs && Object.hasOwn(node.attrs, "id")) {
mentionedUsers.push(node.attrs.id);
}
}

return mentionedUsers;
}
Loading

0 comments on commit 1db70fb

Please sign in to comment.