Skip to content

Instantly share code, notes, and snippets.

@zurfyx
Created July 21, 2023 18:57
Show Gist options
  • Save zurfyx/1aebb1af7537965e9e42f4ae73a3b534 to your computer and use it in GitHub Desktop.
Save zurfyx/1aebb1af7537965e9e42f4ae73a3b534 to your computer and use it in GitHub Desktop.
Lexical Offline (Multi) Selection

The EditorState is always synced with other parties via Collab, and part of the clipboard logic.

There's times when the result should be kept strictly offline. For example:

  1. Search or multi text search (i.e. VSCode find all). Or to show GenAI GPT results.
  2. Other player cursors in Collab (not part of th EditorState)
  3. GenAI GPT Office/Notion-like popover on edit when the prompt refers to a specific part of the edit but the focus has moved to the modal.

This solution leverages Range to follow DOM updates and render on top of the Lexical editor completely independently from the editor. The MutationObserver introduced below is particularly important to make sure that the position is always up-to-date. For example, additional changes in the padding or additional added/removed nodes somewhere on the contenteditable or immediately before our selection host.

The editor dependency is very lightweight, it could easily be decoupled completely.

export default function lexicalMarkSelection(
editor: LexicalEditor,
onReposition?: (node: Array<HTMLElement>) => void,
): () => void {
let previousAnchorNode = null;
let previousAnchorOffset = null;
let previousFocusNode = null;
let previousFocusOffset = null;
let removeRangeListener: () => void = emptyFunction;
function compute(editorState: EditorState) {
editorState.read(() => {
const selection = $getSelection();
if (!$isRangeSelection(selection)) {
// TODO
previousAnchorNode = null;
previousAnchorOffset = null;
previousFocusNode = null;
previousFocusOffset = null;
removeRangeListener();
removeRangeListener = emptyFunction;
return;
}
const {anchor, focus} = selection;
const currentAnchorNode = anchor.getNode();
const currentAnchorNodeKey = currentAnchorNode.getKey();
const currentAnchorOffset = anchor.offset;
const currentFocusNode = focus.getNode();
const currentFocusNodeKey = currentFocusNode.getKey();
const currentFocusOffset = focus.offset;
const currentAnchorNodeDOM = editor.getElementByKey(currentAnchorNodeKey);
const currentFocusNodeDOM = editor.getElementByKey(currentFocusNodeKey);
const differentAnchorDOM =
previousAnchorNode === null ||
currentAnchorNodeDOM === null ||
currentAnchorOffset !== previousAnchorOffset ||
currentAnchorNodeKey !== previousAnchorNode.getKey() ||
(currentAnchorNode !== previousAnchorNode &&
(!(previousAnchorNode instanceof TextNode) ||
currentAnchorNode.updateDOM(
previousAnchorNode,
currentAnchorNodeDOM,
editor._config,
)));
const differentFocusDOM =
previousFocusNode === null ||
currentFocusNodeDOM === null ||
currentFocusOffset !== previousFocusOffset ||
currentFocusNodeKey !== previousFocusNode.getKey() ||
(currentFocusNode !== previousFocusNode &&
(!(previousFocusNode instanceof TextNode) ||
currentFocusNode.updateDOM(
previousFocusNode,
currentFocusNodeDOM,
editor._config,
)));
if (differentAnchorDOM || differentFocusDOM) {
const anchorHTMLElement = editor.getElementByKey(
anchor.getNode().getKey(),
);
const focusHTMLElement = editor.getElementByKey(
focus.getNode().getKey(),
);
// TODO handle selection beyond the common TextNode
if (
anchorHTMLElement !== null &&
focusHTMLElement !== null &&
anchorHTMLElement.tagName === 'SPAN' &&
focusHTMLElement.tagName === 'SPAN'
) {
const range = document.createRange();
let firstHTMLElement;
let firstOffset;
let lastHTMLElement;
let lastOffset;
if (focus.isBefore(anchor)) {
firstHTMLElement = focusHTMLElement;
firstOffset = focus.offset;
lastHTMLElement = anchorHTMLElement;
lastOffset = anchor.offset;
} else {
firstHTMLElement = anchorHTMLElement;
firstOffset = anchor.offset;
lastHTMLElement = focusHTMLElement;
lastOffset = focus.offset;
}
range.setStart(nullthrows(firstHTMLElement.firstChild), firstOffset);
range.setEnd(nullthrows(lastHTMLElement.firstChild), lastOffset);
removeRangeListener();
removeRangeListener = lexicalPositionNodeOnRange(
editor,
range,
domNodes => {
if (onReposition === undefined) {
for (const domNode of domNodes) {
const domNodeStyle = domNode.style;
if (domNodeStyle.background !== 'Highlight') {
domNodeStyle.background = 'Highlight';
}
if (domNodeStyle.color !== 'HighlightText') {
domNodeStyle.color = 'HighlightText';
}
if (domNodeStyle.zIndex !== '-1') {
domNodeStyle.zIndex = '-1';
}
}
} else {
onReposition(domNodes);
}
},
);
}
}
previousAnchorNode = currentAnchorNode;
previousAnchorOffset = currentAnchorOffset;
previousFocusNode = currentFocusNode;
previousFocusOffset = currentFocusOffset;
});
}
compute(editor.getEditorState());
return mergeRegister(
editor.registerUpdateListener(({editorState}) => compute(editorState)),
removeRangeListener,
() => {
removeRangeListener();
},
);
}
const mutationObserverConfig = {
attributes: true,
childList: true,
subtree: true,
characterData: true,
};
function px(value: number): string {
return `${value}px`;
}
export default function positionNodeOnRange(
editor: LexicalEditor,
range: Range,
onReposition: (node: Array<HTMLElement>) => void,
): () => void {
const uuid = uuidv4();
let rootDOMNode: null | HTMLElement = null;
let parentDOMNode: null | HTMLElement = null;
let observer: null | MutationObserver = null;
let lastNodes: Array<HTMLElement> = [];
const wrapperNode = document.createElement('div');
wrapperNode.setAttribute('data-id', uuid);
function position(): void {
const {left: rootLeft, top: rootTop} =
nullthrows(rootDOMNode).getBoundingClientRect();
const parentDOMNode_ = nullthrows(parentDOMNode);
const rects = createRectsFromDOMRange(editor, range);
if (!wrapperNode.isConnected) {
parentDOMNode_.append(wrapperNode);
}
let hasRepositioned = false;
for (let i = 0; i < rects.length; i++) {
const rect = rects[i];
// Try to reuse the previously created Node when possible, no need to
// remove/create on the most common case reposition case
const rectNode = lastNodes[i] ?? document.createElement('div');
const rectNodeStyle = rectNode.style;
if (rectNodeStyle.position !== 'absolute') {
rectNodeStyle.position = 'absolute';
hasRepositioned = true;
}
const left = px(rect.left - rootLeft);
if (rectNodeStyle.left !== left) {
rectNodeStyle.left = left;
hasRepositioned = true;
}
const top = px(rect.top - rootTop);
if (rectNodeStyle.top !== top) {
rectNode.style.top = top;
hasRepositioned = true;
}
const width = px(rect.width);
if (rectNodeStyle.width !== width) {
rectNode.style.width = width;
hasRepositioned = true;
}
const height = px(rect.height);
if (rectNodeStyle.height !== height) {
rectNode.style.height = height;
hasRepositioned = true;
}
if (rectNode.parentNode !== wrapperNode) {
wrapperNode.append(rectNode);
hasRepositioned = true;
}
lastNodes[i] = rectNode;
}
while (lastNodes.length > rects.length) {
lastNodes.pop();
}
if (hasRepositioned) {
onReposition(lastNodes);
}
}
function stop(): void {
parentDOMNode = null;
rootDOMNode = null;
observer?.disconnect();
observer = null;
wrapperNode.remove();
for (const node of lastNodes) {
node.remove();
}
lastNodes = [];
}
function restart(): void {
const currentRootDOMNode = editor.getRootElement();
if (currentRootDOMNode === null) {
return stop();
}
const currentParentDOMNode = currentRootDOMNode.parentElement;
if (!(currentParentDOMNode instanceof HTMLElement)) {
return stop();
}
stop();
rootDOMNode = currentRootDOMNode;
parentDOMNode = currentParentDOMNode;
observer = new MutationObserver(mutations => {
const nextRootDOMNode = editor.getRootElement();
const nextParentDOMNode = nextRootDOMNode?.parentElement;
if (
nextRootDOMNode !== rootDOMNode ||
nextParentDOMNode !== parentDOMNode
) {
return restart();
}
for (const mutation of mutations) {
if (!wrapperNode.contains(mutation.target)) {
// TODO throttle
return position();
}
}
});
observer.observe(currentParentDOMNode, mutationObserverConfig);
position();
}
const removeRootListener = editor.registerRootListener(restart);
return () => {
removeRootListener();
stop();
};
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment