From ff10b1d820097cdc248eefe531c627323c41f8db Mon Sep 17 00:00:00 2001 From: Bedru Umer <63902795+bedre7@users.noreply.github.com> Date: Tue, 26 Nov 2024 18:48:17 +0300 Subject: [PATCH] [lexical-playground] Bug Fix: autocomplete format before and after insertion (#6845) --- .../__tests__/e2e/Autocomplete.spec.mjs | 112 ++++++++++++++++-- packages/lexical-playground/src/App.tsx | 33 +++--- .../src/context/SharedAutocompleteContext.tsx | 71 ----------- .../src/nodes/AutocompleteNode.tsx | 72 +++++------ .../src/plugins/AutocompletePlugin/index.tsx | 43 +++++-- 5 files changed, 180 insertions(+), 151 deletions(-) delete mode 100644 packages/lexical-playground/src/context/SharedAutocompleteContext.tsx diff --git a/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs b/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs index 1f1cf21bcae..39d3ef16e50 100644 --- a/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs +++ b/packages/lexical-playground/__tests__/e2e/Autocomplete.spec.mjs @@ -6,6 +6,14 @@ * */ +import { + decreaseFontSize, + increaseFontSize, + toggleBold, + toggleItalic, + toggleStrikethrough, + toggleUnderline, +} from '../keyboardShortcuts/index.mjs'; import { assertHTML, focusEditor, @@ -30,14 +38,12 @@ test.describe('Autocomplete', () => { class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr" dir="ltr"> Sort by alpha - - - betical (TAB) - + + betical (TAB) -

`, html` @@ -45,8 +51,7 @@ test.describe('Autocomplete', () => { class="PlaygroundEditorTheme__paragraph PlaygroundEditorTheme__ltr" dir="ltr"> Sort by alpha - -
+

`, ); @@ -58,7 +63,94 @@ test.describe('Autocomplete', () => {

- Sort by alphabetical order: + Sort by alpha + + betical order: + +

+ `, + ); + }); + + test('Can autocomplete in the same format as the original text', async ({ + page, + isPlainText, + }) => { + test.skip(isPlainText); + await focusEditor(page); + await toggleBold(page); + await toggleItalic(page); + await toggleUnderline(page); + await toggleStrikethrough(page); + await increaseFontSize(page); + + await page.keyboard.type('Test'); + await sleep(500); + + await assertHTML( + page, + html` +

+ + Test + + + imonials (TAB) + +

+ `, + html` +

+ + Test + + +

+ `, + ); + + await page.keyboard.press('Tab'); + + await toggleBold(page); + await toggleItalic(page); + await toggleUnderline(page); + await toggleStrikethrough(page); + await decreaseFontSize(page); + + await page.keyboard.type(' 2024'); + + await assertHTML( + page, + html` +

+ + Test + + + imonials + + 2024

`, ); diff --git a/packages/lexical-playground/src/App.tsx b/packages/lexical-playground/src/App.tsx index 6f20d8c3841..aa4852cf87e 100644 --- a/packages/lexical-playground/src/App.tsx +++ b/packages/lexical-playground/src/App.tsx @@ -22,7 +22,6 @@ import { import {isDevPlayground} from './appSettings'; import {FlashMessageContext} from './context/FlashMessageContext'; import {SettingsContext, useSettings} from './context/SettingsContext'; -import {SharedAutocompleteContext} from './context/SharedAutocompleteContext'; import {SharedHistoryContext} from './context/SharedHistoryContext'; import {ToolbarContext} from './context/ToolbarContext'; import Editor from './Editor'; @@ -211,24 +210,22 @@ function App(): JSX.Element { - - -
- - Lexical Logo - -
-
- -
- - {isDevPlayground ? : null} - {isDevPlayground ? : null} - {isDevPlayground ? : null} + +
+ + Lexical Logo + +
+
+ +
+ + {isDevPlayground ? : null} + {isDevPlayground ? : null} + {isDevPlayground ? : null} - {measureTypingPerf ? : null} -
-
+ {measureTypingPerf ? : null} +
diff --git a/packages/lexical-playground/src/context/SharedAutocompleteContext.tsx b/packages/lexical-playground/src/context/SharedAutocompleteContext.tsx deleted file mode 100644 index 4f282709eea..00000000000 --- a/packages/lexical-playground/src/context/SharedAutocompleteContext.tsx +++ /dev/null @@ -1,71 +0,0 @@ -/** - * 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'; -import { - createContext, - ReactNode, - useContext, - useEffect, - useMemo, - useState, -} from 'react'; - -type Suggestion = null | string; -type CallbackFn = (newSuggestion: Suggestion) => void; -type SubscribeFn = (callbackFn: CallbackFn) => () => void; -type PublishFn = (newSuggestion: Suggestion) => void; -type ContextShape = [SubscribeFn, PublishFn]; -type HookShape = [suggestion: Suggestion, setSuggestion: PublishFn]; - -const Context: React.Context = createContext([ - (_cb) => () => { - return; - }, - (_newSuggestion: Suggestion) => { - return; - }, -]); - -export const SharedAutocompleteContext = ({ - children, -}: { - children: ReactNode; -}): JSX.Element => { - const context: ContextShape = useMemo(() => { - let suggestion: Suggestion | null = null; - const listeners: Set = new Set(); - return [ - (cb: (newSuggestion: Suggestion) => void) => { - cb(suggestion); - listeners.add(cb); - return () => { - listeners.delete(cb); - }; - }, - (newSuggestion: Suggestion) => { - suggestion = newSuggestion; - for (const listener of listeners) { - listener(newSuggestion); - } - }, - ]; - }, []); - return {children}; -}; - -export const useSharedAutocompleteContext = (): HookShape => { - const [subscribe, publish]: ContextShape = useContext(Context); - const [suggestion, setSuggestion] = useState(null); - useEffect(() => { - return subscribe((newSuggestion: Suggestion) => { - setSuggestion(newSuggestion); - }); - }, [subscribe]); - return [suggestion, publish]; -}; diff --git a/packages/lexical-playground/src/nodes/AutocompleteNode.tsx b/packages/lexical-playground/src/nodes/AutocompleteNode.tsx index f3eb6bd715a..220add6396c 100644 --- a/packages/lexical-playground/src/nodes/AutocompleteNode.tsx +++ b/packages/lexical-playground/src/nodes/AutocompleteNode.tsx @@ -7,36 +7,26 @@ */ import type { + DOMExportOutput, EditorConfig, - EditorThemeClassName, LexicalEditor, NodeKey, - SerializedLexicalNode, + SerializedTextNode, Spread, } from 'lexical'; -import {DecoratorNode} from 'lexical'; -import * as React from 'react'; +import {TextNode} from 'lexical'; -import {useSharedAutocompleteContext} from '../context/SharedAutocompleteContext'; import {uuid as UUID} from '../plugins/AutocompletePlugin'; -declare global { - interface Navigator { - userAgentData?: { - mobile: boolean; - }; - } -} - export type SerializedAutocompleteNode = Spread< { uuid: string; }, - SerializedLexicalNode + SerializedTextNode >; -export class AutocompleteNode extends DecoratorNode { +export class AutocompleteNode extends TextNode { /** * A unique uuid is generated for each session and assigned to the instance. * This helps to: @@ -48,7 +38,7 @@ export class AutocompleteNode extends DecoratorNode { __uuid: string; static clone(node: AutocompleteNode): AutocompleteNode { - return new AutocompleteNode(node.__uuid, node.__key); + return new AutocompleteNode(node.__text, node.__uuid, node.__key); } static getType(): 'autocomplete' { @@ -58,7 +48,14 @@ export class AutocompleteNode extends DecoratorNode { static importJSON( serializedNode: SerializedAutocompleteNode, ): AutocompleteNode { - const node = $createAutocompleteNode(serializedNode.uuid); + const node = $createAutocompleteNode( + serializedNode.text, + serializedNode.uuid, + ); + node.setFormat(serializedNode.format); + node.setDetail(serializedNode.detail); + node.setMode(serializedNode.mode); + node.setStyle(serializedNode.style); return node; } @@ -71,8 +68,8 @@ export class AutocompleteNode extends DecoratorNode { }; } - constructor(uuid: string, key?: NodeKey) { - super(key); + constructor(text: string, uuid: string, key?: NodeKey) { + super(text, key); this.__uuid = uuid; } @@ -84,36 +81,23 @@ export class AutocompleteNode extends DecoratorNode { return false; } - createDOM(config: EditorConfig): HTMLElement { - return document.createElement('span'); + exportDOM(_: LexicalEditor): DOMExportOutput { + return {element: null}; } - decorate(editor: LexicalEditor, config: EditorConfig): JSX.Element | null { + createDOM(config: EditorConfig): HTMLElement { if (this.__uuid !== UUID) { - return null; + return document.createElement('span'); } - return ; + const dom = super.createDOM(config); + dom.classList.add(config.theme.autocomplete); + return dom; } } -export function $createAutocompleteNode(uuid: string): AutocompleteNode { - return new AutocompleteNode(uuid); -} - -function AutocompleteComponent({ - className, -}: { - className: EditorThemeClassName; -}): JSX.Element { - const [suggestion] = useSharedAutocompleteContext(); - const userAgentData = window.navigator.userAgentData; - const isMobile = - userAgentData !== undefined - ? userAgentData.mobile - : window.innerWidth <= 800 && window.innerHeight <= 600; - return ( - - {suggestion} {isMobile ? '(SWIPE \u2B95)' : '(TAB)'} - - ); +export function $createAutocompleteNode( + text: string, + uuid: string, +): AutocompleteNode { + return new AutocompleteNode(text, uuid); } diff --git a/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx b/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx index 7e32e1f4fb1..fa7d5fe5690 100644 --- a/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx +++ b/packages/lexical-playground/src/plugins/AutocompletePlugin/index.tsx @@ -6,7 +6,7 @@ * */ -import type {BaseSelection, NodeKey} from 'lexical'; +import type {BaseSelection, NodeKey, TextNode} from 'lexical'; import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext'; import {$isAtNodeEnd} from '@lexical/selection'; @@ -24,13 +24,21 @@ import { } from 'lexical'; import {useCallback, useEffect} from 'react'; -import {useSharedAutocompleteContext} from '../../context/SharedAutocompleteContext'; +import {useToolbarState} from '../../context/ToolbarContext'; import { $createAutocompleteNode, AutocompleteNode, } from '../../nodes/AutocompleteNode'; import {addSwipeRightListener} from '../../utils/swipe'; +declare global { + interface Navigator { + userAgentData?: { + mobile: boolean; + }; + } +} + type SearchPromise = { dismiss: () => void; promise: Promise; @@ -76,16 +84,27 @@ function useQuery(): (searchText: string) => SearchPromise { }, []); } +function formatSuggestionText(suggestion: string): string { + const userAgentData = window.navigator.userAgentData; + const isMobile = + userAgentData !== undefined + ? userAgentData.mobile + : window.innerWidth <= 800 && window.innerHeight <= 600; + + return `${suggestion} ${isMobile ? '(SWIPE \u2B95)' : '(TAB)'}`; +} + export default function AutocompletePlugin(): JSX.Element | null { const [editor] = useLexicalComposerContext(); - const [, setSuggestion] = useSharedAutocompleteContext(); const query = useQuery(); + const {toolbarState} = useToolbarState(); useEffect(() => { let autocompleteNodeKey: null | NodeKey = null; let lastMatch: null | string = null; let lastSuggestion: null | string = null; let searchPromise: null | SearchPromise = null; + let prevNodeFormat: number = 0; function $clearSuggestion() { const autocompleteNode = autocompleteNodeKey !== null @@ -101,7 +120,7 @@ export default function AutocompletePlugin(): JSX.Element | null { } lastMatch = null; lastSuggestion = null; - setSuggestion(null); + prevNodeFormat = 0; } function updateAsyncSuggestion( refSearchPromise: SearchPromise, @@ -124,12 +143,18 @@ export default function AutocompletePlugin(): JSX.Element | null { return; } const selectionCopy = selection.clone(); - const node = $createAutocompleteNode(uuid); + const prevNode = selection.getNodes()[0] as TextNode; + prevNodeFormat = prevNode.getFormat(); + const node = $createAutocompleteNode( + formatSuggestionText(newSuggestion), + uuid, + ) + .setFormat(prevNodeFormat) + .setStyle(`font-size: ${toolbarState.fontSize}`); autocompleteNodeKey = node.getKey(); selection.insertNodes([node]); $setSelection(selectionCopy); lastSuggestion = newSuggestion; - setSuggestion(newSuggestion); }, {tag: 'history-merge'}, ); @@ -175,7 +200,9 @@ export default function AutocompletePlugin(): JSX.Element | null { if (autocompleteNode === null) { return false; } - const textNode = $createTextNode(lastSuggestion); + const textNode = $createTextNode(lastSuggestion) + .setFormat(prevNodeFormat) + .setStyle(`font-size: ${toolbarState.fontSize}`); autocompleteNode.replace(textNode); textNode.selectNext(); $clearSuggestion(); @@ -224,7 +251,7 @@ export default function AutocompletePlugin(): JSX.Element | null { : []), unmountSuggestion, ); - }, [editor, query, setSuggestion]); + }, [editor, query, toolbarState.fontSize]); return null; }