mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 18:46:19 +08:00
@@ -0,0 +1,49 @@
|
||||
import type { BaseTextAttributes } from '@blocksuite/store';
|
||||
import { html } from 'lit';
|
||||
import { styleMap } from 'lit/directives/style-map.js';
|
||||
|
||||
import type { AttributeRenderer } from '../types.js';
|
||||
|
||||
function inlineTextStyles(
|
||||
props: BaseTextAttributes
|
||||
): ReturnType<typeof styleMap> {
|
||||
let textDecorations = '';
|
||||
if (props.underline) {
|
||||
textDecorations += 'underline';
|
||||
}
|
||||
if (props.strike) {
|
||||
textDecorations += ' line-through';
|
||||
}
|
||||
|
||||
let inlineCodeStyle = {};
|
||||
if (props.code) {
|
||||
inlineCodeStyle = {
|
||||
'font-family':
|
||||
'"SFMono-Regular", Menlo, Consolas, "PT Mono", "Liberation Mono", Courier, monospace',
|
||||
'line-height': 'normal',
|
||||
background: 'rgba(135,131,120,0.15)',
|
||||
color: '#EB5757',
|
||||
'border-radius': '3px',
|
||||
'font-size': '85%',
|
||||
padding: '0.2em 0.4em',
|
||||
};
|
||||
}
|
||||
|
||||
return styleMap({
|
||||
'font-weight': props.bold ? 'bold' : 'normal',
|
||||
'font-style': props.italic ? 'italic' : 'normal',
|
||||
'text-decoration': textDecorations.length > 0 ? textDecorations : 'none',
|
||||
...inlineCodeStyle,
|
||||
});
|
||||
}
|
||||
|
||||
export const getDefaultAttributeRenderer =
|
||||
<T extends BaseTextAttributes>(): AttributeRenderer<T> =>
|
||||
({ delta }) => {
|
||||
const style = delta.attributes
|
||||
? inlineTextStyles(delta.attributes)
|
||||
: styleMap({});
|
||||
return html`<span style=${style}
|
||||
><v-text .str=${delta.insert}></v-text
|
||||
></span>`;
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/store';
|
||||
|
||||
export function transformDelta<TextAttributes extends BaseTextAttributes>(
|
||||
delta: DeltaInsert<TextAttributes>
|
||||
): (DeltaInsert<TextAttributes> | '\n')[] {
|
||||
const result: (DeltaInsert<TextAttributes> | '\n')[] = [];
|
||||
|
||||
let tmpString = delta.insert;
|
||||
while (tmpString.length > 0) {
|
||||
const index = tmpString.indexOf('\n');
|
||||
if (index === -1) {
|
||||
result.push({
|
||||
insert: tmpString,
|
||||
attributes: delta.attributes,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
if (tmpString.slice(0, index).length > 0) {
|
||||
result.push({
|
||||
insert: tmpString.slice(0, index),
|
||||
attributes: delta.attributes,
|
||||
});
|
||||
}
|
||||
|
||||
result.push('\n');
|
||||
tmpString = tmpString.slice(index + 1);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* convert a delta insert array to chunks, each chunk is a line
|
||||
*/
|
||||
export function deltaInsertsToChunks<TextAttributes extends BaseTextAttributes>(
|
||||
delta: DeltaInsert<TextAttributes>[]
|
||||
): DeltaInsert<TextAttributes>[][] {
|
||||
if (delta.length === 0) {
|
||||
return [[]];
|
||||
}
|
||||
|
||||
const transformedDelta = delta.flatMap(transformDelta);
|
||||
|
||||
function* chunksGenerator(arr: (DeltaInsert<TextAttributes> | '\n')[]) {
|
||||
let start = 0;
|
||||
for (let i = 0; i < arr.length; i++) {
|
||||
if (arr[i] === '\n') {
|
||||
const chunk = arr.slice(start, i);
|
||||
start = i + 1;
|
||||
yield chunk as DeltaInsert<TextAttributes>[];
|
||||
} else if (i === arr.length - 1) {
|
||||
yield arr.slice(start) as DeltaInsert<TextAttributes>[];
|
||||
}
|
||||
}
|
||||
|
||||
if (arr.at(-1) === '\n') {
|
||||
yield [];
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(chunksGenerator(transformedDelta));
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/store';
|
||||
|
||||
import { VElement } from '../components/v-element.js';
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
|
||||
export function isInEmbedElement(node: Node): boolean {
|
||||
if (node instanceof Element) {
|
||||
if (node instanceof VElement) {
|
||||
return node.querySelector('[data-v-embed="true"]') !== null;
|
||||
}
|
||||
const vElement = node.closest('[data-v-embed="true"]');
|
||||
return !!vElement;
|
||||
} else {
|
||||
const vElement = node.parentElement?.closest('[data-v-embed="true"]');
|
||||
return !!vElement;
|
||||
}
|
||||
}
|
||||
|
||||
export function isInEmbedGap(node: Node): boolean {
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
if (!el) return false;
|
||||
return !!el.closest('[data-v-embed-gap="true"]');
|
||||
}
|
||||
|
||||
export function transformDeltasToEmbedDeltas<
|
||||
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
|
||||
>(
|
||||
editor: InlineEditor<TextAttributes>,
|
||||
deltas: DeltaInsert<TextAttributes>[]
|
||||
): DeltaInsert<TextAttributes>[] {
|
||||
// According to our regulations, the length of each "embed" node should only be 1.
|
||||
// Therefore, if the length of an "embed" type node is greater than 1,
|
||||
// we will divide it into multiple parts.
|
||||
const result: DeltaInsert<TextAttributes>[] = [];
|
||||
for (const delta of deltas) {
|
||||
if (editor.isEmbed(delta)) {
|
||||
const dividedDeltas = [...delta.insert].map(subInsert => ({
|
||||
insert: subInsert,
|
||||
attributes: delta.attributes,
|
||||
}));
|
||||
result.push(...dividedDeltas);
|
||||
} else {
|
||||
result.push(delta);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { VElement, VLine } from '../components/index.js';
|
||||
|
||||
export function isNativeTextInVText(text: unknown): text is Text {
|
||||
return text instanceof Text && text.parentElement?.dataset.vText === 'true';
|
||||
}
|
||||
|
||||
export function isVElement(element: unknown): element is HTMLElement {
|
||||
return (
|
||||
element instanceof HTMLElement &&
|
||||
(element.dataset.vElement === 'true' || element instanceof VElement)
|
||||
);
|
||||
}
|
||||
|
||||
export function isVLine(element: unknown): element is HTMLElement {
|
||||
return (
|
||||
element instanceof HTMLElement &&
|
||||
(element instanceof VLine || element.parentElement instanceof VLine)
|
||||
);
|
||||
}
|
||||
|
||||
export function isInEmptyLine(element: Node) {
|
||||
const el = element instanceof Element ? element : element.parentElement;
|
||||
const vLine = el?.closest<VLine>('v-line');
|
||||
return !!vLine && vLine.vTextLength === 0;
|
||||
}
|
||||
|
||||
export function isInlineRoot(element: unknown): element is HTMLElement {
|
||||
return element instanceof HTMLElement && element.dataset.vRoot === 'true';
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
export * from './attribute-renderer.js';
|
||||
export * from './delta-convert.js';
|
||||
export * from './embed.js';
|
||||
export * from './guard.js';
|
||||
export * from './point-conversion.js';
|
||||
export * from './query.js';
|
||||
export * from './range-conversion.js';
|
||||
export * from './renderer.js';
|
||||
export * from './text.js';
|
||||
export * from './transform-input.js';
|
||||
@@ -0,0 +1,74 @@
|
||||
import type { InlineRange } from '../types.js';
|
||||
|
||||
export function isMaybeInlineRangeEqual(
|
||||
a: InlineRange | null,
|
||||
b: InlineRange | null
|
||||
): boolean {
|
||||
return a === b || (a && b ? isInlineRangeEqual(a, b) : false);
|
||||
}
|
||||
|
||||
export function isInlineRangeContain(a: InlineRange, b: InlineRange): boolean {
|
||||
return a.index <= b.index && a.index + a.length >= b.index + b.length;
|
||||
}
|
||||
|
||||
export function isInlineRangeEqual(a: InlineRange, b: InlineRange): boolean {
|
||||
return a.index === b.index && a.length === b.length;
|
||||
}
|
||||
|
||||
export function isInlineRangeIntersect(
|
||||
a: InlineRange,
|
||||
b: InlineRange
|
||||
): boolean {
|
||||
return a.index <= b.index + b.length && a.index + a.length >= b.index;
|
||||
}
|
||||
|
||||
export function isInlineRangeBefore(a: InlineRange, b: InlineRange): boolean {
|
||||
return a.index + a.length <= b.index;
|
||||
}
|
||||
|
||||
export function isInlineRangeAfter(a: InlineRange, b: InlineRange): boolean {
|
||||
return a.index >= b.index + b.length;
|
||||
}
|
||||
|
||||
export function isInlineRangeEdge(
|
||||
index: InlineRange['index'],
|
||||
range: InlineRange
|
||||
): boolean {
|
||||
return index === range.index || index === range.index + range.length;
|
||||
}
|
||||
|
||||
export function isInlineRangeEdgeBefore(
|
||||
index: InlineRange['index'],
|
||||
range: InlineRange
|
||||
): boolean {
|
||||
return index === range.index;
|
||||
}
|
||||
|
||||
export function isInlineRangeEdgeAfter(
|
||||
index: InlineRange['index'],
|
||||
range: InlineRange
|
||||
): boolean {
|
||||
return index === range.index + range.length;
|
||||
}
|
||||
|
||||
export function isPoint(range: InlineRange): boolean {
|
||||
return range.length === 0;
|
||||
}
|
||||
|
||||
export function mergeInlineRange(a: InlineRange, b: InlineRange): InlineRange {
|
||||
const index = Math.min(a.index, b.index);
|
||||
const length = Math.max(a.index + a.length, b.index + b.length) - index;
|
||||
return { index, length };
|
||||
}
|
||||
|
||||
export function intersectInlineRange(
|
||||
a: InlineRange,
|
||||
b: InlineRange
|
||||
): InlineRange | null {
|
||||
if (!isInlineRangeIntersect(a, b)) {
|
||||
return null;
|
||||
}
|
||||
const index = Math.max(a.index, b.index);
|
||||
const length = Math.min(a.index + a.length, b.index + b.length) - index;
|
||||
return { index, length };
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
|
||||
import type { VElement, VLine } from '../components/index.js';
|
||||
import { INLINE_ROOT_ATTR, ZERO_WIDTH_SPACE } from '../consts.js';
|
||||
import type { DomPoint, TextPoint } from '../types.js';
|
||||
import {
|
||||
isInlineRoot,
|
||||
isNativeTextInVText,
|
||||
isVElement,
|
||||
isVLine,
|
||||
} from './guard.js';
|
||||
import { calculateTextLength, getTextNodesFromElement } from './text.js';
|
||||
|
||||
export function nativePointToTextPoint(
|
||||
node: unknown,
|
||||
offset: number
|
||||
): TextPoint | null {
|
||||
if (isNativeTextInVText(node)) {
|
||||
return [node, offset];
|
||||
}
|
||||
|
||||
if (isVElement(node)) {
|
||||
const texts = getTextNodesFromElement(node);
|
||||
const vElement = texts[0].parentElement?.closest('[data-v-element="true"]');
|
||||
|
||||
if (
|
||||
texts.length === 1 &&
|
||||
vElement instanceof HTMLElement &&
|
||||
vElement.dataset.vEmbed === 'true'
|
||||
) {
|
||||
return [texts[0], 0];
|
||||
}
|
||||
|
||||
if (texts.length > 0) {
|
||||
return texts[offset] ? [texts[offset], 0] : null;
|
||||
}
|
||||
}
|
||||
|
||||
if (isVLine(node) || isInlineRoot(node)) {
|
||||
return getTextPointRoughlyFromElementByOffset(node, offset, true);
|
||||
}
|
||||
|
||||
if (!(node instanceof Node)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const vNodes = getVNodesFromNode(node);
|
||||
|
||||
if (vNodes) {
|
||||
return getTextPointFromVNodes(vNodes, node, offset);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function textPointToDomPoint(
|
||||
text: Text,
|
||||
offset: number,
|
||||
rootElement: HTMLElement
|
||||
): DomPoint | null {
|
||||
if (rootElement.dataset.vRoot !== 'true') {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'textRangeToDomPoint should be called with editor root element'
|
||||
);
|
||||
}
|
||||
|
||||
if (!rootElement.contains(text)) return null;
|
||||
|
||||
const texts = getTextNodesFromElement(rootElement);
|
||||
if (texts.length === 0) return null;
|
||||
|
||||
const goalIndex = texts.indexOf(text);
|
||||
let index = 0;
|
||||
for (const text of texts.slice(0, goalIndex)) {
|
||||
index += calculateTextLength(text);
|
||||
}
|
||||
|
||||
if (text.wholeText !== ZERO_WIDTH_SPACE) {
|
||||
index += offset;
|
||||
}
|
||||
|
||||
const textParentElement = text.parentElement;
|
||||
if (!textParentElement) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'text element parent not found'
|
||||
);
|
||||
}
|
||||
|
||||
const lineElement = textParentElement.closest('v-line');
|
||||
|
||||
if (!lineElement) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.InlineEditorError,
|
||||
'line element not found'
|
||||
);
|
||||
}
|
||||
|
||||
const lineIndex = Array.from(rootElement.querySelectorAll('v-line')).indexOf(
|
||||
lineElement
|
||||
);
|
||||
|
||||
return { text, index: index + lineIndex };
|
||||
}
|
||||
|
||||
function getVNodesFromNode(node: Node): VElement[] | VLine[] | null {
|
||||
const vLine = node.parentElement?.closest('v-line');
|
||||
|
||||
if (vLine) {
|
||||
return Array.from(vLine.querySelectorAll('v-element'));
|
||||
}
|
||||
|
||||
const container =
|
||||
node instanceof Element
|
||||
? node.closest(`[${INLINE_ROOT_ATTR}]`)
|
||||
: node.parentElement?.closest(`[${INLINE_ROOT_ATTR}]`);
|
||||
|
||||
if (container) {
|
||||
return Array.from(container.querySelectorAll('v-line'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getTextPointFromVNodes(
|
||||
vNodes: VLine[] | VElement[],
|
||||
node: Node,
|
||||
offset: number
|
||||
): TextPoint | null {
|
||||
const first = vNodes[0];
|
||||
for (let i = 0; i < vNodes.length; i++) {
|
||||
const vNode = vNodes[i];
|
||||
|
||||
if (i === 0 && AFollowedByB(node, vNode)) {
|
||||
return getTextPointRoughlyFromElementByOffset(first, offset, true);
|
||||
}
|
||||
|
||||
if (AInsideB(node, vNode)) {
|
||||
return getTextPointRoughlyFromElementByOffset(first, offset, false);
|
||||
}
|
||||
|
||||
if (i === vNodes.length - 1 && APrecededByB(node, vNode)) {
|
||||
return getTextPointRoughlyFromElement(vNode);
|
||||
}
|
||||
|
||||
if (
|
||||
i < vNodes.length - 1 &&
|
||||
APrecededByB(node, vNode) &&
|
||||
AFollowedByB(node, vNodes[i + 1])
|
||||
) {
|
||||
return getTextPointRoughlyFromElement(vNode);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function getTextPointRoughlyFromElement(element: Element): TextPoint | null {
|
||||
const texts = getTextNodesFromElement(element);
|
||||
if (texts.length === 0) return null;
|
||||
const text = texts[texts.length - 1];
|
||||
return [text, calculateTextLength(text)];
|
||||
}
|
||||
|
||||
function getTextPointRoughlyFromElementByOffset(
|
||||
element: Element,
|
||||
offset: number,
|
||||
fromStart: boolean
|
||||
): TextPoint | null {
|
||||
const texts = getTextNodesFromElement(element);
|
||||
if (texts.length === 0) return null;
|
||||
const text = fromStart ? texts[0] : texts[texts.length - 1];
|
||||
return [text, offset === 0 ? offset : text.length];
|
||||
}
|
||||
|
||||
function AInsideB(a: Node, b: Node): boolean {
|
||||
return (
|
||||
b.compareDocumentPosition(a) === Node.DOCUMENT_POSITION_CONTAINED_BY ||
|
||||
b.compareDocumentPosition(a) ===
|
||||
(Node.DOCUMENT_POSITION_CONTAINED_BY | Node.DOCUMENT_POSITION_FOLLOWING)
|
||||
);
|
||||
}
|
||||
|
||||
function AFollowedByB(a: Node, b: Node): boolean {
|
||||
return a.compareDocumentPosition(b) === Node.DOCUMENT_POSITION_FOLLOWING;
|
||||
}
|
||||
|
||||
function APrecededByB(a: Node, b: Node): boolean {
|
||||
return a.compareDocumentPosition(b) === Node.DOCUMENT_POSITION_PRECEDING;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { INLINE_ROOT_ATTR } from '../consts.js';
|
||||
import type { InlineEditor, InlineRootElement } from '../inline-editor.js';
|
||||
|
||||
export function getInlineEditorInsideRoot(
|
||||
element: Element
|
||||
): InlineEditor | null {
|
||||
const rootElement = element.closest(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
) as InlineRootElement;
|
||||
if (!rootElement) {
|
||||
console.error('element must be inside a v-root');
|
||||
return null;
|
||||
}
|
||||
const inlineEditor = rootElement.inlineEditor;
|
||||
if (!inlineEditor) {
|
||||
console.error('element must be inside a v-root with inline-editor');
|
||||
return null;
|
||||
}
|
||||
return inlineEditor;
|
||||
}
|
||||
@@ -0,0 +1,368 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { BaseTextAttributes, DeltaInsert } from '@blocksuite/store';
|
||||
import { html, type TemplateResult } from 'lit';
|
||||
|
||||
export function renderElement<TextAttributes extends BaseTextAttributes>(
|
||||
delta: DeltaInsert<TextAttributes>,
|
||||
parseAttributes: (
|
||||
textAttributes?: TextAttributes
|
||||
) => TextAttributes | undefined,
|
||||
selected: boolean
|
||||
): TemplateResult<1> {
|
||||
return html`<v-element
|
||||
.selected=${selected}
|
||||
.delta=${{
|
||||
insert: delta.insert,
|
||||
attributes: parseAttributes(delta.attributes),
|
||||
}}
|
||||
></v-element>`;
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { ZERO_WIDTH_SPACE } from '../consts.js';
|
||||
|
||||
export function calculateTextLength(text: Text): number {
|
||||
if (text.wholeText === ZERO_WIDTH_SPACE) {
|
||||
return 0;
|
||||
} else {
|
||||
return text.wholeText.length;
|
||||
}
|
||||
}
|
||||
|
||||
export function getTextNodesFromElement(element: Element): Text[] {
|
||||
const textSpanElements = Array.from(
|
||||
element.querySelectorAll('[data-v-text="true"]')
|
||||
);
|
||||
const textNodes = textSpanElements.flatMap(textSpanElement => {
|
||||
const textNode = Array.from(textSpanElement.childNodes).find(
|
||||
(node): node is Text => node instanceof Text
|
||||
);
|
||||
if (!textNode) return [];
|
||||
|
||||
return textNode;
|
||||
});
|
||||
|
||||
return textNodes;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import type { BaseTextAttributes } from '@blocksuite/store';
|
||||
|
||||
import type { InlineEditor } from '../inline-editor.js';
|
||||
import type { InlineRange } from '../types.js';
|
||||
|
||||
function handleInsertText<TextAttributes extends BaseTextAttributes>(
|
||||
inlineRange: InlineRange,
|
||||
data: string | null,
|
||||
editor: InlineEditor,
|
||||
attributes: TextAttributes
|
||||
) {
|
||||
if (!data) return;
|
||||
editor.insertText(inlineRange, data, attributes);
|
||||
editor.setInlineRange({
|
||||
index: inlineRange.index + data.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function handleInsertReplacementText<TextAttributes extends BaseTextAttributes>(
|
||||
inlineRange: InlineRange,
|
||||
data: string | null,
|
||||
editor: InlineEditor,
|
||||
attributes: TextAttributes
|
||||
) {
|
||||
editor.getDeltasByInlineRange(inlineRange).forEach(deltaEntry => {
|
||||
attributes = { ...deltaEntry[0].attributes, ...attributes };
|
||||
});
|
||||
if (data) {
|
||||
editor.insertText(inlineRange, data, attributes);
|
||||
editor.setInlineRange({
|
||||
index: inlineRange.index + data.length,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleInsertParagraph(inlineRange: InlineRange, editor: InlineEditor) {
|
||||
editor.insertLineBreak(inlineRange);
|
||||
editor.setInlineRange({
|
||||
index: inlineRange.index + 1,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(inlineRange: InlineRange, editor: InlineEditor) {
|
||||
editor.deleteText(inlineRange);
|
||||
editor.setInlineRange({
|
||||
index: inlineRange.index,
|
||||
length: 0,
|
||||
});
|
||||
}
|
||||
|
||||
export function transformInput<TextAttributes extends BaseTextAttributes>(
|
||||
inputType: string,
|
||||
data: string | null,
|
||||
attributes: TextAttributes,
|
||||
inlineRange: InlineRange,
|
||||
editor: InlineEditor
|
||||
) {
|
||||
if (!editor.isValidInlineRange(inlineRange)) return;
|
||||
|
||||
if (inputType === 'insertText') {
|
||||
handleInsertText(inlineRange, data, editor, attributes);
|
||||
} else if (
|
||||
inputType === 'insertParagraph' ||
|
||||
inputType === 'insertLineBreak'
|
||||
) {
|
||||
handleInsertParagraph(inlineRange, editor);
|
||||
} else if (inputType.startsWith('delete')) {
|
||||
handleDelete(inlineRange, editor);
|
||||
} else if (inputType === 'insertReplacementText') {
|
||||
// Spell Checker
|
||||
handleInsertReplacementText(inlineRange, data, editor, attributes);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user