Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: migrate editor to prosekit #4855

Merged
merged 48 commits into from May 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
a1da034
feat: migrate editor to prosekit
ocavue Apr 21, 2024
a3c6b5f
Merge remote-tracking branch 'heyxyz/main' into ocavue/prosekit
ocavue Apr 21, 2024
eb7098a
chore: fix lint
ocavue Apr 21, 2024
0038f3f
Add tag matching
ocavue Apr 28, 2024
517fff5
Merge remote-tracking branch 'heyxyz/main' into ocavue/prosekit
ocavue Apr 28, 2024
2f94831
Fix lockfile
ocavue Apr 28, 2024
19f8fec
Fix build
ocavue Apr 28, 2024
3cfb842
Add image pasting
ocavue Apr 28, 2024
ed8f233
Add email matching
ocavue Apr 28, 2024
152baad
Merge remote-tracking branch 'heyxyz/main' into ocavue/prosekit
ocavue Apr 29, 2024
a141292
Update prosekit
ocavue Apr 29, 2024
e0c3187
Tweaking
ocavue Apr 29, 2024
c278874
Small refactor
ocavue Apr 29, 2024
9fa7ef3
Use arrow functions
ocavue Apr 29, 2024
59c259a
Remove email mathching
ocavue Apr 29, 2024
efee401
Remove `fetch` and add `useQuery`
ocavue Apr 29, 2024
27b516e
Address review comment
ocavue Apr 29, 2024
5983a7b
Merge remote-tracking branch 'heyxyz/main' into ocavue/prosekit
ocavue Apr 29, 2024
86ab3ca
Merge remote-tracking branch 'heyxyz/main' into ocavue/prosekit
ocavue May 2, 2024
5d511e0
Support draft
ocavue May 2, 2024
2bcb914
Remove Lexical
ocavue May 2, 2024
c795f38
Fix user mention
ocavue May 2, 2024
01898d6
Merge remote-tracking branch 'heyxyz/main' into ocavue/prosekit
ocavue May 4, 2024
721e183
Address review comments
ocavue May 4, 2024
680f938
small refactor
ocavue May 4, 2024
56c0595
Improve emoji picker
ocavue May 4, 2024
d70cf41
Fix emoji regex
ocavue May 4, 2024
1458a75
Fix link protocol
ocavue May 4, 2024
c325a7f
Rename TextEditor to Editor
ocavue May 4, 2024
057d569
Fix mention regex
ocavue May 4, 2024
3c4c43c
Fix mention regex
ocavue May 4, 2024
d8b9fd7
Improve emoji insertion
ocavue May 4, 2024
2dc9fcb
Update prosekit
ocavue May 4, 2024
ade8f39
Fix strikethought rendering
ocavue May 4, 2024
290a0c9
Update comment
ocavue May 4, 2024
6fbb92e
Tweak CSS
ocavue May 4, 2024
46d1773
Remove default command-click selection
ocavue May 4, 2024
7a48da4
Merge remote-tracking branch 'heyxyz/main' into ocavue/prosekit
ocavue May 5, 2024
92985d9
Try to clear editor content
ocavue May 5, 2024
70dd889
Merge branch 'main' into ocavue/prosekit
bigint May 5, 2024
bc364dc
Small refactor
ocavue May 5, 2024
62c7b21
Only start delay when the document is large
ocavue May 5, 2024
f5b7f8c
Merge remote-tracking branch 'heyxyz/main' into ocavue/prosekit
ocavue May 5, 2024
b00948b
Fix editor overflow
ocavue May 5, 2024
8d8563a
Merge remote-tracking branch 'heyxyz/main' into ocavue/prosekit
ocavue May 5, 2024
c4624cf
Update prosekit
ocavue May 5, 2024
788e447
Merge branch 'main' into ocavue/prosekit
bigint May 5, 2024
fb74ddd
Merge branch 'main' into ocavue/prosekit
bigint May 5, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
17 changes: 9 additions & 8 deletions apps/web/package.json
ocavue marked this conversation as resolved.
Show resolved Hide resolved
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;