Files
AFFiNE-Mirror/blocksuite/framework/block-std/src/inline/utils/range-conversion.ts
2025-03-20 05:46:56 +00:00

369 lines
8.8 KiB
TypeScript

import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
import type * as Y from 'yjs';
import { VElement } from '../components/v-element.js';
import type { InlineRange } from '../types.js';
import { isInEmbedElement } from './embed.js';
import {
nativePointToTextPoint,
textPointToDomPoint,
} from './point-conversion.js';
import { calculateTextLength, getTextNodesFromElement } from './text.js';
type InlineRangeRunnerContext = {
rootElement: HTMLElement;
range: Range;
yText: Y.Text;
startNode: Node | null;
startOffset: number;
startText: Text;
startTextOffset: number;
endNode: Node | null;
endOffset: number;
endText: Text;
endTextOffset: number;
};
type Predict = (context: InlineRangeRunnerContext) => boolean;
type Handler = (context: InlineRangeRunnerContext) => InlineRange | null;
const rangeHasAnchorAndFocus: Predict = ({
rootElement,
startText,
endText,
}) => {
return rootElement.contains(startText) && rootElement.contains(endText);
};
const rangeHasAnchorAndFocusHandler: Handler = ({
rootElement,
startText,
endText,
startTextOffset,
endTextOffset,
}) => {
const anchorDomPoint = textPointToDomPoint(
startText,
startTextOffset,
rootElement
);
const focusDomPoint = textPointToDomPoint(
endText,
endTextOffset,
rootElement
);
if (!anchorDomPoint || !focusDomPoint) {
return null;
}
return {
index: Math.min(anchorDomPoint.index, focusDomPoint.index),
length: Math.abs(anchorDomPoint.index - focusDomPoint.index),
};
};
const rangeOnlyHasFocus: Predict = ({ rootElement, startText, endText }) => {
return !rootElement.contains(startText) && rootElement.contains(endText);
};
const rangeOnlyHasFocusHandler: Handler = ({
rootElement,
endText,
endTextOffset,
}) => {
const focusDomPoint = textPointToDomPoint(
endText,
endTextOffset,
rootElement
);
if (!focusDomPoint) {
return null;
}
return {
index: 0,
length: focusDomPoint.index,
};
};
const rangeOnlyHasAnchor: Predict = ({ rootElement, startText, endText }) => {
return rootElement.contains(startText) && !rootElement.contains(endText);
};
const rangeOnlyHasAnchorHandler: Handler = ({
yText,
rootElement,
startText,
startTextOffset,
}) => {
const startDomPoint = textPointToDomPoint(
startText,
startTextOffset,
rootElement
);
if (!startDomPoint) {
return null;
}
return {
index: startDomPoint.index,
length: yText.length - startDomPoint.index,
};
};
const rangeHasNoAnchorAndFocus: Predict = ({
rootElement,
startText,
endText,
range,
}) => {
return (
!rootElement.contains(startText) &&
!rootElement.contains(endText) &&
range.intersectsNode(rootElement)
);
};
const rangeHasNoAnchorAndFocusHandler: Handler = ({ yText }) => {
return {
index: 0,
length: yText.length,
};
};
const buildContext = (
range: Range,
rootElement: HTMLElement,
yText: Y.Text
): InlineRangeRunnerContext | null => {
const { startContainer, startOffset, endContainer, endOffset } = range;
const startTextPoint = nativePointToTextPoint(startContainer, startOffset);
const endTextPoint = nativePointToTextPoint(endContainer, endOffset);
if (!startTextPoint || !endTextPoint) {
return null;
}
const [startText, startTextOffset] = startTextPoint;
const [endText, endTextOffset] = endTextPoint;
return {
rootElement,
range,
yText,
startNode: startContainer,
startOffset,
endNode: endContainer,
endOffset,
startText,
startTextOffset,
endText,
endTextOffset,
};
};
/**
* calculate the inline range from dom selection for **this Editor**
* there are three cases when the inline range of this Editor is not null:
* (In the following, "|" mean anchor and focus, each line is a separate Editor)
* 1. anchor and focus are in this Editor
* aaaaaa
* b|bbbb|b
* cccccc
* the inline range of second Editor is {index: 1, length: 4}, the others are null
* 2. anchor and focus one in this Editor, one in another Editor
* aaa|aaa aaaaaa
* bbbbb|b or bbbbb|b
* cccccc cc|cccc
* 2.1
* the inline range of first Editor is {index: 3, length: 3}, the second is {index: 0, length: 5},
* the third is null
* 2.2
* the inline range of first Editor is null, the second is {index: 5, length: 1},
* the third is {index: 0, length: 2}
* 3. anchor and focus are in another Editor
* aa|aaaa
* bbbbbb
* cccc|cc
* the inline range of first Editor is {index: 2, length: 4},
* the second is {index: 0, length: 6}, the third is {index: 0, length: 4}
*/
export function domRangeToInlineRange(
range: Range,
rootElement: HTMLElement,
yText: Y.Text
): InlineRange | null {
const context = buildContext(range, rootElement, yText);
if (!context) return null;
// handle embed
if (
context.startNode &&
context.startNode === context.endNode &&
isInEmbedElement(context.startNode)
) {
const anchorDomPoint = textPointToDomPoint(
context.startText,
context.startTextOffset,
rootElement
);
if (anchorDomPoint) {
return {
index: anchorDomPoint.index,
length: 1,
};
}
}
// case 1
if (rangeHasAnchorAndFocus(context)) {
return rangeHasAnchorAndFocusHandler(context);
}
// case 2.1
if (rangeOnlyHasFocus(context)) {
return rangeOnlyHasFocusHandler(context);
}
// case 2.2
if (rangeOnlyHasAnchor(context)) {
return rangeOnlyHasAnchorHandler(context);
}
// case 3
if (rangeHasNoAnchorAndFocus(context)) {
return rangeHasNoAnchorAndFocusHandler(context);
}
return null;
}
/**
* calculate the dom selection from inline range for **this Editor**
*/
export function inlineRangeToDomRange(
rootElement: HTMLElement,
inlineRange: InlineRange
): Range | null {
const lineElements = Array.from(rootElement.querySelectorAll('v-line'));
// calculate anchorNode and focusNode
let startText: Text | null = null;
let endText: Text | null = null;
let anchorOffset = 0;
let focusOffset = 0;
let index = 0;
// eslint-disable-next-line @typescript-eslint/prefer-for-of
for (let i = 0; i < lineElements.length; i++) {
if (startText && endText) {
break;
}
const texts = getTextNodesFromElement(lineElements[i]);
if (texts.length === 0) {
return null;
}
for (const text of texts) {
const textLength = calculateTextLength(text);
if (!startText && index + textLength >= inlineRange.index) {
startText = text;
anchorOffset = inlineRange.index - index;
}
if (
!endText &&
index + textLength >= inlineRange.index + inlineRange.length
) {
endText = text;
focusOffset = inlineRange.index + inlineRange.length - index;
}
if (startText && endText) {
break;
}
index += textLength;
}
// the one because of the line break
index += 1;
}
if (!startText || !endText) {
return null;
}
if (isInEmbedElement(startText)) {
const anchorVElement = startText.parentElement?.closest('v-element');
if (!anchorVElement) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'failed to find vElement for a text note in an embed element'
);
}
const nextSibling = anchorVElement.nextElementSibling;
if (!nextSibling) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'failed to find nextSibling sibling of an embed element'
);
}
const texts = getTextNodesFromElement(nextSibling);
if (texts.length === 0) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'text node in v-text not found'
);
}
if (nextSibling instanceof VElement) {
startText = texts[texts.length - 1];
anchorOffset = calculateTextLength(startText);
} else {
// nextSibling is a gap
startText = texts[0];
anchorOffset = 0;
}
}
if (isInEmbedElement(endText)) {
const focusVElement = endText.parentElement?.closest('v-element');
if (!focusVElement) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'failed to find vElement for a text note in an embed element'
);
}
const nextSibling = focusVElement.nextElementSibling;
if (!nextSibling) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'failed to find nextSibling sibling of an embed element'
);
}
const texts = getTextNodesFromElement(nextSibling);
if (texts.length === 0) {
throw new BlockSuiteError(
ErrorCode.InlineEditorError,
'text node in v-text not found'
);
}
endText = texts[0];
focusOffset = 0;
}
const range = document.createRange();
range.setStart(startText, anchorOffset);
range.setEnd(endText, focusOffset);
return range;
}