Skip to content

Commit

Permalink
feat: migrate editor to prosekit (#4855)
Browse files Browse the repository at this point in the history
  • Loading branch information
bigint committed May 5, 2024
2 parents 58b59ee + fb74ddd commit 484abca
Show file tree
Hide file tree
Showing 32 changed files with 3,604 additions and 2,498 deletions.
17 changes: 9 additions & 8 deletions apps/web/package.json
Expand Up @@ -28,13 +28,6 @@
"@hey/lens": "workspace:*",
"@hey/ui": "workspace:*",
"@lens-protocol/metadata": "^1.1.6",
"@lexical/code": "^0.14.5",
"@lexical/hashtag": "^0.14.5",
"@lexical/link": "^0.14.5",
"@lexical/markdown": "^0.14.5",
"@lexical/react": "^0.14.5",
"@lexical/rich-text": "^0.14.5",
"@lexical/utils": "^0.14.5",
"@livepeer/react": "3.1.9",
"@radix-ui/react-hover-card": "^1.0.7",
"@rajesh896/broprint.js": "^2.1.1",
Expand All @@ -54,12 +47,12 @@
"framer-motion": "^11.1.7",
"graphql": "^16.8.1",
"idb-keyval": "^6.2.1",
"lexical": "^0.14.5",
"next": "^14.2.3",
"next-themes": "^0.3.0",
"party-js": "^2.2.0",
"plur": "^5.1.0",
"plyr-react": "^5.3.0",
"prosekit": "0.6.10",
"rc-slider": "10.6.2",
"react": "^18.3.1",
"react-chartjs-2": "^5.2.0",
Expand All @@ -69,9 +62,17 @@
"react-markdown": "^9.0.0",
"react-tracked": "^1.7.14",
"react-virtuoso": "^4.7.10",
"rehype-parse": "^9.0.0",
"rehype-remark": "^10.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.0",
"remark-html": "^16.0.1",
"remark-linkify-regex": "^1.2.1",
"remark-parse": "^11.0.0",
"remark-stringify": "^11.0.0",
"strip-markdown": "^6.0.0",
"unified": "^11.0.4",
"unist-util-visit-parents": "^6.0.1",
"urlcat": "^3.1.0",
"use-resize-observer": "^9.1.0",
"uuid": "^9.0.1",
Expand Down
@@ -1,14 +1,13 @@
import type { Draft } from '@hey/types/hey';
import type { FC } from 'react';

import { useEditorContext } from '@components/Composer/Editor';
import Loader from '@components/Shared/Loader';
import getAuthApiHeaders from '@helpers/getAuthApiHeaders';
import { ArchiveBoxArrowDownIcon } from '@heroicons/react/24/outline';
import { HEY_API_URL } from '@hey/data/constants';
import stopEventPropagation from '@hey/helpers/stopEventPropagation';
import { Button, EmptyState, ErrorMessage } from '@hey/ui';
import { $convertFromMarkdownString } from '@lexical/markdown';
import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext';
import { useQuery } from '@tanstack/react-query';
import axios from 'axios';
import { useState } from 'react';
Expand All @@ -27,7 +26,7 @@ const List: FC<ListProps> = ({ setShowModal }) => {
const [drafts, setDrafts] = useState<Draft[]>([]);
const [deleting, setDeleting] = useState(false);

const [editor] = useLexicalComposerContext();
const editor = useEditorContext();

const getDrafts = async (): Promise<[] | Draft[]> => {
try {
Expand Down Expand Up @@ -95,9 +94,7 @@ const List: FC<ListProps> = ({ setShowModal }) => {
};

const onSelectDraft = (draft: Draft) => {
editor.update(() => {
$convertFromMarkdownString(draft.content);
});
editor?.setMarkdown(draft.content);

setPublicationContent(draft.content);

Expand Down
63 changes: 63 additions & 0 deletions apps/web/src/components/Composer/Editor/Editor.tsx
@@ -0,0 +1,63 @@
import getAvatar from '@hey/helpers/getAvatar';
import { Image } from '@hey/ui';
import dynamic from 'next/dynamic';
import 'prosekit/basic/style.css';
import { createEditor } from 'prosekit/core';
import { ProseKit } from 'prosekit/react';
import { useMemo, useRef } from 'react';
import { useProfileStore } from 'src/store/persisted/useProfileStore';

import { useEditorHandle } from './EditorHandle';
import { defineEditorExtension } from './extension';
import { htmlFromMarkdown } from './markdown';
import { useContentChange } from './useContentChange';
import { usePaste } from './usePaste';

// Lazy load EditorMenus to reduce bundle size
const EditorMenus = dynamic(() => import('./EditorMenus'), { ssr: false });

const Editor = (props: {
/**
* The initial content of the editor in Markdown format.
*/
defaultMarkdown?: string;
}) => {
const { currentProfile } = useProfileStore();

const defaultMarkdownRef = useRef(props.defaultMarkdown);

const defaultHTML = useMemo(() => {
const markdown = defaultMarkdownRef.current;
return markdown ? htmlFromMarkdown(markdown) : undefined;
}, []);

const editor = useMemo(() => {
const extension = defineEditorExtension();
return createEditor({ defaultHTML, extension });
}, [defaultHTML]);

useContentChange(editor);
usePaste(editor);
useEditorHandle(editor);

return (
<ProseKit editor={editor}>
<div className="box-border flex h-full w-full justify-stretch overflow-y-auto overflow-x-hidden px-5 py-4">
<Image
alt={currentProfile?.id}
className="mr-3 size-11 rounded-full border bg-gray-200 dark:border-gray-700"
src={getAvatar(currentProfile)}
/>
<div className="flex flex-1 flex-col overflow-x-hidden">
<EditorMenus />
<div
className="relative mt-[8.5px] box-border h-full min-h-[80px] flex-1 overflow-auto leading-6 outline-0 sm:leading-[26px]"
ref={editor.mount}
/>
</div>
</div>
</ProseKit>
);
};

export default Editor;
88 changes: 88 additions & 0 deletions apps/web/src/components/Composer/Editor/EditorHandle.tsx
@@ -0,0 +1,88 @@
import type { Editor } from 'prosekit/core';
import type { FC } from 'react';

import { createContext, useContext, useEffect, useState } from 'react';

import type { EditorExtension } from './extension';

import { setMarkdownContent } from './markdownContent';

/**
* Some methods for operating the editor from outside the editor component.
*/
export interface EditorHandle {
/**
* Insert text at the current text cursor position.
*/
insertText: (text: string) => void;

/**
* Replace the current document with the given markdown.
*/
setMarkdown: (markdown: string) => void;
}

const HandleContext = createContext<EditorHandle | null>(null);
const SetHandleContext = createContext<((handle: EditorHandle) => void) | null>(
null
);

const Provider = ({ children }: { children: React.ReactNode }) => {
const [handle, setHandle] = useState<EditorHandle | null>(null);

return (
<HandleContext.Provider value={handle}>
<SetHandleContext.Provider value={setHandle}>
{children}
</SetHandleContext.Provider>
</HandleContext.Provider>
);
};

/**
* A hook for accessing the text editor handle.
*/
export const useEditorContext = (): EditorHandle | null => {
return useContext(HandleContext);
};

/**
* A hook to register the text editor handle.
*/
export const useEditorHandle = (editor: Editor<EditorExtension>) => {
const setHandle = useContext(SetHandleContext);

useEffect(() => {
const handle: EditorHandle = {
insertText: (text: string): void => {
if (!editor.mounted) {
return;
}

editor.commands.insertText({ text });
},
setMarkdown: (markdown: string): void => {
setMarkdownContent(editor, markdown);
}
};

setHandle?.(handle);
}, [setHandle, editor]);
};

/**
* A higher-order component for providing the text editor handle.
*/
export const withEditorContext = <Props extends object>(
Component: FC<Props>
): FC<Props> => {
const WithEditorContext: FC<Props> = (props: Props) => {
return (
<Provider>
<Component {...props} />
</Provider>
);
};

return WithEditorContext;
};
17 changes: 17 additions & 0 deletions apps/web/src/components/Composer/Editor/EditorMenus.tsx
@@ -0,0 +1,17 @@
import type { FC } from 'react';

import EmojiPicker from './EmojiPicker';
import InlineMenu from './InlineMenu';
import MentionPicker from './MentionPicker';

const EditorMenus: FC = () => {
return (
<>
<InlineMenu />
<MentionPicker />
<EmojiPicker />
</>
);
};

export default EditorMenus;
70 changes: 70 additions & 0 deletions apps/web/src/components/Composer/Editor/EmojiPicker.tsx
@@ -0,0 +1,70 @@
import type { Emoji } from '@hey/types/misc';

import { Regex } from '@hey/data/regex';
import cn from '@hey/ui/cn';
import { useEditor } from 'prosekit/react';
import {
AutocompleteItem,
AutocompleteList,
AutocompletePopover
} from 'prosekit/react/autocomplete';
import { useState } from 'react';

import type { EditorExtension } from './extension';

import { useEmojiQuery } from './useEmojiQuery';

const EmojiItem = ({
emoji,
onSelect
}: {
emoji: Emoji;
onSelect: VoidFunction;
}) => {
return (
<AutocompleteItem
className="focusable-dropdown-item m-1 block cursor-pointer rounded-lg p-2 outline-none"
onSelect={onSelect}
>
<div className="flex items-center space-x-2">
<span className="text-base">{emoji.emoji}</span>
<span className="text-sm capitalize">{emoji.aliases[0]}</span>
</div>
</AutocompleteItem>
);
};

const EmojiPicker = () => {
const editor = useEditor<EditorExtension>();

const handleInsert = (emoji: Emoji) => {
editor.commands.insertText({ text: emoji.emoji });
};

const [query, setQuery] = useState('');
const emojis = useEmojiQuery(query);

return (
<AutocompletePopover
className={cn(
'relative z-10 box-border block w-52 select-none overflow-auto whitespace-nowrap rounded-xl border bg-white p-1 shadow-sm dark:border-gray-700 dark:bg-gray-900',
emojis.length === 0 && 'hidden'
)}
offset={10}
onQueryChange={setQuery}
regex={Regex.emoji}
>
<AutocompleteList filter={null}>
{emojis.map((emoji) => (
<EmojiItem
emoji={emoji}
key={emoji.emoji}
onSelect={() => handleInsert(emoji)}
/>
))}
</AutocompleteList>
</AutocompletePopover>
);
};

export default EmojiPicker;
68 changes: 68 additions & 0 deletions apps/web/src/components/Composer/Editor/InlineMenu.tsx
@@ -0,0 +1,68 @@
import { useEditor } from 'prosekit/react';
import { InlinePopover } from 'prosekit/react/inline-popover';

import type { EditorExtension } from './extension';

import {
BoldIcon,
CodeIcon,
ItalicIcon,
StrikethroughIcon,
UnderlineIcon
} from './icons';
import Toggle from './Toggle';

const InlineMenu = () => {
const editor = useEditor<EditorExtension>({ update: true });

return (
<InlinePopover className="relative z-10 box-border flex min-w-[120px] space-x-1 overflow-auto whitespace-nowrap rounded-md border border-gray-200 bg-white p-1 shadow-lg dark:border-gray-800 dark:bg-neutral-900">
<Toggle
disabled={!editor.commands.toggleBold.canApply()}
onClick={() => editor.commands.toggleBold()}
pressed={editor.marks.bold.isActive()}
tooltip="Bold"
>
<BoldIcon className="h-5 w-5" />
</Toggle>

<Toggle
disabled={!editor.commands.toggleItalic.canApply()}
onClick={() => editor.commands.toggleItalic()}
pressed={editor.marks.italic.isActive()}
tooltip="Italic"
>
<ItalicIcon className="h-5 w-5" />
</Toggle>

<Toggle
disabled={!editor.commands.toggleUnderline.canApply()}
onClick={() => editor.commands.toggleUnderline()}
pressed={editor.marks.underline.isActive()}
tooltip="Underline"
>
<UnderlineIcon className="h-5 w-5" />
</Toggle>

<Toggle
disabled={!editor.commands.toggleCode.canApply()}
onClick={() => editor.commands.toggleCode()}
pressed={editor.marks.code.isActive()}
tooltip="Code"
>
<CodeIcon className="h-5 w-5" />
</Toggle>

<Toggle
disabled={!editor.commands.toggleStrike.canApply()}
onClick={() => editor.commands.toggleStrike()}
pressed={editor.marks.strike.isActive()}
tooltip="Strikethrough"
>
<StrikethroughIcon className="h-5 w-5" />
</Toggle>
</InlinePopover>
);
};

export default InlineMenu;

0 comments on commit 484abca

Please sign in to comment.