Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
228 changes: 130 additions & 98 deletions packages/koenig-lexical/src/plugins/AtLinkPlugin.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
});
}
}
};

Expand All @@ -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();
Expand Down Expand Up @@ -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);
}
}
}

Expand Down Expand Up @@ -316,6 +332,11 @@ export const KoenigAtLinkPlugin = ({searchLinks, siteUrl}) => {
return true;
}
}

if ($isAtLinkNode(anchorNode) && anchorNode.getTextContent() === '') {
$removeAtLink(anchorNode, {focus: true});
return true;
}
}
return false;
},
Expand Down Expand Up @@ -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);
}
Expand Down
Loading