mirror of
https://github.com/BookStackApp/BookStack.git
synced 2024-12-15 07:33:36 +08:00
428 lines
14 KiB
TypeScript
428 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 {
|
||
|
$createTextNode,
|
||
|
$getCharacterOffsets,
|
||
|
$getNodeByKey,
|
||
|
$getPreviousSelection,
|
||
|
$isElementNode,
|
||
|
$isRangeSelection,
|
||
|
$isRootNode,
|
||
|
$isTextNode,
|
||
|
$isTokenOrSegmented,
|
||
|
BaseSelection,
|
||
|
LexicalEditor,
|
||
|
LexicalNode,
|
||
|
Point,
|
||
|
RangeSelection,
|
||
|
TextNode,
|
||
|
} from 'lexical';
|
||
|
import invariant from 'lexical/shared/invariant';
|
||
|
|
||
|
import {CSS_TO_STYLES} from './constants';
|
||
|
import {
|
||
|
getCSSFromStyleObject,
|
||
|
getStyleObjectFromCSS,
|
||
|
getStyleObjectFromRawCSS,
|
||
|
} from './utils';
|
||
|
|
||
|
/**
|
||
|
* Generally used to append text content to HTML and JSON. Grabs the text content and "slices"
|
||
|
* it to be generated into the new TextNode.
|
||
|
* @param selection - The selection containing the node whose TextNode is to be edited.
|
||
|
* @param textNode - The TextNode to be edited.
|
||
|
* @returns The updated TextNode.
|
||
|
*/
|
||
|
export function $sliceSelectedTextNodeContent(
|
||
|
selection: BaseSelection,
|
||
|
textNode: TextNode,
|
||
|
): LexicalNode {
|
||
|
const anchorAndFocus = selection.getStartEndPoints();
|
||
|
if (
|
||
|
textNode.isSelected(selection) &&
|
||
|
!textNode.isSegmented() &&
|
||
|
!textNode.isToken() &&
|
||
|
anchorAndFocus !== null
|
||
|
) {
|
||
|
const [anchor, focus] = anchorAndFocus;
|
||
|
const isBackward = selection.isBackward();
|
||
|
const anchorNode = anchor.getNode();
|
||
|
const focusNode = focus.getNode();
|
||
|
const isAnchor = textNode.is(anchorNode);
|
||
|
const isFocus = textNode.is(focusNode);
|
||
|
|
||
|
if (isAnchor || isFocus) {
|
||
|
const [anchorOffset, focusOffset] = $getCharacterOffsets(selection);
|
||
|
const isSame = anchorNode.is(focusNode);
|
||
|
const isFirst = textNode.is(isBackward ? focusNode : anchorNode);
|
||
|
const isLast = textNode.is(isBackward ? anchorNode : focusNode);
|
||
|
let startOffset = 0;
|
||
|
let endOffset = undefined;
|
||
|
|
||
|
if (isSame) {
|
||
|
startOffset = anchorOffset > focusOffset ? focusOffset : anchorOffset;
|
||
|
endOffset = anchorOffset > focusOffset ? anchorOffset : focusOffset;
|
||
|
} else if (isFirst) {
|
||
|
const offset = isBackward ? focusOffset : anchorOffset;
|
||
|
startOffset = offset;
|
||
|
endOffset = undefined;
|
||
|
} else if (isLast) {
|
||
|
const offset = isBackward ? anchorOffset : focusOffset;
|
||
|
startOffset = 0;
|
||
|
endOffset = offset;
|
||
|
}
|
||
|
|
||
|
textNode.__text = textNode.__text.slice(startOffset, endOffset);
|
||
|
return textNode;
|
||
|
}
|
||
|
}
|
||
|
return textNode;
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Determines if the current selection is at the end of the node.
|
||
|
* @param point - The point of the selection to test.
|
||
|
* @returns true if the provided point offset is in the last possible position, false otherwise.
|
||
|
*/
|
||
|
export function $isAtNodeEnd(point: Point): boolean {
|
||
|
if (point.type === 'text') {
|
||
|
return point.offset === point.getNode().getTextContentSize();
|
||
|
}
|
||
|
const node = point.getNode();
|
||
|
invariant(
|
||
|
$isElementNode(node),
|
||
|
'isAtNodeEnd: node must be a TextNode or ElementNode',
|
||
|
);
|
||
|
|
||
|
return point.offset === node.getChildrenSize();
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Trims text from a node in order to shorten it, eg. to enforce a text's max length. If it deletes text
|
||
|
* that is an ancestor of the anchor then it will leave 2 indents, otherwise, if no text content exists, it deletes
|
||
|
* the TextNode. It will move the focus to either the end of any left over text or beginning of a new TextNode.
|
||
|
* @param editor - The lexical editor.
|
||
|
* @param anchor - The anchor of the current selection, where the selection should be pointing.
|
||
|
* @param delCount - The amount of characters to delete. Useful as a dynamic variable eg. textContentSize - maxLength;
|
||
|
*/
|
||
|
export function $trimTextContentFromAnchor(
|
||
|
editor: LexicalEditor,
|
||
|
anchor: Point,
|
||
|
delCount: number,
|
||
|
): void {
|
||
|
// Work from the current selection anchor point
|
||
|
let currentNode: LexicalNode | null = anchor.getNode();
|
||
|
let remaining: number = delCount;
|
||
|
|
||
|
if ($isElementNode(currentNode)) {
|
||
|
const descendantNode = currentNode.getDescendantByIndex(anchor.offset);
|
||
|
if (descendantNode !== null) {
|
||
|
currentNode = descendantNode;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
while (remaining > 0 && currentNode !== null) {
|
||
|
if ($isElementNode(currentNode)) {
|
||
|
const lastDescendant: null | LexicalNode =
|
||
|
currentNode.getLastDescendant<LexicalNode>();
|
||
|
if (lastDescendant !== null) {
|
||
|
currentNode = lastDescendant;
|
||
|
}
|
||
|
}
|
||
|
let nextNode: LexicalNode | null = currentNode.getPreviousSibling();
|
||
|
let additionalElementWhitespace = 0;
|
||
|
if (nextNode === null) {
|
||
|
let parent: LexicalNode | null = currentNode.getParentOrThrow();
|
||
|
let parentSibling: LexicalNode | null = parent.getPreviousSibling();
|
||
|
|
||
|
while (parentSibling === null) {
|
||
|
parent = parent.getParent();
|
||
|
if (parent === null) {
|
||
|
nextNode = null;
|
||
|
break;
|
||
|
}
|
||
|
parentSibling = parent.getPreviousSibling();
|
||
|
}
|
||
|
if (parent !== null) {
|
||
|
additionalElementWhitespace = parent.isInline() ? 0 : 2;
|
||
|
nextNode = parentSibling;
|
||
|
}
|
||
|
}
|
||
|
let text = currentNode.getTextContent();
|
||
|
// If the text is empty, we need to consider adding in two line breaks to match
|
||
|
// the content if we were to get it from its parent.
|
||
|
if (text === '' && $isElementNode(currentNode) && !currentNode.isInline()) {
|
||
|
// TODO: should this be handled in core?
|
||
|
text = '\n\n';
|
||
|
}
|
||
|
const currentNodeSize = text.length;
|
||
|
|
||
|
if (!$isTextNode(currentNode) || remaining >= currentNodeSize) {
|
||
|
const parent = currentNode.getParent();
|
||
|
currentNode.remove();
|
||
|
if (
|
||
|
parent != null &&
|
||
|
parent.getChildrenSize() === 0 &&
|
||
|
!$isRootNode(parent)
|
||
|
) {
|
||
|
parent.remove();
|
||
|
}
|
||
|
remaining -= currentNodeSize + additionalElementWhitespace;
|
||
|
currentNode = nextNode;
|
||
|
} else {
|
||
|
const key = currentNode.getKey();
|
||
|
// See if we can just revert it to what was in the last editor state
|
||
|
const prevTextContent: string | null = editor
|
||
|
.getEditorState()
|
||
|
.read(() => {
|
||
|
const prevNode = $getNodeByKey(key);
|
||
|
if ($isTextNode(prevNode) && prevNode.isSimpleText()) {
|
||
|
return prevNode.getTextContent();
|
||
|
}
|
||
|
return null;
|
||
|
});
|
||
|
const offset = currentNodeSize - remaining;
|
||
|
const slicedText = text.slice(0, offset);
|
||
|
if (prevTextContent !== null && prevTextContent !== text) {
|
||
|
const prevSelection = $getPreviousSelection();
|
||
|
let target = currentNode;
|
||
|
if (!currentNode.isSimpleText()) {
|
||
|
const textNode = $createTextNode(prevTextContent);
|
||
|
currentNode.replace(textNode);
|
||
|
target = textNode;
|
||
|
} else {
|
||
|
currentNode.setTextContent(prevTextContent);
|
||
|
}
|
||
|
if ($isRangeSelection(prevSelection) && prevSelection.isCollapsed()) {
|
||
|
const prevOffset = prevSelection.anchor.offset;
|
||
|
target.select(prevOffset, prevOffset);
|
||
|
}
|
||
|
} else if (currentNode.isSimpleText()) {
|
||
|
// Split text
|
||
|
const isSelected = anchor.key === key;
|
||
|
let anchorOffset = anchor.offset;
|
||
|
// Move offset to end if it's less than the remaining number, otherwise
|
||
|
// we'll have a negative splitStart.
|
||
|
if (anchorOffset < remaining) {
|
||
|
anchorOffset = currentNodeSize;
|
||
|
}
|
||
|
const splitStart = isSelected ? anchorOffset - remaining : 0;
|
||
|
const splitEnd = isSelected ? anchorOffset : offset;
|
||
|
if (isSelected && splitStart === 0) {
|
||
|
const [excessNode] = currentNode.splitText(splitStart, splitEnd);
|
||
|
excessNode.remove();
|
||
|
} else {
|
||
|
const [, excessNode] = currentNode.splitText(splitStart, splitEnd);
|
||
|
excessNode.remove();
|
||
|
}
|
||
|
} else {
|
||
|
const textNode = $createTextNode(slicedText);
|
||
|
currentNode.replace(textNode);
|
||
|
}
|
||
|
remaining = 0;
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Gets the TextNode's style object and adds the styles to the CSS.
|
||
|
* @param node - The TextNode to add styles to.
|
||
|
*/
|
||
|
export function $addNodeStyle(node: TextNode): void {
|
||
|
const CSSText = node.getStyle();
|
||
|
const styles = getStyleObjectFromRawCSS(CSSText);
|
||
|
CSS_TO_STYLES.set(CSSText, styles);
|
||
|
}
|
||
|
|
||
|
function $patchStyle(
|
||
|
target: TextNode | RangeSelection,
|
||
|
patch: Record<
|
||
|
string,
|
||
|
| string
|
||
|
| null
|
||
|
| ((currentStyleValue: string | null, _target: typeof target) => string)
|
||
|
>,
|
||
|
): void {
|
||
|
const prevStyles = getStyleObjectFromCSS(
|
||
|
'getStyle' in target ? target.getStyle() : target.style,
|
||
|
);
|
||
|
const newStyles = Object.entries(patch).reduce<Record<string, string>>(
|
||
|
(styles, [key, value]) => {
|
||
|
if (typeof value === 'function') {
|
||
|
styles[key] = value(prevStyles[key], target);
|
||
|
} else if (value === null) {
|
||
|
delete styles[key];
|
||
|
} else {
|
||
|
styles[key] = value;
|
||
|
}
|
||
|
return styles;
|
||
|
},
|
||
|
{...prevStyles} || {},
|
||
|
);
|
||
|
const newCSSText = getCSSFromStyleObject(newStyles);
|
||
|
target.setStyle(newCSSText);
|
||
|
CSS_TO_STYLES.set(newCSSText, newStyles);
|
||
|
}
|
||
|
|
||
|
/**
|
||
|
* Applies the provided styles to the TextNodes in the provided Selection.
|
||
|
* Will update partially selected TextNodes by splitting the TextNode and applying
|
||
|
* the styles to the appropriate one.
|
||
|
* @param selection - The selected node(s) to update.
|
||
|
* @param patch - The patch to apply, which can include multiple styles. \\{CSSProperty: value\\} . Can also accept a function that returns the new property value.
|
||
|
*/
|
||
|
export function $patchStyleText(
|
||
|
selection: BaseSelection,
|
||
|
patch: Record<
|
||
|
string,
|
||
|
| string
|
||
|
| null
|
||
|
| ((
|
||
|
currentStyleValue: string | null,
|
||
|
target: TextNode | RangeSelection,
|
||
|
) => string)
|
||
|
>,
|
||
|
): void {
|
||
|
const selectedNodes = selection.getNodes();
|
||
|
const selectedNodesLength = selectedNodes.length;
|
||
|
const anchorAndFocus = selection.getStartEndPoints();
|
||
|
if (anchorAndFocus === null) {
|
||
|
return;
|
||
|
}
|
||
|
const [anchor, focus] = anchorAndFocus;
|
||
|
|
||
|
const lastIndex = selectedNodesLength - 1;
|
||
|
let firstNode = selectedNodes[0];
|
||
|
let lastNode = selectedNodes[lastIndex];
|
||
|
|
||
|
if (selection.isCollapsed() && $isRangeSelection(selection)) {
|
||
|
$patchStyle(selection, patch);
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
const firstNodeText = firstNode.getTextContent();
|
||
|
const firstNodeTextLength = firstNodeText.length;
|
||
|
const focusOffset = focus.offset;
|
||
|
let anchorOffset = anchor.offset;
|
||
|
const isBefore = anchor.isBefore(focus);
|
||
|
let startOffset = isBefore ? anchorOffset : focusOffset;
|
||
|
let endOffset = isBefore ? focusOffset : anchorOffset;
|
||
|
const startType = isBefore ? anchor.type : focus.type;
|
||
|
const endType = isBefore ? focus.type : anchor.type;
|
||
|
const endKey = isBefore ? focus.key : anchor.key;
|
||
|
|
||
|
// This is the case where the user only selected the very end of the
|
||
|
// first node so we don't want to include it in the formatting change.
|
||
|
if ($isTextNode(firstNode) && startOffset === firstNodeTextLength) {
|
||
|
const nextSibling = firstNode.getNextSibling();
|
||
|
|
||
|
if ($isTextNode(nextSibling)) {
|
||
|
// we basically make the second node the firstNode, changing offsets accordingly
|
||
|
anchorOffset = 0;
|
||
|
startOffset = 0;
|
||
|
firstNode = nextSibling;
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// This is the case where we only selected a single node
|
||
|
if (selectedNodes.length === 1) {
|
||
|
if ($isTextNode(firstNode) && firstNode.canHaveFormat()) {
|
||
|
startOffset =
|
||
|
startType === 'element'
|
||
|
? 0
|
||
|
: anchorOffset > focusOffset
|
||
|
? focusOffset
|
||
|
: anchorOffset;
|
||
|
endOffset =
|
||
|
endType === 'element'
|
||
|
? firstNodeTextLength
|
||
|
: anchorOffset > focusOffset
|
||
|
? anchorOffset
|
||
|
: focusOffset;
|
||
|
|
||
|
// No actual text is selected, so do nothing.
|
||
|
if (startOffset === endOffset) {
|
||
|
return;
|
||
|
}
|
||
|
|
||
|
// The entire node is selected or a token/segment, so just format it
|
||
|
if (
|
||
|
$isTokenOrSegmented(firstNode) ||
|
||
|
(startOffset === 0 && endOffset === firstNodeTextLength)
|
||
|
) {
|
||
|
$patchStyle(firstNode, patch);
|
||
|
firstNode.select(startOffset, endOffset);
|
||
|
} else {
|
||
|
// The node is partially selected, so split it into two nodes
|
||
|
// and style the selected one.
|
||
|
const splitNodes = firstNode.splitText(startOffset, endOffset);
|
||
|
const replacement = startOffset === 0 ? splitNodes[0] : splitNodes[1];
|
||
|
$patchStyle(replacement, patch);
|
||
|
replacement.select(0, endOffset - startOffset);
|
||
|
}
|
||
|
} // multiple nodes selected.
|
||
|
} else {
|
||
|
if (
|
||
|
$isTextNode(firstNode) &&
|
||
|
startOffset < firstNode.getTextContentSize() &&
|
||
|
firstNode.canHaveFormat()
|
||
|
) {
|
||
|
if (startOffset !== 0 && !$isTokenOrSegmented(firstNode)) {
|
||
|
// the entire first node isn't selected and it isn't a token or segmented, so split it
|
||
|
firstNode = firstNode.splitText(startOffset)[1];
|
||
|
startOffset = 0;
|
||
|
if (isBefore) {
|
||
|
anchor.set(firstNode.getKey(), startOffset, 'text');
|
||
|
} else {
|
||
|
focus.set(firstNode.getKey(), startOffset, 'text');
|
||
|
}
|
||
|
}
|
||
|
|
||
|
$patchStyle(firstNode as TextNode, patch);
|
||
|
}
|
||
|
|
||
|
if ($isTextNode(lastNode) && lastNode.canHaveFormat()) {
|
||
|
const lastNodeText = lastNode.getTextContent();
|
||
|
const lastNodeTextLength = lastNodeText.length;
|
||
|
|
||
|
// The last node might not actually be the end node
|
||
|
//
|
||
|
// If not, assume the last node is fully-selected unless the end offset is
|
||
|
// zero.
|
||
|
if (lastNode.__key !== endKey && endOffset !== 0) {
|
||
|
endOffset = lastNodeTextLength;
|
||
|
}
|
||
|
|
||
|
// if the entire last node isn't selected and it isn't a token or segmented, split it
|
||
|
if (endOffset !== lastNodeTextLength && !$isTokenOrSegmented(lastNode)) {
|
||
|
[lastNode] = lastNode.splitText(endOffset);
|
||
|
}
|
||
|
|
||
|
if (endOffset !== 0 || endType === 'element') {
|
||
|
$patchStyle(lastNode as TextNode, patch);
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// style all the text nodes in between
|
||
|
for (let i = 1; i < lastIndex; i++) {
|
||
|
const selectedNode = selectedNodes[i];
|
||
|
const selectedNodeKey = selectedNode.getKey();
|
||
|
|
||
|
if (
|
||
|
$isTextNode(selectedNode) &&
|
||
|
selectedNode.canHaveFormat() &&
|
||
|
selectedNodeKey !== firstNode.getKey() &&
|
||
|
selectedNodeKey !== lastNode.getKey() &&
|
||
|
!selectedNode.isToken()
|
||
|
) {
|
||
|
$patchStyle(selectedNode, patch);
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|