chore: merge blocksuite source code (#9213)

This commit is contained in:
Mirone
2024-12-20 15:38:06 +08:00
committed by GitHub
parent 2c9ef916f4
commit 30200ff86d
2031 changed files with 238888 additions and 229 deletions
@@ -0,0 +1,49 @@
import { html } from 'lit';
import { styleMap } from 'lit/directives/style-map.js';
import type { AttributeRenderer } from '../types.js';
import type { BaseTextAttributes } from './base-attributes.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,12 @@
import { z } from 'zod';
export const baseTextAttributes = z.object({
bold: z.literal(true).optional().nullable().catch(undefined),
italic: z.literal(true).optional().nullable().catch(undefined),
underline: z.literal(true).optional().nullable().catch(undefined),
strike: z.literal(true).optional().nullable().catch(undefined),
code: z.literal(true).optional().nullable().catch(undefined),
link: z.string().optional().nullable().catch(undefined),
});
export type BaseTextAttributes = z.infer<typeof baseTextAttributes>;
@@ -0,0 +1,64 @@
import type { DeltaInsert } from '../types.js';
import type { BaseTextAttributes } from './base-attributes.js';
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 { VElement } from '../components/v-element.js';
import type { InlineEditor } from '../inline-editor.js';
import type { DeltaInsert } from '../types.js';
import type { BaseTextAttributes } from './base-attributes.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,12 @@
export * from './attribute-renderer.js';
export * from './base-attributes.js';
export * from './delta-convert.js';
export * from './embed.js';
export * from './guard.js';
export * from './keyboard.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,135 @@
import { IS_IOS, IS_MAC } from '@blocksuite/global/env';
import type { InlineEditor } from '../inline-editor.js';
import type { InlineRange } from '../types.js';
import type { BaseTextAttributes } from './base-attributes.js';
const SHORT_KEY_PROPERTY = IS_IOS || IS_MAC ? 'metaKey' : 'ctrlKey';
export const KEYBOARD_PREVENT_DEFAULT = false;
export const KEYBOARD_ALLOW_DEFAULT = true;
export interface KeyboardBinding {
key: number | string | string[];
handler: KeyboardBindingHandler;
prefix?: RegExp;
suffix?: RegExp;
shortKey?: boolean;
shiftKey?: boolean;
altKey?: boolean;
metaKey?: boolean;
ctrlKey?: boolean;
}
export type KeyboardBindingRecord = Record<string, KeyboardBinding>;
export interface KeyboardBindingContext<
TextAttributes extends BaseTextAttributes = BaseTextAttributes,
> {
inlineRange: InlineRange;
inlineEditor: InlineEditor<TextAttributes>;
collapsed: boolean;
prefixText: string;
suffixText: string;
raw: KeyboardEvent;
}
export type KeyboardBindingHandler = (
context: KeyboardBindingContext
) => typeof KEYBOARD_PREVENT_DEFAULT | typeof KEYBOARD_ALLOW_DEFAULT;
export function createInlineKeyDownHandler(
inlineEditor: InlineEditor,
bindings: KeyboardBindingRecord
): (evt: KeyboardEvent) => void {
const bindingStore: Record<string, KeyboardBinding[]> = {};
function normalize(binding: KeyboardBinding): KeyboardBinding {
if (binding.shortKey) {
binding[SHORT_KEY_PROPERTY] = binding.shortKey;
delete binding.shortKey;
}
return binding;
}
function keyMatch(evt: KeyboardEvent, binding: KeyboardBinding) {
if (
(['altKey', 'ctrlKey', 'metaKey', 'shiftKey'] as const).some(
key => Object.hasOwn(binding, key) && binding[key] !== evt[key]
)
) {
return false;
}
return binding.key === evt.key;
}
function addBinding(keyBinding: KeyboardBinding) {
const binding = normalize(keyBinding);
const keys = Array.isArray(binding.key) ? binding.key : [binding.key];
keys.forEach(key => {
const singleBinding = {
...binding,
key,
};
bindingStore[key] = bindingStore[key] ?? [];
bindingStore[key].push(singleBinding);
});
}
Object.values(bindings).forEach(binding => {
addBinding(binding);
});
function keyDownHandler(evt: KeyboardEvent) {
if (evt.defaultPrevented || evt.isComposing) return;
const keyBindings = bindingStore[evt.key] ?? [];
const keyMatches = keyBindings.filter(binding => keyMatch(evt, binding));
if (keyMatches.length === 0) return;
const inlineRange = inlineEditor.getInlineRange();
if (!inlineRange) return;
const startTextPoint = inlineEditor.getTextPoint(inlineRange.index);
if (!startTextPoint) return;
const [leafStart, offsetStart] = startTextPoint;
let leafEnd: Text;
let offsetEnd: number;
if (inlineRange.length === 0) {
leafEnd = leafStart;
offsetEnd = offsetStart;
} else {
const endTextPoint = inlineEditor.getTextPoint(
inlineRange.index + inlineRange.length
);
if (!endTextPoint) return;
[leafEnd, offsetEnd] = endTextPoint;
}
const prefixText = leafStart.textContent
? leafStart.textContent.slice(0, offsetStart)
: '';
const suffixText = leafEnd.textContent
? leafEnd.textContent.slice(offsetEnd)
: '';
const currContext: KeyboardBindingContext = {
inlineRange,
inlineEditor: inlineEditor,
collapsed: inlineRange.length === 0,
prefixText,
suffixText,
raw: evt,
};
const prevented = keyMatches.some(binding => {
if (binding.prefix && !binding.prefix.test(currContext.prefixText)) {
return false;
}
if (binding.suffix && !binding.suffix.test(currContext.suffixText)) {
return false;
}
return binding.handler(currContext) === KEYBOARD_PREVENT_DEFAULT;
});
if (prevented) {
evt.preventDefault();
}
}
return keyDownHandler;
}
@@ -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,20 @@
import { html, type TemplateResult } from 'lit';
import type { DeltaInsert } from '../types.js';
import type { BaseTextAttributes } from './base-attributes.js';
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,77 @@
import type { InlineEditor } from '../inline-editor.js';
import type { InlineRange } from '../types.js';
import type { BaseTextAttributes } from './base-attributes.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;
}
}