mirror of
https://github.com/BookStackApp/BookStack.git
synced 2025-08-09 10:22:51 +03:00
Lexical: Imported core lexical libs
Imported at 0.17.1, Modified to work in-app. Added & configured test dependancies. Tests need to be altered to avoid using non-included deps including react dependancies.
This commit is contained in:
536
resources/js/wysiwyg/lexical/yjs/SyncCursors.ts
Normal file
536
resources/js/wysiwyg/lexical/yjs/SyncCursors.ts
Normal file
@@ -0,0 +1,536 @@
|
||||
/**
|
||||
* 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 {Binding} from './Bindings';
|
||||
import type {BaseSelection, NodeKey, NodeMap, Point} from 'lexical';
|
||||
import type {AbsolutePosition, RelativePosition} from 'yjs';
|
||||
|
||||
import {createDOMRange, createRectsFromDOMRange} from '@lexical/selection';
|
||||
import {
|
||||
$getNodeByKey,
|
||||
$getSelection,
|
||||
$isElementNode,
|
||||
$isLineBreakNode,
|
||||
$isRangeSelection,
|
||||
$isTextNode,
|
||||
} from 'lexical';
|
||||
import invariant from 'lexical/shared/invariant';
|
||||
import {
|
||||
compareRelativePositions,
|
||||
createAbsolutePositionFromRelativePosition,
|
||||
createRelativePositionFromTypeIndex,
|
||||
} from 'yjs';
|
||||
|
||||
import {Provider} from '.';
|
||||
import {CollabDecoratorNode} from './CollabDecoratorNode';
|
||||
import {CollabElementNode} from './CollabElementNode';
|
||||
import {CollabLineBreakNode} from './CollabLineBreakNode';
|
||||
import {CollabTextNode} from './CollabTextNode';
|
||||
import {getPositionFromElementAndOffset} from './Utils';
|
||||
|
||||
export type CursorSelection = {
|
||||
anchor: {
|
||||
key: NodeKey;
|
||||
offset: number;
|
||||
};
|
||||
caret: HTMLElement;
|
||||
color: string;
|
||||
focus: {
|
||||
key: NodeKey;
|
||||
offset: number;
|
||||
};
|
||||
name: HTMLSpanElement;
|
||||
selections: Array<HTMLElement>;
|
||||
};
|
||||
export type Cursor = {
|
||||
color: string;
|
||||
name: string;
|
||||
selection: null | CursorSelection;
|
||||
};
|
||||
|
||||
function createRelativePosition(
|
||||
point: Point,
|
||||
binding: Binding,
|
||||
): null | RelativePosition {
|
||||
const collabNodeMap = binding.collabNodeMap;
|
||||
const collabNode = collabNodeMap.get(point.key);
|
||||
|
||||
if (collabNode === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let offset = point.offset;
|
||||
let sharedType = collabNode.getSharedType();
|
||||
|
||||
if (collabNode instanceof CollabTextNode) {
|
||||
sharedType = collabNode._parent._xmlText;
|
||||
const currentOffset = collabNode.getOffset();
|
||||
|
||||
if (currentOffset === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
offset = currentOffset + 1 + offset;
|
||||
} else if (
|
||||
collabNode instanceof CollabElementNode &&
|
||||
point.type === 'element'
|
||||
) {
|
||||
const parent = point.getNode();
|
||||
invariant($isElementNode(parent), 'Element point must be an element node');
|
||||
let accumulatedOffset = 0;
|
||||
let i = 0;
|
||||
let node = parent.getFirstChild();
|
||||
while (node !== null && i++ < offset) {
|
||||
if ($isTextNode(node)) {
|
||||
accumulatedOffset += node.getTextContentSize() + 1;
|
||||
} else {
|
||||
accumulatedOffset++;
|
||||
}
|
||||
node = node.getNextSibling();
|
||||
}
|
||||
offset = accumulatedOffset;
|
||||
}
|
||||
|
||||
return createRelativePositionFromTypeIndex(sharedType, offset);
|
||||
}
|
||||
|
||||
function createAbsolutePosition(
|
||||
relativePosition: RelativePosition,
|
||||
binding: Binding,
|
||||
): AbsolutePosition | null {
|
||||
return createAbsolutePositionFromRelativePosition(
|
||||
relativePosition,
|
||||
binding.doc,
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUpdatePosition(
|
||||
currentPos: RelativePosition | null | undefined,
|
||||
pos: RelativePosition | null | undefined,
|
||||
): boolean {
|
||||
if (currentPos == null) {
|
||||
if (pos != null) {
|
||||
return true;
|
||||
}
|
||||
} else if (pos == null || !compareRelativePositions(currentPos, pos)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function createCursor(name: string, color: string): Cursor {
|
||||
return {
|
||||
color: color,
|
||||
name: name,
|
||||
selection: null,
|
||||
};
|
||||
}
|
||||
|
||||
function destroySelection(binding: Binding, selection: CursorSelection) {
|
||||
const cursorsContainer = binding.cursorsContainer;
|
||||
|
||||
if (cursorsContainer !== null) {
|
||||
const selections = selection.selections;
|
||||
const selectionsLength = selections.length;
|
||||
|
||||
for (let i = 0; i < selectionsLength; i++) {
|
||||
cursorsContainer.removeChild(selections[i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function destroyCursor(binding: Binding, cursor: Cursor) {
|
||||
const selection = cursor.selection;
|
||||
|
||||
if (selection !== null) {
|
||||
destroySelection(binding, selection);
|
||||
}
|
||||
}
|
||||
|
||||
function createCursorSelection(
|
||||
cursor: Cursor,
|
||||
anchorKey: NodeKey,
|
||||
anchorOffset: number,
|
||||
focusKey: NodeKey,
|
||||
focusOffset: number,
|
||||
): CursorSelection {
|
||||
const color = cursor.color;
|
||||
const caret = document.createElement('span');
|
||||
caret.style.cssText = `position:absolute;top:0;bottom:0;right:-1px;width:1px;background-color:${color};z-index:10;`;
|
||||
const name = document.createElement('span');
|
||||
name.textContent = cursor.name;
|
||||
name.style.cssText = `position:absolute;left:-2px;top:-16px;background-color:${color};color:#fff;line-height:12px;font-size:12px;padding:2px;font-family:Arial;font-weight:bold;white-space:nowrap;`;
|
||||
caret.appendChild(name);
|
||||
return {
|
||||
anchor: {
|
||||
key: anchorKey,
|
||||
offset: anchorOffset,
|
||||
},
|
||||
caret,
|
||||
color,
|
||||
focus: {
|
||||
key: focusKey,
|
||||
offset: focusOffset,
|
||||
},
|
||||
name,
|
||||
selections: [],
|
||||
};
|
||||
}
|
||||
|
||||
function updateCursor(
|
||||
binding: Binding,
|
||||
cursor: Cursor,
|
||||
nextSelection: null | CursorSelection,
|
||||
nodeMap: NodeMap,
|
||||
): void {
|
||||
const editor = binding.editor;
|
||||
const rootElement = editor.getRootElement();
|
||||
const cursorsContainer = binding.cursorsContainer;
|
||||
|
||||
if (cursorsContainer === null || rootElement === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cursorsContainerOffsetParent = cursorsContainer.offsetParent;
|
||||
if (cursorsContainerOffsetParent === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const containerRect = cursorsContainerOffsetParent.getBoundingClientRect();
|
||||
const prevSelection = cursor.selection;
|
||||
|
||||
if (nextSelection === null) {
|
||||
if (prevSelection === null) {
|
||||
return;
|
||||
} else {
|
||||
cursor.selection = null;
|
||||
destroySelection(binding, prevSelection);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
cursor.selection = nextSelection;
|
||||
}
|
||||
|
||||
const caret = nextSelection.caret;
|
||||
const color = nextSelection.color;
|
||||
const selections = nextSelection.selections;
|
||||
const anchor = nextSelection.anchor;
|
||||
const focus = nextSelection.focus;
|
||||
const anchorKey = anchor.key;
|
||||
const focusKey = focus.key;
|
||||
const anchorNode = nodeMap.get(anchorKey);
|
||||
const focusNode = nodeMap.get(focusKey);
|
||||
|
||||
if (anchorNode == null || focusNode == null) {
|
||||
return;
|
||||
}
|
||||
let selectionRects: Array<DOMRect>;
|
||||
|
||||
// In the case of a collapsed selection on a linebreak, we need
|
||||
// to improvise as the browser will return nothing here as <br>
|
||||
// apparantly take up no visual space :/
|
||||
// This won't work in all cases, but it's better than just showing
|
||||
// nothing all the time.
|
||||
if (anchorNode === focusNode && $isLineBreakNode(anchorNode)) {
|
||||
const brRect = (
|
||||
editor.getElementByKey(anchorKey) as HTMLElement
|
||||
).getBoundingClientRect();
|
||||
selectionRects = [brRect];
|
||||
} else {
|
||||
const range = createDOMRange(
|
||||
editor,
|
||||
anchorNode,
|
||||
anchor.offset,
|
||||
focusNode,
|
||||
focus.offset,
|
||||
);
|
||||
|
||||
if (range === null) {
|
||||
return;
|
||||
}
|
||||
selectionRects = createRectsFromDOMRange(editor, range);
|
||||
}
|
||||
|
||||
const selectionsLength = selections.length;
|
||||
const selectionRectsLength = selectionRects.length;
|
||||
|
||||
for (let i = 0; i < selectionRectsLength; i++) {
|
||||
const selectionRect = selectionRects[i];
|
||||
let selection = selections[i];
|
||||
|
||||
if (selection === undefined) {
|
||||
selection = document.createElement('span');
|
||||
selections[i] = selection;
|
||||
const selectionBg = document.createElement('span');
|
||||
selection.appendChild(selectionBg);
|
||||
cursorsContainer.appendChild(selection);
|
||||
}
|
||||
|
||||
const top = selectionRect.top - containerRect.top;
|
||||
const left = selectionRect.left - containerRect.left;
|
||||
const style = `position:absolute;top:${top}px;left:${left}px;height:${selectionRect.height}px;width:${selectionRect.width}px;pointer-events:none;z-index:5;`;
|
||||
selection.style.cssText = style;
|
||||
|
||||
(
|
||||
selection.firstChild as HTMLSpanElement
|
||||
).style.cssText = `${style}left:0;top:0;background-color:${color};opacity:0.3;`;
|
||||
|
||||
if (i === selectionRectsLength - 1) {
|
||||
if (caret.parentNode !== selection) {
|
||||
selection.appendChild(caret);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = selectionsLength - 1; i >= selectionRectsLength; i--) {
|
||||
const selection = selections[i];
|
||||
cursorsContainer.removeChild(selection);
|
||||
selections.pop();
|
||||
}
|
||||
}
|
||||
|
||||
export function $syncLocalCursorPosition(
|
||||
binding: Binding,
|
||||
provider: Provider,
|
||||
): void {
|
||||
const awareness = provider.awareness;
|
||||
const localState = awareness.getLocalState();
|
||||
|
||||
if (localState === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const anchorPos = localState.anchorPos;
|
||||
const focusPos = localState.focusPos;
|
||||
|
||||
if (anchorPos !== null && focusPos !== null) {
|
||||
const anchorAbsPos = createAbsolutePosition(anchorPos, binding);
|
||||
const focusAbsPos = createAbsolutePosition(focusPos, binding);
|
||||
|
||||
if (anchorAbsPos !== null && focusAbsPos !== null) {
|
||||
const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(
|
||||
anchorAbsPos.type,
|
||||
anchorAbsPos.index,
|
||||
);
|
||||
const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(
|
||||
focusAbsPos.type,
|
||||
focusAbsPos.index,
|
||||
);
|
||||
|
||||
if (anchorCollabNode !== null && focusCollabNode !== null) {
|
||||
const anchorKey = anchorCollabNode.getKey();
|
||||
const focusKey = focusCollabNode.getKey();
|
||||
|
||||
const selection = $getSelection();
|
||||
|
||||
if (!$isRangeSelection(selection)) {
|
||||
return;
|
||||
}
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
|
||||
$setPoint(anchor, anchorKey, anchorOffset);
|
||||
$setPoint(focus, focusKey, focusOffset);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function $setPoint(point: Point, key: NodeKey, offset: number): void {
|
||||
if (point.key !== key || point.offset !== offset) {
|
||||
let anchorNode = $getNodeByKey(key);
|
||||
if (
|
||||
anchorNode !== null &&
|
||||
!$isElementNode(anchorNode) &&
|
||||
!$isTextNode(anchorNode)
|
||||
) {
|
||||
const parent = anchorNode.getParentOrThrow();
|
||||
key = parent.getKey();
|
||||
offset = anchorNode.getIndexWithinParent();
|
||||
anchorNode = parent;
|
||||
}
|
||||
point.set(key, offset, $isElementNode(anchorNode) ? 'element' : 'text');
|
||||
}
|
||||
}
|
||||
|
||||
function getCollabNodeAndOffset(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
sharedType: any,
|
||||
offset: number,
|
||||
): [
|
||||
(
|
||||
| null
|
||||
| CollabDecoratorNode
|
||||
| CollabElementNode
|
||||
| CollabTextNode
|
||||
| CollabLineBreakNode
|
||||
),
|
||||
number,
|
||||
] {
|
||||
const collabNode = sharedType._collabNode;
|
||||
|
||||
if (collabNode === undefined) {
|
||||
return [null, 0];
|
||||
}
|
||||
|
||||
if (collabNode instanceof CollabElementNode) {
|
||||
const {node, offset: collabNodeOffset} = getPositionFromElementAndOffset(
|
||||
collabNode,
|
||||
offset,
|
||||
true,
|
||||
);
|
||||
|
||||
if (node === null) {
|
||||
return [collabNode, 0];
|
||||
} else {
|
||||
return [node, collabNodeOffset];
|
||||
}
|
||||
}
|
||||
|
||||
return [null, 0];
|
||||
}
|
||||
|
||||
export function syncCursorPositions(
|
||||
binding: Binding,
|
||||
provider: Provider,
|
||||
): void {
|
||||
const awarenessStates = Array.from(provider.awareness.getStates());
|
||||
const localClientID = binding.clientID;
|
||||
const cursors = binding.cursors;
|
||||
const editor = binding.editor;
|
||||
const nodeMap = editor._editorState._nodeMap;
|
||||
const visitedClientIDs = new Set();
|
||||
|
||||
for (let i = 0; i < awarenessStates.length; i++) {
|
||||
const awarenessState = awarenessStates[i];
|
||||
const [clientID, awareness] = awarenessState;
|
||||
|
||||
if (clientID !== localClientID) {
|
||||
visitedClientIDs.add(clientID);
|
||||
const {anchorPos, focusPos, name, color, focusing} = awareness;
|
||||
let selection = null;
|
||||
|
||||
let cursor = cursors.get(clientID);
|
||||
|
||||
if (cursor === undefined) {
|
||||
cursor = createCursor(name, color);
|
||||
cursors.set(clientID, cursor);
|
||||
}
|
||||
|
||||
if (anchorPos !== null && focusPos !== null && focusing) {
|
||||
const anchorAbsPos = createAbsolutePosition(anchorPos, binding);
|
||||
const focusAbsPos = createAbsolutePosition(focusPos, binding);
|
||||
|
||||
if (anchorAbsPos !== null && focusAbsPos !== null) {
|
||||
const [anchorCollabNode, anchorOffset] = getCollabNodeAndOffset(
|
||||
anchorAbsPos.type,
|
||||
anchorAbsPos.index,
|
||||
);
|
||||
const [focusCollabNode, focusOffset] = getCollabNodeAndOffset(
|
||||
focusAbsPos.type,
|
||||
focusAbsPos.index,
|
||||
);
|
||||
|
||||
if (anchorCollabNode !== null && focusCollabNode !== null) {
|
||||
const anchorKey = anchorCollabNode.getKey();
|
||||
const focusKey = focusCollabNode.getKey();
|
||||
selection = cursor.selection;
|
||||
|
||||
if (selection === null) {
|
||||
selection = createCursorSelection(
|
||||
cursor,
|
||||
anchorKey,
|
||||
anchorOffset,
|
||||
focusKey,
|
||||
focusOffset,
|
||||
);
|
||||
} else {
|
||||
const anchor = selection.anchor;
|
||||
const focus = selection.focus;
|
||||
anchor.key = anchorKey;
|
||||
anchor.offset = anchorOffset;
|
||||
focus.key = focusKey;
|
||||
focus.offset = focusOffset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateCursor(binding, cursor, selection, nodeMap);
|
||||
}
|
||||
}
|
||||
|
||||
const allClientIDs = Array.from(cursors.keys());
|
||||
|
||||
for (let i = 0; i < allClientIDs.length; i++) {
|
||||
const clientID = allClientIDs[i];
|
||||
|
||||
if (!visitedClientIDs.has(clientID)) {
|
||||
const cursor = cursors.get(clientID);
|
||||
|
||||
if (cursor !== undefined) {
|
||||
destroyCursor(binding, cursor);
|
||||
cursors.delete(clientID);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function syncLexicalSelectionToYjs(
|
||||
binding: Binding,
|
||||
provider: Provider,
|
||||
prevSelection: null | BaseSelection,
|
||||
nextSelection: null | BaseSelection,
|
||||
): void {
|
||||
const awareness = provider.awareness;
|
||||
const localState = awareness.getLocalState();
|
||||
|
||||
if (localState === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const {
|
||||
anchorPos: currentAnchorPos,
|
||||
focusPos: currentFocusPos,
|
||||
name,
|
||||
color,
|
||||
focusing,
|
||||
awarenessData,
|
||||
} = localState;
|
||||
let anchorPos = null;
|
||||
let focusPos = null;
|
||||
|
||||
if (
|
||||
nextSelection === null ||
|
||||
(currentAnchorPos !== null && !nextSelection.is(prevSelection))
|
||||
) {
|
||||
if (prevSelection === null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if ($isRangeSelection(nextSelection)) {
|
||||
anchorPos = createRelativePosition(nextSelection.anchor, binding);
|
||||
focusPos = createRelativePosition(nextSelection.focus, binding);
|
||||
}
|
||||
|
||||
if (
|
||||
shouldUpdatePosition(currentAnchorPos, anchorPos) ||
|
||||
shouldUpdatePosition(currentFocusPos, focusPos)
|
||||
) {
|
||||
awareness.setLocalState({
|
||||
anchorPos,
|
||||
awarenessData,
|
||||
color,
|
||||
focusPos,
|
||||
focusing,
|
||||
name,
|
||||
});
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user