mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-12-14 15:13:37 +08:00
22d078b47f
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.
537 lines
14 KiB
TypeScript
537 lines
14 KiB
TypeScript
/**
|
|
* 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,
|
|
});
|
|
}
|
|
}
|