diff --git a/packages/koenig-lexical/src/plugins/AtLinkPlugin.jsx b/packages/koenig-lexical/src/plugins/AtLinkPlugin.jsx index 44f352ba0d..0efe9c468d 100644 --- a/packages/koenig-lexical/src/plugins/AtLinkPlugin.jsx +++ b/packages/koenig-lexical/src/plugins/AtLinkPlugin.jsx @@ -57,6 +57,90 @@ function noResultOptions() { }]; } +const handleAtDetected = (editor) => { + let replaceAt = false; + + editor.getEditorState().read(() => { + // get the current selection + const selection = $getSelection(); + if (!$isRangeSelection(selection) || !selection.isCollapsed()) { + return; + } + + const anchor = selection.anchor; + if (anchor.type !== 'text') { + return; + } + + const anchorNode = anchor.getNode(); + if (!anchorNode.isSimpleText()) { + return; + } + + let anchorOffset = anchor.offset; + let textBeforeAnchor = anchorNode.getTextContent().slice(0, anchorOffset); + let textAfterAnchor = anchorNode.getTextContent().slice(anchorOffset); + + // adjust before/after text if we're immediately preceded/followed by a text node + // because that content needs to be accounted for in our regex match + const prevSibling = anchorNode.getPreviousSibling(); + const nextSibling = anchorNode.getNextSibling(); + + if (anchorOffset === 0 && $isTextNode(prevSibling)) { + textBeforeAnchor = prevSibling.getTextContent(); + } + + if (anchorOffset === anchorNode.getTextContent().length && $isTextNode(nextSibling)) { + textAfterAnchor = nextSibling.getTextContent(); + } + + const textBeforeRegExp = /(^|\s)@$/; + const textAfterRegExp = /^($|\s|\.)/; + + if ( + textBeforeRegExp.test(textBeforeAnchor) + && textAfterRegExp.test(textAfterAnchor) + ) { + replaceAt = true; + } + }); + + if (replaceAt) { + editor.update(() => { + // selection should now be where the '@' character was + const selection = $getSelection(); + + // store current node's format so it can be re-applied to the eventual link node + const linkFormat = selection.anchor.getNode().getFormat(); + + // delete the '@' character + selection.deleteCharacter(true); + + // prep the at-link node + const atLinkNode = $createAtLinkNode(); + atLinkNode.setLinkFormat(linkFormat); + const zwnjNode = $createZWNJNode(); + atLinkNode.append(zwnjNode); + const atLinkSearchNode = $createAtLinkSearchNode(''); + atLinkNode.append(atLinkSearchNode); + + // insert it + selection.insertNodes([atLinkNode]); + + // ensure we still have a cursor and it's inside the search node + atLinkNode.select(1, 1); + + const searchNode = atLinkNode.getChildAtIndex(1); + const rangeSelection = $getSelection(); + if ($isRangeSelection(rangeSelection)) { + rangeSelection.anchor.set(searchNode.getKey(), 0, 'element'); + rangeSelection.focus.set(searchNode.getKey(), 0, 'element'); + } + }); + return true; + } +}; + // Manages at-link search nodes and display of the search results panel when appropriate export const KoenigAtLinkPlugin = ({searchLinks, siteUrl}) => { const [editor] = useLexicalComposerContext(); @@ -80,98 +164,20 @@ export const KoenigAtLinkPlugin = ({searchLinks, siteUrl}) => { } if (event.inputType === 'insertText' && event.data === '@') { - let replaceAt = false; - - editor.getEditorState().read(() => { - // get the current selection - const selection = $getSelection(); - if (!$isRangeSelection(selection) || !selection.isCollapsed()) { - return; - } - - const anchor = selection.anchor; - if (anchor.type !== 'text') { - return; - } - - const anchorNode = anchor.getNode(); - if (!anchorNode.isSimpleText()) { - return; - } - - let anchorOffset = anchor.offset; - let textBeforeAnchor = anchorNode.getTextContent().slice(0, anchorOffset); - let textAfterAnchor = anchorNode.getTextContent().slice(anchorOffset); - - // adjust before/after text if we're immediately preceded/followed by a text node - // because that content needs to be accounted for in our regex match - const prevSibling = anchorNode.getPreviousSibling(); - const nextSibling = anchorNode.getNextSibling(); - - if (anchorOffset === 0 && $isTextNode(prevSibling)) { - textBeforeAnchor = prevSibling.getTextContent(); - } - - if (anchorOffset === anchorNode.getTextContent().length && $isTextNode(nextSibling)) { - textAfterAnchor = nextSibling.getTextContent(); - } - - const textBeforeRegExp = /(^|\s)@$/; - const textAfterRegExp = /^($|\s|\.)/; - - if ( - textBeforeRegExp.test(textBeforeAnchor) - && textAfterRegExp.test(textAfterAnchor) - ) { - replaceAt = true; - } - }); - - if (replaceAt) { - editor.update(() => { - // selection should now be where the '@' character was - const selection = $getSelection(); - - // store current node's format so it can be re-applied to the eventual link node - const linkFormat = selection.anchor.getNode().getFormat(); - - // delete the '@' character - selection.deleteCharacter(true); - - // prep the at-link node - const atLinkNode = $createAtLinkNode(); - atLinkNode.setLinkFormat(linkFormat); - const zwnjNode = $createZWNJNode(); - atLinkNode.append(zwnjNode); - const atLinkSearchNode = $createAtLinkSearchNode(''); - atLinkNode.append(atLinkSearchNode); - - // insert it - selection.insertNodes([atLinkNode]); - - // ensure we still have a cursor and it's inside the search node - atLinkNode.select(1, 1); - - const searchNode = atLinkNode.getChildAtIndex(1); - const rangeSelection = $getSelection(); - if ($isRangeSelection(rangeSelection)) { - rangeSelection.anchor.set(searchNode.getKey(), 0, 'element'); - rangeSelection.focus.set(searchNode.getKey(), 0, 'element'); - } - }); - } + handleAtDetected(editor); } }; - // weirdly the 'input' event doesn't fire for the first character typed in a paragraph const handleAtBeforeInput = (event) => { if (event.inputType === 'insertText' && event.data === '@') { - editor.update(() => { - const selection = $getSelection(); - if ($isRangeSelection(selection) && selection.isCollapsed() && !selection.anchor.getNode().getPreviousSibling()) { - handleAtInsert(event); - } - }); + const atHandled = handleAtDetected(editor); + + if (!atHandled) { // Firefox gives us the beforeinput event before the editor state is updated - catch the next update instead + const deregister = editor.registerUpdateListener(() => { + handleAtDetected(editor); + deregister(); + }); + } } }; @@ -189,12 +195,20 @@ export const KoenigAtLinkPlugin = ({searchLinks, siteUrl}) => { // - update plugin state with search query based on at-link-search node text content // - remove at-link nodes when they don't have focus (i.e. using arrow keys to move out of them) React.useEffect(() => { - return editor.registerUpdateListener(() => { + return editor.registerUpdateListener(({prevEditorState}) => { // do nothing if we're in the middle of composing text if (editor.isComposing()) { return; } + let prevSelectionType; + let prevSelectionOffset; + prevEditorState.read(() => { + const selection = $getSelection(); + prevSelectionType = selection?.anchor?.getNode().getType(); + prevSelectionOffset = selection?.anchor?.offset; + }); + editor.update(() => { const atLinkNodes = $nodesOfType(AtLinkNode); const selection = $getSelection(); @@ -243,8 +257,10 @@ export const KoenigAtLinkPlugin = ({searchLinks, siteUrl}) => { selectedAtLinkNode.select(1, 1); const rangeSelection = $getSelection(); if ($isRangeSelection(rangeSelection)) { - rangeSelection.anchor.set(searchNode.getKey(), 0, 'element'); - rangeSelection.focus.set(searchNode.getKey(), 0, 'element'); + if (!(prevSelectionType === 'at-link-search' && prevSelectionOffset === 0)) { // Avoid infinite loop + rangeSelection.anchor.set(searchNode.getKey(), 0); + rangeSelection.focus.set(searchNode.getKey(), 0); + } } } @@ -316,6 +332,11 @@ export const KoenigAtLinkPlugin = ({searchLinks, siteUrl}) => { return true; } } + + if ($isAtLinkNode(anchorNode) && anchorNode.getTextContent() === '') { + $removeAtLink(anchorNode, {focus: true}); + return true; + } } return false; }, @@ -393,15 +414,26 @@ export const KoenigAtLinkPlugin = ({searchLinks, siteUrl}) => { }); // consolidate multiple search nodes from previous step into single node - const searchNode = atLinkNode.getChildAtIndex(1); - const currentText = searchNode.getTextContent(); - let consolidatedText = currentText; - atLinkNode.getChildren().forEach((child, index) => { - if (index > 1) { - consolidatedText += child.getTextContent(); - child.remove(); - } + // NB: Leave the first non-empty search node in-tact if possible + + let searchNodes = atLinkNode.getChildren().slice(1); + const currentText = searchNodes.map(node => node.getTextContent()).join(); + if (currentText !== '') { + searchNodes.forEach((node) => { + if (!node.getTextContent()) { + node.remove(); + } + }); + searchNodes = atLinkNode.getChildren().slice(1); + } + + const [searchNode, ...restNodes] = searchNodes; + let consolidatedText = searchNode.getTextContent(); + restNodes.forEach((node) => { + consolidatedText += node.getTextContent(); + node.remove(); }); + if (consolidatedText !== currentText) { searchNode.setTextContent(consolidatedText); } diff --git a/packages/koenig-lexical/test/e2e/internal-linking-firefox-test.js b/packages/koenig-lexical/test/e2e/internal-linking-firefox-test.js new file mode 100644 index 0000000000..d4df332ca2 --- /dev/null +++ b/packages/koenig-lexical/test/e2e/internal-linking-firefox-test.js @@ -0,0 +1,373 @@ +import {assertHTML, focusEditor, html, initialize, insertCard, pasteText} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; + +test.describe('Internal linking (Firefox)', async () => { + let page; + + test.beforeAll(async ({browser}) => { + page = await browser.newPage(); + }); + + test.beforeEach(async () => { + await initialize({page, uri: '/#/?content=false&labs=internalLinking'}); + }); + + test.afterAll(async () => { + await page.close(); + }); + + test.describe('Bookmark card', function () { + test('shows default options when opening', async function () { + await focusEditor(page); + await insertCard(page, {cardName: 'bookmark'}); + await expect(page.getByTestId('bookmark-url-dropdown')).toBeVisible(); + await assertHTML(page, html` +
+ + + + + +
+ `); + + await assertHTML(page, html` ++ + + + Emo + +
+ `); + + // wait for search to complete + await expect(page.locator('[data-testid="at-link-results-listOption-label"]')).toContainText(['✨ Emoji autocomplete ✨']); + + await assertHTML(page, html` +@Unknown page
+ `); + }); + + test('removes at-linking when backspacing', async function () { + await focusEditor(page); + await page.keyboard.type('@'); + await page.keyboard.type('AB'); + + await page.keyboard.press('Backspace'); + await page.keyboard.press('Backspace'); + // we should now have an empty input field with placeholder text + await assertHTML(page, html` ++ + + + + +
+ `); + + await page.keyboard.press('Backspace'); + + // it should now remove the at-linking entirely leaving only an @ + await assertHTML(page, html` +@
+ `); + }); + + test('creates a bookmark when at-linking from a line', async function () { + await focusEditor(page); + + await page.keyboard.type('@'); + await page.keyboard.type('Emo'); + await expect(page.locator('[data-testid="at-link-results-listOption-label"]')).toContainText(['✨ Emoji autocomplete ✨']); + await page.keyboard.press('Enter'); + // now wait till data-testid="bookmark-container" appears + await page.waitForSelector('[data-testid="bookmark-container"]'); + await assertHTML(page, html` +
+ + + + https://ghost.org + +
+ `); + }); + }); +});