(
+ CONNECTED_COMMAND,
+ (payload) => {
+ const isConnected = payload;
+ setConnected(isConnected);
+ return false;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ SAVE_COMMAND,
+ (event) => {
+ console.log('========SAVE_COMMAND=============')
+ // Do something with `event`, e.g. `event.preventDefault() && saveData()`
+ event.preventDefault();
+ console.log('========saveData=============')
+ saveData(editor);
+ },
+ COMMAND_PRIORITY_HIGH,
+ )
+ );
+ }, [editor]);
+
+ useEffect(() => {
+ return editor.registerUpdateListener(
+ ({dirtyElements, prevEditorState, tags}) => {
+ // If we are in read only mode, send the editor state
+ // to server and ask for validation if possible.
+ if (
+ !isEditable &&
+ dirtyElements.size > 0 &&
+ !tags.has('historic') &&
+ !tags.has('collaboration')
+ ) {
+ validateEditorState(editor);
+ }
+ editor.getEditorState().read(() => {
+ const root = $getRoot();
+ const children = root.getChildren();
+
+ if (children.length > 1) {
+ setIsEditorEmpty(false);
+ } else {
+ if ($isParagraphNode(children[0])) {
+ const paragraphChildren = children[0].getChildren();
+ setIsEditorEmpty(paragraphChildren.length === 0);
+ } else {
+ setIsEditorEmpty(false);
+ }
+ }
+ });
+ },
+ );
+ }, [editor, isEditable]);
+
+ const handleMarkdownToggle = useCallback(() => {
+ editor.update(() => {
+ const root = $getRoot();
+ const firstChild = root.getFirstChild();
+ if ($isCodeNode(firstChild) && firstChild.getLanguage() === 'markdown') {
+ $convertFromMarkdownString(
+ firstChild.getTextContent(),
+ PLAYGROUND_TRANSFORMERS,
+ );
+ } else {
+ const markdown = $convertToMarkdownString(PLAYGROUND_TRANSFORMERS);
+ root
+ .clear()
+ .append(
+ $createCodeNode('markdown').append($createTextNode(markdown)),
+ );
+ }
+ root.selectEnd();
+ });
+ }, [editor]);
+
+ const saveData = (editor) => {
+ const x = document.getElementsByClassName("semi-input semi-input-large");
+ console.log('================当前noteTitle x: ', x)
+ console.log('当前ID: ', props.noteId)
+ console.log('当前noteTitle: ', props.noteTitle)
+ console.log('当前noteTitle2: ', x[0].value)
+ // console.log('当前editor3: ', editor.getEditorState().toJSON())
+ const obj = editor.getEditorState().toJSON();
+ // console.log('当前editor4: ', JSON.stringify(obj))
+
+ NoteService.update(props.noteId,x[0].value, JSON.stringify(obj)).then(
+ response => {
+ console.log(response.data)
+ const data = response.data
+ console.log(data)
+ console.log('handleLawClick_item3')
+ props.handleLawClick_item3('1');
+ Toast.info('保存成功' );
+ },
+ error => {
+ console.log(error)
+ }
+ );
+ };
+
+ return (
+
+ saveData(editor)}
+ title="Save"
+ aria-label="Save editor state from JSON">
+
+
+ {SUPPORT_SPEECH_RECOGNITION && (
+ {
+ editor.dispatchCommand(SPEECH_TO_TEXT_COMMAND, !isSpeechToText);
+ setIsSpeechToText(!isSpeechToText);
+ }}
+ className={
+ 'action-button action-button-mic ' +
+ (isSpeechToText ? 'active' : '')
+ }
+ title="Speech To Text"
+ aria-label={`${
+ isSpeechToText ? 'Enable' : 'Disable'
+ } speech to text`}>
+
+
+ )}
+ importFile(editor)}
+ title="Import"
+ aria-label="Import editor state from JSON">
+
+
+
+ exportFile(editor, {
+ fileName: `Playground ${new Date().toISOString()}`,
+ source: 'Playground',
+ })
+ }
+ title="Export"
+ aria-label="Export editor state to JSON">
+
+
+ {
+ showModal('Clear editor', (onClose) => (
+
+ ));
+ }}
+ title="Clear"
+ aria-label="Clear editor contents">
+
+
+ {
+ // Send latest editor state to commenting validation server
+ if (isEditable) {
+ sendEditorState(editor);
+ }
+ editor.setEditable(!editor.isEditable());
+ }}
+ title="Read-Only Mode"
+ aria-label={`${!isEditable ? 'Unlock' : 'Lock'} read-only mode`}>
+
+
+
+
+
+ {isCollabActive && (
+ {
+ editor.dispatchCommand(TOGGLE_CONNECT_COMMAND, !connected);
+ }}
+ title={`${
+ connected ? 'Disconnect' : 'Connect'
+ } Collaborative Editing`}
+ aria-label={`${
+ connected ? 'Disconnect from' : 'Connect to'
+ } a collaborative editing server`}>
+
+
+ )}
+ {modal}
+
+ );
+}
+
+function ShowClearDialog({
+ editor,
+ onClose,
+}: {
+ editor: LexicalEditor;
+ onClose: () => void;
+}): JSX.Element {
+ return (
+ <>
+ Are you sure you want to clear the editor?
+
+ {
+ editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
+ editor.focus();
+ onClose();
+ }}>
+ Clear
+ {' '}
+ {
+ editor.focus();
+ onClose();
+ }}>
+ Cancel
+
+
+ >
+ );
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/AutoEmbedPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/AutoEmbedPlugin/index.tsx
new file mode 100644
index 0000000..9b58ee2
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/AutoEmbedPlugin/index.tsx
@@ -0,0 +1,354 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type {LexicalEditor} from 'lexical';
+
+import {
+ AutoEmbedOption,
+ EmbedConfig,
+ EmbedMatchResult,
+ LexicalAutoEmbedPlugin,
+ URL_MATCHER,
+} from '@lexical/react/LexicalAutoEmbedPlugin';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {useMemo, useState} from 'react';
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+
+import useModal from '../../hooks/useModal';
+import Button from '../../ui/Button';
+import {DialogActions} from '../../ui/Dialog';
+import {INSERT_FIGMA_COMMAND} from '../FigmaPlugin';
+import {INSERT_TWEET_COMMAND} from '../TwitterPlugin';
+import {INSERT_YOUTUBE_COMMAND} from '../YouTubePlugin';
+
+interface PlaygroundEmbedConfig extends EmbedConfig {
+ // Human readable name of the embeded content e.g. Tweet or Google Map.
+ contentName: string;
+
+ // Icon for display.
+ icon?: JSX.Element;
+
+ // An example of a matching url https://twitter.com/jack/status/20
+ exampleUrl: string;
+
+ // For extra searching.
+ keywords: Array;
+
+ // Embed a Figma Project.
+ description?: string;
+}
+
+export const YoutubeEmbedConfig: PlaygroundEmbedConfig = {
+ contentName: 'Youtube Video',
+
+ exampleUrl: 'https://www.youtube.com/watch?v=jNQXAC9IVRw',
+
+ // Icon for display.
+ icon: ,
+
+ insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
+ editor.dispatchCommand(INSERT_YOUTUBE_COMMAND, result.id);
+ },
+
+ keywords: ['youtube', 'video'],
+
+ // Determine if a given URL is a match and return url data.
+ parseUrl: async (url: string) => {
+ const match =
+ /^.*(youtu\.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=)([^#&?]*).*/.exec(url);
+
+ const id = match ? (match?.[2].length === 11 ? match[2] : null) : null;
+
+ if (id != null) {
+ return {
+ id,
+ url,
+ };
+ }
+
+ return null;
+ },
+
+ type: 'youtube-video',
+};
+
+export const TwitterEmbedConfig: PlaygroundEmbedConfig = {
+ // e.g. Tweet or Google Map.
+ contentName: 'Tweet',
+
+ exampleUrl: 'https://twitter.com/jack/status/20',
+
+ // Icon for display.
+ icon: ,
+
+ // Create the Lexical embed node from the url data.
+ insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
+ editor.dispatchCommand(INSERT_TWEET_COMMAND, result.id);
+ },
+
+ // For extra searching.
+ keywords: ['tweet', 'twitter'],
+
+ // Determine if a given URL is a match and return url data.
+ parseUrl: (text: string) => {
+ const match =
+ /^https:\/\/(twitter|x)\.com\/(#!\/)?(\w+)\/status(es)*\/(\d+)/.exec(
+ text,
+ );
+
+ if (match != null) {
+ return {
+ id: match[5],
+ url: match[1],
+ };
+ }
+
+ return null;
+ },
+
+ type: 'tweet',
+};
+
+export const FigmaEmbedConfig: PlaygroundEmbedConfig = {
+ contentName: 'Figma Document',
+
+ exampleUrl: 'https://www.figma.com/file/LKQ4FJ4bTnCSjedbRpk931/Sample-File',
+
+ icon: ,
+
+ insertNode: (editor: LexicalEditor, result: EmbedMatchResult) => {
+ editor.dispatchCommand(INSERT_FIGMA_COMMAND, result.id);
+ },
+
+ keywords: ['figma', 'figma.com', 'mock-up'],
+
+ // Determine if a given URL is a match and return url data.
+ parseUrl: (text: string) => {
+ const match =
+ /https:\/\/([\w.-]+\.)?figma.com\/(file|proto)\/([0-9a-zA-Z]{22,128})(?:\/.*)?$/.exec(
+ text,
+ );
+
+ if (match != null) {
+ return {
+ id: match[3],
+ url: match[0],
+ };
+ }
+
+ return null;
+ },
+
+ type: 'figma',
+};
+
+export const EmbedConfigs = [
+ TwitterEmbedConfig,
+ YoutubeEmbedConfig,
+ FigmaEmbedConfig,
+];
+
+function AutoEmbedMenuItem({
+ index,
+ isSelected,
+ onClick,
+ onMouseEnter,
+ option,
+}: {
+ index: number;
+ isSelected: boolean;
+ onClick: () => void;
+ onMouseEnter: () => void;
+ option: AutoEmbedOption;
+}) {
+ let className = 'item';
+ if (isSelected) {
+ className += ' selected';
+ }
+ return (
+
+ {option.title}
+
+ );
+}
+
+function AutoEmbedMenu({
+ options,
+ selectedItemIndex,
+ onOptionClick,
+ onOptionMouseEnter,
+}: {
+ selectedItemIndex: number | null;
+ onOptionClick: (option: AutoEmbedOption, index: number) => void;
+ onOptionMouseEnter: (index: number) => void;
+ options: Array;
+}) {
+ return (
+
+
+ {options.map((option: AutoEmbedOption, i: number) => (
+ onOptionClick(option, i)}
+ onMouseEnter={() => onOptionMouseEnter(i)}
+ key={option.key}
+ option={option}
+ />
+ ))}
+
+
+ );
+}
+
+const debounce = (callback: (text: string) => void, delay: number) => {
+ let timeoutId: number;
+ return (text: string) => {
+ window.clearTimeout(timeoutId);
+ timeoutId = window.setTimeout(() => {
+ callback(text);
+ }, delay);
+ };
+};
+
+export function AutoEmbedDialog({
+ embedConfig,
+ onClose,
+}: {
+ embedConfig: PlaygroundEmbedConfig;
+ onClose: () => void;
+}): JSX.Element {
+ const [text, setText] = useState('');
+ const [editor] = useLexicalComposerContext();
+ const [embedResult, setEmbedResult] = useState(null);
+
+ const validateText = useMemo(
+ () =>
+ debounce((inputText: string) => {
+ const urlMatch = URL_MATCHER.exec(inputText);
+ if (embedConfig != null && inputText != null && urlMatch != null) {
+ Promise.resolve(embedConfig.parseUrl(inputText)).then(
+ (parseResult) => {
+ setEmbedResult(parseResult);
+ },
+ );
+ } else if (embedResult != null) {
+ setEmbedResult(null);
+ }
+ }, 200),
+ [embedConfig, embedResult],
+ );
+
+ const onClick = () => {
+ if (embedResult != null) {
+ embedConfig.insertNode(editor, embedResult);
+ onClose();
+ }
+ };
+
+ return (
+
+
+ {
+ const {value} = e.target;
+ setText(value);
+ validateText(value);
+ }}
+ />
+
+
+
+ Embed
+
+
+
+ );
+}
+
+export default function AutoEmbedPlugin(): JSX.Element {
+ const [modal, showModal] = useModal();
+
+ const openEmbedModal = (embedConfig: PlaygroundEmbedConfig) => {
+ showModal(`Embed ${embedConfig.contentName}`, (onClose) => (
+
+ ));
+ };
+
+ const getMenuOptions = (
+ activeEmbedConfig: PlaygroundEmbedConfig,
+ embedFn: () => void,
+ dismissFn: () => void,
+ ) => {
+ return [
+ new AutoEmbedOption('Dismiss', {
+ onSelect: dismissFn,
+ }),
+ new AutoEmbedOption(`Embed ${activeEmbedConfig.contentName}`, {
+ onSelect: embedFn,
+ }),
+ ];
+ };
+
+ return (
+ <>
+ {modal}
+
+ embedConfigs={EmbedConfigs}
+ onOpenEmbedModalForConfig={openEmbedModal}
+ getMenuOptions={getMenuOptions}
+ menuRenderFn={(
+ anchorElementRef,
+ {selectedIndex, options, selectOptionAndCleanUp, setHighlightedIndex},
+ ) =>
+ anchorElementRef.current
+ ? ReactDOM.createPortal(
+
+
{
+ setHighlightedIndex(index);
+ selectOptionAndCleanUp(option);
+ }}
+ onOptionMouseEnter={(index: number) => {
+ setHighlightedIndex(index);
+ }}
+ />
+ ,
+ anchorElementRef.current,
+ )
+ : null
+ }
+ />
+ >
+ );
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/AutoLinkPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/AutoLinkPlugin/index.tsx
new file mode 100644
index 0000000..509c3de
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/AutoLinkPlugin/index.tsx
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {
+ AutoLinkPlugin,
+ createLinkMatcherWithRegExp,
+} from '@lexical/react/LexicalAutoLinkPlugin';
+import * as React from 'react';
+
+const URL_REGEX =
+ /((https?:\/\/(www\.)?)|(www\.))[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/;
+
+const EMAIL_REGEX =
+ /(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/;
+
+const MATCHERS = [
+ createLinkMatcherWithRegExp(URL_REGEX, (text) => {
+ return text.startsWith('http') ? text : `https://${text}`;
+ }),
+ createLinkMatcherWithRegExp(EMAIL_REGEX, (text) => {
+ return `mailto:${text}`;
+ }),
+];
+
+export default function LexicalAutoLinkPlugin(): JSX.Element {
+ return ;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/AutocompletePlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/AutocompletePlugin/index.tsx
new file mode 100644
index 0000000..92f925e
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/AutocompletePlugin/index.tsx
@@ -0,0 +1,2529 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type {BaseSelection, NodeKey} from 'lexical';
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {$isAtNodeEnd} from '@lexical/selection';
+import {mergeRegister} from '@lexical/utils';
+import {
+ $createTextNode,
+ $getNodeByKey,
+ $getSelection,
+ $isRangeSelection,
+ $isTextNode,
+ $setSelection,
+ COMMAND_PRIORITY_LOW,
+ KEY_ARROW_RIGHT_COMMAND,
+ KEY_TAB_COMMAND,
+} from 'lexical';
+import {useCallback, useEffect} from 'react';
+
+import {useSharedAutocompleteContext} from '../../context/SharedAutocompleteContext';
+import {
+ $createAutocompleteNode,
+ AutocompleteNode,
+} from '../../nodes/AutocompleteNode';
+import {addSwipeRightListener} from '../../utils/swipe';
+
+type SearchPromise = {
+ dismiss: () => void;
+ promise: Promise;
+};
+
+export const uuid = Math.random()
+ .toString(36)
+ .replace(/[^a-z]+/g, '')
+ .substr(0, 5);
+
+// TODO lookup should be custom
+function $search(selection: null | BaseSelection): [boolean, string] {
+ if (!$isRangeSelection(selection) || !selection.isCollapsed()) {
+ return [false, ''];
+ }
+ const node = selection.getNodes()[0];
+ const anchor = selection.anchor;
+ // Check siblings?
+ if (!$isTextNode(node) || !node.isSimpleText() || !$isAtNodeEnd(anchor)) {
+ return [false, ''];
+ }
+ const word = [];
+ const text = node.getTextContent();
+ let i = node.getTextContentSize();
+ let c;
+ while (i-- && i >= 0 && (c = text[i]) !== ' ') {
+ word.push(c);
+ }
+ if (word.length === 0) {
+ return [false, ''];
+ }
+ return [true, word.reverse().join('')];
+}
+
+// TODO query should be custom
+function useQuery(): (searchText: string) => SearchPromise {
+ return useCallback((searchText: string) => {
+ const server = new AutocompleteServer();
+ console.time('query');
+ const response = server.query(searchText);
+ console.timeEnd('query');
+ return response;
+ }, []);
+}
+
+export default function AutocompletePlugin(): JSX.Element | null {
+ const [editor] = useLexicalComposerContext();
+ const [, setSuggestion] = useSharedAutocompleteContext();
+ const query = useQuery();
+
+ useEffect(() => {
+ let autocompleteNodeKey: null | NodeKey = null;
+ let lastMatch: null | string = null;
+ let lastSuggestion: null | string = null;
+ let searchPromise: null | SearchPromise = null;
+ function $clearSuggestion() {
+ const autocompleteNode =
+ autocompleteNodeKey !== null
+ ? $getNodeByKey(autocompleteNodeKey)
+ : null;
+ if (autocompleteNode !== null && autocompleteNode.isAttached()) {
+ autocompleteNode.remove();
+ autocompleteNodeKey = null;
+ }
+ if (searchPromise !== null) {
+ searchPromise.dismiss();
+ searchPromise = null;
+ }
+ lastMatch = null;
+ lastSuggestion = null;
+ setSuggestion(null);
+ }
+ function updateAsyncSuggestion(
+ refSearchPromise: SearchPromise,
+ newSuggestion: null | string,
+ ) {
+ if (searchPromise !== refSearchPromise || newSuggestion === null) {
+ // Outdated or no suggestion
+ return;
+ }
+ editor.update(
+ () => {
+ const selection = $getSelection();
+ const [hasMatch, match] = $search(selection);
+ if (
+ !hasMatch ||
+ match !== lastMatch ||
+ !$isRangeSelection(selection)
+ ) {
+ // Outdated
+ return;
+ }
+ const selectionCopy = selection.clone();
+ const node = $createAutocompleteNode(uuid);
+ autocompleteNodeKey = node.getKey();
+ selection.insertNodes([node]);
+ $setSelection(selectionCopy);
+ lastSuggestion = newSuggestion;
+ setSuggestion(newSuggestion);
+ },
+ {tag: 'history-merge'},
+ );
+ }
+
+ function handleAutocompleteNodeTransform(node: AutocompleteNode) {
+ const key = node.getKey();
+ if (node.__uuid === uuid && key !== autocompleteNodeKey) {
+ // Max one Autocomplete node per session
+ $clearSuggestion();
+ }
+ }
+ function handleUpdate() {
+ editor.update(() => {
+ const selection = $getSelection();
+ const [hasMatch, match] = $search(selection);
+ if (!hasMatch) {
+ $clearSuggestion();
+ return;
+ }
+ if (match === lastMatch) {
+ return;
+ }
+ $clearSuggestion();
+ searchPromise = query(match);
+ searchPromise.promise
+ .then((newSuggestion) => {
+ if (searchPromise !== null) {
+ updateAsyncSuggestion(searchPromise, newSuggestion);
+ }
+ })
+ .catch((e) => {
+ console.error(e);
+ });
+ lastMatch = match;
+ });
+ }
+ function $handleAutocompleteIntent(): boolean {
+ if (lastSuggestion === null || autocompleteNodeKey === null) {
+ return false;
+ }
+ const autocompleteNode = $getNodeByKey(autocompleteNodeKey);
+ if (autocompleteNode === null) {
+ return false;
+ }
+ const textNode = $createTextNode(lastSuggestion);
+ autocompleteNode.replace(textNode);
+ textNode.selectNext();
+ $clearSuggestion();
+ return true;
+ }
+ function $handleKeypressCommand(e: Event) {
+ if ($handleAutocompleteIntent()) {
+ e.preventDefault();
+ return true;
+ }
+ return false;
+ }
+ function handleSwipeRight(_force: number, e: TouchEvent) {
+ editor.update(() => {
+ if ($handleAutocompleteIntent()) {
+ e.preventDefault();
+ }
+ });
+ }
+ function unmountSuggestion() {
+ editor.update(() => {
+ $clearSuggestion();
+ });
+ }
+
+ const rootElem = editor.getRootElement();
+
+ return mergeRegister(
+ editor.registerNodeTransform(
+ AutocompleteNode,
+ handleAutocompleteNodeTransform,
+ ),
+ editor.registerUpdateListener(handleUpdate),
+ editor.registerCommand(
+ KEY_TAB_COMMAND,
+ $handleKeypressCommand,
+ COMMAND_PRIORITY_LOW,
+ ),
+ editor.registerCommand(
+ KEY_ARROW_RIGHT_COMMAND,
+ $handleKeypressCommand,
+ COMMAND_PRIORITY_LOW,
+ ),
+ ...(rootElem !== null
+ ? [addSwipeRightListener(rootElem, handleSwipeRight)]
+ : []),
+ unmountSuggestion,
+ );
+ }, [editor, query, setSuggestion]);
+
+ return null;
+}
+
+/*
+ * Simulate an asynchronous autocomplete server (typical in more common use cases like GMail where
+ * the data is not static).
+ */
+class AutocompleteServer {
+ DATABASE = DICTIONARY;
+ LATENCY = 200;
+
+ query = (searchText: string): SearchPromise => {
+ let isDismissed = false;
+
+ const dismiss = () => {
+ isDismissed = true;
+ };
+ const promise: Promise = new Promise((resolve, reject) => {
+ setTimeout(() => {
+ if (isDismissed) {
+ // TODO cache result
+ return reject('Dismissed');
+ }
+ const searchTextLength = searchText.length;
+ if (searchText === '' || searchTextLength < 4) {
+ return resolve(null);
+ }
+ const char0 = searchText.charCodeAt(0);
+ const isCapitalized = char0 >= 65 && char0 <= 90;
+ const caseInsensitiveSearchText = isCapitalized
+ ? String.fromCharCode(char0 + 32) + searchText.substring(1)
+ : searchText;
+ const match = this.DATABASE.find(
+ (dictionaryWord) =>
+ dictionaryWord.startsWith(caseInsensitiveSearchText) ?? null,
+ );
+ if (match === undefined) {
+ return resolve(null);
+ }
+ const matchCapitalized = isCapitalized
+ ? String.fromCharCode(match.charCodeAt(0) - 32) + match.substring(1)
+ : match;
+ const autocompleteChunk = matchCapitalized.substring(searchTextLength);
+ if (autocompleteChunk === '') {
+ return resolve(null);
+ }
+ return resolve(autocompleteChunk);
+ }, this.LATENCY);
+ });
+
+ return {
+ dismiss,
+ promise,
+ };
+ };
+}
+
+// https://raw.githubusercontent.com/first20hours/google-10000-english/master/google-10000-english-usa-no-swears-long.txt
+const DICTIONARY = [
+ 'information',
+ 'available',
+ 'copyright',
+ 'university',
+ 'management',
+ 'international',
+ 'development',
+ 'education',
+ 'community',
+ 'technology',
+ 'following',
+ 'resources',
+ 'including',
+ 'directory',
+ 'government',
+ 'department',
+ 'description',
+ 'insurance',
+ 'different',
+ 'categories',
+ 'conditions',
+ 'accessories',
+ 'september',
+ 'questions',
+ 'application',
+ 'financial',
+ 'equipment',
+ 'performance',
+ 'experience',
+ 'important',
+ 'activities',
+ 'additional',
+ 'something',
+ 'professional',
+ 'committee',
+ 'washington',
+ 'california',
+ 'reference',
+ 'companies',
+ 'computers',
+ 'president',
+ 'australia',
+ 'discussion',
+ 'entertainment',
+ 'agreement',
+ 'marketing',
+ 'association',
+ 'collection',
+ 'solutions',
+ 'electronics',
+ 'technical',
+ 'microsoft',
+ 'conference',
+ 'environment',
+ 'statement',
+ 'downloads',
+ 'applications',
+ 'requirements',
+ 'individual',
+ 'subscribe',
+ 'everything',
+ 'production',
+ 'commercial',
+ 'advertising',
+ 'treatment',
+ 'newsletter',
+ 'knowledge',
+ 'currently',
+ 'construction',
+ 'registered',
+ 'protection',
+ 'engineering',
+ 'published',
+ 'corporate',
+ 'customers',
+ 'materials',
+ 'countries',
+ 'standards',
+ 'political',
+ 'advertise',
+ 'environmental',
+ 'availability',
+ 'employment',
+ 'commission',
+ 'administration',
+ 'institute',
+ 'sponsored',
+ 'electronic',
+ 'condition',
+ 'effective',
+ 'organization',
+ 'selection',
+ 'corporation',
+ 'executive',
+ 'necessary',
+ 'according',
+ 'particular',
+ 'facilities',
+ 'opportunities',
+ 'appropriate',
+ 'statistics',
+ 'investment',
+ 'christmas',
+ 'registration',
+ 'furniture',
+ 'wednesday',
+ 'structure',
+ 'distribution',
+ 'industrial',
+ 'potential',
+ 'responsible',
+ 'communications',
+ 'associated',
+ 'foundation',
+ 'documents',
+ 'communication',
+ 'independent',
+ 'operating',
+ 'developed',
+ 'telephone',
+ 'population',
+ 'navigation',
+ 'operations',
+ 'therefore',
+ 'christian',
+ 'understand',
+ 'publications',
+ 'worldwide',
+ 'connection',
+ 'publisher',
+ 'introduction',
+ 'properties',
+ 'accommodation',
+ 'excellent',
+ 'opportunity',
+ 'assessment',
+ 'especially',
+ 'interface',
+ 'operation',
+ 'restaurants',
+ 'beautiful',
+ 'locations',
+ 'significant',
+ 'technologies',
+ 'manufacturer',
+ 'providing',
+ 'authority',
+ 'considered',
+ 'programme',
+ 'enterprise',
+ 'educational',
+ 'employees',
+ 'alternative',
+ 'processing',
+ 'responsibility',
+ 'resolution',
+ 'publication',
+ 'relations',
+ 'photography',
+ 'components',
+ 'assistance',
+ 'completed',
+ 'organizations',
+ 'otherwise',
+ 'transportation',
+ 'disclaimer',
+ 'membership',
+ 'recommended',
+ 'background',
+ 'character',
+ 'maintenance',
+ 'functions',
+ 'trademarks',
+ 'phentermine',
+ 'submitted',
+ 'television',
+ 'interested',
+ 'throughout',
+ 'established',
+ 'programming',
+ 'regarding',
+ 'instructions',
+ 'increased',
+ 'understanding',
+ 'beginning',
+ 'associates',
+ 'instruments',
+ 'businesses',
+ 'specified',
+ 'restaurant',
+ 'procedures',
+ 'relationship',
+ 'traditional',
+ 'sometimes',
+ 'themselves',
+ 'transport',
+ 'interesting',
+ 'evaluation',
+ 'implementation',
+ 'galleries',
+ 'references',
+ 'presented',
+ 'literature',
+ 'respective',
+ 'definition',
+ 'secretary',
+ 'networking',
+ 'australian',
+ 'magazines',
+ 'francisco',
+ 'individuals',
+ 'guidelines',
+ 'installation',
+ 'described',
+ 'attention',
+ 'difference',
+ 'regulations',
+ 'certificate',
+ 'directions',
+ 'documentation',
+ 'automotive',
+ 'successful',
+ 'communities',
+ 'situation',
+ 'publishing',
+ 'emergency',
+ 'developing',
+ 'determine',
+ 'temperature',
+ 'announcements',
+ 'historical',
+ 'ringtones',
+ 'difficult',
+ 'scientific',
+ 'satellite',
+ 'particularly',
+ 'functional',
+ 'monitoring',
+ 'architecture',
+ 'recommend',
+ 'dictionary',
+ 'accounting',
+ 'manufacturing',
+ 'professor',
+ 'generally',
+ 'continued',
+ 'techniques',
+ 'permission',
+ 'generation',
+ 'component',
+ 'guarantee',
+ 'processes',
+ 'interests',
+ 'paperback',
+ 'classifieds',
+ 'supported',
+ 'competition',
+ 'providers',
+ 'characters',
+ 'thousands',
+ 'apartments',
+ 'generated',
+ 'administrative',
+ 'practices',
+ 'reporting',
+ 'essential',
+ 'affiliate',
+ 'immediately',
+ 'designated',
+ 'integrated',
+ 'configuration',
+ 'comprehensive',
+ 'universal',
+ 'presentation',
+ 'languages',
+ 'compliance',
+ 'improvement',
+ 'pennsylvania',
+ 'challenge',
+ 'acceptance',
+ 'strategies',
+ 'affiliates',
+ 'multimedia',
+ 'certified',
+ 'computing',
+ 'interactive',
+ 'procedure',
+ 'leadership',
+ 'religious',
+ 'breakfast',
+ 'developer',
+ 'approximately',
+ 'recommendations',
+ 'comparison',
+ 'automatically',
+ 'minnesota',
+ 'adventure',
+ 'institutions',
+ 'assistant',
+ 'advertisement',
+ 'headlines',
+ 'yesterday',
+ 'determined',
+ 'wholesale',
+ 'extension',
+ 'statements',
+ 'completely',
+ 'electrical',
+ 'applicable',
+ 'manufacturers',
+ 'classical',
+ 'dedicated',
+ 'direction',
+ 'basketball',
+ 'wisconsin',
+ 'personnel',
+ 'identified',
+ 'professionals',
+ 'advantage',
+ 'newsletters',
+ 'estimated',
+ 'anonymous',
+ 'miscellaneous',
+ 'integration',
+ 'interview',
+ 'framework',
+ 'installed',
+ 'massachusetts',
+ 'associate',
+ 'frequently',
+ 'discussions',
+ 'laboratory',
+ 'destination',
+ 'intelligence',
+ 'specifications',
+ 'tripadvisor',
+ 'residential',
+ 'decisions',
+ 'industries',
+ 'partnership',
+ 'editorial',
+ 'expression',
+ 'provisions',
+ 'principles',
+ 'suggestions',
+ 'replacement',
+ 'strategic',
+ 'economics',
+ 'compatible',
+ 'apartment',
+ 'netherlands',
+ 'consulting',
+ 'recreation',
+ 'participants',
+ 'favorites',
+ 'translation',
+ 'estimates',
+ 'protected',
+ 'philadelphia',
+ 'officials',
+ 'contained',
+ 'legislation',
+ 'parameters',
+ 'relationships',
+ 'tennessee',
+ 'representative',
+ 'frequency',
+ 'introduced',
+ 'departments',
+ 'residents',
+ 'displayed',
+ 'performed',
+ 'administrator',
+ 'addresses',
+ 'permanent',
+ 'agriculture',
+ 'constitutes',
+ 'portfolio',
+ 'practical',
+ 'delivered',
+ 'collectibles',
+ 'infrastructure',
+ 'exclusive',
+ 'originally',
+ 'utilities',
+ 'philosophy',
+ 'regulation',
+ 'reduction',
+ 'nutrition',
+ 'recording',
+ 'secondary',
+ 'wonderful',
+ 'announced',
+ 'prevention',
+ 'mentioned',
+ 'automatic',
+ 'healthcare',
+ 'maintained',
+ 'increasing',
+ 'connected',
+ 'directors',
+ 'participation',
+ 'containing',
+ 'combination',
+ 'amendment',
+ 'guaranteed',
+ 'libraries',
+ 'distributed',
+ 'singapore',
+ 'enterprises',
+ 'convention',
+ 'principal',
+ 'certification',
+ 'previously',
+ 'buildings',
+ 'household',
+ 'batteries',
+ 'positions',
+ 'subscription',
+ 'contemporary',
+ 'panasonic',
+ 'permalink',
+ 'signature',
+ 'provision',
+ 'certainly',
+ 'newspaper',
+ 'liability',
+ 'trademark',
+ 'trackback',
+ 'americans',
+ 'promotion',
+ 'conversion',
+ 'reasonable',
+ 'broadband',
+ 'influence',
+ 'importance',
+ 'webmaster',
+ 'prescription',
+ 'specifically',
+ 'represent',
+ 'conservation',
+ 'louisiana',
+ 'javascript',
+ 'marketplace',
+ 'evolution',
+ 'certificates',
+ 'objectives',
+ 'suggested',
+ 'concerned',
+ 'structures',
+ 'encyclopedia',
+ 'continuing',
+ 'interracial',
+ 'competitive',
+ 'suppliers',
+ 'preparation',
+ 'receiving',
+ 'accordance',
+ 'discussed',
+ 'elizabeth',
+ 'reservations',
+ 'playstation',
+ 'instruction',
+ 'annotation',
+ 'differences',
+ 'establish',
+ 'expressed',
+ 'paragraph',
+ 'mathematics',
+ 'compensation',
+ 'conducted',
+ 'percentage',
+ 'mississippi',
+ 'requested',
+ 'connecticut',
+ 'personals',
+ 'immediate',
+ 'agricultural',
+ 'supporting',
+ 'collections',
+ 'participate',
+ 'specialist',
+ 'experienced',
+ 'investigation',
+ 'institution',
+ 'searching',
+ 'proceedings',
+ 'transmission',
+ 'characteristics',
+ 'experiences',
+ 'extremely',
+ 'verzeichnis',
+ 'contracts',
+ 'concerning',
+ 'developers',
+ 'equivalent',
+ 'chemistry',
+ 'neighborhood',
+ 'variables',
+ 'continues',
+ 'curriculum',
+ 'psychology',
+ 'responses',
+ 'circumstances',
+ 'identification',
+ 'appliances',
+ 'elementary',
+ 'unlimited',
+ 'printable',
+ 'enforcement',
+ 'hardcover',
+ 'celebrity',
+ 'chocolate',
+ 'hampshire',
+ 'bluetooth',
+ 'controlled',
+ 'requirement',
+ 'authorities',
+ 'representatives',
+ 'pregnancy',
+ 'biography',
+ 'attractions',
+ 'transactions',
+ 'authorized',
+ 'retirement',
+ 'financing',
+ 'efficiency',
+ 'efficient',
+ 'commitment',
+ 'specialty',
+ 'interviews',
+ 'qualified',
+ 'discovery',
+ 'classified',
+ 'confidence',
+ 'lifestyle',
+ 'consistent',
+ 'clearance',
+ 'connections',
+ 'inventory',
+ 'converter',
+ 'organisation',
+ 'objective',
+ 'indicated',
+ 'securities',
+ 'volunteer',
+ 'democratic',
+ 'switzerland',
+ 'parameter',
+ 'processor',
+ 'dimensions',
+ 'contribute',
+ 'challenges',
+ 'recognition',
+ 'submission',
+ 'encourage',
+ 'regulatory',
+ 'inspection',
+ 'consumers',
+ 'territory',
+ 'transaction',
+ 'manchester',
+ 'contributions',
+ 'continuous',
+ 'resulting',
+ 'cambridge',
+ 'initiative',
+ 'execution',
+ 'disability',
+ 'increases',
+ 'contractor',
+ 'examination',
+ 'indicates',
+ 'committed',
+ 'extensive',
+ 'affordable',
+ 'candidate',
+ 'databases',
+ 'outstanding',
+ 'perspective',
+ 'messenger',
+ 'tournament',
+ 'consideration',
+ 'discounts',
+ 'catalogue',
+ 'publishers',
+ 'caribbean',
+ 'reservation',
+ 'remaining',
+ 'depending',
+ 'expansion',
+ 'purchased',
+ 'performing',
+ 'collected',
+ 'absolutely',
+ 'featuring',
+ 'implement',
+ 'scheduled',
+ 'calculator',
+ 'significantly',
+ 'temporary',
+ 'sufficient',
+ 'awareness',
+ 'vancouver',
+ 'contribution',
+ 'measurement',
+ 'constitution',
+ 'packaging',
+ 'consultation',
+ 'northwest',
+ 'classroom',
+ 'democracy',
+ 'wallpaper',
+ 'merchandise',
+ 'resistance',
+ 'baltimore',
+ 'candidates',
+ 'charlotte',
+ 'biological',
+ 'transition',
+ 'preferences',
+ 'instrument',
+ 'classification',
+ 'physician',
+ 'hollywood',
+ 'wikipedia',
+ 'spiritual',
+ 'photographs',
+ 'relatively',
+ 'satisfaction',
+ 'represents',
+ 'pittsburgh',
+ 'preferred',
+ 'intellectual',
+ 'comfortable',
+ 'interaction',
+ 'listening',
+ 'effectively',
+ 'experimental',
+ 'revolution',
+ 'consolidation',
+ 'landscape',
+ 'dependent',
+ 'mechanical',
+ 'consultants',
+ 'applicant',
+ 'cooperation',
+ 'acquisition',
+ 'implemented',
+ 'directories',
+ 'recognized',
+ 'notification',
+ 'licensing',
+ 'textbooks',
+ 'diversity',
+ 'cleveland',
+ 'investments',
+ 'accessibility',
+ 'sensitive',
+ 'templates',
+ 'completion',
+ 'universities',
+ 'technique',
+ 'contractors',
+ 'subscriptions',
+ 'calculate',
+ 'alexander',
+ 'broadcast',
+ 'converted',
+ 'anniversary',
+ 'improvements',
+ 'specification',
+ 'accessible',
+ 'accessory',
+ 'typically',
+ 'representation',
+ 'arrangements',
+ 'conferences',
+ 'uniprotkb',
+ 'consumption',
+ 'birmingham',
+ 'afternoon',
+ 'consultant',
+ 'controller',
+ 'ownership',
+ 'committees',
+ 'legislative',
+ 'researchers',
+ 'unsubscribe',
+ 'molecular',
+ 'residence',
+ 'attorneys',
+ 'operators',
+ 'sustainable',
+ 'philippines',
+ 'statistical',
+ 'innovation',
+ 'employers',
+ 'definitions',
+ 'elections',
+ 'stainless',
+ 'newspapers',
+ 'hospitals',
+ 'exception',
+ 'successfully',
+ 'indonesia',
+ 'primarily',
+ 'capabilities',
+ 'recommendation',
+ 'recruitment',
+ 'organized',
+ 'improving',
+ 'expensive',
+ 'organisations',
+ 'explained',
+ 'programmes',
+ 'expertise',
+ 'mechanism',
+ 'jewellery',
+ 'eventually',
+ 'agreements',
+ 'considering',
+ 'innovative',
+ 'conclusion',
+ 'disorders',
+ 'collaboration',
+ 'detection',
+ 'formation',
+ 'engineers',
+ 'proposals',
+ 'moderator',
+ 'tutorials',
+ 'settlement',
+ 'collectables',
+ 'fantastic',
+ 'governments',
+ 'purchasing',
+ 'appointed',
+ 'operational',
+ 'corresponding',
+ 'descriptions',
+ 'determination',
+ 'animation',
+ 'productions',
+ 'telecommunications',
+ 'instructor',
+ 'approaches',
+ 'highlights',
+ 'designers',
+ 'melbourne',
+ 'scientists',
+ 'blackjack',
+ 'argentina',
+ 'possibility',
+ 'commissioner',
+ 'dangerous',
+ 'reliability',
+ 'unfortunately',
+ 'respectively',
+ 'volunteers',
+ 'attachment',
+ 'appointment',
+ 'workshops',
+ 'hurricane',
+ 'represented',
+ 'mortgages',
+ 'responsibilities',
+ 'carefully',
+ 'productivity',
+ 'investors',
+ 'underground',
+ 'diagnosis',
+ 'principle',
+ 'vacations',
+ 'calculated',
+ 'appearance',
+ 'incorporated',
+ 'notebooks',
+ 'algorithm',
+ 'valentine',
+ 'involving',
+ 'investing',
+ 'christopher',
+ 'admission',
+ 'terrorism',
+ 'parliament',
+ 'situations',
+ 'allocated',
+ 'corrections',
+ 'structural',
+ 'municipal',
+ 'describes',
+ 'disabilities',
+ 'substance',
+ 'prohibited',
+ 'addressed',
+ 'simulation',
+ 'initiatives',
+ 'concentration',
+ 'interpretation',
+ 'bankruptcy',
+ 'optimization',
+ 'substances',
+ 'discovered',
+ 'restrictions',
+ 'participating',
+ 'exhibition',
+ 'composition',
+ 'nationwide',
+ 'definitely',
+ 'existence',
+ 'commentary',
+ 'limousines',
+ 'developments',
+ 'immigration',
+ 'destinations',
+ 'necessarily',
+ 'attribute',
+ 'apparently',
+ 'surrounding',
+ 'mountains',
+ 'popularity',
+ 'postposted',
+ 'coordinator',
+ 'obviously',
+ 'fundamental',
+ 'substantial',
+ 'progressive',
+ 'championship',
+ 'sacramento',
+ 'impossible',
+ 'depression',
+ 'testimonials',
+ 'memorabilia',
+ 'cartridge',
+ 'explanation',
+ 'cincinnati',
+ 'subsection',
+ 'electricity',
+ 'permitted',
+ 'workplace',
+ 'confirmed',
+ 'wallpapers',
+ 'infection',
+ 'eligibility',
+ 'involvement',
+ 'placement',
+ 'observations',
+ 'vbulletin',
+ 'subsequent',
+ 'motorcycle',
+ 'disclosure',
+ 'establishment',
+ 'presentations',
+ 'undergraduate',
+ 'occupation',
+ 'donations',
+ 'associations',
+ 'citysearch',
+ 'radiation',
+ 'seriously',
+ 'elsewhere',
+ 'pollution',
+ 'conservative',
+ 'guestbook',
+ 'effectiveness',
+ 'demonstrate',
+ 'atmosphere',
+ 'experiment',
+ 'purchases',
+ 'federation',
+ 'assignment',
+ 'chemicals',
+ 'everybody',
+ 'nashville',
+ 'counseling',
+ 'acceptable',
+ 'satisfied',
+ 'measurements',
+ 'milwaukee',
+ 'medication',
+ 'warehouse',
+ 'shareware',
+ 'violation',
+ 'configure',
+ 'stability',
+ 'southwest',
+ 'institutional',
+ 'expectations',
+ 'independence',
+ 'metabolism',
+ 'personally',
+ 'excellence',
+ 'somewhere',
+ 'attributes',
+ 'recognize',
+ 'screening',
+ 'thumbnail',
+ 'forgotten',
+ 'intelligent',
+ 'edinburgh',
+ 'obligation',
+ 'regardless',
+ 'restricted',
+ 'republican',
+ 'merchants',
+ 'attendance',
+ 'arguments',
+ 'amsterdam',
+ 'adventures',
+ 'announcement',
+ 'appreciate',
+ 'regularly',
+ 'mechanisms',
+ 'customize',
+ 'tradition',
+ 'indicators',
+ 'emissions',
+ 'physicians',
+ 'complaint',
+ 'experiments',
+ 'afghanistan',
+ 'scholarship',
+ 'governance',
+ 'supplements',
+ 'camcorder',
+ 'implementing',
+ 'ourselves',
+ 'conversation',
+ 'capability',
+ 'producing',
+ 'precision',
+ 'contributed',
+ 'reproduction',
+ 'ingredients',
+ 'franchise',
+ 'complaints',
+ 'promotions',
+ 'rehabilitation',
+ 'maintaining',
+ 'environments',
+ 'reception',
+ 'correctly',
+ 'consequences',
+ 'geography',
+ 'appearing',
+ 'integrity',
+ 'discrimination',
+ 'processed',
+ 'implications',
+ 'functionality',
+ 'intermediate',
+ 'emotional',
+ 'platforms',
+ 'overnight',
+ 'geographic',
+ 'preliminary',
+ 'districts',
+ 'introduce',
+ 'promotional',
+ 'chevrolet',
+ 'specialists',
+ 'generator',
+ 'suspension',
+ 'correction',
+ 'authentication',
+ 'communicate',
+ 'supplement',
+ 'showtimes',
+ 'promoting',
+ 'machinery',
+ 'bandwidth',
+ 'probability',
+ 'dimension',
+ 'schedules',
+ 'admissions',
+ 'quarterly',
+ 'illustrated',
+ 'continental',
+ 'alternate',
+ 'achievement',
+ 'limitations',
+ 'automated',
+ 'passenger',
+ 'convenient',
+ 'orientation',
+ 'childhood',
+ 'flexibility',
+ 'jurisdiction',
+ 'displaying',
+ 'encouraged',
+ 'cartridges',
+ 'declaration',
+ 'automation',
+ 'advantages',
+ 'preparing',
+ 'recipient',
+ 'extensions',
+ 'athletics',
+ 'southeast',
+ 'alternatives',
+ 'determining',
+ 'personalized',
+ 'conditioning',
+ 'partnerships',
+ 'destruction',
+ 'increasingly',
+ 'migration',
+ 'basically',
+ 'conventional',
+ 'applicants',
+ 'occupational',
+ 'adjustment',
+ 'treatments',
+ 'camcorders',
+ 'difficulty',
+ 'collective',
+ 'coalition',
+ 'enrollment',
+ 'producers',
+ 'collector',
+ 'interfaces',
+ 'advertisers',
+ 'representing',
+ 'observation',
+ 'restoration',
+ 'convenience',
+ 'returning',
+ 'opposition',
+ 'container',
+ 'defendant',
+ 'confirmation',
+ 'supervisor',
+ 'peripherals',
+ 'bestsellers',
+ 'departure',
+ 'minneapolis',
+ 'interactions',
+ 'intervention',
+ 'attraction',
+ 'modification',
+ 'customized',
+ 'understood',
+ 'assurance',
+ 'happening',
+ 'amendments',
+ 'metropolitan',
+ 'compilation',
+ 'verification',
+ 'attractive',
+ 'recordings',
+ 'jefferson',
+ 'gardening',
+ 'obligations',
+ 'orchestra',
+ 'polyphonic',
+ 'outsourcing',
+ 'adjustable',
+ 'allocation',
+ 'discipline',
+ 'demonstrated',
+ 'identifying',
+ 'alphabetical',
+ 'dispatched',
+ 'installing',
+ 'voluntary',
+ 'photographer',
+ 'messaging',
+ 'constructed',
+ 'additions',
+ 'requiring',
+ 'engagement',
+ 'refinance',
+ 'calendars',
+ 'arrangement',
+ 'conclusions',
+ 'bibliography',
+ 'compatibility',
+ 'furthermore',
+ 'cooperative',
+ 'measuring',
+ 'jacksonville',
+ 'headquarters',
+ 'transfers',
+ 'transformation',
+ 'attachments',
+ 'administrators',
+ 'personality',
+ 'facilitate',
+ 'subscriber',
+ 'priorities',
+ 'bookstore',
+ 'parenting',
+ 'incredible',
+ 'commonwealth',
+ 'pharmaceutical',
+ 'manhattan',
+ 'workforce',
+ 'organizational',
+ 'portuguese',
+ 'everywhere',
+ 'discharge',
+ 'halloween',
+ 'hazardous',
+ 'methodology',
+ 'housewares',
+ 'reputation',
+ 'resistant',
+ 'democrats',
+ 'recycling',
+ 'qualifications',
+ 'slideshow',
+ 'variation',
+ 'transferred',
+ 'photograph',
+ 'distributor',
+ 'underlying',
+ 'wrestling',
+ 'photoshop',
+ 'gathering',
+ 'projection',
+ 'mathematical',
+ 'specialized',
+ 'diagnostic',
+ 'indianapolis',
+ 'corporations',
+ 'criticism',
+ 'automobile',
+ 'confidential',
+ 'statutory',
+ 'accommodations',
+ 'northeast',
+ 'downloaded',
+ 'paintings',
+ 'injection',
+ 'yorkshire',
+ 'populations',
+ 'protective',
+ 'initially',
+ 'indicator',
+ 'eliminate',
+ 'sunglasses',
+ 'preference',
+ 'threshold',
+ 'venezuela',
+ 'exploration',
+ 'sequences',
+ 'astronomy',
+ 'translate',
+ 'announces',
+ 'compression',
+ 'establishing',
+ 'constitutional',
+ 'perfectly',
+ 'instantly',
+ 'litigation',
+ 'submissions',
+ 'broadcasting',
+ 'horizontal',
+ 'terrorist',
+ 'informational',
+ 'ecommerce',
+ 'suffering',
+ 'prospective',
+ 'ultimately',
+ 'artificial',
+ 'spectacular',
+ 'coordination',
+ 'connector',
+ 'affiliated',
+ 'activation',
+ 'naturally',
+ 'subscribers',
+ 'mitsubishi',
+ 'underwear',
+ 'potentially',
+ 'constraints',
+ 'inclusive',
+ 'dimensional',
+ 'considerable',
+ 'selecting',
+ 'processors',
+ 'pantyhose',
+ 'difficulties',
+ 'complexity',
+ 'constantly',
+ 'barcelona',
+ 'presidential',
+ 'documentary',
+ 'territories',
+ 'palestinian',
+ 'legislature',
+ 'hospitality',
+ 'procurement',
+ 'theoretical',
+ 'exercises',
+ 'surveillance',
+ 'protocols',
+ 'highlight',
+ 'substitute',
+ 'inclusion',
+ 'hopefully',
+ 'brilliant',
+ 'evaluated',
+ 'assignments',
+ 'termination',
+ 'households',
+ 'authentic',
+ 'montgomery',
+ 'architectural',
+ 'louisville',
+ 'macintosh',
+ 'movements',
+ 'amenities',
+ 'virtually',
+ 'authorization',
+ 'projector',
+ 'comparative',
+ 'psychological',
+ 'surprised',
+ 'genealogy',
+ 'expenditure',
+ 'liverpool',
+ 'connectivity',
+ 'algorithms',
+ 'similarly',
+ 'collaborative',
+ 'excluding',
+ 'commander',
+ 'suggestion',
+ 'spotlight',
+ 'investigate',
+ 'connecting',
+ 'logistics',
+ 'proportion',
+ 'significance',
+ 'symposium',
+ 'essentials',
+ 'protecting',
+ 'transmitted',
+ 'screenshots',
+ 'intensive',
+ 'switching',
+ 'correspondence',
+ 'supervision',
+ 'expenditures',
+ 'separation',
+ 'testimony',
+ 'celebrities',
+ 'mandatory',
+ 'boundaries',
+ 'syndication',
+ 'celebration',
+ 'filtering',
+ 'luxembourg',
+ 'offensive',
+ 'deployment',
+ 'colleagues',
+ 'separated',
+ 'directive',
+ 'governing',
+ 'retailers',
+ 'occasionally',
+ 'attending',
+ 'recruiting',
+ 'instructional',
+ 'traveling',
+ 'permissions',
+ 'biotechnology',
+ 'prescribed',
+ 'catherine',
+ 'reproduced',
+ 'calculation',
+ 'consolidated',
+ 'occasions',
+ 'equations',
+ 'exceptional',
+ 'respondents',
+ 'considerations',
+ 'queensland',
+ 'musicians',
+ 'composite',
+ 'unavailable',
+ 'essentially',
+ 'designing',
+ 'assessments',
+ 'brunswick',
+ 'sensitivity',
+ 'preservation',
+ 'streaming',
+ 'intensity',
+ 'technological',
+ 'syndicate',
+ 'antivirus',
+ 'addressing',
+ 'discounted',
+ 'bangladesh',
+ 'constitute',
+ 'concluded',
+ 'desperate',
+ 'demonstration',
+ 'governmental',
+ 'manufactured',
+ 'graduation',
+ 'variations',
+ 'addiction',
+ 'springfield',
+ 'synthesis',
+ 'undefined',
+ 'unemployment',
+ 'enhancement',
+ 'newcastle',
+ 'performances',
+ 'societies',
+ 'brazilian',
+ 'identical',
+ 'petroleum',
+ 'norwegian',
+ 'retention',
+ 'exchanges',
+ 'soundtrack',
+ 'wondering',
+ 'profession',
+ 'separately',
+ 'physiology',
+ 'collecting',
+ 'participant',
+ 'scholarships',
+ 'recreational',
+ 'dominican',
+ 'friendship',
+ 'expanding',
+ 'provincial',
+ 'investigations',
+ 'medications',
+ 'rochester',
+ 'advertiser',
+ 'encryption',
+ 'downloadable',
+ 'sophisticated',
+ 'possession',
+ 'laboratories',
+ 'vegetables',
+ 'thumbnails',
+ 'stockings',
+ 'respondent',
+ 'destroyed',
+ 'manufacture',
+ 'wordpress',
+ 'vulnerability',
+ 'accountability',
+ 'celebrate',
+ 'accredited',
+ 'appliance',
+ 'compressed',
+ 'scheduling',
+ 'perspectives',
+ 'mortality',
+ 'christians',
+ 'therapeutic',
+ 'impressive',
+ 'accordingly',
+ 'architect',
+ 'challenging',
+ 'microwave',
+ 'accidents',
+ 'relocation',
+ 'contributors',
+ 'violations',
+ 'temperatures',
+ 'competitions',
+ 'discretion',
+ 'cosmetics',
+ 'repository',
+ 'concentrations',
+ 'christianity',
+ 'negotiations',
+ 'realistic',
+ 'generating',
+ 'christina',
+ 'congressional',
+ 'photographic',
+ 'modifications',
+ 'millennium',
+ 'achieving',
+ 'fisheries',
+ 'exceptions',
+ 'reactions',
+ 'macromedia',
+ 'companion',
+ 'divisions',
+ 'additionally',
+ 'fellowship',
+ 'victorian',
+ 'copyrights',
+ 'lithuania',
+ 'mastercard',
+ 'chronicles',
+ 'obtaining',
+ 'distribute',
+ 'decorative',
+ 'enlargement',
+ 'campaigns',
+ 'conjunction',
+ 'instances',
+ 'indigenous',
+ 'validation',
+ 'corruption',
+ 'incentives',
+ 'cholesterol',
+ 'differential',
+ 'scientist',
+ 'starsmerchant',
+ 'arthritis',
+ 'nevertheless',
+ 'practitioners',
+ 'transcript',
+ 'inflation',
+ 'compounds',
+ 'contracting',
+ 'structured',
+ 'reasonably',
+ 'graduates',
+ 'recommends',
+ 'controlling',
+ 'distributors',
+ 'arlington',
+ 'particles',
+ 'extraordinary',
+ 'indicating',
+ 'coordinate',
+ 'exclusively',
+ 'limitation',
+ 'widescreen',
+ 'illustration',
+ 'construct',
+ 'inquiries',
+ 'inspiration',
+ 'affecting',
+ 'downloading',
+ 'aggregate',
+ 'forecasts',
+ 'complicated',
+ 'shopzilla',
+ 'decorating',
+ 'expressions',
+ 'shakespeare',
+ 'connectors',
+ 'conflicts',
+ 'travelers',
+ 'offerings',
+ 'incorrect',
+ 'furnishings',
+ 'guatemala',
+ 'perception',
+ 'renaissance',
+ 'pathology',
+ 'ordinance',
+ 'photographers',
+ 'infections',
+ 'configured',
+ 'festivals',
+ 'possibilities',
+ 'contributing',
+ 'analytical',
+ 'circulation',
+ 'assumption',
+ 'jerusalem',
+ 'transexuales',
+ 'invention',
+ 'technician',
+ 'executives',
+ 'enquiries',
+ 'cognitive',
+ 'exploring',
+ 'registrar',
+ 'supporters',
+ 'withdrawal',
+ 'predicted',
+ 'saskatchewan',
+ 'cancellation',
+ 'ministers',
+ 'veterinary',
+ 'prostores',
+ 'relevance',
+ 'incentive',
+ 'butterfly',
+ 'mechanics',
+ 'numerical',
+ 'reflection',
+ 'accompanied',
+ 'invitation',
+ 'princeton',
+ 'spirituality',
+ 'meanwhile',
+ 'proprietary',
+ 'childrens',
+ 'thumbzilla',
+ 'porcelain',
+ 'pichunter',
+ 'translated',
+ 'columnists',
+ 'consensus',
+ 'delivering',
+ 'journalism',
+ 'intention',
+ 'undertaken',
+ 'statewide',
+ 'semiconductor',
+ 'illustrations',
+ 'happiness',
+ 'substantially',
+ 'identifier',
+ 'calculations',
+ 'conducting',
+ 'accomplished',
+ 'calculators',
+ 'impression',
+ 'correlation',
+ 'fragrance',
+ 'neighbors',
+ 'transparent',
+ 'charleston',
+ 'champions',
+ 'selections',
+ 'projectors',
+ 'inappropriate',
+ 'comparing',
+ 'vocational',
+ 'pharmacies',
+ 'introducing',
+ 'appreciated',
+ 'albuquerque',
+ 'distinguished',
+ 'projected',
+ 'assumptions',
+ 'shareholders',
+ 'developmental',
+ 'regulated',
+ 'anticipated',
+ 'completing',
+ 'comparable',
+ 'confusion',
+ 'copyrighted',
+ 'warranties',
+ 'documented',
+ 'paperbacks',
+ 'keyboards',
+ 'vulnerable',
+ 'reflected',
+ 'respiratory',
+ 'notifications',
+ 'transexual',
+ 'mainstream',
+ 'evaluating',
+ 'subcommittee',
+ 'maternity',
+ 'journalists',
+ 'foundations',
+ 'volleyball',
+ 'liabilities',
+ 'decreased',
+ 'tolerance',
+ 'creativity',
+ 'describing',
+ 'lightning',
+ 'quotations',
+ 'inspector',
+ 'bookmarks',
+ 'behavioral',
+ 'riverside',
+ 'bathrooms',
+ 'abilities',
+ 'initiated',
+ 'nonprofit',
+ 'lancaster',
+ 'suspended',
+ 'containers',
+ 'attitudes',
+ 'simultaneously',
+ 'integrate',
+ 'sociology',
+ 'screenshot',
+ 'exhibitions',
+ 'confident',
+ 'retrieved',
+ 'officially',
+ 'consortium',
+ 'recipients',
+ 'delicious',
+ 'traditions',
+ 'periodically',
+ 'hungarian',
+ 'referring',
+ 'transform',
+ 'educators',
+ 'vegetable',
+ 'humanities',
+ 'independently',
+ 'alignment',
+ 'henderson',
+ 'britannica',
+ 'competitors',
+ 'visibility',
+ 'consciousness',
+ 'encounter',
+ 'resolutions',
+ 'accessing',
+ 'attempted',
+ 'witnesses',
+ 'administered',
+ 'strengthen',
+ 'frederick',
+ 'aggressive',
+ 'advertisements',
+ 'sublimedirectory',
+ 'disturbed',
+ 'determines',
+ 'sculpture',
+ 'motivation',
+ 'pharmacology',
+ 'passengers',
+ 'quantities',
+ 'petersburg',
+ 'consistently',
+ 'powerpoint',
+ 'obituaries',
+ 'punishment',
+ 'appreciation',
+ 'subsequently',
+ 'providence',
+ 'restriction',
+ 'incorporate',
+ 'backgrounds',
+ 'treasurer',
+ 'lightweight',
+ 'transcription',
+ 'complications',
+ 'scripting',
+ 'remembered',
+ 'synthetic',
+ 'testament',
+ 'specifics',
+ 'partially',
+ 'wilderness',
+ 'generations',
+ 'tournaments',
+ 'sponsorship',
+ 'headphones',
+ 'proceeding',
+ 'volkswagen',
+ 'uncertainty',
+ 'breakdown',
+ 'reconstruction',
+ 'subsidiary',
+ 'strengths',
+ 'encouraging',
+ 'furnished',
+ 'terrorists',
+ 'comparisons',
+ 'beneficial',
+ 'distributions',
+ 'viewpicture',
+ 'threatened',
+ 'republicans',
+ 'discusses',
+ 'responded',
+ 'abstracts',
+ 'prediction',
+ 'pharmaceuticals',
+ 'thesaurus',
+ 'individually',
+ 'battlefield',
+ 'literally',
+ 'ecological',
+ 'appraisal',
+ 'consisting',
+ 'submitting',
+ 'citations',
+ 'geographical',
+ 'mozambique',
+ 'disclaimers',
+ 'championships',
+ 'sheffield',
+ 'finishing',
+ 'wellington',
+ 'prospects',
+ 'bulgarian',
+ 'aboriginal',
+ 'remarkable',
+ 'preventing',
+ 'productive',
+ 'boulevard',
+ 'compliant',
+ 'penalties',
+ 'imagination',
+ 'refurbished',
+ 'activated',
+ 'conferencing',
+ 'armstrong',
+ 'politicians',
+ 'trackbacks',
+ 'accommodate',
+ 'christine',
+ 'accepting',
+ 'precipitation',
+ 'isolation',
+ 'sustained',
+ 'approximate',
+ 'programmer',
+ 'greetings',
+ 'inherited',
+ 'incomplete',
+ 'chronicle',
+ 'legitimate',
+ 'biographies',
+ 'investigator',
+ 'plaintiff',
+ 'prisoners',
+ 'mediterranean',
+ 'nightlife',
+ 'architects',
+ 'entrepreneur',
+ 'freelance',
+ 'excessive',
+ 'screensaver',
+ 'valuation',
+ 'unexpected',
+ 'cigarette',
+ 'characteristic',
+ 'metallica',
+ 'consequently',
+ 'appointments',
+ 'narrative',
+ 'academics',
+ 'quantitative',
+ 'screensavers',
+ 'subdivision',
+ 'distinction',
+ 'livestock',
+ 'exemption',
+ 'sustainability',
+ 'formatting',
+ 'nutritional',
+ 'nicaragua',
+ 'affiliation',
+ 'relatives',
+ 'satisfactory',
+ 'revolutionary',
+ 'bracelets',
+ 'telephony',
+ 'breathing',
+ 'thickness',
+ 'adjustments',
+ 'graphical',
+ 'discussing',
+ 'aerospace',
+ 'meaningful',
+ 'maintains',
+ 'shortcuts',
+ 'voyeurweb',
+ 'extending',
+ 'specifies',
+ 'accreditation',
+ 'blackberry',
+ 'meditation',
+ 'microphone',
+ 'macedonia',
+ 'combining',
+ 'instrumental',
+ 'organizing',
+ 'moderators',
+ 'kazakhstan',
+ 'standings',
+ 'partition',
+ 'invisible',
+ 'translations',
+ 'commodity',
+ 'kilometers',
+ 'thanksgiving',
+ 'guarantees',
+ 'indication',
+ 'congratulations',
+ 'cigarettes',
+ 'controllers',
+ 'consultancy',
+ 'conventions',
+ 'coordinates',
+ 'responding',
+ 'physically',
+ 'stakeholders',
+ 'hydrocodone',
+ 'consecutive',
+ 'attempting',
+ 'representations',
+ 'competing',
+ 'peninsula',
+ 'accurately',
+ 'considers',
+ 'ministries',
+ 'vacancies',
+ 'parliamentary',
+ 'acknowledge',
+ 'thoroughly',
+ 'nottingham',
+ 'identifies',
+ 'questionnaire',
+ 'qualification',
+ 'modelling',
+ 'miniature',
+ 'interstate',
+ 'consequence',
+ 'systematic',
+ 'perceived',
+ 'madagascar',
+ 'presenting',
+ 'troubleshooting',
+ 'uzbekistan',
+ 'centuries',
+ 'magnitude',
+ 'richardson',
+ 'fragrances',
+ 'vocabulary',
+ 'earthquake',
+ 'fundraising',
+ 'geological',
+ 'assessing',
+ 'introduces',
+ 'webmasters',
+ 'computational',
+ 'acdbentity',
+ 'participated',
+ 'handhelds',
+ 'answering',
+ 'impressed',
+ 'conspiracy',
+ 'organizer',
+ 'combinations',
+ 'preceding',
+ 'cumulative',
+ 'amplifier',
+ 'arbitrary',
+ 'prominent',
+ 'lexington',
+ 'contacted',
+ 'recorders',
+ 'occasional',
+ 'innovations',
+ 'postcards',
+ 'reviewing',
+ 'explicitly',
+ 'transsexual',
+ 'citizenship',
+ 'informative',
+ 'girlfriend',
+ 'bloomberg',
+ 'hierarchy',
+ 'influenced',
+ 'abandoned',
+ 'complement',
+ 'mauritius',
+ 'checklist',
+ 'requesting',
+ 'lauderdale',
+ 'scenarios',
+ 'extraction',
+ 'elevation',
+ 'utilization',
+ 'beverages',
+ 'calibration',
+ 'efficiently',
+ 'entertaining',
+ 'prerequisite',
+ 'hypothesis',
+ 'medicines',
+ 'regression',
+ 'enhancements',
+ 'renewable',
+ 'intersection',
+ 'passwords',
+ 'consistency',
+ 'collectors',
+ 'azerbaijan',
+ 'astrology',
+ 'occurring',
+ 'supplemental',
+ 'travelling',
+ 'induction',
+ 'precisely',
+ 'spreading',
+ 'provinces',
+ 'widespread',
+ 'incidence',
+ 'incidents',
+ 'enhancing',
+ 'interference',
+ 'palestine',
+ 'listprice',
+ 'atmospheric',
+ 'knowledgestorm',
+ 'referenced',
+ 'publicity',
+ 'proposition',
+ 'allowance',
+ 'designation',
+ 'duplicate',
+ 'criterion',
+ 'civilization',
+ 'vietnamese',
+ 'tremendous',
+ 'corrected',
+ 'encountered',
+ 'internationally',
+ 'surrounded',
+ 'creatures',
+ 'commented',
+ 'accomplish',
+ 'vegetarian',
+ 'newfoundland',
+ 'investigated',
+ 'ambassador',
+ 'stephanie',
+ 'contacting',
+ 'vegetation',
+ 'findarticles',
+ 'specially',
+ 'infectious',
+ 'continuity',
+ 'phenomenon',
+ 'conscious',
+ 'referrals',
+ 'differently',
+ 'integrating',
+ 'revisions',
+ 'reasoning',
+ 'charitable',
+ 'annotated',
+ 'convinced',
+ 'burlington',
+ 'replacing',
+ 'researcher',
+ 'watershed',
+ 'occupations',
+ 'acknowledged',
+ 'equilibrium',
+ 'characterized',
+ 'privilege',
+ 'qualifying',
+ 'estimation',
+ 'pediatric',
+ 'techrepublic',
+ 'institutes',
+ 'brochures',
+ 'traveller',
+ 'appropriations',
+ 'suspected',
+ 'benchmark',
+ 'beginners',
+ 'instructors',
+ 'highlighted',
+ 'stationery',
+ 'unauthorized',
+ 'competent',
+ 'contributor',
+ 'demonstrates',
+ 'gradually',
+ 'desirable',
+ 'journalist',
+ 'afterwards',
+ 'religions',
+ 'explosion',
+ 'signatures',
+ 'disciplines',
+ 'daughters',
+ 'conversations',
+ 'simplified',
+ 'motherboard',
+ 'bibliographic',
+ 'champagne',
+ 'deviation',
+ 'superintendent',
+ 'housewives',
+ 'influences',
+ 'inspections',
+ 'irrigation',
+ 'hydraulic',
+ 'robertson',
+ 'penetration',
+ 'conviction',
+ 'omissions',
+ 'retrieval',
+ 'qualities',
+ 'prototype',
+ 'importantly',
+ 'apparatus',
+ 'explaining',
+ 'nomination',
+ 'empirical',
+ 'dependence',
+ 'sexuality',
+ 'polyester',
+ 'commitments',
+ 'suggesting',
+ 'remainder',
+ 'privileges',
+ 'televisions',
+ 'specializing',
+ 'commodities',
+ 'motorcycles',
+ 'concentrate',
+ 'reproductive',
+ 'molecules',
+ 'refrigerator',
+ 'intervals',
+ 'sentences',
+ 'exclusion',
+ 'workstation',
+ 'holocaust',
+ 'receivers',
+ 'disposition',
+ 'navigator',
+ 'investigators',
+ 'marijuana',
+ 'cathedral',
+ 'fairfield',
+ 'fascinating',
+ 'landscapes',
+ 'lafayette',
+ 'computation',
+ 'cardiovascular',
+ 'salvation',
+ 'predictions',
+ 'accompanying',
+ 'selective',
+ 'arbitration',
+ 'configuring',
+ 'editorials',
+ 'sacrifice',
+ 'removable',
+ 'convergence',
+ 'gibraltar',
+ 'anthropology',
+ 'malpractice',
+ 'reporters',
+ 'necessity',
+ 'rendering',
+ 'hepatitis',
+ 'nationally',
+ 'waterproof',
+ 'specialties',
+ 'humanitarian',
+ 'invitations',
+ 'functioning',
+ 'economies',
+ 'alexandria',
+ 'bacterial',
+ 'undertake',
+ 'continuously',
+ 'achievements',
+ 'convertible',
+ 'secretariat',
+ 'paragraphs',
+ 'adolescent',
+ 'nominations',
+ 'cancelled',
+ 'introductory',
+ 'reservoir',
+ 'occurrence',
+ 'worcester',
+ 'demographic',
+ 'disciplinary',
+ 'respected',
+ 'portraits',
+ 'interpreted',
+ 'evaluations',
+ 'elimination',
+ 'hypothetical',
+ 'immigrants',
+ 'complimentary',
+ 'helicopter',
+ 'performer',
+ 'commissions',
+ 'powerseller',
+ 'graduated',
+ 'surprising',
+ 'unnecessary',
+ 'dramatically',
+ 'yugoslavia',
+ 'characterization',
+ 'likelihood',
+ 'fundamentals',
+ 'contamination',
+ 'endangered',
+ 'compromise',
+ 'expiration',
+ 'namespace',
+ 'peripheral',
+ 'negotiation',
+ 'opponents',
+ 'nominated',
+ 'confidentiality',
+ 'electoral',
+ 'changelog',
+ 'alternatively',
+ 'greensboro',
+ 'controversial',
+ 'recovered',
+ 'upgrading',
+ 'frontpage',
+ 'demanding',
+ 'defensive',
+ 'forbidden',
+ 'programmers',
+ 'monitored',
+ 'installations',
+ 'deutschland',
+ 'practitioner',
+ 'motivated',
+ 'smithsonian',
+ 'examining',
+ 'revelation',
+ 'delegation',
+ 'dictionaries',
+ 'greenhouse',
+ 'transparency',
+ 'currencies',
+ 'survivors',
+ 'positioning',
+ 'descending',
+ 'temporarily',
+ 'frequencies',
+ 'reflections',
+ 'municipality',
+ 'detective',
+ 'experiencing',
+ 'fireplace',
+ 'endorsement',
+ 'psychiatry',
+ 'persistent',
+ 'summaries',
+ 'looksmart',
+ 'magnificent',
+ 'colleague',
+ 'adaptation',
+ 'paintball',
+ 'enclosure',
+ 'supervisors',
+ 'westminster',
+ 'distances',
+ 'absorption',
+ 'treasures',
+ 'transcripts',
+ 'disappointed',
+ 'continually',
+ 'communist',
+ 'collectible',
+ 'entrepreneurs',
+ 'creations',
+ 'acquisitions',
+ 'biodiversity',
+ 'excitement',
+ 'presently',
+ 'mysterious',
+ 'librarian',
+ 'subsidiaries',
+ 'stockholm',
+ 'indonesian',
+ 'therapist',
+ 'promising',
+ 'relaxation',
+ 'thereafter',
+ 'commissioners',
+ 'forwarding',
+ 'nightmare',
+ 'reductions',
+ 'southampton',
+ 'organisms',
+ 'telescope',
+ 'portsmouth',
+ 'advancement',
+ 'harassment',
+ 'generators',
+ 'generates',
+ 'replication',
+ 'inexpensive',
+ 'receptors',
+ 'interventions',
+ 'huntington',
+ 'internship',
+ 'aluminium',
+ 'snowboard',
+ 'beastality',
+ 'evanescence',
+ 'coordinated',
+ 'shipments',
+ 'antarctica',
+ 'chancellor',
+ 'controversy',
+ 'legendary',
+ 'beautifully',
+ 'antibodies',
+ 'examinations',
+ 'immunology',
+ 'departmental',
+ 'terminology',
+ 'gentleman',
+ 'reproduce',
+ 'convicted',
+ 'roommates',
+ 'threatening',
+ 'spokesman',
+ 'activists',
+ 'frankfurt',
+ 'encourages',
+ 'assembled',
+ 'restructuring',
+ 'terminals',
+ 'simulations',
+ 'sufficiently',
+ 'conditional',
+ 'crossword',
+ 'conceptual',
+ 'liechtenstein',
+ 'translator',
+ 'automobiles',
+ 'continent',
+ 'longitude',
+ 'challenged',
+ 'telecharger',
+ 'insertion',
+ 'instrumentation',
+ 'constraint',
+ 'groundwater',
+ 'strengthening',
+ 'insulation',
+ 'infringement',
+ 'subjective',
+ 'swaziland',
+ 'varieties',
+ 'mediawiki',
+ 'configurations',
+];
diff --git a/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/components/CopyButton/index.tsx b/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/components/CopyButton/index.tsx
new file mode 100644
index 0000000..c1e0b24
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/components/CopyButton/index.tsx
@@ -0,0 +1,70 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import {$isCodeNode} from '@lexical/code';
+import {
+ $getNearestNodeFromDOMNode,
+ $getSelection,
+ $setSelection,
+ LexicalEditor,
+} from 'lexical';
+import * as React from 'react';
+import {useState} from 'react';
+
+import {useDebounce} from '../../utils';
+
+interface Props {
+ editor: LexicalEditor;
+ getCodeDOMNode: () => HTMLElement | null;
+}
+
+export function CopyButton({editor, getCodeDOMNode}: Props) {
+ const [isCopyCompleted, setCopyCompleted] = useState(false);
+
+ const removeSuccessIcon = useDebounce(() => {
+ setCopyCompleted(false);
+ }, 1000);
+
+ async function handleClick(): Promise {
+ const codeDOMNode = getCodeDOMNode();
+
+ if (!codeDOMNode) {
+ return;
+ }
+
+ let content = '';
+
+ editor.update(() => {
+ const codeNode = $getNearestNodeFromDOMNode(codeDOMNode);
+
+ if ($isCodeNode(codeNode)) {
+ content = codeNode.getTextContent();
+ }
+
+ const selection = $getSelection();
+ $setSelection(selection);
+ });
+
+ try {
+ await navigator.clipboard.writeText(content);
+ setCopyCompleted(true);
+ removeSuccessIcon();
+ } catch (err) {
+ console.error('Failed to copy: ', err);
+ }
+ }
+
+ return (
+
+ {isCopyCompleted ? (
+
+ ) : (
+
+ )}
+
+ );
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/components/PrettierButton/index.css b/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/components/PrettierButton/index.css
new file mode 100644
index 0000000..3a559d5
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/components/PrettierButton/index.css
@@ -0,0 +1,14 @@
+.code-action-menu-container .prettier-wrapper {
+ position: relative;
+}
+
+.code-action-menu-container .prettier-wrapper .code-error-tips {
+ padding: 5px;
+ border-radius: 4px;
+ color: #fff;
+ background: #222;
+ margin-top: 4px;
+ position: absolute;
+ top: 26px;
+ right: 0;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/components/PrettierButton/index.tsx b/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/components/PrettierButton/index.tsx
new file mode 100644
index 0000000..203e637
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/components/PrettierButton/index.tsx
@@ -0,0 +1,156 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import './index.css';
+
+import {$isCodeNode} from '@lexical/code';
+import {$getNearestNodeFromDOMNode, LexicalEditor} from 'lexical';
+import {Options} from 'prettier';
+import * as React from 'react';
+import {useState} from 'react';
+
+interface Props {
+ lang: string;
+ editor: LexicalEditor;
+ getCodeDOMNode: () => HTMLElement | null;
+}
+
+const PRETTIER_PARSER_MODULES = {
+ css: () => import('prettier/parser-postcss'),
+ html: () => import('prettier/parser-html'),
+ js: () => import('prettier/parser-babel'),
+ markdown: () => import('prettier/parser-markdown'),
+} as const;
+
+type LanguagesType = keyof typeof PRETTIER_PARSER_MODULES;
+
+async function loadPrettierParserByLang(lang: string) {
+ const dynamicImport = PRETTIER_PARSER_MODULES[lang as LanguagesType];
+ return await dynamicImport();
+}
+
+async function loadPrettierFormat() {
+ const {format} = await import('prettier/standalone');
+ return format;
+}
+
+const PRETTIER_OPTIONS_BY_LANG: Record = {
+ css: {
+ parser: 'css',
+ },
+ html: {
+ parser: 'html',
+ },
+ js: {
+ parser: 'babel',
+ },
+ markdown: {
+ parser: 'markdown',
+ },
+};
+
+const LANG_CAN_BE_PRETTIER = Object.keys(PRETTIER_OPTIONS_BY_LANG);
+
+export function canBePrettier(lang: string): boolean {
+ return LANG_CAN_BE_PRETTIER.includes(lang);
+}
+
+function getPrettierOptions(lang: string): Options {
+ const options = PRETTIER_OPTIONS_BY_LANG[lang];
+ if (!options) {
+ throw new Error(
+ `CodeActionMenuPlugin: Prettier does not support this language: ${lang}`,
+ );
+ }
+
+ return options;
+}
+
+export function PrettierButton({lang, editor, getCodeDOMNode}: Props) {
+ const [syntaxError, setSyntaxError] = useState('');
+ const [tipsVisible, setTipsVisible] = useState(false);
+
+ async function handleClick(): Promise {
+ const codeDOMNode = getCodeDOMNode();
+
+ try {
+ const format = await loadPrettierFormat();
+ const options = getPrettierOptions(lang);
+ options.plugins = [await loadPrettierParserByLang(lang)];
+
+ if (!codeDOMNode) {
+ return;
+ }
+
+ editor.update(() => {
+ const codeNode = $getNearestNodeFromDOMNode(codeDOMNode);
+
+ if ($isCodeNode(codeNode)) {
+ const content = codeNode.getTextContent();
+
+ let parsed = '';
+
+ try {
+ parsed = format(content, options);
+ } catch (error: unknown) {
+ setError(error);
+ }
+
+ if (parsed !== '') {
+ const selection = codeNode.select(0);
+ selection.insertText(parsed);
+ setSyntaxError('');
+ setTipsVisible(false);
+ }
+ }
+ });
+ } catch (error: unknown) {
+ setError(error);
+ }
+ }
+
+ function setError(error: unknown) {
+ if (error instanceof Error) {
+ setSyntaxError(error.message);
+ setTipsVisible(true);
+ } else {
+ console.error('Unexpected error: ', error);
+ }
+ }
+
+ function handleMouseEnter() {
+ if (syntaxError !== '') {
+ setTipsVisible(true);
+ }
+ }
+
+ function handleMouseLeave() {
+ if (syntaxError !== '') {
+ setTipsVisible(false);
+ }
+ }
+
+ return (
+
+
+ {syntaxError ? (
+
+ ) : (
+
+ )}
+
+ {tipsVisible ? (
+
{syntaxError}
+ ) : null}
+
+ );
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/index.css b/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/index.css
new file mode 100644
index 0000000..0000c11
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/index.css
@@ -0,0 +1,46 @@
+.code-action-menu-container {
+ height: 35.8px;
+ font-size: 10px;
+ color: rgba(0, 0, 0, 0.5);
+ position: absolute;
+ display: flex;
+ align-items: center;
+ flex-direction: row;
+ user-select: none;
+}
+
+.code-action-menu-container .code-highlight-language {
+ margin-right: 4px;
+}
+
+.code-action-menu-container button.menu-item {
+ border: 1px solid transparent;
+ border-radius: 4px;
+ padding: 4px;
+ background: none;
+ cursor: pointer;
+ flex-shrink: 0;
+ display: flex;
+ align-items: center;
+ color: rgba(0, 0, 0, 0.5);
+ text-transform: uppercase;
+}
+
+.code-action-menu-container button.menu-item i.format {
+ height: 16px;
+ width: 16px;
+ opacity: 0.6;
+ display: flex;
+ color: rgba(0, 0, 0, 0.5);
+ background-size: contain;
+}
+
+.code-action-menu-container button.menu-item:hover {
+ border: 1px solid rgba(0, 0, 0, 0.3);
+ opacity: 0.9;
+}
+
+.code-action-menu-container button.menu-item:active {
+ background-color: rgba(223, 232, 250);
+ border: 1px solid rgba(0, 0, 0, 0.45);
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/index.tsx
new file mode 100644
index 0000000..53809f0
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/index.tsx
@@ -0,0 +1,184 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import './index.css';
+
+import {
+ $isCodeNode,
+ CodeNode,
+ getLanguageFriendlyName,
+ normalizeCodeLang,
+} from '@lexical/code';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {$getNearestNodeFromDOMNode} from 'lexical';
+import {useEffect, useRef, useState} from 'react';
+import * as React from 'react';
+import {createPortal} from 'react-dom';
+
+import {CopyButton} from './components/CopyButton';
+import {canBePrettier, PrettierButton} from './components/PrettierButton';
+import {useDebounce} from './utils';
+
+const CODE_PADDING = 8;
+
+interface Position {
+ top: string;
+ right: string;
+}
+
+function CodeActionMenuContainer({
+ anchorElem,
+}: {
+ anchorElem: HTMLElement;
+}): JSX.Element {
+ const [editor] = useLexicalComposerContext();
+
+ const [lang, setLang] = useState('');
+ const [isShown, setShown] = useState(false);
+ const [shouldListenMouseMove, setShouldListenMouseMove] =
+ useState(false);
+ const [position, setPosition] = useState({
+ right: '0',
+ top: '0',
+ });
+ const codeSetRef = useRef>(new Set());
+ const codeDOMNodeRef = useRef(null);
+
+ function getCodeDOMNode(): HTMLElement | null {
+ return codeDOMNodeRef.current;
+ }
+
+ const debouncedOnMouseMove = useDebounce(
+ (event: MouseEvent) => {
+ const {codeDOMNode, isOutside} = getMouseInfo(event);
+ if (isOutside) {
+ setShown(false);
+ return;
+ }
+
+ if (!codeDOMNode) {
+ return;
+ }
+
+ codeDOMNodeRef.current = codeDOMNode;
+
+ let codeNode: CodeNode | null = null;
+ let _lang = '';
+
+ editor.update(() => {
+ const maybeCodeNode = $getNearestNodeFromDOMNode(codeDOMNode);
+
+ if ($isCodeNode(maybeCodeNode)) {
+ codeNode = maybeCodeNode;
+ _lang = codeNode.getLanguage() || '';
+ }
+ });
+
+ if (codeNode) {
+ const {y: editorElemY, right: editorElemRight} =
+ anchorElem.getBoundingClientRect();
+ const {y, right} = codeDOMNode.getBoundingClientRect();
+ setLang(_lang);
+ setShown(true);
+ setPosition({
+ right: `${editorElemRight - right + CODE_PADDING}px`,
+ top: `${y - editorElemY}px`,
+ });
+ }
+ },
+ 50,
+ 1000,
+ );
+
+ useEffect(() => {
+ if (!shouldListenMouseMove) {
+ return;
+ }
+
+ document.addEventListener('mousemove', debouncedOnMouseMove);
+
+ return () => {
+ setShown(false);
+ debouncedOnMouseMove.cancel();
+ document.removeEventListener('mousemove', debouncedOnMouseMove);
+ };
+ }, [shouldListenMouseMove, debouncedOnMouseMove]);
+
+ editor.registerMutationListener(CodeNode, (mutations) => {
+ editor.getEditorState().read(() => {
+ for (const [key, type] of mutations) {
+ switch (type) {
+ case 'created':
+ codeSetRef.current.add(key);
+ setShouldListenMouseMove(codeSetRef.current.size > 0);
+ break;
+
+ case 'destroyed':
+ codeSetRef.current.delete(key);
+ setShouldListenMouseMove(codeSetRef.current.size > 0);
+ break;
+
+ default:
+ break;
+ }
+ }
+ });
+ });
+ const normalizedLang = normalizeCodeLang(lang);
+ const codeFriendlyName = getLanguageFriendlyName(lang);
+
+ return (
+ <>
+ {isShown ? (
+
+
{codeFriendlyName}
+
+ {canBePrettier(normalizedLang) ? (
+
+ ) : null}
+
+ ) : null}
+ >
+ );
+}
+
+function getMouseInfo(event: MouseEvent): {
+ codeDOMNode: HTMLElement | null;
+ isOutside: boolean;
+} {
+ const target = event.target;
+
+ if (target && target instanceof HTMLElement) {
+ const codeDOMNode = target.closest(
+ 'code.PlaygroundEditorTheme__code',
+ );
+ const isOutside = !(
+ codeDOMNode ||
+ target.closest('div.code-action-menu-container')
+ );
+
+ return {codeDOMNode, isOutside};
+ } else {
+ return {codeDOMNode: null, isOutside: true};
+ }
+}
+
+export default function CodeActionMenuPlugin({
+ anchorElem = document.body,
+}: {
+ anchorElem?: HTMLElement;
+}): React.ReactPortal | null {
+ return createPortal(
+ ,
+ anchorElem,
+ );
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/utils.ts b/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/utils.ts
new file mode 100644
index 0000000..2fccf4a
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CodeActionMenuPlugin/utils.ts
@@ -0,0 +1,32 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import {debounce} from 'lodash-es';
+import {useMemo, useRef} from 'react';
+
+export function useDebounce void>(
+ fn: T,
+ ms: number,
+ maxWait?: number,
+) {
+ const funcRef = useRef(null);
+ funcRef.current = fn;
+
+ return useMemo(
+ () =>
+ debounce(
+ (...args: Parameters) => {
+ if (funcRef.current) {
+ funcRef.current(...args);
+ }
+ },
+ ms,
+ {maxWait},
+ ),
+ [ms, maxWait],
+ );
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CodeHighlightPlugin/index.ts b/cyynote-frontend/src/components/lexical/plugins/CodeHighlightPlugin/index.ts
new file mode 100644
index 0000000..7813912
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CodeHighlightPlugin/index.ts
@@ -0,0 +1,21 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {registerCodeHighlighting} from '@lexical/code';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {useEffect} from 'react';
+
+export default function CodeHighlightPlugin(): JSX.Element | null {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ return registerCodeHighlighting(editor);
+ }, [editor]);
+
+ return null;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/Collapsible.css b/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/Collapsible.css
new file mode 100644
index 0000000..6212dbf
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/Collapsible.css
@@ -0,0 +1,57 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ *
+ */
+
+.Collapsible__container {
+ background: #fcfcfc;
+ border: 1px solid #eee;
+ border-radius: 10px;
+ margin-bottom: 8px;
+}
+
+.Collapsible__title {
+ cursor: pointer;
+ padding: 5px 5px 5px 20px;
+ position: relative;
+ font-weight: bold;
+ list-style: none;
+ outline: none;
+}
+
+.Collapsible__title::marker,
+.Collapsible__title::-webkit-details-marker {
+ display: none;
+}
+
+.Collapsible__title:before {
+ border-style: solid;
+ border-color: transparent;
+ border-width: 4px 6px 4px 6px;
+ border-left-color: #000;
+ display: block;
+ content: '';
+ position: absolute;
+ left: 7px;
+ top: 50%;
+ transform: translateY(-50%);
+}
+
+.Collapsible__container[open] > .Collapsible__title:before {
+ border-color: transparent;
+ border-width: 6px 4px 0 4px;
+ border-top-color: #000;
+}
+
+.Collapsible__content {
+ padding: 0 5px 5px 20px;
+}
+
+.Collapsible__collapsed .Collapsible__content {
+ display: none;
+ user-select: none;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/CollapsibleContainerNode.ts b/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/CollapsibleContainerNode.ts
new file mode 100644
index 0000000..7ba6129
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/CollapsibleContainerNode.ts
@@ -0,0 +1,136 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ EditorConfig,
+ ElementNode,
+ LexicalEditor,
+ LexicalNode,
+ NodeKey,
+ SerializedElementNode,
+ Spread,
+} from 'lexical';
+
+type SerializedCollapsibleContainerNode = Spread<
+ {
+ open: boolean;
+ },
+ SerializedElementNode
+>;
+
+export function convertDetailsElement(
+ domNode: HTMLDetailsElement,
+): DOMConversionOutput | null {
+ const isOpen = domNode.open !== undefined ? domNode.open : true;
+ const node = $createCollapsibleContainerNode(isOpen);
+ return {
+ node,
+ };
+}
+
+export class CollapsibleContainerNode extends ElementNode {
+ __open: boolean;
+
+ constructor(open: boolean, key?: NodeKey) {
+ super(key);
+ this.__open = open;
+ }
+
+ static getType(): string {
+ return 'collapsible-container';
+ }
+
+ static clone(node: CollapsibleContainerNode): CollapsibleContainerNode {
+ return new CollapsibleContainerNode(node.__open, node.__key);
+ }
+
+ createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
+ const dom = document.createElement('details');
+ dom.classList.add('Collapsible__container');
+ dom.open = this.__open;
+ dom.addEventListener('toggle', () => {
+ const open = editor.getEditorState().read(() => this.getOpen());
+ if (open !== dom.open) {
+ editor.update(() => this.toggleOpen());
+ }
+ });
+ return dom;
+ }
+
+ updateDOM(
+ prevNode: CollapsibleContainerNode,
+ dom: HTMLDetailsElement,
+ ): boolean {
+ if (prevNode.__open !== this.__open) {
+ dom.open = this.__open;
+ }
+
+ return false;
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ details: (domNode: HTMLDetailsElement) => {
+ return {
+ conversion: convertDetailsElement,
+ priority: 1,
+ };
+ },
+ };
+ }
+
+ static importJSON(
+ serializedNode: SerializedCollapsibleContainerNode,
+ ): CollapsibleContainerNode {
+ const node = $createCollapsibleContainerNode(serializedNode.open);
+ return node;
+ }
+
+ exportDOM(): DOMExportOutput {
+ const element = document.createElement('details');
+ element.setAttribute('open', this.__open.toString());
+ return {element};
+ }
+
+ exportJSON(): SerializedCollapsibleContainerNode {
+ return {
+ ...super.exportJSON(),
+ open: this.__open,
+ type: 'collapsible-container',
+ version: 1,
+ };
+ }
+
+ setOpen(open: boolean): void {
+ const writable = this.getWritable();
+ writable.__open = open;
+ }
+
+ getOpen(): boolean {
+ return this.getLatest().__open;
+ }
+
+ toggleOpen(): void {
+ this.setOpen(!this.getOpen());
+ }
+}
+
+export function $createCollapsibleContainerNode(
+ isOpen: boolean,
+): CollapsibleContainerNode {
+ return new CollapsibleContainerNode(isOpen);
+}
+
+export function $isCollapsibleContainerNode(
+ node: LexicalNode | null | undefined,
+): node is CollapsibleContainerNode {
+ return node instanceof CollapsibleContainerNode;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/CollapsibleContentNode.ts b/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/CollapsibleContentNode.ts
new file mode 100644
index 0000000..a0c0239
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/CollapsibleContentNode.ts
@@ -0,0 +1,96 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ EditorConfig,
+ ElementNode,
+ LexicalNode,
+ SerializedElementNode,
+} from 'lexical';
+
+type SerializedCollapsibleContentNode = SerializedElementNode;
+
+export function convertCollapsibleContentElement(
+ domNode: HTMLElement,
+): DOMConversionOutput | null {
+ const node = $createCollapsibleContentNode();
+ return {
+ node,
+ };
+}
+
+export class CollapsibleContentNode extends ElementNode {
+ static getType(): string {
+ return 'collapsible-content';
+ }
+
+ static clone(node: CollapsibleContentNode): CollapsibleContentNode {
+ return new CollapsibleContentNode(node.__key);
+ }
+
+ createDOM(config: EditorConfig): HTMLElement {
+ const dom = document.createElement('div');
+ dom.classList.add('Collapsible__content');
+ return dom;
+ }
+
+ updateDOM(prevNode: CollapsibleContentNode, dom: HTMLElement): boolean {
+ return false;
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ div: (domNode: HTMLElement) => {
+ if (!domNode.hasAttribute('data-lexical-collapsible-content')) {
+ return null;
+ }
+ return {
+ conversion: convertCollapsibleContentElement,
+ priority: 2,
+ };
+ },
+ };
+ }
+
+ exportDOM(): DOMExportOutput {
+ const element = document.createElement('div');
+ element.setAttribute('data-lexical-collapsible-content', 'true');
+ return {element};
+ }
+
+ static importJSON(
+ serializedNode: SerializedCollapsibleContentNode,
+ ): CollapsibleContentNode {
+ return $createCollapsibleContentNode();
+ }
+
+ isShadowRoot(): boolean {
+ return true;
+ }
+
+ exportJSON(): SerializedCollapsibleContentNode {
+ return {
+ ...super.exportJSON(),
+ type: 'collapsible-content',
+ version: 1,
+ };
+ }
+}
+
+export function $createCollapsibleContentNode(): CollapsibleContentNode {
+ return new CollapsibleContentNode();
+}
+
+export function $isCollapsibleContentNode(
+ node: LexicalNode | null | undefined,
+): node is CollapsibleContentNode {
+ return node instanceof CollapsibleContentNode;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts b/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts
new file mode 100644
index 0000000..0288762
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/CollapsibleTitleNode.ts
@@ -0,0 +1,132 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {
+ $createParagraphNode,
+ $isElementNode,
+ DOMConversionMap,
+ DOMConversionOutput,
+ DOMExportOutput,
+ EditorConfig,
+ ElementNode,
+ LexicalEditor,
+ LexicalNode,
+ RangeSelection,
+ SerializedElementNode,
+} from 'lexical';
+
+import {$isCollapsibleContainerNode} from './CollapsibleContainerNode';
+import {$isCollapsibleContentNode} from './CollapsibleContentNode';
+
+type SerializedCollapsibleTitleNode = SerializedElementNode;
+
+export function convertSummaryElement(
+ domNode: HTMLElement,
+): DOMConversionOutput | null {
+ const node = $createCollapsibleTitleNode();
+ return {
+ node,
+ };
+}
+
+export class CollapsibleTitleNode extends ElementNode {
+ static getType(): string {
+ return 'collapsible-title';
+ }
+
+ static clone(node: CollapsibleTitleNode): CollapsibleTitleNode {
+ return new CollapsibleTitleNode(node.__key);
+ }
+
+ createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
+ const dom = document.createElement('summary');
+ dom.classList.add('Collapsible__title');
+ return dom;
+ }
+
+ updateDOM(prevNode: CollapsibleTitleNode, dom: HTMLElement): boolean {
+ return false;
+ }
+
+ static importDOM(): DOMConversionMap | null {
+ return {
+ summary: (domNode: HTMLElement) => {
+ return {
+ conversion: convertSummaryElement,
+ priority: 1,
+ };
+ },
+ };
+ }
+
+ static importJSON(
+ serializedNode: SerializedCollapsibleTitleNode,
+ ): CollapsibleTitleNode {
+ return $createCollapsibleTitleNode();
+ }
+
+ exportDOM(): DOMExportOutput {
+ const element = document.createElement('summary');
+ return {element};
+ }
+
+ exportJSON(): SerializedCollapsibleTitleNode {
+ return {
+ ...super.exportJSON(),
+ type: 'collapsible-title',
+ version: 1,
+ };
+ }
+
+ collapseAtStart(_selection: RangeSelection): boolean {
+ this.getParentOrThrow().insertBefore(this);
+ return true;
+ }
+
+ insertNewAfter(_: RangeSelection, restoreSelection = true): ElementNode {
+ const containerNode = this.getParentOrThrow();
+
+ if (!$isCollapsibleContainerNode(containerNode)) {
+ throw new Error(
+ 'CollapsibleTitleNode expects to be child of CollapsibleContainerNode',
+ );
+ }
+
+ if (containerNode.getOpen()) {
+ const contentNode = this.getNextSibling();
+ if (!$isCollapsibleContentNode(contentNode)) {
+ throw new Error(
+ 'CollapsibleTitleNode expects to have CollapsibleContentNode sibling',
+ );
+ }
+
+ const firstChild = contentNode.getFirstChild();
+ if ($isElementNode(firstChild)) {
+ return firstChild;
+ } else {
+ const paragraph = $createParagraphNode();
+ contentNode.append(paragraph);
+ return paragraph;
+ }
+ } else {
+ const paragraph = $createParagraphNode();
+ containerNode.insertAfter(paragraph, restoreSelection);
+ return paragraph;
+ }
+ }
+}
+
+export function $createCollapsibleTitleNode(): CollapsibleTitleNode {
+ return new CollapsibleTitleNode();
+}
+
+export function $isCollapsibleTitleNode(
+ node: LexicalNode | null | undefined,
+): node is CollapsibleTitleNode {
+ return node instanceof CollapsibleTitleNode;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/index.ts b/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/index.ts
new file mode 100644
index 0000000..eb3c3f6
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CollapsiblePlugin/index.ts
@@ -0,0 +1,289 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import './Collapsible.css';
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {
+ $findMatchingParent,
+ $insertNodeToNearestRoot,
+ mergeRegister,
+} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $getPreviousSelection,
+ $getSelection,
+ $isElementNode,
+ $isRangeSelection,
+ $setSelection,
+ COMMAND_PRIORITY_LOW,
+ createCommand,
+ DELETE_CHARACTER_COMMAND,
+ ElementNode,
+ INSERT_PARAGRAPH_COMMAND,
+ KEY_ARROW_DOWN_COMMAND,
+ KEY_ARROW_LEFT_COMMAND,
+ KEY_ARROW_RIGHT_COMMAND,
+ KEY_ARROW_UP_COMMAND,
+ LexicalNode,
+ NodeKey,
+} from 'lexical';
+import {useEffect} from 'react';
+
+import {
+ $createCollapsibleContainerNode,
+ $isCollapsibleContainerNode,
+ CollapsibleContainerNode,
+} from './CollapsibleContainerNode';
+import {
+ $createCollapsibleContentNode,
+ $isCollapsibleContentNode,
+ CollapsibleContentNode,
+} from './CollapsibleContentNode';
+import {
+ $createCollapsibleTitleNode,
+ $isCollapsibleTitleNode,
+ CollapsibleTitleNode,
+} from './CollapsibleTitleNode';
+
+export const INSERT_COLLAPSIBLE_COMMAND = createCommand();
+export const TOGGLE_COLLAPSIBLE_COMMAND = createCommand();
+
+export default function CollapsiblePlugin(): null {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ if (
+ !editor.hasNodes([
+ CollapsibleContainerNode,
+ CollapsibleTitleNode,
+ CollapsibleContentNode,
+ ])
+ ) {
+ throw new Error(
+ 'CollapsiblePlugin: CollapsibleContainerNode, CollapsibleTitleNode, or CollapsibleContentNode not registered on editor',
+ );
+ }
+
+ const onEscapeUp = () => {
+ const selection = $getSelection();
+ if (
+ $isRangeSelection(selection) &&
+ selection.isCollapsed() &&
+ selection.anchor.offset === 0
+ ) {
+ const container = $findMatchingParent(
+ selection.anchor.getNode(),
+ $isCollapsibleContainerNode,
+ );
+
+ if ($isCollapsibleContainerNode(container)) {
+ const parent = container.getParent();
+ if (
+ parent !== null &&
+ parent.getFirstChild() === container &&
+ selection.anchor.key ===
+ container.getFirstDescendant()?.getKey()
+ ) {
+ container.insertBefore($createParagraphNode());
+ }
+ }
+ }
+
+ return false;
+ };
+
+ const onEscapeDown = () => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
+ const container = $findMatchingParent(
+ selection.anchor.getNode(),
+ $isCollapsibleContainerNode,
+ );
+
+ if ($isCollapsibleContainerNode(container)) {
+ const parent = container.getParent();
+ if (
+ parent !== null &&
+ parent.getLastChild() === container
+ ) {
+ const lastDescendant = container.getLastDescendant();
+ if (
+ lastDescendant !== null &&
+ selection.anchor.key === lastDescendant.getKey() &&
+ selection.anchor.offset === lastDescendant.getTextContentSize()
+ ) {
+ container.insertAfter($createParagraphNode());
+ }
+ }
+ }
+ }
+
+ return false;
+ };
+
+ return mergeRegister(
+ // Structure enforcing transformers for each node type. In case nesting structure is not
+ // "Container > Title + Content" it'll unwrap nodes and convert it back
+ // to regular content.
+ editor.registerNodeTransform(CollapsibleContentNode, (node) => {
+ const parent = node.getParent();
+ if (!$isCollapsibleContainerNode(parent)) {
+ const children = node.getChildren();
+ for (const child of children) {
+ node.insertBefore(child);
+ }
+ node.remove();
+ }
+ }),
+
+ editor.registerNodeTransform(CollapsibleTitleNode, (node) => {
+ const parent = node.getParent();
+ if (!$isCollapsibleContainerNode(parent)) {
+ node.replace(
+ $createParagraphNode().append(...node.getChildren()),
+ );
+ return;
+ }
+ }),
+
+ editor.registerNodeTransform(CollapsibleContainerNode, (node) => {
+ const children = node.getChildren();
+ if (
+ children.length !== 2 ||
+ !$isCollapsibleTitleNode(children[0]) ||
+ !$isCollapsibleContentNode(children[1])
+ ) {
+ for (const child of children) {
+ node.insertBefore(child);
+ }
+ node.remove();
+ }
+ }),
+
+ // This handles the case when container is collapsed and we delete its previous sibling
+ // into it, it would cause collapsed content deleted (since it's display: none, and selection
+ // swallows it when deletes single char). Instead we expand container, which is although
+ // not perfect, but avoids bigger problem
+ editor.registerCommand(
+ DELETE_CHARACTER_COMMAND,
+ () => {
+ const selection = $getSelection();
+ if (
+ !$isRangeSelection(selection) ||
+ !selection.isCollapsed() ||
+ selection.anchor.offset !== 0
+ ) {
+ return false;
+ }
+
+ const anchorNode = selection.anchor.getNode();
+ const topLevelElement = anchorNode.getTopLevelElement();
+ if (topLevelElement === null) {
+ return false;
+ }
+
+ const container = topLevelElement.getPreviousSibling();
+ if (!$isCollapsibleContainerNode(container) || container.getOpen()) {
+ return false;
+ }
+
+ container.setOpen(true);
+ return true;
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+
+ // When collapsible is the last child pressing down/right arrow will insert paragraph
+ // below it to allow adding more content. It's similar what $insertBlockNode
+ // (mainly for decorators), except it'll always be possible to continue adding
+ // new content even if trailing paragraph is accidentally deleted
+ editor.registerCommand(
+ KEY_ARROW_DOWN_COMMAND,
+ onEscapeDown,
+ COMMAND_PRIORITY_LOW,
+ ),
+
+ editor.registerCommand(
+ KEY_ARROW_RIGHT_COMMAND,
+ onEscapeDown,
+ COMMAND_PRIORITY_LOW,
+ ),
+
+ // When collapsible is the first child pressing up/left arrow will insert paragraph
+ // above it to allow adding more content. It's similar what $insertBlockNode
+ // (mainly for decorators), except it'll always be possible to continue adding
+ // new content even if leading paragraph is accidentally deleted
+ editor.registerCommand(
+ KEY_ARROW_UP_COMMAND,
+ onEscapeUp,
+ COMMAND_PRIORITY_LOW,
+ ),
+
+ editor.registerCommand(
+ KEY_ARROW_LEFT_COMMAND,
+ onEscapeUp,
+ COMMAND_PRIORITY_LOW,
+ ),
+
+ // Handling CMD+Enter to toggle collapsible element collapsed state
+ editor.registerCommand(
+ INSERT_PARAGRAPH_COMMAND,
+ () => {
+ // @ts-ignore
+ const windowEvent: KeyboardEvent | undefined = editor._window?.event;
+
+ if (
+ windowEvent &&
+ (windowEvent.ctrlKey || windowEvent.metaKey) &&
+ windowEvent.key === 'Enter'
+ ) {
+ const selection = $getPreviousSelection();
+ if ($isRangeSelection(selection) && selection.isCollapsed()) {
+ const parent = $findMatchingParent(
+ selection.anchor.getNode(),
+ (node) => $isElementNode(node) && !node.isInline(),
+ );
+
+ if ($isCollapsibleTitleNode(parent)) {
+ const container = parent.getParent();
+ if ($isCollapsibleContainerNode(container)) {
+ container.toggleOpen();
+ $setSelection(selection.clone());
+ return true;
+ }
+ }
+ }
+ }
+
+ return false;
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ editor.registerCommand(
+ INSERT_COLLAPSIBLE_COMMAND,
+ () => {
+ editor.update(() => {
+ const title = $createCollapsibleTitleNode();
+ $insertNodeToNearestRoot(
+ $createCollapsibleContainerNode(true).append(
+ title.append($createParagraphNode()),
+ $createCollapsibleContentNode().append($createParagraphNode()),
+ ),
+ );
+ title.select();
+ });
+ return true;
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ );
+ }, [editor]);
+
+ return null;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CommentPlugin/index.css b/cyynote-frontend/src/components/lexical/plugins/CommentPlugin/index.css
new file mode 100644
index 0000000..54ad6f3
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CommentPlugin/index.css
@@ -0,0 +1,445 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ *
+ */
+
+.CommentPlugin_AddCommentBox {
+ display: block;
+ position: fixed;
+ border-radius: 20px;
+ background-color: white;
+ width: 40px;
+ height: 60px;
+ box-shadow: 0 0 3px rgba(0, 0, 0, 0.2);
+ z-index: 10;
+}
+
+.CommentPlugin_AddCommentBox_button {
+ border-radius: 20px;
+ border: 0;
+ background: none;
+ width: 40px;
+ height: 60px;
+ position: absolute;
+ top: 0;
+ left: 0;
+ cursor: pointer;
+}
+
+.CommentPlugin_AddCommentBox_button:hover {
+ background-color: #f6f6f6;
+}
+
+i.add-comment {
+ background-size: contain;
+ display: inline-block;
+ height: 20px;
+ width: 20px;
+ vertical-align: -10px;
+ background-image: url(../../images/icons/chat-left-text.svg);
+}
+
+@media (max-width: 1024px) {
+ .CommentPlugin_AddCommentBox {
+ display: none;
+ }
+}
+
+.CommentPlugin_CommentInputBox {
+ display: block;
+ position: absolute;
+ width: 250px;
+ min-height: 80px;
+ background-color: #fff;
+ box-shadow: 0 0 5px 0 rgba(0, 0, 0, 0.1);
+ border-radius: 5px;
+ z-index: 24;
+ animation: show-input-box 0.4s ease;
+}
+
+.CommentPlugin_CommentInputBox::before {
+ content: '';
+ position: absolute;
+ width: 0;
+ height: 0;
+ margin-left: 0.5em;
+ right: -1em;
+ top: 0;
+ left: calc(50% + 0.25em);
+ box-sizing: border-box;
+ border: 0.5em solid black;
+ border-color: transparent transparent #fff #fff;
+ transform-origin: 0 0;
+ transform: rotate(135deg);
+ box-shadow: -3px 3px 3px 0 rgba(0, 0, 0, 0.05);
+}
+
+@keyframes show-input-box {
+ 0% {
+ opacity: 0;
+ transform: translateY(50px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+.CommentPlugin_CommentInputBox_Buttons {
+ display: flex;
+ flex-direction: row;
+ padding: 0 10px 10px 10px;
+ gap: 10px;
+}
+
+.CommentPlugin_CommentInputBox_Button {
+ flex: 1;
+}
+
+.CommentPlugin_CommentInputBox_Button.primary {
+ background-color: rgb(66, 135, 245);
+ font-weight: bold;
+ color: #fff;
+}
+
+.CommentPlugin_CommentInputBox_Button.primary:hover {
+ background-color: rgb(53, 114, 211);
+}
+
+.CommentPlugin_CommentInputBox_Button[disabled] {
+ background-color: #eee;
+ opacity: 0.5;
+ cursor: not-allowed;
+ font-weight: normal;
+ color: #444;
+}
+
+.CommentPlugin_CommentInputBox_Button[disabled]:hover {
+ opacity: 0.5;
+ background-color: #eee;
+}
+
+.CommentPlugin_CommentInputBox_EditorContainer {
+ position: relative;
+ margin: 10px;
+ border-radius: 5px;
+}
+
+.CommentPlugin_CommentInputBox_Editor {
+ position: relative;
+ border: 1px solid #ccc;
+ background-color: #fff;
+ border-radius: 5px;
+ font-size: 15px;
+ caret-color: rgb(5, 5, 5);
+ display: block;
+ padding: 9px 10px 10px 9px;
+ min-height: 80px;
+}
+
+.CommentPlugin_CommentInputBox_Editor:focus {
+ outline: 1px solid rgb(66, 135, 245);
+}
+
+.CommentPlugin_ShowCommentsButton {
+ position: fixed;
+ /*top: 10px;*/
+ bottom: 10px;
+ right: 10px;
+ background-color: #ddd;
+ border-radius: 10px;
+}
+
+i.comments {
+ background-size: contain;
+ display: inline-block;
+ height: 20px;
+ width: 20px;
+ vertical-align: -10px;
+ background-image: url(../../images/icons/comments.svg);
+ opacity: 0.5;
+ transition: opacity 0.2s linear;
+}
+
+@media (max-width: 1024px) {
+ .CommentPlugin_ShowCommentsButton {
+ display: none;
+ }
+}
+
+.CommentPlugin_ShowCommentsButton:hover i.comments {
+ opacity: 1;
+}
+
+.CommentPlugin_ShowCommentsButton.active {
+ background-color: #ccc;
+}
+
+.CommentPlugin_CommentsPanel {
+ position: fixed;
+ right: 0;
+ width: 300px;
+ height: calc(100% - 88px);
+ /*top: 88px;*/
+ bottom: 60px;
+ background-color: #fff;
+ border-top-left-radius: 10px;
+ box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
+ animation: show-comments 0.2s ease;
+ z-index: 25;
+}
+
+@keyframes show-comments {
+ 0% {
+ opacity: 0;
+ transform: translateX(300px);
+ }
+ 100% {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.CommentPlugin_CommentsPanel_Heading {
+ padding-left: 15px;
+ padding-top: 10px;
+ margin: 0;
+ height: 34px;
+ border-bottom: 1px solid #eee;
+ font-size: 20px;
+ display: block;
+ width: 100%;
+ color: #444;
+ overflow: hidden;
+}
+
+.CommentPlugin_CommentsPanel_Editor {
+ position: relative;
+ border: 1px solid #ccc;
+ background-color: #fff;
+ border-radius: 5px;
+ font-size: 15px;
+ caret-color: rgb(5, 5, 5);
+ display: block;
+ padding: 9px 10px 10px 9px;
+ min-height: 20px;
+}
+
+.CommentPlugin_CommentsPanel_Editor::before {
+ content: '';
+ width: 30px;
+ height: 20px;
+ float: right;
+}
+
+.CommentPlugin_CommentsPanel_SendButton {
+ position: absolute;
+ right: 10px;
+ top: 8px;
+ background: none;
+}
+
+.CommentPlugin_CommentsPanel_SendButton:hover {
+ background: none;
+}
+
+i.send {
+ background-size: contain;
+ display: inline-block;
+ height: 20px;
+ width: 20px;
+ vertical-align: -10px;
+ background-image: url(../../images/icons/send.svg);
+ opacity: 0.5;
+ transition: opacity 0.2s linear;
+}
+
+.CommentPlugin_CommentsPanel_SendButton:hover i.send {
+ opacity: 1;
+ filter: invert(45%) sepia(98%) saturate(2299%) hue-rotate(201deg)
+ brightness(100%) contrast(92%);
+}
+
+.CommentPlugin_CommentsPanel_SendButton[disabled] i.send {
+ opacity: 0.3;
+}
+
+.CommentPlugin_CommentsPanel_SendButton:hover[disabled] i.send {
+ opacity: 0.3;
+ filter: none;
+}
+
+.CommentPlugin_CommentsPanel_Empty {
+ color: #777;
+ font-size: 15px;
+ text-align: center;
+ position: absolute;
+ top: calc(50% - 15px);
+ margin: 0;
+ padding: 0;
+ width: 100%;
+}
+
+.CommentPlugin_CommentsPanel_List {
+ padding: 0;
+ list-style-type: none;
+ margin: 0;
+ padding: 0;
+ width: 100%;
+ position: absolute;
+ top: 45px;
+ overflow-y: auto;
+ height: calc(100% - 45px);
+}
+
+.CommentPlugin_CommentsPanel_List_Comment {
+ padding: 15px 0 15px 15px;
+ margin: 0;
+ font-size: 14px;
+ position: relative;
+ transition: all 0.2s linear;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread.active
+ .CommentPlugin_CommentsPanel_List_Comment:hover {
+ background-color: inherit;
+}
+
+.CommentPlugin_CommentsPanel_List_Comment p {
+ margin: 0;
+ color: #444;
+}
+
+.CommentPlugin_CommentsPanel_List_Details {
+ color: #444;
+ padding-bottom: 5px;
+ vertical-align: top;
+}
+
+.CommentPlugin_CommentsPanel_List_Comment_Author {
+ font-weight: bold;
+ padding-right: 5px;
+}
+
+.CommentPlugin_CommentsPanel_List_Comment_Time {
+ color: #999;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread {
+ padding: 0 0 0 0;
+ margin: 0;
+ border-top: 1px solid #eee;
+ border-bottom: 1px solid #eee;
+ position: relative;
+ transition: all 0.2s linear;
+ border-left: 0 solid #eee;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread:first-child,
+.CommentPlugin_CommentsPanel_List_Thread
+ + .CommentPlugin_CommentsPanel_List_Thread {
+ border-top: none;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread.interactive {
+ cursor: pointer;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread.interactive:hover {
+ background-color: #fafafa;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread.active {
+ background-color: #fafafa;
+ border-left: 15px solid #eee;
+ cursor: inherit;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread_QuoteBox {
+ padding-top: 10px;
+ color: #ccc;
+ display: block;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread_Quote {
+ margin: 0px 10px 0 10px;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread_Quote span {
+ color: #222;
+ background-color: rgba(255, 212, 0, 0.4);
+ padding: 1px;
+ line-height: 1.4;
+ display: inline;
+ font-weight: bold;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread_Comments {
+ padding-left: 10px;
+ list-style-type: none;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread_Comments
+ .CommentPlugin_CommentsPanel_List_Comment:first-child {
+ border: none;
+ margin-left: 0;
+ padding-left: 5px;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread_Comments
+ .CommentPlugin_CommentsPanel_List_Comment:first-child.CommentPlugin_CommentsPanel_List_Comment:last-child {
+ padding-bottom: 5px;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread_Comments
+ .CommentPlugin_CommentsPanel_List_Comment {
+ padding-left: 10px;
+ border-left: 5px solid #eee;
+ margin-left: 5px;
+}
+
+.CommentPlugin_CommentsPanel_List_Thread_Editor {
+ position: relative;
+ padding-top: 1px;
+}
+
+.CommentPlugin_CommentsPanel_List_DeleteButton {
+ position: absolute;
+ top: 10px;
+ right: 10px;
+ width: 30px;
+ height: 30px;
+ background-color: transparent;
+ opacity: 0;
+}
+
+.CommentPlugin_CommentsPanel_DeletedComment,
+.CommentPlugin_CommentsPanel_List_Comment:hover
+ .CommentPlugin_CommentsPanel_List_DeleteButton,
+.CommentPlugin_CommentsPanel_List_Thread_QuoteBox:hover
+ .CommentPlugin_CommentsPanel_List_DeleteButton {
+ opacity: 0.5;
+}
+
+.CommentPlugin_CommentsPanel_List_DeleteButton:hover {
+ background-color: transparent;
+ opacity: 1;
+ filter: invert(45%) sepia(98%) saturate(2299%) hue-rotate(201deg)
+ brightness(100%) contrast(92%);
+}
+
+.CommentPlugin_CommentsPanel_List_DeleteButton i.delete {
+ background-size: contain;
+ position: absolute;
+ left: 5px;
+ top: 5px;
+ height: 15px;
+ width: 15px;
+ vertical-align: -10px;
+ background-image: url(../../images/icons/trash3.svg);
+ transition: opacity 0.2s linear;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/CommentPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/CommentPlugin/index.tsx
new file mode 100644
index 0000000..0ffc727
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/CommentPlugin/index.tsx
@@ -0,0 +1,980 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type {Provider} from '@lexical/yjs';
+import type {
+ EditorState,
+ LexicalCommand,
+ LexicalEditor,
+ NodeKey,
+ RangeSelection,
+} from 'lexical';
+import type {Doc} from 'yjs';
+
+import './index.css';
+
+import {
+ $createMarkNode,
+ $getMarkIDs,
+ $isMarkNode,
+ $unwrapMarkNode,
+ $wrapSelectionInMarkNode,
+ MarkNode,
+} from '@lexical/mark';
+import {AutoFocusPlugin} from '@lexical/react/LexicalAutoFocusPlugin';
+import {ClearEditorPlugin} from '@lexical/react/LexicalClearEditorPlugin';
+import {useCollaborationContext} from '@lexical/react/LexicalCollaborationContext';
+import {LexicalComposer} from '@lexical/react/LexicalComposer';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {EditorRefPlugin} from '@lexical/react/LexicalEditorRefPlugin';
+import LexicalErrorBoundary from '@lexical/react/LexicalErrorBoundary';
+import {HistoryPlugin} from '@lexical/react/LexicalHistoryPlugin';
+import {OnChangePlugin} from '@lexical/react/LexicalOnChangePlugin';
+import {PlainTextPlugin} from '@lexical/react/LexicalPlainTextPlugin';
+import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection';
+import {$isRootTextContentEmpty, $rootTextContent} from '@lexical/text';
+import {mergeRegister, registerNestedElementResolver} from '@lexical/utils';
+import {
+ $getNodeByKey,
+ $getSelection,
+ $isRangeSelection,
+ $isTextNode,
+ CLEAR_EDITOR_COMMAND,
+ COMMAND_PRIORITY_EDITOR,
+ createCommand,
+ KEY_ESCAPE_COMMAND,
+} from 'lexical';
+import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import * as React from 'react';
+import {createPortal} from 'react-dom';
+import useLayoutEffect from '../../shared/src/useLayoutEffect';
+
+import {
+ Comment,
+ Comments,
+ CommentStore,
+ createComment,
+ createThread,
+ Thread,
+ useCommentStore,
+} from '../../commenting';
+import useModal from '../../hooks/useModal';
+import CommentEditorTheme from '../../themes/CommentEditorTheme';
+import Button from '../../ui/Button';
+import ContentEditable from '../../ui/ContentEditable';
+import Placeholder from '../../ui/Placeholder';
+
+export const INSERT_INLINE_COMMAND: LexicalCommand = createCommand(
+ 'INSERT_INLINE_COMMAND',
+);
+
+function AddCommentBox({
+ anchorKey,
+ editor,
+ onAddComment,
+}: {
+ anchorKey: NodeKey;
+ editor: LexicalEditor;
+ onAddComment: () => void;
+}): JSX.Element {
+ const boxRef = useRef(null);
+
+ const updatePosition = useCallback(() => {
+ const boxElem = boxRef.current;
+ const rootElement = editor.getRootElement();
+ const anchorElement = editor.getElementByKey(anchorKey);
+
+ if (boxElem !== null && rootElement !== null && anchorElement !== null) {
+ const {right} = rootElement.getBoundingClientRect();
+ const {top} = anchorElement.getBoundingClientRect();
+ boxElem.style.left = `${right - 20}px`;
+ boxElem.style.top = `${top - 30}px`;
+ }
+ }, [anchorKey, editor]);
+
+ useEffect(() => {
+ window.addEventListener('resize', updatePosition);
+
+ return () => {
+ window.removeEventListener('resize', updatePosition);
+ };
+ }, [editor, updatePosition]);
+
+ useLayoutEffect(() => {
+ updatePosition();
+ }, [anchorKey, editor, updatePosition]);
+
+ return (
+
+
+
+
+
+ );
+}
+
+function EscapeHandlerPlugin({
+ onEscape,
+}: {
+ onEscape: (e: KeyboardEvent) => boolean;
+}): null {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ return editor.registerCommand(
+ KEY_ESCAPE_COMMAND,
+ (event: KeyboardEvent) => {
+ return onEscape(event);
+ },
+ 2,
+ );
+ }, [editor, onEscape]);
+
+ return null;
+}
+
+function PlainTextEditor({
+ className,
+ autoFocus,
+ onEscape,
+ onChange,
+ editorRef,
+ placeholder = 'Type a comment...',
+}: {
+ autoFocus?: boolean;
+ className?: string;
+ editorRef?: {current: null | LexicalEditor};
+ onChange: (editorState: EditorState, editor: LexicalEditor) => void;
+ onEscape: (e: KeyboardEvent) => boolean;
+ placeholder?: string;
+}) {
+ const initialConfig = {
+ namespace: 'Commenting',
+ nodes: [],
+ onError: (error: Error) => {
+ throw error;
+ },
+ theme: CommentEditorTheme,
+ };
+
+ return (
+
+
+
}
+ placeholder={
{placeholder} }
+ ErrorBoundary={LexicalErrorBoundary}
+ />
+
+
+ {autoFocus !== false &&
}
+
+
+ {editorRef !== undefined &&
}
+
+
+ );
+}
+
+function useOnChange(
+ setContent: (text: string) => void,
+ setCanSubmit: (canSubmit: boolean) => void,
+) {
+ return useCallback(
+ (editorState: EditorState, _editor: LexicalEditor) => {
+ editorState.read(() => {
+ setContent($rootTextContent());
+ setCanSubmit(!$isRootTextContentEmpty(_editor.isComposing(), true));
+ });
+ },
+ [setCanSubmit, setContent],
+ );
+}
+
+function CommentInputBox({
+ editor,
+ cancelAddComment,
+ submitAddComment,
+}: {
+ cancelAddComment: () => void;
+ editor: LexicalEditor;
+ submitAddComment: (
+ commentOrThread: Comment | Thread,
+ isInlineComment: boolean,
+ thread?: Thread,
+ selection?: RangeSelection | null,
+ ) => void;
+}) {
+ const [content, setContent] = useState('');
+ const [canSubmit, setCanSubmit] = useState(false);
+ const boxRef = useRef(null);
+ const selectionState = useMemo(
+ () => ({
+ container: document.createElement('div'),
+ elements: [],
+ }),
+ [],
+ );
+ const selectionRef = useRef(null);
+ const author = useCollabAuthorName();
+
+ const updateLocation = useCallback(() => {
+ editor.getEditorState().read(() => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ selectionRef.current = selection.clone();
+ const anchor = selection.anchor;
+ const focus = selection.focus;
+ const range = createDOMRange(
+ editor,
+ anchor.getNode(),
+ anchor.offset,
+ focus.getNode(),
+ focus.offset,
+ );
+ const boxElem = boxRef.current;
+ if (range !== null && boxElem !== null) {
+ const {left, bottom, width} = range.getBoundingClientRect();
+ const selectionRects = createRectsFromDOMRange(editor, range);
+ let correctedLeft =
+ selectionRects.length === 1 ? left + width / 2 - 125 : left - 125;
+ if (correctedLeft < 10) {
+ correctedLeft = 10;
+ }
+ boxElem.style.left = `${correctedLeft}px`;
+ boxElem.style.top = `${
+ bottom +
+ 20 +
+ (window.pageYOffset || document.documentElement.scrollTop)
+ }px`;
+ const selectionRectsLength = selectionRects.length;
+ const {container} = selectionState;
+ const elements: Array = selectionState.elements;
+ const elementsLength = elements.length;
+
+ for (let i = 0; i < selectionRectsLength; i++) {
+ const selectionRect = selectionRects[i];
+ let elem: HTMLSpanElement = elements[i];
+ if (elem === undefined) {
+ elem = document.createElement('span');
+ elements[i] = elem;
+ container.appendChild(elem);
+ }
+ const color = '255, 212, 0';
+ const style = `position:absolute;top:${
+ selectionRect.top +
+ (window.pageYOffset || document.documentElement.scrollTop)
+ }px;left:${selectionRect.left}px;height:${
+ selectionRect.height
+ }px;width:${
+ selectionRect.width
+ }px;background-color:rgba(${color}, 0.3);pointer-events:none;z-index:5;`;
+ elem.style.cssText = style;
+ }
+ for (let i = elementsLength - 1; i >= selectionRectsLength; i--) {
+ const elem = elements[i];
+ container.removeChild(elem);
+ elements.pop();
+ }
+ }
+ }
+ });
+ }, [editor, selectionState]);
+
+ useLayoutEffect(() => {
+ updateLocation();
+ const container = selectionState.container;
+ const body = document.body;
+ if (body !== null) {
+ body.appendChild(container);
+ return () => {
+ body.removeChild(container);
+ };
+ }
+ }, [selectionState.container, updateLocation]);
+
+ useEffect(() => {
+ window.addEventListener('resize', updateLocation);
+
+ return () => {
+ window.removeEventListener('resize', updateLocation);
+ };
+ }, [updateLocation]);
+
+ const onEscape = (event: KeyboardEvent): boolean => {
+ event.preventDefault();
+ cancelAddComment();
+ return true;
+ };
+
+ const submitComment = () => {
+ if (canSubmit) {
+ let quote = editor.getEditorState().read(() => {
+ const selection = selectionRef.current;
+ return selection ? selection.getTextContent() : '';
+ });
+ if (quote.length > 100) {
+ quote = quote.slice(0, 99) + '…';
+ }
+ submitAddComment(
+ createThread(quote, [createComment(content, author)]),
+ true,
+ undefined,
+ selectionRef.current,
+ );
+ selectionRef.current = null;
+ }
+ };
+
+ const onChange = useOnChange(setContent, setCanSubmit);
+
+ return (
+
+
+
+
+ Cancel
+
+
+ Comment
+
+
+
+ );
+}
+
+function CommentsComposer({
+ submitAddComment,
+ thread,
+ placeholder,
+}: {
+ placeholder?: string;
+ submitAddComment: (
+ commentOrThread: Comment,
+ isInlineComment: boolean,
+ // eslint-disable-next-line no-shadow
+ thread?: Thread,
+ ) => void;
+ thread?: Thread;
+}) {
+ const [content, setContent] = useState('');
+ const [canSubmit, setCanSubmit] = useState(false);
+ const editorRef = useRef(null);
+ const author = useCollabAuthorName();
+
+ const onChange = useOnChange(setContent, setCanSubmit);
+
+ const submitComment = () => {
+ if (canSubmit) {
+ submitAddComment(createComment(content, author), false, thread);
+ const editor = editorRef.current;
+ if (editor !== null) {
+ editor.dispatchCommand(CLEAR_EDITOR_COMMAND, undefined);
+ }
+ }
+ };
+
+ return (
+ <>
+ {
+ return true;
+ }}
+ onChange={onChange}
+ editorRef={editorRef}
+ placeholder={placeholder}
+ />
+
+
+
+ >
+ );
+}
+
+function ShowDeleteCommentOrThreadDialog({
+ commentOrThread,
+ deleteCommentOrThread,
+ onClose,
+ thread = undefined,
+}: {
+ commentOrThread: Comment | Thread;
+
+ deleteCommentOrThread: (
+ comment: Comment | Thread,
+ // eslint-disable-next-line no-shadow
+ thread?: Thread,
+ ) => void;
+ onClose: () => void;
+ thread?: Thread;
+}): JSX.Element {
+ return (
+ <>
+ Are you sure you want to delete this {commentOrThread.type}?
+
+ {
+ deleteCommentOrThread(commentOrThread, thread);
+ onClose();
+ }}>
+ Delete
+ {' '}
+ {
+ onClose();
+ }}>
+ Cancel
+
+
+ >
+ );
+}
+
+function CommentsPanelListComment({
+ comment,
+ deleteComment,
+ thread,
+ rtf,
+}: {
+ comment: Comment;
+ deleteComment: (
+ commentOrThread: Comment | Thread,
+ // eslint-disable-next-line no-shadow
+ thread?: Thread,
+ ) => void;
+ rtf: Intl.RelativeTimeFormat;
+ thread?: Thread;
+}): JSX.Element {
+ const seconds = Math.round((comment.timeStamp - performance.now()) / 1000);
+ const minutes = Math.round(seconds / 60);
+ const [modal, showModal] = useModal();
+
+ return (
+
+
+
+ {comment.author}
+
+
+ · {seconds > -10 ? 'Just now' : rtf.format(minutes, 'minute')}
+
+
+
+ {comment.content}
+
+ {!comment.deleted && (
+ <>
+ {
+ showModal('Delete Comment', (onClose) => (
+
+ ));
+ }}
+ className="CommentPlugin_CommentsPanel_List_DeleteButton">
+
+
+ {modal}
+ >
+ )}
+
+ );
+}
+
+function CommentsPanelList({
+ activeIDs,
+ comments,
+ deleteCommentOrThread,
+ listRef,
+ submitAddComment,
+ markNodeMap,
+}: {
+ activeIDs: Array;
+ comments: Comments;
+ deleteCommentOrThread: (
+ commentOrThread: Comment | Thread,
+ thread?: Thread,
+ ) => void;
+ listRef: {current: null | HTMLUListElement};
+ markNodeMap: Map>;
+ submitAddComment: (
+ commentOrThread: Comment | Thread,
+ isInlineComment: boolean,
+ thread?: Thread,
+ ) => void;
+}): JSX.Element {
+ const [editor] = useLexicalComposerContext();
+ const [counter, setCounter] = useState(0);
+ const [modal, showModal] = useModal();
+ const rtf = useMemo(
+ () =>
+ new Intl.RelativeTimeFormat('en', {
+ localeMatcher: 'best fit',
+ numeric: 'auto',
+ style: 'short',
+ }),
+ [],
+ );
+
+ useEffect(() => {
+ // Used to keep the time stamp up to date
+ const id = setTimeout(() => {
+ setCounter(counter + 1);
+ }, 10000);
+
+ return () => {
+ clearTimeout(id);
+ };
+ }, [counter]);
+
+ return (
+
+ {comments.map((commentOrThread) => {
+ const id = commentOrThread.id;
+ if (commentOrThread.type === 'thread') {
+ const handleClickThread = () => {
+ const markNodeKeys = markNodeMap.get(id);
+ if (
+ markNodeKeys !== undefined &&
+ (activeIDs === null || activeIDs.indexOf(id) === -1)
+ ) {
+ const activeElement = document.activeElement;
+ // Move selection to the start of the mark, so that we
+ // update the UI with the selected thread.
+ editor.update(
+ () => {
+ const markNodeKey = Array.from(markNodeKeys)[0];
+ const markNode = $getNodeByKey(markNodeKey);
+ if ($isMarkNode(markNode)) {
+ markNode.selectStart();
+ }
+ },
+ {
+ onUpdate() {
+ // Restore selection to the previous element
+ if (activeElement !== null) {
+ (activeElement as HTMLElement).focus();
+ }
+ },
+ },
+ );
+ }
+ };
+
+ return (
+ // eslint-disable-next-line jsx-a11y/no-noninteractive-element-interactions
+
+
+
+ {'> '}
+ {commentOrThread.quote}
+
+ {/* INTRODUCE DELETE THREAD HERE*/}
+
{
+ showModal('Delete Thread', (onClose) => (
+
+ ));
+ }}
+ className="CommentPlugin_CommentsPanel_List_DeleteButton">
+
+
+ {modal}
+
+
+ {commentOrThread.comments.map((comment) => (
+
+ ))}
+
+
+
+
+
+ );
+ }
+ return (
+
+ );
+ })}
+
+ );
+}
+
+function CommentsPanel({
+ activeIDs,
+ deleteCommentOrThread,
+ comments,
+ submitAddComment,
+ markNodeMap,
+}: {
+ activeIDs: Array;
+ comments: Comments;
+ deleteCommentOrThread: (
+ commentOrThread: Comment | Thread,
+ thread?: Thread,
+ ) => void;
+ markNodeMap: Map>;
+ submitAddComment: (
+ commentOrThread: Comment | Thread,
+ isInlineComment: boolean,
+ thread?: Thread,
+ ) => void;
+}): JSX.Element {
+ const listRef = useRef(null);
+ const isEmpty = comments.length === 0;
+
+ return (
+
+
Comments
+ {isEmpty ? (
+
No Comments
+ ) : (
+
+ )}
+
+ );
+}
+
+function useCollabAuthorName(): string {
+ const collabContext = useCollaborationContext();
+ const {yjsDocMap, name} = collabContext;
+ return yjsDocMap.has('comments') ? name : 'Playground User';
+}
+
+export default function CommentPlugin({
+ providerFactory,
+}: {
+ providerFactory?: (id: string, yjsDocMap: Map) => Provider;
+}): JSX.Element {
+ const collabContext = useCollaborationContext();
+ const [editor] = useLexicalComposerContext();
+ const commentStore = useMemo(() => new CommentStore(editor), [editor]);
+ const comments = useCommentStore(commentStore);
+ const markNodeMap = useMemo>>(() => {
+ return new Map();
+ }, []);
+ const [activeAnchorKey, setActiveAnchorKey] = useState();
+ const [activeIDs, setActiveIDs] = useState>([]);
+ const [showCommentInput, setShowCommentInput] = useState(false);
+ const [showComments, setShowComments] = useState(false);
+ const {yjsDocMap} = collabContext;
+
+ useEffect(() => {
+ if (providerFactory) {
+ const provider = providerFactory('comments', yjsDocMap);
+ return commentStore.registerCollaboration(provider);
+ }
+ }, [commentStore, providerFactory, yjsDocMap]);
+
+ const cancelAddComment = useCallback(() => {
+ editor.update(() => {
+ const selection = $getSelection();
+ // Restore selection
+ if (selection !== null) {
+ selection.dirty = true;
+ }
+ });
+ setShowCommentInput(false);
+ }, [editor]);
+
+ const deleteCommentOrThread = useCallback(
+ (comment: Comment | Thread, thread?: Thread) => {
+ if (comment.type === 'comment') {
+ const deletionInfo = commentStore.deleteCommentOrThread(
+ comment,
+ thread,
+ );
+ if (!deletionInfo) return;
+ const {markedComment, index} = deletionInfo;
+ commentStore.addComment(markedComment, thread, index);
+ } else {
+ commentStore.deleteCommentOrThread(comment);
+ // Remove ids from associated marks
+ const id = thread !== undefined ? thread.id : comment.id;
+ const markNodeKeys = markNodeMap.get(id);
+ if (markNodeKeys !== undefined) {
+ // Do async to avoid causing a React infinite loop
+ setTimeout(() => {
+ editor.update(() => {
+ for (const key of markNodeKeys) {
+ const node: null | MarkNode = $getNodeByKey(key);
+ if ($isMarkNode(node)) {
+ node.deleteID(id);
+ if (node.getIDs().length === 0) {
+ $unwrapMarkNode(node);
+ }
+ }
+ }
+ });
+ });
+ }
+ }
+ },
+ [commentStore, editor, markNodeMap],
+ );
+
+ const submitAddComment = useCallback(
+ (
+ commentOrThread: Comment | Thread,
+ isInlineComment: boolean,
+ thread?: Thread,
+ selection?: RangeSelection | null,
+ ) => {
+ commentStore.addComment(commentOrThread, thread);
+ if (isInlineComment) {
+ editor.update(() => {
+ if ($isRangeSelection(selection)) {
+ const isBackward = selection.isBackward();
+ const id = commentOrThread.id;
+
+ // Wrap content in a MarkNode
+ $wrapSelectionInMarkNode(selection, isBackward, id);
+ }
+ });
+ setShowCommentInput(false);
+ }
+ },
+ [commentStore, editor],
+ );
+
+ useEffect(() => {
+ const changedElems: Array = [];
+ for (let i = 0; i < activeIDs.length; i++) {
+ const id = activeIDs[i];
+ const keys = markNodeMap.get(id);
+ if (keys !== undefined) {
+ for (const key of keys) {
+ const elem = editor.getElementByKey(key);
+ if (elem !== null) {
+ elem.classList.add('selected');
+ changedElems.push(elem);
+ setShowComments(true);
+ }
+ }
+ }
+ }
+ return () => {
+ for (let i = 0; i < changedElems.length; i++) {
+ const changedElem = changedElems[i];
+ changedElem.classList.remove('selected');
+ }
+ };
+ }, [activeIDs, editor, markNodeMap]);
+
+ useEffect(() => {
+ const markNodeKeysToIDs: Map> = new Map();
+
+ return mergeRegister(
+ registerNestedElementResolver(
+ editor,
+ MarkNode,
+ (from: MarkNode) => {
+ return $createMarkNode(from.getIDs());
+ },
+ (from: MarkNode, to: MarkNode) => {
+ // Merge the IDs
+ const ids = from.getIDs();
+ ids.forEach((id) => {
+ to.addID(id);
+ });
+ },
+ ),
+ editor.registerMutationListener(MarkNode, (mutations) => {
+ editor.getEditorState().read(() => {
+ for (const [key, mutation] of mutations) {
+ const node: null | MarkNode = $getNodeByKey(key);
+ let ids: NodeKey[] = [];
+
+ if (mutation === 'destroyed') {
+ ids = markNodeKeysToIDs.get(key) || [];
+ } else if ($isMarkNode(node)) {
+ ids = node.getIDs();
+ }
+
+ for (let i = 0; i < ids.length; i++) {
+ const id = ids[i];
+ let markNodeKeys = markNodeMap.get(id);
+ markNodeKeysToIDs.set(key, ids);
+
+ if (mutation === 'destroyed') {
+ if (markNodeKeys !== undefined) {
+ markNodeKeys.delete(key);
+ if (markNodeKeys.size === 0) {
+ markNodeMap.delete(id);
+ }
+ }
+ } else {
+ if (markNodeKeys === undefined) {
+ markNodeKeys = new Set();
+ markNodeMap.set(id, markNodeKeys);
+ }
+ if (!markNodeKeys.has(key)) {
+ markNodeKeys.add(key);
+ }
+ }
+ }
+ }
+ });
+ }),
+ editor.registerUpdateListener(({editorState, tags}) => {
+ editorState.read(() => {
+ const selection = $getSelection();
+ let hasActiveIds = false;
+ let hasAnchorKey = false;
+
+ if ($isRangeSelection(selection)) {
+ const anchorNode = selection.anchor.getNode();
+
+ if ($isTextNode(anchorNode)) {
+ const commentIDs = $getMarkIDs(
+ anchorNode,
+ selection.anchor.offset,
+ );
+ if (commentIDs !== null) {
+ setActiveIDs(commentIDs);
+ hasActiveIds = true;
+ }
+ if (!selection.isCollapsed()) {
+ setActiveAnchorKey(anchorNode.getKey());
+ hasAnchorKey = true;
+ }
+ }
+ }
+ if (!hasActiveIds) {
+ setActiveIDs((_activeIds) =>
+ _activeIds.length === 0 ? _activeIds : [],
+ );
+ }
+ if (!hasAnchorKey) {
+ setActiveAnchorKey(null);
+ }
+ if (!tags.has('collaboration') && $isRangeSelection(selection)) {
+ setShowCommentInput(false);
+ }
+ });
+ }),
+ editor.registerCommand(
+ INSERT_INLINE_COMMAND,
+ () => {
+ const domSelection = window.getSelection();
+ if (domSelection !== null) {
+ domSelection.removeAllRanges();
+ }
+ setShowCommentInput(true);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ );
+ }, [editor, markNodeMap]);
+
+ const onAddComment = () => {
+ editor.dispatchCommand(INSERT_INLINE_COMMAND, undefined);
+ };
+
+ return (
+ <>
+ {showCommentInput &&
+ createPortal(
+ ,
+ document.body,
+ )}
+ {activeAnchorKey !== null &&
+ activeAnchorKey !== undefined &&
+ !showCommentInput &&
+ createPortal(
+ ,
+ document.body,
+ )}
+ {createPortal(
+ setShowComments(!showComments)}
+ title={showComments ? 'Hide Comments' : 'Show Comments'}>
+
+ ,
+ document.body,
+ )}
+ {showComments &&
+ createPortal(
+ ,
+ document.body,
+ )}
+ >
+ );
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/ComponentPickerPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/ComponentPickerPlugin/index.tsx
new file mode 100644
index 0000000..1940608
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/ComponentPickerPlugin/index.tsx
@@ -0,0 +1,400 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {$createCodeNode} from '@lexical/code';
+import {
+ INSERT_CHECK_LIST_COMMAND,
+ INSERT_ORDERED_LIST_COMMAND,
+ INSERT_UNORDERED_LIST_COMMAND,
+} from '@lexical/list';
+import {INSERT_EMBED_COMMAND} from '@lexical/react/LexicalAutoEmbedPlugin';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {INSERT_HORIZONTAL_RULE_COMMAND} from '@lexical/react/LexicalHorizontalRuleNode';
+import {
+ LexicalTypeaheadMenuPlugin,
+ MenuOption,
+ useBasicTypeaheadTriggerMatch,
+} from '@lexical/react/LexicalTypeaheadMenuPlugin';
+import {$createHeadingNode, $createQuoteNode} from '@lexical/rich-text';
+import {$setBlocksType} from '@lexical/selection';
+import {INSERT_TABLE_COMMAND} from '@lexical/table';
+import {
+ $createParagraphNode,
+ $getSelection,
+ $isRangeSelection,
+ FORMAT_ELEMENT_COMMAND,
+ LexicalEditor,
+ TextNode,
+} from 'lexical';
+import {useCallback, useMemo, useState} from 'react';
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+
+import useModal from '../../hooks/useModal';
+import catTypingGif from '../../images/cat-typing.gif';
+import {EmbedConfigs} from '../AutoEmbedPlugin';
+import {INSERT_COLLAPSIBLE_COMMAND} from '../CollapsiblePlugin';
+import {InsertEquationDialog} from '../EquationsPlugin';
+import {INSERT_EXCALIDRAW_COMMAND} from '../ExcalidrawPlugin';
+import {INSERT_IMAGE_COMMAND, InsertImageDialog} from '../ImagesPlugin';
+import InsertLayoutDialog from '../LayoutPlugin/InsertLayoutDialog';
+import {INSERT_PAGE_BREAK} from '../PageBreakPlugin';
+import {InsertPollDialog} from '../PollPlugin';
+import {InsertTableDialog} from '../TablePlugin';
+
+class ComponentPickerOption extends MenuOption {
+ // What shows up in the editor
+ title: string;
+ // Icon for display
+ icon?: JSX.Element;
+ // For extra searching.
+ keywords: Array;
+ // TBD
+ keyboardShortcut?: string;
+ // What happens when you select this option?
+ onSelect: (queryString: string) => void;
+
+ constructor(
+ title: string,
+ options: {
+ icon?: JSX.Element;
+ keywords?: Array;
+ keyboardShortcut?: string;
+ onSelect: (queryString: string) => void;
+ },
+ ) {
+ super(title);
+ this.title = title;
+ this.keywords = options.keywords || [];
+ this.icon = options.icon;
+ this.keyboardShortcut = options.keyboardShortcut;
+ this.onSelect = options.onSelect.bind(this);
+ }
+}
+
+function ComponentPickerMenuItem({
+ index,
+ isSelected,
+ onClick,
+ onMouseEnter,
+ option,
+}: {
+ index: number;
+ isSelected: boolean;
+ onClick: () => void;
+ onMouseEnter: () => void;
+ option: ComponentPickerOption;
+}) {
+ let className = 'item';
+ if (isSelected) {
+ className += ' selected';
+ }
+ return (
+
+ {option.icon}
+ {option.title}
+
+ );
+}
+
+function getDynamicOptions(editor: LexicalEditor, queryString: string) {
+ const options: Array = [];
+
+ if (queryString == null) {
+ return options;
+ }
+
+ const tableMatch = queryString.match(/^([1-9]\d?)(?:x([1-9]\d?)?)?$/);
+
+ if (tableMatch !== null) {
+ const rows = tableMatch[1];
+ const colOptions = tableMatch[2]
+ ? [tableMatch[2]]
+ : [1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map(String);
+
+ options.push(
+ ...colOptions.map(
+ (columns) =>
+ new ComponentPickerOption(`${rows}x${columns} Table`, {
+ icon: ,
+ keywords: ['table'],
+ onSelect: () =>
+ editor.dispatchCommand(INSERT_TABLE_COMMAND, {columns, rows}),
+ }),
+ ),
+ );
+ }
+
+ return options;
+}
+
+type ShowModal = ReturnType[1];
+
+function getBaseOptions(editor: LexicalEditor, showModal: ShowModal) {
+ return [
+ new ComponentPickerOption('Paragraph', {
+ icon: ,
+ keywords: ['normal', 'paragraph', 'p', 'text'],
+ onSelect: () =>
+ editor.update(() => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ $setBlocksType(selection, () => $createParagraphNode());
+ }
+ }),
+ }),
+ ...([1, 2, 3] as const).map(
+ (n) =>
+ new ComponentPickerOption(`Heading ${n}`, {
+ icon: ,
+ keywords: ['heading', 'header', `h${n}`],
+ onSelect: () =>
+ editor.update(() => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ $setBlocksType(selection, () => $createHeadingNode(`h${n}`));
+ }
+ }),
+ }),
+ ),
+ new ComponentPickerOption('Table', {
+ icon: ,
+ keywords: ['table', 'grid', 'spreadsheet', 'rows', 'columns'],
+ onSelect: () =>
+ showModal('Insert Table', (onClose) => (
+
+ )),
+ }),
+ new ComponentPickerOption('Numbered List', {
+ icon: ,
+ keywords: ['numbered list', 'ordered list', 'ol'],
+ onSelect: () =>
+ editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined),
+ }),
+ new ComponentPickerOption('Bulleted List', {
+ icon: ,
+ keywords: ['bulleted list', 'unordered list', 'ul'],
+ onSelect: () =>
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined),
+ }),
+ new ComponentPickerOption('Check List', {
+ icon: ,
+ keywords: ['check list', 'todo list'],
+ onSelect: () =>
+ editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined),
+ }),
+ new ComponentPickerOption('Quote', {
+ icon: ,
+ keywords: ['block quote'],
+ onSelect: () =>
+ editor.update(() => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ $setBlocksType(selection, () => $createQuoteNode());
+ }
+ }),
+ }),
+ new ComponentPickerOption('Code', {
+ icon: ,
+ keywords: ['javascript', 'python', 'js', 'codeblock'],
+ onSelect: () =>
+ editor.update(() => {
+ const selection = $getSelection();
+
+ if ($isRangeSelection(selection)) {
+ if (selection.isCollapsed()) {
+ $setBlocksType(selection, () => $createCodeNode());
+ } else {
+ // Will this ever happen?
+ const textContent = selection.getTextContent();
+ const codeNode = $createCodeNode();
+ selection.insertNodes([codeNode]);
+ selection.insertRawText(textContent);
+ }
+ }
+ }),
+ }),
+ new ComponentPickerOption('Divider', {
+ icon: ,
+ keywords: ['horizontal rule', 'divider', 'hr'],
+ onSelect: () =>
+ editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined),
+ }),
+ new ComponentPickerOption('Page Break', {
+ icon: ,
+ keywords: ['page break', 'divider'],
+ onSelect: () => editor.dispatchCommand(INSERT_PAGE_BREAK, undefined),
+ }),
+ new ComponentPickerOption('Excalidraw', {
+ icon: ,
+ keywords: ['excalidraw', 'diagram', 'drawing'],
+ onSelect: () =>
+ editor.dispatchCommand(INSERT_EXCALIDRAW_COMMAND, undefined),
+ }),
+ new ComponentPickerOption('Poll', {
+ icon: ,
+ keywords: ['poll', 'vote'],
+ onSelect: () =>
+ showModal('Insert Poll', (onClose) => (
+
+ )),
+ }),
+ ...EmbedConfigs.map(
+ (embedConfig) =>
+ new ComponentPickerOption(`Embed ${embedConfig.contentName}`, {
+ icon: embedConfig.icon,
+ keywords: [...embedConfig.keywords, 'embed'],
+ onSelect: () =>
+ editor.dispatchCommand(INSERT_EMBED_COMMAND, embedConfig.type),
+ }),
+ ),
+ new ComponentPickerOption('Equation', {
+ icon: ,
+ keywords: ['equation', 'latex', 'math'],
+ onSelect: () =>
+ showModal('Insert Equation', (onClose) => (
+
+ )),
+ }),
+ new ComponentPickerOption('GIF', {
+ icon: ,
+ keywords: ['gif', 'animate', 'image', 'file'],
+ onSelect: () =>
+ editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
+ altText: 'Cat typing on a laptop',
+ src: catTypingGif,
+ }),
+ }),
+ new ComponentPickerOption('Image', {
+ icon: ,
+ keywords: ['image', 'photo', 'picture', 'file'],
+ onSelect: () =>
+ showModal('Insert Image', (onClose) => (
+
+ )),
+ }),
+ new ComponentPickerOption('Collapsible', {
+ icon: ,
+ keywords: ['collapse', 'collapsible', 'toggle'],
+ onSelect: () =>
+ editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined),
+ }),
+ new ComponentPickerOption('Columns Layout', {
+ icon: ,
+ keywords: ['columns', 'layout', 'grid'],
+ onSelect: () =>
+ showModal('Insert Columns Layout', (onClose) => (
+
+ )),
+ }),
+ ...(['left', 'center', 'right', 'justify'] as const).map(
+ (alignment) =>
+ new ComponentPickerOption(`Align ${alignment}`, {
+ icon: ,
+ keywords: ['align', 'justify', alignment],
+ onSelect: () =>
+ editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, alignment),
+ }),
+ ),
+ ];
+}
+
+export default function ComponentPickerMenuPlugin(): JSX.Element {
+ const [editor] = useLexicalComposerContext();
+ const [modal, showModal] = useModal();
+ const [queryString, setQueryString] = useState(null);
+
+ const checkForTriggerMatch = useBasicTypeaheadTriggerMatch('/', {
+ minLength: 0,
+ });
+
+ const options = useMemo(() => {
+ const baseOptions = getBaseOptions(editor, showModal);
+
+ if (!queryString) {
+ return baseOptions;
+ }
+
+ const regex = new RegExp(queryString, 'i');
+
+ return [
+ ...getDynamicOptions(editor, queryString),
+ ...baseOptions.filter(
+ (option) =>
+ regex.test(option.title) ||
+ option.keywords.some((keyword) => regex.test(keyword)),
+ ),
+ ];
+ }, [editor, queryString, showModal]);
+
+ const onSelectOption = useCallback(
+ (
+ selectedOption: ComponentPickerOption,
+ nodeToRemove: TextNode | null,
+ closeMenu: () => void,
+ matchingString: string,
+ ) => {
+ editor.update(() => {
+ nodeToRemove?.remove();
+ selectedOption.onSelect(matchingString);
+ closeMenu();
+ });
+ },
+ [editor],
+ );
+
+ return (
+ <>
+ {modal}
+
+ onQueryChange={setQueryString}
+ onSelectOption={onSelectOption}
+ triggerFn={checkForTriggerMatch}
+ options={options}
+ menuRenderFn={(
+ anchorElementRef,
+ {selectedIndex, selectOptionAndCleanUp, setHighlightedIndex},
+ ) =>
+ anchorElementRef.current && options.length
+ ? ReactDOM.createPortal(
+
+
+ {options.map((option, i: number) => (
+ {
+ setHighlightedIndex(i);
+ selectOptionAndCleanUp(option);
+ }}
+ onMouseEnter={() => {
+ setHighlightedIndex(i);
+ }}
+ key={option.key}
+ option={option}
+ />
+ ))}
+
+
,
+ anchorElementRef.current,
+ )
+ : null
+ }
+ />
+ >
+ );
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/ContextMenuPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/ContextMenuPlugin/index.tsx
new file mode 100644
index 0000000..e4acd66
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/ContextMenuPlugin/index.tsx
@@ -0,0 +1,244 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {
+ LexicalContextMenuPlugin,
+ MenuOption,
+} from '@lexical/react/LexicalContextMenuPlugin';
+import {
+ type LexicalNode,
+ $getSelection,
+ $isRangeSelection,
+ COPY_COMMAND,
+ CUT_COMMAND,
+ PASTE_COMMAND,
+} from 'lexical';
+import {useCallback, useMemo} from 'react';
+import * as React from 'react';
+import * as ReactDOM from 'react-dom';
+
+function ContextMenuItem({
+ index,
+ isSelected,
+ onClick,
+ onMouseEnter,
+ option,
+}: {
+ index: number;
+ isSelected: boolean;
+ onClick: () => void;
+ onMouseEnter: () => void;
+ option: ContextMenuOption;
+}) {
+ let className = 'item';
+ if (isSelected) {
+ className += ' selected';
+ }
+ return (
+
+ {option.title}
+
+ );
+}
+
+function ContextMenu({
+ options,
+ selectedItemIndex,
+ onOptionClick,
+ onOptionMouseEnter,
+}: {
+ selectedItemIndex: number | null;
+ onOptionClick: (option: ContextMenuOption, index: number) => void;
+ onOptionMouseEnter: (index: number) => void;
+ options: Array;
+}) {
+ return (
+
+
+ {options.map((option: ContextMenuOption, i: number) => (
+ onOptionClick(option, i)}
+ onMouseEnter={() => onOptionMouseEnter(i)}
+ key={option.key}
+ option={option}
+ />
+ ))}
+
+
+ );
+}
+
+export class ContextMenuOption extends MenuOption {
+ title: string;
+ onSelect: (targetNode: LexicalNode | null) => void;
+ constructor(
+ title: string,
+ options: {
+ onSelect: (targetNode: LexicalNode | null) => void;
+ },
+ ) {
+ super(title);
+ this.title = title;
+ this.onSelect = options.onSelect.bind(this);
+ }
+}
+
+export default function ContextMenuPlugin(): JSX.Element {
+ const [editor] = useLexicalComposerContext();
+
+ const options = useMemo(() => {
+ return [
+ new ContextMenuOption(`Copy`, {
+ onSelect: (_node) => {
+ editor.dispatchCommand(COPY_COMMAND, null);
+ },
+ }),
+ new ContextMenuOption(`Cut`, {
+ onSelect: (_node) => {
+ editor.dispatchCommand(CUT_COMMAND, null);
+ },
+ }),
+ new ContextMenuOption(`Paste`, {
+ onSelect: (_node) => {
+ navigator.clipboard.read().then(async (...args) => {
+ const data = new DataTransfer();
+
+ const items = await navigator.clipboard.read();
+ const item = items[0];
+
+ const permission = await navigator.permissions.query({
+ // @ts-ignore These types are incorrect.
+ name: 'clipboard-read',
+ });
+ if (permission.state === 'denied') {
+ alert('Not allowed to paste from clipboard.');
+ return;
+ }
+
+ for (const type of item.types) {
+ const dataString = await (await item.getType(type)).text();
+ data.setData(type, dataString);
+ }
+
+ const event = new ClipboardEvent('paste', {
+ clipboardData: data,
+ });
+
+ editor.dispatchCommand(PASTE_COMMAND, event);
+ });
+ },
+ }),
+ new ContextMenuOption(`Paste as Plain Text`, {
+ onSelect: (_node) => {
+ navigator.clipboard.read().then(async (...args) => {
+ const permission = await navigator.permissions.query({
+ // @ts-ignore These types are incorrect.
+ name: 'clipboard-read',
+ });
+
+ if (permission.state === 'denied') {
+ alert('Not allowed to paste from clipboard.');
+ return;
+ }
+
+ const data = new DataTransfer();
+ const items = await navigator.clipboard.readText();
+ data.setData('text/plain', items);
+
+ const event = new ClipboardEvent('paste', {
+ clipboardData: data,
+ });
+ editor.dispatchCommand(PASTE_COMMAND, event);
+ });
+ },
+ }),
+ new ContextMenuOption(`Delete Node`, {
+ onSelect: (_node) => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ const currentNode = selection.anchor.getNode();
+ const ancestorNodeWithRootAsParent = currentNode
+ .getParents()
+ .at(-2);
+
+ ancestorNodeWithRootAsParent?.remove();
+ }
+ },
+ }),
+ ];
+ }, [editor]);
+
+ const onSelectOption = useCallback(
+ (
+ selectedOption: ContextMenuOption,
+ targetNode: LexicalNode | null,
+ closeMenu: () => void,
+ ) => {
+ editor.update(() => {
+ selectedOption.onSelect(targetNode);
+ closeMenu();
+ });
+ },
+ [editor],
+ );
+
+ return (
+
+ anchorElementRef.current
+ ? ReactDOM.createPortal(
+
+ {
+ setHighlightedIndex(index);
+ selectOptionAndCleanUp(option);
+ }}
+ onOptionMouseEnter={(index: number) => {
+ setHighlightedIndex(index);
+ }}
+ />
+
,
+ anchorElementRef.current,
+ )
+ : null
+ }
+ />
+ );
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/DocsPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/DocsPlugin/index.tsx
new file mode 100644
index 0000000..fe0e240
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/DocsPlugin/index.tsx
@@ -0,0 +1,20 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import * as React from 'react';
+
+export default function DocsPlugin(): JSX.Element {
+ return (
+
+
+
+ );
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/DragDropPastePlugin/index.ts b/cyynote-frontend/src/components/lexical/plugins/DragDropPastePlugin/index.ts
new file mode 100644
index 0000000..b38a10c
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/DragDropPastePlugin/index.ts
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {DRAG_DROP_PASTE} from '@lexical/rich-text';
+import {isMimeType, mediaFileReader} from '@lexical/utils';
+import {COMMAND_PRIORITY_LOW} from 'lexical';
+import {useEffect} from 'react';
+
+import {INSERT_IMAGE_COMMAND} from '../ImagesPlugin';
+
+const ACCEPTABLE_IMAGE_TYPES = [
+ 'image/',
+ 'image/heic',
+ 'image/heif',
+ 'image/gif',
+ 'image/webp',
+];
+
+export default function DragDropPaste(): null {
+ const [editor] = useLexicalComposerContext();
+ useEffect(() => {
+ return editor.registerCommand(
+ DRAG_DROP_PASTE,
+ (files) => {
+ (async () => {
+ const filesResult = await mediaFileReader(
+ files,
+ [ACCEPTABLE_IMAGE_TYPES].flatMap((x) => x),
+ );
+ for (const {file, result} of filesResult) {
+ if (isMimeType(file, ACCEPTABLE_IMAGE_TYPES)) {
+ editor.dispatchCommand(INSERT_IMAGE_COMMAND, {
+ altText: file.name,
+ src: result,
+ });
+ }
+ }
+ })();
+ return true;
+ },
+ COMMAND_PRIORITY_LOW,
+ );
+ }, [editor]);
+ return null;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/DraggableBlockPlugin/index.css b/cyynote-frontend/src/components/lexical/plugins/DraggableBlockPlugin/index.css
new file mode 100644
index 0000000..050a57d
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/DraggableBlockPlugin/index.css
@@ -0,0 +1,36 @@
+.draggable-block-menu {
+ border-radius: 4px;
+ padding: 2px 1px;
+ cursor: grab;
+ opacity: 0;
+ position: absolute;
+ left: 0;
+ top: 0;
+ will-change: transform;
+}
+
+.draggable-block-menu .icon {
+ width: 16px;
+ height: 16px;
+ opacity: 0.3;
+ background-image: url(../../images/icons/draggable-block-menu.svg);
+}
+
+.draggable-block-menu:active {
+ cursor: grabbing;
+}
+
+.draggable-block-menu:hover {
+ background-color: #efefef;
+}
+
+.draggable-block-target-line {
+ pointer-events: none;
+ background: deepskyblue;
+ height: 4px;
+ position: absolute;
+ left: 0;
+ top: 0;
+ opacity: 0;
+ will-change: transform;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/DraggableBlockPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/DraggableBlockPlugin/index.tsx
new file mode 100644
index 0000000..8f9329b
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/DraggableBlockPlugin/index.tsx
@@ -0,0 +1,431 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import './index.css';
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {eventFiles} from '@lexical/rich-text';
+import {mergeRegister} from '@lexical/utils';
+import {
+ $getNearestNodeFromDOMNode,
+ $getNodeByKey,
+ $getRoot,
+ COMMAND_PRIORITY_HIGH,
+ COMMAND_PRIORITY_LOW,
+ DRAGOVER_COMMAND,
+ DROP_COMMAND,
+ LexicalEditor,
+} from 'lexical';
+import * as React from 'react';
+import {DragEvent as ReactDragEvent, useEffect, useRef, useState} from 'react';
+import {createPortal} from 'react-dom';
+
+import {isHTMLElement} from '../../utils/guard';
+import {Point} from '../../utils/point';
+import {Rect} from '../../utils/rect';
+
+const SPACE = 4;
+const TARGET_LINE_HALF_HEIGHT = 2;
+const DRAGGABLE_BLOCK_MENU_CLASSNAME = 'draggable-block-menu';
+const DRAG_DATA_FORMAT = 'application/x-lexical-drag-block';
+const TEXT_BOX_HORIZONTAL_PADDING = 28;
+
+const Downward = 1;
+const Upward = -1;
+const Indeterminate = 0;
+
+let prevIndex = Infinity;
+
+function getCurrentIndex(keysLength: number): number {
+ if (keysLength === 0) {
+ return Infinity;
+ }
+ if (prevIndex >= 0 && prevIndex < keysLength) {
+ return prevIndex;
+ }
+
+ return Math.floor(keysLength / 2);
+}
+
+function getTopLevelNodeKeys(editor: LexicalEditor): string[] {
+ return editor.getEditorState().read(() => $getRoot().getChildrenKeys());
+}
+
+function getCollapsedMargins(elem: HTMLElement): {
+ marginTop: number;
+ marginBottom: number;
+} {
+ const getMargin = (
+ element: Element | null,
+ margin: 'marginTop' | 'marginBottom',
+ ): number =>
+ element ? parseFloat(window.getComputedStyle(element)[margin]) : 0;
+
+ const {marginTop, marginBottom} = window.getComputedStyle(elem);
+ const prevElemSiblingMarginBottom = getMargin(
+ elem.previousElementSibling,
+ 'marginBottom',
+ );
+ const nextElemSiblingMarginTop = getMargin(
+ elem.nextElementSibling,
+ 'marginTop',
+ );
+ const collapsedTopMargin = Math.max(
+ parseFloat(marginTop),
+ prevElemSiblingMarginBottom,
+ );
+ const collapsedBottomMargin = Math.max(
+ parseFloat(marginBottom),
+ nextElemSiblingMarginTop,
+ );
+
+ return {marginBottom: collapsedBottomMargin, marginTop: collapsedTopMargin};
+}
+
+function getBlockElement(
+ anchorElem: HTMLElement,
+ editor: LexicalEditor,
+ event: MouseEvent,
+ useEdgeAsDefault = false,
+): HTMLElement | null {
+ const anchorElementRect = anchorElem.getBoundingClientRect();
+ const topLevelNodeKeys = getTopLevelNodeKeys(editor);
+
+ let blockElem: HTMLElement | null = null;
+
+ editor.getEditorState().read(() => {
+ if (useEdgeAsDefault) {
+ const [firstNode, lastNode] = [
+ editor.getElementByKey(topLevelNodeKeys[0]),
+ editor.getElementByKey(topLevelNodeKeys[topLevelNodeKeys.length - 1]),
+ ];
+
+ const [firstNodeRect, lastNodeRect] = [
+ firstNode?.getBoundingClientRect(),
+ lastNode?.getBoundingClientRect(),
+ ];
+
+ if (firstNodeRect && lastNodeRect) {
+ if (event.y < firstNodeRect.top) {
+ blockElem = firstNode;
+ } else if (event.y > lastNodeRect.bottom) {
+ blockElem = lastNode;
+ }
+
+ if (blockElem) {
+ return;
+ }
+ }
+ }
+
+ let index = getCurrentIndex(topLevelNodeKeys.length);
+ let direction = Indeterminate;
+
+ while (index >= 0 && index < topLevelNodeKeys.length) {
+ const key = topLevelNodeKeys[index];
+ const elem = editor.getElementByKey(key);
+ if (elem === null) {
+ break;
+ }
+ const point = new Point(event.x, event.y);
+ const domRect = Rect.fromDOM(elem);
+ const {marginTop, marginBottom} = getCollapsedMargins(elem);
+
+ const rect = domRect.generateNewRect({
+ bottom: domRect.bottom + marginBottom,
+ left: anchorElementRect.left,
+ right: anchorElementRect.right,
+ top: domRect.top - marginTop,
+ });
+
+ const {
+ result,
+ reason: {isOnTopSide, isOnBottomSide},
+ } = rect.contains(point);
+
+ if (result) {
+ blockElem = elem;
+ prevIndex = index;
+ break;
+ }
+
+ if (direction === Indeterminate) {
+ if (isOnTopSide) {
+ direction = Upward;
+ } else if (isOnBottomSide) {
+ direction = Downward;
+ } else {
+ // stop search block element
+ direction = Infinity;
+ }
+ }
+
+ index += direction;
+ }
+ });
+
+ return blockElem;
+}
+
+function isOnMenu(element: HTMLElement): boolean {
+ return !!element.closest(`.${DRAGGABLE_BLOCK_MENU_CLASSNAME}`);
+}
+
+function setMenuPosition(
+ targetElem: HTMLElement | null,
+ floatingElem: HTMLElement,
+ anchorElem: HTMLElement,
+) {
+ if (!targetElem) {
+ floatingElem.style.opacity = '0';
+ floatingElem.style.transform = 'translate(-10000px, -10000px)';
+ return;
+ }
+
+ const targetRect = targetElem.getBoundingClientRect();
+ const targetStyle = window.getComputedStyle(targetElem);
+ const floatingElemRect = floatingElem.getBoundingClientRect();
+ const anchorElementRect = anchorElem.getBoundingClientRect();
+
+ const top =
+ targetRect.top +
+ (parseInt(targetStyle.lineHeight, 10) - floatingElemRect.height) / 2 -
+ anchorElementRect.top;
+
+ const left = SPACE;
+
+ floatingElem.style.opacity = '1';
+ floatingElem.style.transform = `translate(${left}px, ${top}px)`;
+}
+
+function setDragImage(
+ dataTransfer: DataTransfer,
+ draggableBlockElem: HTMLElement,
+) {
+ const {transform} = draggableBlockElem.style;
+
+ // Remove dragImage borders
+ draggableBlockElem.style.transform = 'translateZ(0)';
+ dataTransfer.setDragImage(draggableBlockElem, 0, 0);
+
+ setTimeout(() => {
+ draggableBlockElem.style.transform = transform;
+ });
+}
+
+function setTargetLine(
+ targetLineElem: HTMLElement,
+ targetBlockElem: HTMLElement,
+ mouseY: number,
+ anchorElem: HTMLElement,
+) {
+ const {top: targetBlockElemTop, height: targetBlockElemHeight} =
+ targetBlockElem.getBoundingClientRect();
+ const {top: anchorTop, width: anchorWidth} =
+ anchorElem.getBoundingClientRect();
+
+ const {marginTop, marginBottom} = getCollapsedMargins(targetBlockElem);
+ let lineTop = targetBlockElemTop;
+ if (mouseY >= targetBlockElemTop) {
+ lineTop += targetBlockElemHeight + marginBottom / 2;
+ } else {
+ lineTop -= marginTop / 2;
+ }
+
+ const top = lineTop - anchorTop - TARGET_LINE_HALF_HEIGHT;
+ const left = TEXT_BOX_HORIZONTAL_PADDING - SPACE;
+
+ targetLineElem.style.transform = `translate(${left}px, ${top}px)`;
+ targetLineElem.style.width = `${
+ anchorWidth - (TEXT_BOX_HORIZONTAL_PADDING - SPACE) * 2
+ }px`;
+ targetLineElem.style.opacity = '.4';
+}
+
+function hideTargetLine(targetLineElem: HTMLElement | null) {
+ if (targetLineElem) {
+ targetLineElem.style.opacity = '0';
+ targetLineElem.style.transform = 'translate(-10000px, -10000px)';
+ }
+}
+
+function useDraggableBlockMenu(
+ editor: LexicalEditor,
+ anchorElem: HTMLElement,
+ isEditable: boolean,
+): JSX.Element {
+ const scrollerElem = anchorElem.parentElement;
+
+ const menuRef = useRef(null);
+ const targetLineRef = useRef(null);
+ const isDraggingBlockRef = useRef(false);
+ const [draggableBlockElem, setDraggableBlockElem] =
+ useState(null);
+
+ useEffect(() => {
+ function onMouseMove(event: MouseEvent) {
+ const target = event.target;
+ if (!isHTMLElement(target)) {
+ setDraggableBlockElem(null);
+ return;
+ }
+
+ if (isOnMenu(target)) {
+ return;
+ }
+
+ const _draggableBlockElem = getBlockElement(anchorElem, editor, event);
+
+ setDraggableBlockElem(_draggableBlockElem);
+ }
+
+ function onMouseLeave() {
+ setDraggableBlockElem(null);
+ }
+
+ scrollerElem?.addEventListener('mousemove', onMouseMove);
+ scrollerElem?.addEventListener('mouseleave', onMouseLeave);
+
+ return () => {
+ scrollerElem?.removeEventListener('mousemove', onMouseMove);
+ scrollerElem?.removeEventListener('mouseleave', onMouseLeave);
+ };
+ }, [scrollerElem, anchorElem, editor]);
+
+ useEffect(() => {
+ if (menuRef.current) {
+ setMenuPosition(draggableBlockElem, menuRef.current, anchorElem);
+ }
+ }, [anchorElem, draggableBlockElem]);
+
+ useEffect(() => {
+ function onDragover(event: DragEvent): boolean {
+ if (!isDraggingBlockRef.current) {
+ return false;
+ }
+ const [isFileTransfer] = eventFiles(event);
+ if (isFileTransfer) {
+ return false;
+ }
+ const {pageY, target} = event;
+ if (!isHTMLElement(target)) {
+ return false;
+ }
+ const targetBlockElem = getBlockElement(anchorElem, editor, event, true);
+ const targetLineElem = targetLineRef.current;
+ if (targetBlockElem === null || targetLineElem === null) {
+ return false;
+ }
+ setTargetLine(targetLineElem, targetBlockElem, pageY, anchorElem);
+ // Prevent default event to be able to trigger onDrop events
+ event.preventDefault();
+ return true;
+ }
+
+ function onDrop(event: DragEvent): boolean {
+ if (!isDraggingBlockRef.current) {
+ return false;
+ }
+ const [isFileTransfer] = eventFiles(event);
+ if (isFileTransfer) {
+ return false;
+ }
+ const {target, dataTransfer, pageY} = event;
+ const dragData = dataTransfer?.getData(DRAG_DATA_FORMAT) || '';
+ const draggedNode = $getNodeByKey(dragData);
+ if (!draggedNode) {
+ return false;
+ }
+ if (!isHTMLElement(target)) {
+ return false;
+ }
+ const targetBlockElem = getBlockElement(anchorElem, editor, event, true);
+ if (!targetBlockElem) {
+ return false;
+ }
+ const targetNode = $getNearestNodeFromDOMNode(targetBlockElem);
+ if (!targetNode) {
+ return false;
+ }
+ if (targetNode === draggedNode) {
+ return true;
+ }
+ const targetBlockElemTop = targetBlockElem.getBoundingClientRect().top;
+ if (pageY >= targetBlockElemTop) {
+ targetNode.insertAfter(draggedNode);
+ } else {
+ targetNode.insertBefore(draggedNode);
+ }
+ setDraggableBlockElem(null);
+
+ return true;
+ }
+
+ return mergeRegister(
+ editor.registerCommand(
+ DRAGOVER_COMMAND,
+ (event) => {
+ return onDragover(event);
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ editor.registerCommand(
+ DROP_COMMAND,
+ (event) => {
+ return onDrop(event);
+ },
+ COMMAND_PRIORITY_HIGH,
+ ),
+ );
+ }, [anchorElem, editor]);
+
+ function onDragStart(event: ReactDragEvent): void {
+ const dataTransfer = event.dataTransfer;
+ if (!dataTransfer || !draggableBlockElem) {
+ return;
+ }
+ setDragImage(dataTransfer, draggableBlockElem);
+ let nodeKey = '';
+ editor.update(() => {
+ const node = $getNearestNodeFromDOMNode(draggableBlockElem);
+ if (node) {
+ nodeKey = node.getKey();
+ }
+ });
+ isDraggingBlockRef.current = true;
+ dataTransfer.setData(DRAG_DATA_FORMAT, nodeKey);
+ }
+
+ function onDragEnd(): void {
+ isDraggingBlockRef.current = false;
+ hideTargetLine(targetLineRef.current);
+ }
+
+ return createPortal(
+ <>
+
+
+ >,
+ anchorElem,
+ );
+}
+
+export default function DraggableBlockPlugin({
+ anchorElem = document.body,
+}: {
+ anchorElem?: HTMLElement;
+}): JSX.Element {
+ const [editor] = useLexicalComposerContext();
+ return useDraggableBlockMenu(editor, anchorElem, editor._editable);
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/EmojiPickerPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/EmojiPickerPlugin/index.tsx
new file mode 100644
index 0000000..05e7c56
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/EmojiPickerPlugin/index.tsx
@@ -0,0 +1,200 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {
+ LexicalTypeaheadMenuPlugin,
+ MenuOption,
+ useBasicTypeaheadTriggerMatch,
+} from '@lexical/react/LexicalTypeaheadMenuPlugin';
+import {
+ $createTextNode,
+ $getSelection,
+ $isRangeSelection,
+ TextNode,
+} from 'lexical';
+import * as React from 'react';
+import {useCallback, useEffect, useMemo, useState} from 'react';
+import * as ReactDOM from 'react-dom';
+
+class EmojiOption extends MenuOption {
+ title: string;
+ emoji: string;
+ keywords: Array;
+
+ constructor(
+ title: string,
+ emoji: string,
+ options: {
+ keywords?: Array;
+ },
+ ) {
+ super(title);
+ this.title = title;
+ this.emoji = emoji;
+ this.keywords = options.keywords || [];
+ }
+}
+function EmojiMenuItem({
+ index,
+ isSelected,
+ onClick,
+ onMouseEnter,
+ option,
+}: {
+ index: number;
+ isSelected: boolean;
+ onClick: () => void;
+ onMouseEnter: () => void;
+ option: EmojiOption;
+}) {
+ let className = 'item';
+ if (isSelected) {
+ className += ' selected';
+ }
+ return (
+
+
+ {option.emoji} {option.title}
+
+
+ );
+}
+
+type Emoji = {
+ emoji: string;
+ description: string;
+ category: string;
+ aliases: Array;
+ tags: Array;
+ unicode_version: string;
+ ios_version: string;
+ skin_tones?: boolean;
+};
+
+const MAX_EMOJI_SUGGESTION_COUNT = 10;
+
+export default function EmojiPickerPlugin() {
+ const [editor] = useLexicalComposerContext();
+ const [queryString, setQueryString] = useState(null);
+ const [emojis, setEmojis] = useState>([]);
+
+ useEffect(() => {
+ // @ts-ignore
+ import('../../utils/emoji-list.ts').then((file) => setEmojis(file.default));
+ }, []);
+
+ const emojiOptions = useMemo(
+ () =>
+ emojis != null
+ ? emojis.map(
+ ({emoji, aliases, tags}) =>
+ new EmojiOption(aliases[0], emoji, {
+ keywords: [...aliases, ...tags],
+ }),
+ )
+ : [],
+ [emojis],
+ );
+
+ const checkForTriggerMatch = useBasicTypeaheadTriggerMatch(':', {
+ minLength: 0,
+ });
+
+ const options: Array = useMemo(() => {
+ return emojiOptions
+ .filter((option: EmojiOption) => {
+ return queryString != null
+ ? new RegExp(queryString, 'gi').exec(option.title) ||
+ option.keywords != null
+ ? option.keywords.some((keyword: string) =>
+ new RegExp(queryString, 'gi').exec(keyword),
+ )
+ : false
+ : emojiOptions;
+ })
+ .slice(0, MAX_EMOJI_SUGGESTION_COUNT);
+ }, [emojiOptions, queryString]);
+
+ const onSelectOption = useCallback(
+ (
+ selectedOption: EmojiOption,
+ nodeToRemove: TextNode | null,
+ closeMenu: () => void,
+ ) => {
+ editor.update(() => {
+ const selection = $getSelection();
+
+ if (!$isRangeSelection(selection) || selectedOption == null) {
+ return;
+ }
+
+ if (nodeToRemove) {
+ nodeToRemove.remove();
+ }
+
+ selection.insertNodes([$createTextNode(selectedOption.emoji)]);
+
+ closeMenu();
+ });
+ },
+ [editor],
+ );
+
+ return (
+ {
+ if (anchorElementRef.current == null || options.length === 0) {
+ return null;
+ }
+
+ return anchorElementRef.current && options.length
+ ? ReactDOM.createPortal(
+
+
+ {options.map((option: EmojiOption, index) => (
+
+ {
+ setHighlightedIndex(index);
+ selectOptionAndCleanUp(option);
+ }}
+ onMouseEnter={() => {
+ setHighlightedIndex(index);
+ }}
+ option={option}
+ />
+
+ ))}
+
+
,
+ anchorElementRef.current,
+ )
+ : null;
+ }}
+ />
+ );
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/EmojisPlugin/index.ts b/cyynote-frontend/src/components/lexical/plugins/EmojisPlugin/index.ts
new file mode 100644
index 0000000..d2e661c
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/EmojisPlugin/index.ts
@@ -0,0 +1,79 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import type {LexicalEditor} from 'lexical';
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {TextNode} from 'lexical';
+import {useEffect} from 'react';
+
+import {$createEmojiNode, EmojiNode} from '../../nodes/EmojiNode';
+
+const emojis: Map = new Map([
+ [':)', ['emoji happysmile', '🙂']],
+ [':D', ['emoji veryhappysmile', '😀']],
+ [':(', ['emoji unhappysmile', '🙁']],
+ ['<3', ['emoji heart', '❤']],
+ ['🙂', ['emoji happysmile', '🙂']],
+ ['😀', ['emoji veryhappysmile', '😀']],
+ ['🙁', ['emoji unhappysmile', '🙁']],
+ ['❤', ['emoji heart', '❤']],
+]);
+
+function findAndTransformEmoji(node: TextNode): null | TextNode {
+ const text = node.getTextContent();
+
+ for (let i = 0; i < text.length; i++) {
+ const emojiData = emojis.get(text[i]) || emojis.get(text.slice(i, i + 2));
+
+ if (emojiData !== undefined) {
+ const [emojiStyle, emojiText] = emojiData;
+ let targetNode;
+
+ if (i === 0) {
+ [targetNode] = node.splitText(i + 2);
+ } else {
+ [, targetNode] = node.splitText(i, i + 2);
+ }
+
+ const emojiNode = $createEmojiNode(emojiStyle, emojiText);
+ targetNode.replace(emojiNode);
+ return emojiNode;
+ }
+ }
+
+ return null;
+}
+
+function textNodeTransform(node: TextNode): void {
+ let targetNode: TextNode | null = node;
+
+ while (targetNode !== null) {
+ if (!targetNode.isSimpleText()) {
+ return;
+ }
+
+ targetNode = findAndTransformEmoji(targetNode);
+ }
+}
+
+function useEmojis(editor: LexicalEditor): void {
+ useEffect(() => {
+ if (!editor.hasNodes([EmojiNode])) {
+ throw new Error('EmojisPlugin: EmojiNode not registered on editor');
+ }
+
+ return editor.registerNodeTransform(TextNode, textNodeTransform);
+ }, [editor]);
+}
+
+export default function EmojisPlugin(): JSX.Element | null {
+ const [editor] = useLexicalComposerContext();
+ useEmojis(editor);
+ return null;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/EquationsPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/EquationsPlugin/index.tsx
new file mode 100644
index 0000000..e22fc55
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/EquationsPlugin/index.tsx
@@ -0,0 +1,82 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import 'katex/dist/katex.css';
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {$wrapNodeInElement} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $insertNodes,
+ $isRootOrShadowRoot,
+ COMMAND_PRIORITY_EDITOR,
+ createCommand,
+ LexicalCommand,
+ LexicalEditor,
+} from 'lexical';
+import {useCallback, useEffect} from 'react';
+import * as React from 'react';
+
+import {$createEquationNode, EquationNode} from '../../nodes/EquationNode';
+import KatexEquationAlterer from '../../ui/KatexEquationAlterer';
+
+type CommandPayload = {
+ equation: string;
+ inline: boolean;
+};
+
+export const INSERT_EQUATION_COMMAND: LexicalCommand =
+ createCommand('INSERT_EQUATION_COMMAND');
+
+export function InsertEquationDialog({
+ activeEditor,
+ onClose,
+}: {
+ activeEditor: LexicalEditor;
+ onClose: () => void;
+}): JSX.Element {
+ const onEquationConfirm = useCallback(
+ (equation: string, inline: boolean) => {
+ activeEditor.dispatchCommand(INSERT_EQUATION_COMMAND, {equation, inline});
+ onClose();
+ },
+ [activeEditor, onClose],
+ );
+
+ return ;
+}
+
+export default function EquationsPlugin(): JSX.Element | null {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ if (!editor.hasNodes([EquationNode])) {
+ throw new Error(
+ 'EquationsPlugins: EquationsNode not registered on editor',
+ );
+ }
+
+ return editor.registerCommand(
+ INSERT_EQUATION_COMMAND,
+ (payload) => {
+ const {equation, inline} = payload;
+ const equationNode = $createEquationNode(equation, inline);
+
+ $insertNodes([equationNode]);
+ if ($isRootOrShadowRoot(equationNode.getParentOrThrow())) {
+ $wrapNodeInElement(equationNode, $createParagraphNode).selectEnd();
+ }
+
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ );
+ }, [editor]);
+
+ return null;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/ExcalidrawPlugin/index.ts b/cyynote-frontend/src/components/lexical/plugins/ExcalidrawPlugin/index.ts
new file mode 100644
index 0000000..0746bd1
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/ExcalidrawPlugin/index.ts
@@ -0,0 +1,55 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {$wrapNodeInElement} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $insertNodes,
+ $isRootOrShadowRoot,
+ COMMAND_PRIORITY_EDITOR,
+ createCommand,
+ LexicalCommand,
+} from 'lexical';
+import {useEffect} from 'react';
+
+import {
+ $createExcalidrawNode,
+ ExcalidrawNode,
+} from '../../nodes/ExcalidrawNode';
+
+export const INSERT_EXCALIDRAW_COMMAND: LexicalCommand = createCommand(
+ 'INSERT_EXCALIDRAW_COMMAND',
+);
+
+export default function ExcalidrawPlugin(): null {
+ const [editor] = useLexicalComposerContext();
+ useEffect(() => {
+ if (!editor.hasNodes([ExcalidrawNode])) {
+ throw new Error(
+ 'ExcalidrawPlugin: ExcalidrawNode not registered on editor',
+ );
+ }
+
+ return editor.registerCommand(
+ INSERT_EXCALIDRAW_COMMAND,
+ () => {
+ const excalidrawNode = $createExcalidrawNode();
+
+ $insertNodes([excalidrawNode]);
+ if ($isRootOrShadowRoot(excalidrawNode.getParentOrThrow())) {
+ $wrapNodeInElement(excalidrawNode, $createParagraphNode).selectEnd();
+ }
+
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ );
+ }, [editor]);
+
+ return null;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/FigmaPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/FigmaPlugin/index.tsx
new file mode 100644
index 0000000..36c4637
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/FigmaPlugin/index.tsx
@@ -0,0 +1,40 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {$insertNodeToNearestRoot} from '@lexical/utils';
+import {COMMAND_PRIORITY_EDITOR, createCommand, LexicalCommand} from 'lexical';
+import {useEffect} from 'react';
+
+import {$createFigmaNode, FigmaNode} from '../../nodes/FigmaNode';
+
+export const INSERT_FIGMA_COMMAND: LexicalCommand = createCommand(
+ 'INSERT_FIGMA_COMMAND',
+);
+
+export default function FigmaPlugin(): JSX.Element | null {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ if (!editor.hasNodes([FigmaNode])) {
+ throw new Error('FigmaPlugin: FigmaNode not registered on editor');
+ }
+
+ return editor.registerCommand(
+ INSERT_FIGMA_COMMAND,
+ (payload) => {
+ const figmaNode = $createFigmaNode(payload);
+ $insertNodeToNearestRoot(figmaNode);
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ );
+ }, [editor]);
+
+ return null;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/FloatingLinkEditorPlugin/index.css b/cyynote-frontend/src/components/lexical/plugins/FloatingLinkEditorPlugin/index.css
new file mode 100644
index 0000000..8c56f98
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/FloatingLinkEditorPlugin/index.css
@@ -0,0 +1,41 @@
+.link-editor {
+ display: flex;
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 10;
+ max-width: 400px;
+ width: 100%;
+ opacity: 0;
+ background-color: #fff;
+ box-shadow: 0 5px 10px rgba(0, 0, 0, 0.3);
+ border-radius: 0 0 8px 8px;
+ transition: opacity 0.5s;
+ will-change: transform;
+}
+
+.link-editor .button {
+ width: 20px;
+ height: 20px;
+ display: inline-block;
+ padding: 6px;
+ border-radius: 8px;
+ cursor: pointer;
+ margin: 0 2px;
+}
+
+.link-editor .button.hovered {
+ width: 20px;
+ height: 20px;
+ display: inline-block;
+ background-color: #eee;
+}
+
+.link-editor .button i,
+.actions i {
+ background-size: contain;
+ display: inline-block;
+ height: 20px;
+ width: 20px;
+ vertical-align: -0.25em;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/FloatingLinkEditorPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/FloatingLinkEditorPlugin/index.tsx
new file mode 100644
index 0000000..a070433
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/FloatingLinkEditorPlugin/index.tsx
@@ -0,0 +1,349 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import './index.css';
+
+import {$isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {$findMatchingParent, mergeRegister} from '@lexical/utils';
+import {
+ $getSelection,
+ $isRangeSelection,
+ BaseSelection,
+ CLICK_COMMAND,
+ COMMAND_PRIORITY_CRITICAL,
+ COMMAND_PRIORITY_HIGH,
+ COMMAND_PRIORITY_LOW,
+ KEY_ESCAPE_COMMAND,
+ LexicalEditor,
+ SELECTION_CHANGE_COMMAND,
+} from 'lexical';
+import {Dispatch, useCallback, useEffect, useRef, useState} from 'react';
+import * as React from 'react';
+import {createPortal} from 'react-dom';
+
+import {getSelectedNode} from '../../utils/getSelectedNode';
+import {setFloatingElemPositionForLinkEditor} from '../../utils/setFloatingElemPositionForLinkEditor';
+import {sanitizeUrl} from '../../utils/url';
+
+function FloatingLinkEditor({
+ editor,
+ isLink,
+ setIsLink,
+ anchorElem,
+ isLinkEditMode,
+ setIsLinkEditMode,
+}: {
+ editor: LexicalEditor;
+ isLink: boolean;
+ setIsLink: Dispatch;
+ anchorElem: HTMLElement;
+ isLinkEditMode: boolean;
+ setIsLinkEditMode: Dispatch;
+}): JSX.Element {
+ const editorRef = useRef(null);
+ const inputRef = useRef(null);
+ const [linkUrl, setLinkUrl] = useState('');
+ const [editedLinkUrl, setEditedLinkUrl] = useState('https://');
+ const [lastSelection, setLastSelection] = useState(
+ null,
+ );
+
+ const updateLinkEditor = useCallback(() => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ const node = getSelectedNode(selection);
+ const linkParent = $findMatchingParent(node, $isLinkNode);
+
+ if (linkParent) {
+ setLinkUrl(linkParent.getURL());
+ } else if ($isLinkNode(node)) {
+ setLinkUrl(node.getURL());
+ } else {
+ setLinkUrl('');
+ }
+ }
+ const editorElem = editorRef.current;
+ const nativeSelection = window.getSelection();
+ const activeElement = document.activeElement;
+
+ if (editorElem === null) {
+ return;
+ }
+
+ const rootElement = editor.getRootElement();
+
+ if (
+ selection !== null &&
+ nativeSelection !== null &&
+ rootElement !== null &&
+ rootElement.contains(nativeSelection.anchorNode) &&
+ editor.isEditable()
+ ) {
+ const domRect: DOMRect | undefined =
+ nativeSelection.focusNode?.parentElement?.getBoundingClientRect();
+ if (domRect) {
+ domRect.y += 40;
+ setFloatingElemPositionForLinkEditor(domRect, editorElem, anchorElem);
+ }
+ setLastSelection(selection);
+ } else if (!activeElement || activeElement.className !== 'link-input') {
+ if (rootElement !== null) {
+ setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem);
+ }
+ setLastSelection(null);
+ setIsLinkEditMode(false);
+ setLinkUrl('');
+ }
+
+ return true;
+ }, [anchorElem, editor, setIsLinkEditMode]);
+
+ useEffect(() => {
+ const scrollerElem = anchorElem.parentElement;
+
+ const update = () => {
+ editor.getEditorState().read(() => {
+ updateLinkEditor();
+ });
+ };
+
+ window.addEventListener('resize', update);
+
+ if (scrollerElem) {
+ scrollerElem.addEventListener('scroll', update);
+ }
+
+ return () => {
+ window.removeEventListener('resize', update);
+
+ if (scrollerElem) {
+ scrollerElem.removeEventListener('scroll', update);
+ }
+ };
+ }, [anchorElem.parentElement, editor, updateLinkEditor]);
+
+ useEffect(() => {
+ return mergeRegister(
+ editor.registerUpdateListener(({editorState}) => {
+ editorState.read(() => {
+ updateLinkEditor();
+ });
+ }),
+
+ editor.registerCommand(
+ SELECTION_CHANGE_COMMAND,
+ () => {
+ updateLinkEditor();
+ return true;
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ editor.registerCommand(
+ KEY_ESCAPE_COMMAND,
+ () => {
+ if (isLink) {
+ setIsLink(false);
+ return true;
+ }
+ return false;
+ },
+ COMMAND_PRIORITY_HIGH,
+ ),
+ );
+ }, [editor, updateLinkEditor, setIsLink, isLink]);
+
+ useEffect(() => {
+ editor.getEditorState().read(() => {
+ updateLinkEditor();
+ });
+ }, [editor, updateLinkEditor]);
+
+ useEffect(() => {
+ if (isLinkEditMode && inputRef.current) {
+ inputRef.current.focus();
+ }
+ }, [isLinkEditMode, isLink]);
+
+ const monitorInputInteraction = (
+ event: React.KeyboardEvent,
+ ) => {
+ if (event.key === 'Enter') {
+ event.preventDefault();
+ handleLinkSubmission();
+ } else if (event.key === 'Escape') {
+ event.preventDefault();
+ setIsLinkEditMode(false);
+ }
+ };
+
+ const handleLinkSubmission = () => {
+ if (lastSelection !== null) {
+ if (linkUrl !== '') {
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(editedLinkUrl));
+ }
+ setEditedLinkUrl('https://');
+ setIsLinkEditMode(false);
+ }
+ };
+
+ return (
+
+ {!isLink ? null : isLinkEditMode ? (
+ <>
+
{
+ setEditedLinkUrl(event.target.value);
+ }}
+ onKeyDown={(event) => {
+ monitorInputInteraction(event);
+ }}
+ />
+
+
event.preventDefault()}
+ onClick={() => {
+ setIsLinkEditMode(false);
+ }}
+ />
+
+
event.preventDefault()}
+ onClick={handleLinkSubmission}
+ />
+
+ >
+ ) : (
+
+
+ {linkUrl}
+
+
event.preventDefault()}
+ onClick={() => {
+ setEditedLinkUrl(linkUrl);
+ setIsLinkEditMode(true);
+ }}
+ />
+
event.preventDefault()}
+ onClick={() => {
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
+ }}
+ />
+
+ )}
+
+ );
+}
+
+function useFloatingLinkEditorToolbar(
+ editor: LexicalEditor,
+ anchorElem: HTMLElement,
+ isLinkEditMode: boolean,
+ setIsLinkEditMode: Dispatch
,
+): JSX.Element | null {
+ const [activeEditor, setActiveEditor] = useState(editor);
+ const [isLink, setIsLink] = useState(false);
+
+ useEffect(() => {
+ function updateToolbar() {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ const node = getSelectedNode(selection);
+ const linkParent = $findMatchingParent(node, $isLinkNode);
+ const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode);
+ // We don't want this menu to open for auto links.
+ if (linkParent !== null && autoLinkParent === null) {
+ setIsLink(true);
+ } else {
+ setIsLink(false);
+ }
+ }
+ }
+ return mergeRegister(
+ editor.registerUpdateListener(({editorState}) => {
+ editorState.read(() => {
+ updateToolbar();
+ });
+ }),
+ editor.registerCommand(
+ SELECTION_CHANGE_COMMAND,
+ (_payload, newEditor) => {
+ updateToolbar();
+ setActiveEditor(newEditor);
+ return false;
+ },
+ COMMAND_PRIORITY_CRITICAL,
+ ),
+ editor.registerCommand(
+ CLICK_COMMAND,
+ (payload) => {
+ const selection = $getSelection();
+ if ($isRangeSelection(selection)) {
+ const node = getSelectedNode(selection);
+ const linkNode = $findMatchingParent(node, $isLinkNode);
+ if ($isLinkNode(linkNode) && (payload.metaKey || payload.ctrlKey)) {
+ window.open(linkNode.getURL(), '_blank');
+ return true;
+ }
+ }
+ return false;
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ );
+ }, [editor]);
+
+ return createPortal(
+ ,
+ anchorElem,
+ );
+}
+
+export default function FloatingLinkEditorPlugin({
+ anchorElem = document.body,
+ isLinkEditMode,
+ setIsLinkEditMode,
+}: {
+ anchorElem?: HTMLElement;
+ isLinkEditMode: boolean;
+ setIsLinkEditMode: Dispatch;
+}): JSX.Element | null {
+ const [editor] = useLexicalComposerContext();
+ return useFloatingLinkEditorToolbar(
+ editor,
+ anchorElem,
+ isLinkEditMode,
+ setIsLinkEditMode,
+ );
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/FloatingTextFormatToolbarPlugin/index.css b/cyynote-frontend/src/components/lexical/plugins/FloatingTextFormatToolbarPlugin/index.css
new file mode 100644
index 0000000..7e722fc
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/FloatingTextFormatToolbarPlugin/index.css
@@ -0,0 +1,133 @@
+.floating-text-format-popup {
+ display: flex;
+ background: #fff;
+ padding: 4px;
+ vertical-align: middle;
+ position: absolute;
+ top: 0;
+ left: 0;
+ z-index: 10;
+ opacity: 0;
+ box-shadow: 0px 5px 10px rgba(0, 0, 0, 0.3);
+ border-radius: 8px;
+ transition: opacity 0.5s;
+ height: 35px;
+ will-change: transform;
+}
+
+.floating-text-format-popup button.popup-item {
+ border: 0;
+ display: flex;
+ background: none;
+ border-radius: 10px;
+ padding: 8px;
+ cursor: pointer;
+ vertical-align: middle;
+}
+
+.floating-text-format-popup button.popup-item:disabled {
+ cursor: not-allowed;
+}
+
+.floating-text-format-popup button.popup-item.spaced {
+ margin-right: 2px;
+}
+
+.floating-text-format-popup button.popup-item i.format {
+ background-size: contain;
+ height: 18px;
+ width: 18px;
+ margin-top: 2px;
+ vertical-align: -0.25em;
+ display: flex;
+ opacity: 0.6;
+}
+
+.floating-text-format-popup button.popup-item:disabled i.format {
+ opacity: 0.2;
+}
+
+.floating-text-format-popup button.popup-item.active {
+ background-color: rgba(223, 232, 250, 0.3);
+}
+
+.floating-text-format-popup button.popup-item.active i {
+ opacity: 1;
+}
+
+.floating-text-format-popup .popup-item:hover:not([disabled]) {
+ background-color: #eee;
+}
+
+.floating-text-format-popup select.popup-item {
+ border: 0;
+ display: flex;
+ background: none;
+ border-radius: 10px;
+ padding: 8px;
+ vertical-align: middle;
+ -webkit-appearance: none;
+ -moz-appearance: none;
+ width: 70px;
+ font-size: 14px;
+ color: #777;
+ text-overflow: ellipsis;
+}
+
+.floating-text-format-popup select.code-language {
+ text-transform: capitalize;
+ width: 130px;
+}
+
+.floating-text-format-popup .popup-item .text {
+ display: flex;
+ line-height: 20px;
+ vertical-align: middle;
+ font-size: 14px;
+ color: #777;
+ text-overflow: ellipsis;
+ width: 70px;
+ overflow: hidden;
+ height: 20px;
+ text-align: left;
+}
+
+.floating-text-format-popup .popup-item .icon {
+ display: flex;
+ width: 20px;
+ height: 20px;
+ user-select: none;
+ margin-right: 8px;
+ line-height: 16px;
+ background-size: contain;
+}
+
+.floating-text-format-popup i.chevron-down {
+ margin-top: 3px;
+ width: 16px;
+ height: 16px;
+ display: flex;
+ user-select: none;
+}
+
+.floating-text-format-popup i.chevron-down.inside {
+ width: 16px;
+ height: 16px;
+ display: flex;
+ margin-left: -25px;
+ margin-top: 11px;
+ margin-right: 10px;
+ pointer-events: none;
+}
+
+.floating-text-format-popup .divider {
+ width: 1px;
+ background-color: #eee;
+ margin: 0 4px;
+}
+
+@media (max-width: 1024px) {
+ .floating-text-format-popup button.insert-comment {
+ display: none;
+ }
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/FloatingTextFormatToolbarPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/FloatingTextFormatToolbarPlugin/index.tsx
new file mode 100644
index 0000000..83fac8e
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/FloatingTextFormatToolbarPlugin/index.tsx
@@ -0,0 +1,391 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+
+import './index.css';
+
+import {$isCodeHighlightNode} from '@lexical/code';
+import {$isLinkNode, TOGGLE_LINK_COMMAND} from '@lexical/link';
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {mergeRegister} from '@lexical/utils';
+import {
+ $getSelection,
+ $isParagraphNode,
+ $isRangeSelection,
+ $isTextNode,
+ COMMAND_PRIORITY_LOW,
+ FORMAT_TEXT_COMMAND,
+ LexicalEditor,
+ SELECTION_CHANGE_COMMAND,
+} from 'lexical';
+import {useCallback, useEffect, useRef, useState} from 'react';
+import {createPortal} from 'react-dom';
+
+import {getDOMRangeRect} from '../../utils/getDOMRangeRect';
+import {getSelectedNode} from '../../utils/getSelectedNode';
+import {setFloatingElemPosition} from '../../utils/setFloatingElemPosition';
+import {INSERT_INLINE_COMMAND} from '../CommentPlugin';
+
+function TextFormatFloatingToolbar({
+ editor,
+ anchorElem,
+ isLink,
+ isBold,
+ isItalic,
+ isUnderline,
+ isCode,
+ isStrikethrough,
+ isSubscript,
+ isSuperscript,
+}: {
+ editor: LexicalEditor;
+ anchorElem: HTMLElement;
+ isBold: boolean;
+ isCode: boolean;
+ isItalic: boolean;
+ isLink: boolean;
+ isStrikethrough: boolean;
+ isSubscript: boolean;
+ isSuperscript: boolean;
+ isUnderline: boolean;
+}): JSX.Element {
+ const popupCharStylesEditorRef = useRef(null);
+
+ const insertLink = useCallback(() => {
+ if (!isLink) {
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, 'https://');
+ } else {
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
+ }
+ }, [editor, isLink]);
+
+ const insertComment = () => {
+ editor.dispatchCommand(INSERT_INLINE_COMMAND, undefined);
+ };
+
+ function mouseMoveListener(e: MouseEvent) {
+ if (
+ popupCharStylesEditorRef?.current &&
+ (e.buttons === 1 || e.buttons === 3)
+ ) {
+ if (popupCharStylesEditorRef.current.style.pointerEvents !== 'none') {
+ const x = e.clientX;
+ const y = e.clientY;
+ const elementUnderMouse = document.elementFromPoint(x, y);
+
+ if (!popupCharStylesEditorRef.current.contains(elementUnderMouse)) {
+ // Mouse is not over the target element => not a normal click, but probably a drag
+ popupCharStylesEditorRef.current.style.pointerEvents = 'none';
+ }
+ }
+ }
+ }
+ function mouseUpListener(e: MouseEvent) {
+ if (popupCharStylesEditorRef?.current) {
+ if (popupCharStylesEditorRef.current.style.pointerEvents !== 'auto') {
+ popupCharStylesEditorRef.current.style.pointerEvents = 'auto';
+ }
+ }
+ }
+
+ useEffect(() => {
+ if (popupCharStylesEditorRef?.current) {
+ document.addEventListener('mousemove', mouseMoveListener);
+ document.addEventListener('mouseup', mouseUpListener);
+
+ return () => {
+ document.removeEventListener('mousemove', mouseMoveListener);
+ document.removeEventListener('mouseup', mouseUpListener);
+ };
+ }
+ }, [popupCharStylesEditorRef]);
+
+ const updateTextFormatFloatingToolbar = useCallback(() => {
+ const selection = $getSelection();
+
+ const popupCharStylesEditorElem = popupCharStylesEditorRef.current;
+ const nativeSelection = window.getSelection();
+
+ if (popupCharStylesEditorElem === null) {
+ return;
+ }
+
+ const rootElement = editor.getRootElement();
+ if (
+ selection !== null &&
+ nativeSelection !== null &&
+ !nativeSelection.isCollapsed &&
+ rootElement !== null &&
+ rootElement.contains(nativeSelection.anchorNode)
+ ) {
+ const rangeRect = getDOMRangeRect(nativeSelection, rootElement);
+
+ setFloatingElemPosition(
+ rangeRect,
+ popupCharStylesEditorElem,
+ anchorElem,
+ isLink,
+ );
+ }
+ }, [editor, anchorElem, isLink]);
+
+ useEffect(() => {
+ const scrollerElem = anchorElem.parentElement;
+
+ const update = () => {
+ editor.getEditorState().read(() => {
+ updateTextFormatFloatingToolbar();
+ });
+ };
+
+ window.addEventListener('resize', update);
+ if (scrollerElem) {
+ scrollerElem.addEventListener('scroll', update);
+ }
+
+ return () => {
+ window.removeEventListener('resize', update);
+ if (scrollerElem) {
+ scrollerElem.removeEventListener('scroll', update);
+ }
+ };
+ }, [editor, updateTextFormatFloatingToolbar, anchorElem]);
+
+ useEffect(() => {
+ editor.getEditorState().read(() => {
+ updateTextFormatFloatingToolbar();
+ });
+ return mergeRegister(
+ editor.registerUpdateListener(({editorState}) => {
+ editorState.read(() => {
+ updateTextFormatFloatingToolbar();
+ });
+ }),
+
+ editor.registerCommand(
+ SELECTION_CHANGE_COMMAND,
+ () => {
+ updateTextFormatFloatingToolbar();
+ return false;
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ );
+ }, [editor, updateTextFormatFloatingToolbar]);
+
+ return (
+
+ {editor.isEditable() && (
+ <>
+ {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'bold');
+ }}
+ className={'popup-item spaced ' + (isBold ? 'active' : '')}
+ aria-label="Format text as bold">
+
+
+ {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'italic');
+ }}
+ className={'popup-item spaced ' + (isItalic ? 'active' : '')}
+ aria-label="Format text as italics">
+
+
+ {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'underline');
+ }}
+ className={'popup-item spaced ' + (isUnderline ? 'active' : '')}
+ aria-label="Format text to underlined">
+
+
+ {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'strikethrough');
+ }}
+ className={'popup-item spaced ' + (isStrikethrough ? 'active' : '')}
+ aria-label="Format text with a strikethrough">
+
+
+ {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'subscript');
+ }}
+ className={'popup-item spaced ' + (isSubscript ? 'active' : '')}
+ title="Subscript"
+ aria-label="Format Subscript">
+
+
+ {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'superscript');
+ }}
+ className={'popup-item spaced ' + (isSuperscript ? 'active' : '')}
+ title="Superscript"
+ aria-label="Format Superscript">
+
+
+ {
+ editor.dispatchCommand(FORMAT_TEXT_COMMAND, 'code');
+ }}
+ className={'popup-item spaced ' + (isCode ? 'active' : '')}
+ aria-label="Insert code block">
+
+
+
+
+
+ >
+ )}
+
+
+
+
+ );
+}
+
+function useFloatingTextFormatToolbar(
+ editor: LexicalEditor,
+ anchorElem: HTMLElement,
+): JSX.Element | null {
+ const [isText, setIsText] = useState(false);
+ const [isLink, setIsLink] = useState(false);
+ const [isBold, setIsBold] = useState(false);
+ const [isItalic, setIsItalic] = useState(false);
+ const [isUnderline, setIsUnderline] = useState(false);
+ const [isStrikethrough, setIsStrikethrough] = useState(false);
+ const [isSubscript, setIsSubscript] = useState(false);
+ const [isSuperscript, setIsSuperscript] = useState(false);
+ const [isCode, setIsCode] = useState(false);
+
+ const updatePopup = useCallback(() => {
+ editor.getEditorState().read(() => {
+ // Should not to pop up the floating toolbar when using IME input
+ if (editor.isComposing()) {
+ return;
+ }
+ const selection = $getSelection();
+ const nativeSelection = window.getSelection();
+ const rootElement = editor.getRootElement();
+
+ if (
+ nativeSelection !== null &&
+ (!$isRangeSelection(selection) ||
+ rootElement === null ||
+ !rootElement.contains(nativeSelection.anchorNode))
+ ) {
+ setIsText(false);
+ return;
+ }
+
+ if (!$isRangeSelection(selection)) {
+ return;
+ }
+
+ const node = getSelectedNode(selection);
+
+ // Update text format
+ setIsBold(selection.hasFormat('bold'));
+ setIsItalic(selection.hasFormat('italic'));
+ setIsUnderline(selection.hasFormat('underline'));
+ setIsStrikethrough(selection.hasFormat('strikethrough'));
+ setIsSubscript(selection.hasFormat('subscript'));
+ setIsSuperscript(selection.hasFormat('superscript'));
+ setIsCode(selection.hasFormat('code'));
+
+ // Update links
+ const parent = node.getParent();
+ if ($isLinkNode(parent) || $isLinkNode(node)) {
+ setIsLink(true);
+ } else {
+ setIsLink(false);
+ }
+
+ if (
+ !$isCodeHighlightNode(selection.anchor.getNode()) &&
+ selection.getTextContent() !== ''
+ ) {
+ setIsText($isTextNode(node) || $isParagraphNode(node));
+ } else {
+ setIsText(false);
+ }
+
+ const rawTextContent = selection.getTextContent().replace(/\n/g, '');
+ if (!selection.isCollapsed() && rawTextContent === '') {
+ setIsText(false);
+ return;
+ }
+ });
+ }, [editor]);
+
+ useEffect(() => {
+ document.addEventListener('selectionchange', updatePopup);
+ return () => {
+ document.removeEventListener('selectionchange', updatePopup);
+ };
+ }, [updatePopup]);
+
+ useEffect(() => {
+ return mergeRegister(
+ editor.registerUpdateListener(() => {
+ updatePopup();
+ }),
+ editor.registerRootListener(() => {
+ if (editor.getRootElement() === null) {
+ setIsText(false);
+ }
+ }),
+ );
+ }, [editor, updatePopup]);
+
+ if (!isText) {
+ return null;
+ }
+
+ return createPortal(
+ ,
+ anchorElem,
+ );
+}
+
+export default function FloatingTextFormatToolbarPlugin({
+ anchorElem = document.body,
+}: {
+ anchorElem?: HTMLElement;
+}): JSX.Element | null {
+ const [editor] = useLexicalComposerContext();
+ return useFloatingTextFormatToolbar(editor, anchorElem);
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/GlobalEventsPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/GlobalEventsPlugin/index.tsx
new file mode 100644
index 0000000..6c73243
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/GlobalEventsPlugin/index.tsx
@@ -0,0 +1,33 @@
+import { useLayoutEffect } from 'react'
+import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'
+import { LexicalCommand, createCommand } from 'lexical'
+
+export const SAVE_COMMAND: LexicalCommand = createCommand('SAVE_COMMAND')
+
+const GlobalEventsPlugin = () => {
+ const [editor] = useLexicalComposerContext()
+
+ useLayoutEffect(() => {
+ const onKeyDown = (event: KeyboardEvent) => {
+ console.log(event)
+ // "Ctrl" or "Cmd" + "s"
+ if ((event.ctrlKey || event.metaKey) && event.which === 83) {
+ console.log('ctrlKey')
+ editor.dispatchCommand(SAVE_COMMAND, event)
+ }
+ }
+
+ return editor.registerRootListener((rootElement: HTMLElement | null, prevRootElement: HTMLElement | null) => {
+ if (prevRootElement !== null) {
+ prevRootElement.removeEventListener('keydown', onKeyDown)
+ }
+ if (rootElement !== null) {
+ rootElement.addEventListener('keydown', onKeyDown)
+ }
+ })
+ }, [editor])
+
+ return null
+}
+
+export default GlobalEventsPlugin
diff --git a/cyynote-frontend/src/components/lexical/plugins/ImagesPlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/ImagesPlugin/index.tsx
new file mode 100644
index 0000000..baab7e1
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/ImagesPlugin/index.tsx
@@ -0,0 +1,391 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {$wrapNodeInElement, mergeRegister} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $createRangeSelection,
+ $getSelection,
+ $insertNodes,
+ $isNodeSelection,
+ $isRootOrShadowRoot,
+ $setSelection,
+ COMMAND_PRIORITY_EDITOR,
+ COMMAND_PRIORITY_HIGH,
+ COMMAND_PRIORITY_LOW,
+ createCommand,
+ DRAGOVER_COMMAND,
+ DRAGSTART_COMMAND,
+ DROP_COMMAND,
+ LexicalCommand,
+ LexicalEditor,
+} from 'lexical';
+import {useEffect, useRef, useState} from 'react';
+import * as React from 'react';
+import {CAN_USE_DOM} from '../../shared/src/canUseDOM';
+
+import landscapeImage from '../../images/landscape.jpg';
+import yellowFlowerImage from '../../images/yellow-flower.jpg';
+import {
+ $createImageNode,
+ $isImageNode,
+ ImageNode,
+ ImagePayload,
+} from '../../nodes/ImageNode';
+import Button from '../../ui/Button';
+import {DialogActions, DialogButtonsList} from '../../ui/Dialog';
+import FileInput from '../../ui/FileInput';
+import TextInput from '../../ui/TextInput';
+
+export type InsertImagePayload = Readonly;
+
+const getDOMSelection = (targetWindow: Window | null): Selection | null =>
+ CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
+
+export const INSERT_IMAGE_COMMAND: LexicalCommand =
+ createCommand('INSERT_IMAGE_COMMAND');
+
+export function InsertImageUriDialogBody({
+ onClick,
+}: {
+ onClick: (payload: InsertImagePayload) => void;
+}) {
+ const [src, setSrc] = useState('');
+ const [altText, setAltText] = useState('');
+
+ const isDisabled = src === '';
+
+ return (
+ <>
+
+
+
+ onClick({altText, src})}>
+ Confirm
+
+
+ >
+ );
+}
+
+export function InsertImageUploadedDialogBody({
+ onClick,
+}: {
+ onClick: (payload: InsertImagePayload) => void;
+}) {
+ const [src, setSrc] = useState('');
+ const [altText, setAltText] = useState('');
+
+ const isDisabled = src === '';
+
+ const loadImage = (files: FileList | null) => {
+ const reader = new FileReader();
+ reader.onload = function () {
+ if (typeof reader.result === 'string') {
+ setSrc(reader.result);
+ }
+ return '';
+ };
+ if (files !== null) {
+ reader.readAsDataURL(files[0]);
+ }
+ };
+
+ return (
+ <>
+
+
+
+ onClick({altText, src})}>
+ Confirm
+
+
+ >
+ );
+}
+
+export function InsertImageDialog({
+ activeEditor,
+ onClose,
+}: {
+ activeEditor: LexicalEditor;
+ onClose: () => void;
+}): JSX.Element {
+ const [mode, setMode] = useState(null);
+ const hasModifier = useRef(false);
+
+ useEffect(() => {
+ hasModifier.current = false;
+ const handler = (e: KeyboardEvent) => {
+ hasModifier.current = e.altKey;
+ };
+ document.addEventListener('keydown', handler);
+ return () => {
+ document.removeEventListener('keydown', handler);
+ };
+ }, [activeEditor]);
+
+ const onClick = (payload: InsertImagePayload) => {
+ activeEditor.dispatchCommand(INSERT_IMAGE_COMMAND, payload);
+ onClose();
+ };
+
+ return (
+ <>
+ {!mode && (
+
+
+ onClick(
+ hasModifier.current
+ ? {
+ altText:
+ 'Daylight fir trees forest glacier green high ice landscape',
+ src: landscapeImage,
+ }
+ : {
+ altText: 'Yellow flower in tilt shift lens',
+ src: yellowFlowerImage,
+ },
+ )
+ }>
+ Sample
+
+ setMode('url')}>
+ URL
+
+ setMode('file')}>
+ File
+
+
+ )}
+ {mode === 'url' && }
+ {mode === 'file' && }
+ >
+ );
+}
+
+export default function ImagesPlugin({
+ captionsEnabled,
+}: {
+ captionsEnabled?: boolean;
+}): JSX.Element | null {
+ const [editor] = useLexicalComposerContext();
+
+ useEffect(() => {
+ if (!editor.hasNodes([ImageNode])) {
+ throw new Error('ImagesPlugin: ImageNode not registered on editor');
+ }
+
+ return mergeRegister(
+ editor.registerCommand(
+ INSERT_IMAGE_COMMAND,
+ (payload) => {
+ const imageNode = $createImageNode(payload);
+ $insertNodes([imageNode]);
+ if ($isRootOrShadowRoot(imageNode.getParentOrThrow())) {
+ $wrapNodeInElement(imageNode, $createParagraphNode).selectEnd();
+ }
+
+ return true;
+ },
+ COMMAND_PRIORITY_EDITOR,
+ ),
+ editor.registerCommand(
+ DRAGSTART_COMMAND,
+ (event) => {
+ return onDragStart(event);
+ },
+ COMMAND_PRIORITY_HIGH,
+ ),
+ editor.registerCommand(
+ DRAGOVER_COMMAND,
+ (event) => {
+ return onDragover(event);
+ },
+ COMMAND_PRIORITY_LOW,
+ ),
+ editor.registerCommand(
+ DROP_COMMAND,
+ (event) => {
+ return onDrop(event, editor);
+ },
+ COMMAND_PRIORITY_HIGH,
+ ),
+ );
+ }, [captionsEnabled, editor]);
+
+ return null;
+}
+
+const TRANSPARENT_IMAGE =
+ 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7';
+const img = document.createElement('img');
+img.src = TRANSPARENT_IMAGE;
+
+function onDragStart(event: DragEvent): boolean {
+ const node = getImageNodeInSelection();
+ if (!node) {
+ return false;
+ }
+ const dataTransfer = event.dataTransfer;
+ if (!dataTransfer) {
+ return false;
+ }
+ dataTransfer.setData('text/plain', '_');
+ dataTransfer.setDragImage(img, 0, 0);
+ dataTransfer.setData(
+ 'application/x-lexical-drag',
+ JSON.stringify({
+ data: {
+ altText: node.__altText,
+ caption: node.__caption,
+ height: node.__height,
+ key: node.getKey(),
+ maxWidth: node.__maxWidth,
+ showCaption: node.__showCaption,
+ src: node.__src,
+ width: node.__width,
+ },
+ type: 'image',
+ }),
+ );
+
+ return true;
+}
+
+function onDragover(event: DragEvent): boolean {
+ const node = getImageNodeInSelection();
+ if (!node) {
+ return false;
+ }
+ if (!canDropImage(event)) {
+ event.preventDefault();
+ }
+ return true;
+}
+
+function onDrop(event: DragEvent, editor: LexicalEditor): boolean {
+ const node = getImageNodeInSelection();
+ if (!node) {
+ return false;
+ }
+ const data = getDragImageData(event);
+ if (!data) {
+ return false;
+ }
+ event.preventDefault();
+ if (canDropImage(event)) {
+ const range = getDragSelection(event);
+ node.remove();
+ const rangeSelection = $createRangeSelection();
+ if (range !== null && range !== undefined) {
+ rangeSelection.applyDOMRange(range);
+ }
+ $setSelection(rangeSelection);
+ editor.dispatchCommand(INSERT_IMAGE_COMMAND, data);
+ }
+ return true;
+}
+
+function getImageNodeInSelection(): ImageNode | null {
+ const selection = $getSelection();
+ if (!$isNodeSelection(selection)) {
+ return null;
+ }
+ const nodes = selection.getNodes();
+ const node = nodes[0];
+ return $isImageNode(node) ? node : null;
+}
+
+function getDragImageData(event: DragEvent): null | InsertImagePayload {
+ const dragData = event.dataTransfer?.getData('application/x-lexical-drag');
+ if (!dragData) {
+ return null;
+ }
+ const {type, data} = JSON.parse(dragData);
+ if (type !== 'image') {
+ return null;
+ }
+
+ return data;
+}
+
+declare global {
+ interface DragEvent {
+ rangeOffset?: number;
+ rangeParent?: Node;
+ }
+}
+
+function canDropImage(event: DragEvent): boolean {
+ const target = event.target;
+ return !!(
+ target &&
+ target instanceof HTMLElement &&
+ !target.closest('code, span.editor-image') &&
+ target.parentElement &&
+ target.parentElement.closest('div.ContentEditable__root')
+ );
+}
+
+function getDragSelection(event: DragEvent): Range | null | undefined {
+ let range;
+ const target = event.target as null | Element | Document;
+ const targetWindow =
+ target == null
+ ? null
+ : target.nodeType === 9
+ ? (target as Document).defaultView
+ : (target as Element).ownerDocument.defaultView;
+ const domSelection = getDOMSelection(targetWindow);
+ if (document.caretRangeFromPoint) {
+ range = document.caretRangeFromPoint(event.clientX, event.clientY);
+ } else if (event.rangeParent && domSelection !== null) {
+ domSelection.collapse(event.rangeParent, event.rangeOffset || 0);
+ range = domSelection.getRangeAt(0);
+ } else {
+ throw Error(`Cannot get the selection when dragging`);
+ }
+
+ return range;
+}
diff --git a/cyynote-frontend/src/components/lexical/plugins/InlineImagePlugin/index.tsx b/cyynote-frontend/src/components/lexical/plugins/InlineImagePlugin/index.tsx
new file mode 100644
index 0000000..951a75d
--- /dev/null
+++ b/cyynote-frontend/src/components/lexical/plugins/InlineImagePlugin/index.tsx
@@ -0,0 +1,342 @@
+/**
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
+ *
+ * This source code is licensed under the MIT license found in the
+ * LICENSE file in the root directory of this source tree.
+ *
+ */
+import type {Position} from '../../nodes/InlineImageNode';
+
+import '../../ui/Checkbox.css';
+
+import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
+import {$wrapNodeInElement, mergeRegister} from '@lexical/utils';
+import {
+ $createParagraphNode,
+ $createRangeSelection,
+ $getSelection,
+ $insertNodes,
+ $isNodeSelection,
+ $isRootOrShadowRoot,
+ $setSelection,
+ COMMAND_PRIORITY_EDITOR,
+ COMMAND_PRIORITY_HIGH,
+ COMMAND_PRIORITY_LOW,
+ createCommand,
+ DRAGOVER_COMMAND,
+ DRAGSTART_COMMAND,
+ DROP_COMMAND,
+ LexicalCommand,
+ LexicalEditor,
+} from 'lexical';
+import * as React from 'react';
+import {useEffect, useRef, useState} from 'react';
+import {CAN_USE_DOM} from '../../shared/src/canUseDOM';
+
+import {
+ $createInlineImageNode,
+ $isInlineImageNode,
+ InlineImageNode,
+ InlineImagePayload,
+} from '../../nodes/InlineImageNode';
+import Button from '../../ui/Button';
+import {DialogActions} from '../../ui/Dialog';
+import FileInput from '../../ui/FileInput';
+import Select from '../../ui/Select';
+import TextInput from '../../ui/TextInput';
+
+export type InsertInlineImagePayload = Readonly;
+
+const getDOMSelection = (targetWindow: Window | null): Selection | null =>
+ CAN_USE_DOM ? (targetWindow || window).getSelection() : null;
+
+export const INSERT_INLINE_IMAGE_COMMAND: LexicalCommand =
+ createCommand('INSERT_INLINE_IMAGE_COMMAND');
+
+export function InsertInlineImageDialog({
+ activeEditor,
+ onClose,
+}: {
+ activeEditor: LexicalEditor;
+ onClose: () => void;
+}): JSX.Element {
+ const hasModifier = useRef(false);
+
+ const [src, setSrc] = useState('');
+ const [altText, setAltText] = useState('');
+ const [showCaption, setShowCaption] = useState(false);
+ const [position, setPosition] = useState('left');
+
+ const isDisabled = src === '';
+
+ const handleShowCaptionChange = (e: React.ChangeEvent