mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-14 21:27:20 +00:00
refactor(editor): improve std structure (#10993)
This commit is contained in:
1
blocksuite/framework/block-std/src/inline/index.ts
Normal file
1
blocksuite/framework/block-std/src/inline/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './range';
|
||||
14
blocksuite/framework/block-std/src/inline/range/active.ts
Normal file
14
blocksuite/framework/block-std/src/inline/range/active.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Check if the active element is in the editor host.
|
||||
* TODO(@mirone): this is a trade-off, we need to use separate awareness store for every store to make sure the selection is isolated.
|
||||
*
|
||||
* @param editorHost - The editor host element.
|
||||
* @returns Whether the active element is in the editor host.
|
||||
*/
|
||||
export function isActiveInEditor(editorHost: HTMLElement) {
|
||||
const currentActiveElement = document.activeElement;
|
||||
if (!currentActiveElement) return false;
|
||||
const currentEditorHost = currentActiveElement?.closest('editor-host');
|
||||
if (!currentEditorHost) return false;
|
||||
return currentEditorHost === editorHost;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Used to exclude certain elements when using `getSelectedBlockComponentsByRange`.
|
||||
*/
|
||||
export const RANGE_QUERY_EXCLUDE_ATTR = 'data-range-query-exclude';
|
||||
|
||||
/**
|
||||
* Used to mark certain elements so that they are excluded when synchronizing the native range and text selection (such as database block).
|
||||
*/
|
||||
export const RANGE_SYNC_EXCLUDE_ATTR = 'data-range-sync-exclude';
|
||||
4
blocksuite/framework/block-std/src/inline/range/index.ts
Normal file
4
blocksuite/framework/block-std/src/inline/range/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './consts.js';
|
||||
export * from './inline-range-provider.js';
|
||||
export * from './range-binding.js';
|
||||
export * from './range-manager.js';
|
||||
@@ -0,0 +1,110 @@
|
||||
import type { InlineRange, InlineRangeProvider } from '@blocksuite/inline';
|
||||
import { signal } from '@preact/signals-core';
|
||||
|
||||
import { TextSelection } from '../../selection/index.js';
|
||||
import type { BlockComponent } from '../../view/element/block-component.js';
|
||||
import { isActiveInEditor } from './active.js';
|
||||
|
||||
export const getInlineRangeProvider: (
|
||||
element: BlockComponent
|
||||
) => InlineRangeProvider | null = element => {
|
||||
const editorHost = element.host;
|
||||
const selectionManager = editorHost.selection;
|
||||
const rangeManager = editorHost.range;
|
||||
|
||||
if (!selectionManager || !rangeManager) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const calculateInlineRange = (
|
||||
range: Range,
|
||||
textSelection: TextSelection
|
||||
): InlineRange | null => {
|
||||
const { from, to } = textSelection;
|
||||
|
||||
if (from.blockId === element.blockId) {
|
||||
return {
|
||||
index: from.index,
|
||||
length: from.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (to && to.blockId === element.blockId) {
|
||||
return {
|
||||
index: to.index,
|
||||
length: to.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (!element.model.text) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const elementRange = rangeManager.textSelectionToRange(
|
||||
selectionManager.create(TextSelection, {
|
||||
from: {
|
||||
index: 0,
|
||||
blockId: element.blockId,
|
||||
length: element.model.text.length,
|
||||
},
|
||||
to: null,
|
||||
})
|
||||
);
|
||||
|
||||
if (
|
||||
elementRange &&
|
||||
elementRange.compareBoundaryPoints(Range.START_TO_START, range) > -1 &&
|
||||
elementRange.compareBoundaryPoints(Range.END_TO_END, range) < 1
|
||||
) {
|
||||
return {
|
||||
index: 0,
|
||||
length: element.model.text.length,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const setInlineRange = (inlineRange: InlineRange | null) => {
|
||||
// skip `setInlineRange` from `inlineEditor` when composing happens across blocks,
|
||||
// selection will be updated in `range-binding`
|
||||
if (rangeManager.binding?.isComposing) return;
|
||||
|
||||
if (!inlineRange) {
|
||||
selectionManager.clear(['text']);
|
||||
} else {
|
||||
const textSelection = selectionManager.create(TextSelection, {
|
||||
from: {
|
||||
blockId: element.blockId,
|
||||
index: inlineRange.index,
|
||||
length: inlineRange.length,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
selectionManager.setGroup('note', [textSelection]);
|
||||
}
|
||||
};
|
||||
const inlineRange$: InlineRangeProvider['inlineRange$'] = signal(null);
|
||||
|
||||
editorHost.disposables.add(
|
||||
selectionManager.slots.changed.subscribe(selections => {
|
||||
if (!isActiveInEditor(editorHost)) return;
|
||||
|
||||
const textSelection = selections.find(s => s.type === 'text') as
|
||||
| TextSelection
|
||||
| undefined;
|
||||
const range = rangeManager.value;
|
||||
if (!range || !textSelection) {
|
||||
inlineRange$.value = null;
|
||||
return;
|
||||
}
|
||||
const inlineRange = calculateInlineRange(range, textSelection);
|
||||
inlineRange$.value = inlineRange;
|
||||
})
|
||||
);
|
||||
|
||||
return {
|
||||
setInlineRange,
|
||||
inlineRange$,
|
||||
};
|
||||
};
|
||||
348
blocksuite/framework/block-std/src/inline/range/range-binding.ts
Normal file
348
blocksuite/framework/block-std/src/inline/range/range-binding.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
import type { BaseSelection, BlockModel } from '@blocksuite/store';
|
||||
import throttle from 'lodash-es/throttle';
|
||||
|
||||
import { TextSelection } from '../../selection/index.js';
|
||||
import type { BlockComponent } from '../../view/element/block-component.js';
|
||||
import { BLOCK_ID_ATTR } from '../../view/index.js';
|
||||
import { isActiveInEditor } from './active.js';
|
||||
import { RANGE_SYNC_EXCLUDE_ATTR } from './consts.js';
|
||||
import type { RangeManager } from './range-manager.js';
|
||||
/**
|
||||
* Two-way binding between native range and text selection
|
||||
*/
|
||||
export class RangeBinding {
|
||||
private _compositionStartCallback:
|
||||
| ((event: CompositionEvent) => Promise<void>)
|
||||
| null = null;
|
||||
|
||||
private readonly _computePath = (modelId: string) => {
|
||||
const block = this.host.std.store.getBlock(modelId)?.model;
|
||||
if (!block) return [];
|
||||
|
||||
const path: string[] = [];
|
||||
let parent: BlockModel | null = block;
|
||||
while (parent) {
|
||||
path.unshift(parent.id);
|
||||
parent = this.host.doc.getParent(parent);
|
||||
}
|
||||
|
||||
return path;
|
||||
};
|
||||
|
||||
private readonly _onBeforeInput = (event: InputEvent) => {
|
||||
const selection = this.selectionManager.find(TextSelection);
|
||||
if (!selection) return;
|
||||
|
||||
if (event.isComposing) return;
|
||||
|
||||
const { from, to } = selection;
|
||||
if (!to || from.blockId === to.blockId) return;
|
||||
|
||||
const range = this.rangeManager?.value;
|
||||
if (!range) return;
|
||||
|
||||
const blocks = this.rangeManager.getSelectedBlockComponentsByRange(range, {
|
||||
mode: 'flat',
|
||||
});
|
||||
|
||||
const start = blocks.at(0);
|
||||
const end = blocks.at(-1);
|
||||
if (!start || !end) return;
|
||||
|
||||
const startText = start.model.text;
|
||||
const endText = end.model.text;
|
||||
if (!startText || !endText) return;
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
this.host.doc.transact(() => {
|
||||
startText.delete(from.index, from.length);
|
||||
startText.insert(event.data ?? '', from.index);
|
||||
endText.delete(0, to.length);
|
||||
startText.join(endText);
|
||||
|
||||
blocks
|
||||
.slice(1)
|
||||
// delete from lowest to highest
|
||||
.reverse()
|
||||
.forEach(block => {
|
||||
const parent = this.host.doc.getParent(block.model);
|
||||
if (!parent) return;
|
||||
this.host.doc.deleteBlock(block.model, {
|
||||
bringChildrenTo: parent,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
const newSelection = this.selectionManager.create(TextSelection, {
|
||||
from: {
|
||||
blockId: from.blockId,
|
||||
index: from.index + (event.data?.length ?? 0),
|
||||
length: 0,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
this.selectionManager.setGroup('note', [newSelection]);
|
||||
};
|
||||
|
||||
private readonly _onCompositionEnd = (event: CompositionEvent) => {
|
||||
if (this._compositionStartCallback) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this._compositionStartCallback(event).catch(console.error);
|
||||
this._compositionStartCallback = null;
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _onCompositionStart = () => {
|
||||
const selection = this.selectionManager.find(TextSelection);
|
||||
if (!selection) return;
|
||||
|
||||
const { from, to } = selection;
|
||||
if (!to) return;
|
||||
|
||||
this.isComposing = true;
|
||||
|
||||
const range = this.rangeManager?.value;
|
||||
if (!range) return;
|
||||
|
||||
const blocks = this.rangeManager.getSelectedBlockComponentsByRange(range, {
|
||||
mode: 'flat',
|
||||
});
|
||||
|
||||
const start = blocks.at(0);
|
||||
const end = blocks.at(-1);
|
||||
if (!start || !end) return;
|
||||
|
||||
const startText = start.model.text;
|
||||
const endText = end.model.text;
|
||||
if (!startText || !endText) return;
|
||||
|
||||
this._compositionStartCallback = async event => {
|
||||
this.isComposing = false;
|
||||
|
||||
this.host.renderRoot.replaceChildren();
|
||||
// Because we bypassed Lit and disrupted the DOM structure, this will cause an inconsistency in the original state of `ChildPart`.
|
||||
// Therefore, we need to remove the original `ChildPart`.
|
||||
// https://github.com/lit/lit/blob/a2cd76cfdea4ed717362bb1db32710d70550469d/packages/lit-html/src/lit-html.ts#L2248
|
||||
|
||||
delete (this.host.renderRoot as any)['_$litPart$'];
|
||||
this.host.requestUpdate();
|
||||
await this.host.updateComplete;
|
||||
|
||||
this.host.doc.captureSync();
|
||||
|
||||
this.host.doc.transact(() => {
|
||||
endText.delete(0, to.length);
|
||||
startText.delete(from.index, from.length);
|
||||
startText.insert(event.data, from.index);
|
||||
startText.join(endText);
|
||||
|
||||
blocks
|
||||
.slice(1)
|
||||
// delete from lowest to highest
|
||||
.reverse()
|
||||
.forEach(block => {
|
||||
const parent = this.host.doc.getParent(block.model);
|
||||
if (!parent) return;
|
||||
this.host.doc.deleteBlock(block.model, {
|
||||
bringChildrenTo: parent,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
await this.host.updateComplete;
|
||||
|
||||
const selection = this.selectionManager.create(TextSelection, {
|
||||
from: {
|
||||
blockId: from.blockId,
|
||||
index: from.index + (event.data?.length ?? 0),
|
||||
length: 0,
|
||||
},
|
||||
to: null,
|
||||
});
|
||||
this.host.selection.setGroup('note', [selection]);
|
||||
this.rangeManager?.syncTextSelectionToRange(selection);
|
||||
};
|
||||
};
|
||||
|
||||
private readonly _onNativeSelectionChanged = async () => {
|
||||
if (this.isComposing) return;
|
||||
if (!this.host) return; // Unstable when switching views, card <-> embed
|
||||
if (!isActiveInEditor(this.host)) return;
|
||||
|
||||
await this.host.updateComplete;
|
||||
|
||||
const selection = document.getSelection();
|
||||
if (!selection) {
|
||||
this.selectionManager.clear(['text']);
|
||||
return;
|
||||
}
|
||||
const range = selection.rangeCount > 0 ? selection.getRangeAt(0) : null;
|
||||
|
||||
if (!range) {
|
||||
this._prevTextSelection = null;
|
||||
this.selectionManager.clear(['text']);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.host.contains(range.commonAncestorContainer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// range is in a non-editable element
|
||||
// ex. placeholder
|
||||
const isRangeOutNotEditable =
|
||||
range.startContainer instanceof HTMLElement &&
|
||||
range.startContainer.contentEditable === 'false' &&
|
||||
range.endContainer instanceof HTMLElement &&
|
||||
range.endContainer.contentEditable === 'false';
|
||||
if (isRangeOutNotEditable) {
|
||||
this._prevTextSelection = null;
|
||||
this.selectionManager.clear(['text']);
|
||||
|
||||
// force clear native selection to break inline editor input
|
||||
selection.removeRange(range);
|
||||
return;
|
||||
}
|
||||
|
||||
const el =
|
||||
range.commonAncestorContainer instanceof Element
|
||||
? range.commonAncestorContainer
|
||||
: range.commonAncestorContainer.parentElement;
|
||||
if (!el) return;
|
||||
const block = el.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
|
||||
if (block?.getAttribute(RANGE_SYNC_EXCLUDE_ATTR) === 'true') return;
|
||||
|
||||
const inlineEditor = this.rangeManager?.getClosestInlineEditor(
|
||||
range.commonAncestorContainer
|
||||
);
|
||||
if (inlineEditor?.isComposing) return;
|
||||
|
||||
const isRangeReversed =
|
||||
!!selection.anchorNode &&
|
||||
!!selection.focusNode &&
|
||||
(selection.anchorNode === selection.focusNode
|
||||
? selection.anchorOffset > selection.focusOffset
|
||||
: selection.anchorNode.compareDocumentPosition(selection.focusNode) ===
|
||||
Node.DOCUMENT_POSITION_PRECEDING);
|
||||
const textSelection = this.rangeManager?.rangeToTextSelection(
|
||||
range,
|
||||
isRangeReversed
|
||||
);
|
||||
if (!textSelection) {
|
||||
this._prevTextSelection = null;
|
||||
this.selectionManager.clear(['text']);
|
||||
return;
|
||||
}
|
||||
|
||||
const model = this.host.doc.getModelById(textSelection.blockId);
|
||||
// If the model is not found, the selection maybe in another editor
|
||||
if (!model) return;
|
||||
|
||||
this._prevTextSelection = {
|
||||
selection: textSelection,
|
||||
path: this._computePath(model.id),
|
||||
};
|
||||
this.rangeManager?.syncRangeToTextSelection(range, isRangeReversed);
|
||||
};
|
||||
|
||||
private readonly _onStdSelectionChanged = (selections: BaseSelection[]) => {
|
||||
// TODO(@mirone): this is a trade-off, we need to use separate awareness store for every store to make sure the selection is isolated.
|
||||
const closestHost = document.activeElement?.closest('editor-host');
|
||||
if (closestHost && closestHost !== this.host) return;
|
||||
|
||||
const text =
|
||||
selections.find((selection): selection is TextSelection =>
|
||||
selection.is(TextSelection)
|
||||
) ?? null;
|
||||
|
||||
if (text === this._prevTextSelection) {
|
||||
return;
|
||||
}
|
||||
// wait for lit updated
|
||||
this.host.updateComplete
|
||||
.then(() => {
|
||||
const id = text?.blockId;
|
||||
const path = id && this._computePath(id);
|
||||
|
||||
if (this.host.event.active) {
|
||||
const eq =
|
||||
text && this._prevTextSelection && path
|
||||
? text.equals(this._prevTextSelection.selection) &&
|
||||
path.join('') === this._prevTextSelection.path.join('')
|
||||
: false;
|
||||
|
||||
if (eq) return;
|
||||
}
|
||||
|
||||
this._prevTextSelection =
|
||||
text && path
|
||||
? {
|
||||
selection: text,
|
||||
path,
|
||||
}
|
||||
: null;
|
||||
if (text) {
|
||||
this.rangeManager?.syncTextSelectionToRange(text);
|
||||
} else {
|
||||
this.rangeManager?.clear();
|
||||
}
|
||||
})
|
||||
.catch(console.error);
|
||||
};
|
||||
|
||||
private _prevTextSelection: {
|
||||
selection: TextSelection;
|
||||
path: string[];
|
||||
} | null = null;
|
||||
|
||||
isComposing = false;
|
||||
|
||||
get host() {
|
||||
return this.manager.std.host;
|
||||
}
|
||||
|
||||
get rangeManager() {
|
||||
return this.host.range;
|
||||
}
|
||||
|
||||
get selectionManager() {
|
||||
return this.host.selection;
|
||||
}
|
||||
|
||||
constructor(public manager: RangeManager) {
|
||||
this.host.disposables.add(
|
||||
this.selectionManager.slots.changed.subscribe(this._onStdSelectionChanged)
|
||||
);
|
||||
|
||||
this.host.disposables.addFromEvent(
|
||||
document,
|
||||
'selectionchange',
|
||||
throttle(() => {
|
||||
this._onNativeSelectionChanged().catch(console.error);
|
||||
}, 10)
|
||||
);
|
||||
|
||||
this.host.disposables.add(
|
||||
this.host.event.add('beforeInput', ctx => {
|
||||
const event = ctx.get('defaultState').event as InputEvent;
|
||||
this._onBeforeInput(event);
|
||||
})
|
||||
);
|
||||
|
||||
this.host.disposables.addFromEvent(
|
||||
this.host,
|
||||
'compositionstart',
|
||||
this._onCompositionStart
|
||||
);
|
||||
this.host.disposables.addFromEvent(
|
||||
this.host,
|
||||
'compositionend',
|
||||
this._onCompositionEnd,
|
||||
{
|
||||
capture: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
254
blocksuite/framework/block-std/src/inline/range/range-manager.ts
Normal file
254
blocksuite/framework/block-std/src/inline/range/range-manager.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline';
|
||||
|
||||
import { LifeCycleWatcher } from '../../extension/index.js';
|
||||
import { TextSelection } from '../../selection/index.js';
|
||||
import type { BlockComponent } from '../../view/element/block-component.js';
|
||||
import { BLOCK_ID_ATTR } from '../../view/index.js';
|
||||
import { RANGE_QUERY_EXCLUDE_ATTR, RANGE_SYNC_EXCLUDE_ATTR } from './consts.js';
|
||||
import { RangeBinding } from './range-binding.js';
|
||||
|
||||
/**
|
||||
* CRUD for Range and TextSelection
|
||||
*/
|
||||
export class RangeManager extends LifeCycleWatcher {
|
||||
static override readonly key = 'rangeManager';
|
||||
|
||||
binding: RangeBinding | null = null;
|
||||
|
||||
get value() {
|
||||
const selection = document.getSelection();
|
||||
if (!selection) {
|
||||
return;
|
||||
}
|
||||
if (selection.rangeCount === 0) return null;
|
||||
return selection.getRangeAt(0);
|
||||
}
|
||||
|
||||
private _isRangeSyncExcluded(el: Element) {
|
||||
return !!el.closest(`[${RANGE_SYNC_EXCLUDE_ATTR}="true"]`);
|
||||
}
|
||||
|
||||
clear() {
|
||||
const selection = document.getSelection();
|
||||
if (!selection) return;
|
||||
selection.removeAllRanges();
|
||||
|
||||
const topContenteditableElement = this.std.host.querySelector(
|
||||
'[contenteditable="true"]'
|
||||
);
|
||||
if (topContenteditableElement instanceof HTMLElement) {
|
||||
topContenteditableElement.blur();
|
||||
}
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur();
|
||||
}
|
||||
}
|
||||
|
||||
getClosestBlock(node: Node) {
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
if (!el) return null;
|
||||
const block = el.closest<BlockComponent>(`[${BLOCK_ID_ATTR}]`);
|
||||
if (!block) return null;
|
||||
if (this._isRangeSyncExcluded(block)) return null;
|
||||
return block;
|
||||
}
|
||||
|
||||
getClosestInlineEditor(node: Node) {
|
||||
const el = node instanceof Element ? node : node.parentElement;
|
||||
if (!el) return null;
|
||||
const inlineRoot = el.closest<InlineRootElement>(`[${INLINE_ROOT_ATTR}]`);
|
||||
if (!inlineRoot) return null;
|
||||
|
||||
if (this._isRangeSyncExcluded(inlineRoot)) return null;
|
||||
|
||||
return inlineRoot.inlineEditor;
|
||||
}
|
||||
|
||||
/**
|
||||
* @example
|
||||
* aaa
|
||||
* b[bb
|
||||
* ccc
|
||||
* ddd
|
||||
* ee]e
|
||||
*
|
||||
* all mode: [aaa, bbb, ccc, ddd, eee]
|
||||
* flat mode: [bbb, ccc, ddd, eee]
|
||||
* highest mode: [bbb, ddd]
|
||||
*
|
||||
* match function will be evaluated before filtering using mode
|
||||
*/
|
||||
getSelectedBlockComponentsByRange(
|
||||
range: Range,
|
||||
options: {
|
||||
match?: (el: BlockComponent) => boolean;
|
||||
mode?: 'all' | 'flat' | 'highest';
|
||||
} = {}
|
||||
): BlockComponent[] {
|
||||
const { mode = 'all', match = () => true } = options;
|
||||
|
||||
let result = Array.from<BlockComponent>(
|
||||
this.std.host.querySelectorAll(
|
||||
`[${BLOCK_ID_ATTR}]:not([${RANGE_QUERY_EXCLUDE_ATTR}="true"])`
|
||||
)
|
||||
).filter(el => range.intersectsNode(el) && match(el));
|
||||
|
||||
if (result.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const firstElement = this.getClosestBlock(range.startContainer);
|
||||
if (!firstElement) return [];
|
||||
const firstElementIndex = result.indexOf(firstElement);
|
||||
if (firstElementIndex === -1) return [];
|
||||
|
||||
if (mode === 'flat') {
|
||||
result = result.slice(firstElementIndex);
|
||||
} else if (mode === 'highest') {
|
||||
result = result.slice(firstElementIndex);
|
||||
let parent = result[0];
|
||||
result = result.filter((node, index) => {
|
||||
if (index === 0) return true;
|
||||
if (
|
||||
parent.compareDocumentPosition(node) &
|
||||
Node.DOCUMENT_POSITION_CONTAINED_BY
|
||||
) {
|
||||
return false;
|
||||
} else {
|
||||
parent = node;
|
||||
return true;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
override mounted() {
|
||||
this.binding = new RangeBinding(this);
|
||||
}
|
||||
|
||||
queryInlineEditorByPath(path: string) {
|
||||
const block = this.std.host.view.getBlock(path);
|
||||
if (!block) return null;
|
||||
|
||||
const inlineRoot = block.querySelector<InlineRootElement>(
|
||||
`[${INLINE_ROOT_ATTR}]`
|
||||
);
|
||||
if (!inlineRoot) return null;
|
||||
|
||||
if (this._isRangeSyncExcluded(inlineRoot)) return null;
|
||||
|
||||
return inlineRoot.inlineEditor;
|
||||
}
|
||||
|
||||
rangeToTextSelection(range: Range, reverse = false): TextSelection | null {
|
||||
const { startContainer, endContainer } = range;
|
||||
|
||||
const startBlock = this.getClosestBlock(startContainer);
|
||||
const endBlock = this.getClosestBlock(endContainer);
|
||||
if (!startBlock || !endBlock) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startInlineEditor = this.getClosestInlineEditor(startContainer);
|
||||
const endInlineEditor = this.getClosestInlineEditor(endContainer);
|
||||
if (!startInlineEditor || !endInlineEditor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const startInlineRange = startInlineEditor.toInlineRange(range);
|
||||
const endInlineRange = endInlineEditor.toInlineRange(range);
|
||||
if (!startInlineRange || !endInlineRange) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return this.std.host.selection.create(TextSelection, {
|
||||
from: {
|
||||
blockId: startBlock.blockId,
|
||||
index: startInlineRange.index,
|
||||
length: startInlineRange.length,
|
||||
},
|
||||
to:
|
||||
startBlock === endBlock
|
||||
? null
|
||||
: {
|
||||
blockId: endBlock.blockId,
|
||||
index: endInlineRange.index,
|
||||
length: endInlineRange.length,
|
||||
},
|
||||
reverse,
|
||||
});
|
||||
}
|
||||
|
||||
set(range: Range) {
|
||||
const selection = document.getSelection();
|
||||
if (!selection) return;
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}
|
||||
|
||||
syncRangeToTextSelection(range: Range, isRangeReversed: boolean) {
|
||||
const selectionManager = this.std.host.selection;
|
||||
|
||||
if (!range) {
|
||||
selectionManager.clear(['text']);
|
||||
return;
|
||||
}
|
||||
|
||||
const textSelection = this.rangeToTextSelection(range, isRangeReversed);
|
||||
if (textSelection) {
|
||||
selectionManager.setGroup('note', [textSelection]);
|
||||
} else {
|
||||
selectionManager.clear(['text']);
|
||||
}
|
||||
}
|
||||
|
||||
syncTextSelectionToRange(selection: TextSelection) {
|
||||
const range = this.textSelectionToRange(selection);
|
||||
if (range) {
|
||||
this.set(range);
|
||||
} else {
|
||||
this.clear();
|
||||
}
|
||||
}
|
||||
|
||||
textSelectionToRange(selection: TextSelection): Range | null {
|
||||
const { from, to } = selection;
|
||||
|
||||
const fromInlineEditor = this.queryInlineEditorByPath(from.blockId);
|
||||
if (!fromInlineEditor) return null;
|
||||
|
||||
if (selection.isInSameBlock()) {
|
||||
return fromInlineEditor.toDomRange({
|
||||
index: from.index,
|
||||
length: from.length,
|
||||
});
|
||||
}
|
||||
|
||||
if (!to) return null;
|
||||
const toInlineEditor = this.queryInlineEditorByPath(to.blockId);
|
||||
if (!toInlineEditor) return null;
|
||||
|
||||
const fromRange = fromInlineEditor.toDomRange({
|
||||
index: from.index,
|
||||
length: from.length,
|
||||
});
|
||||
const toRange = toInlineEditor.toDomRange({
|
||||
index: to.index,
|
||||
length: to.length,
|
||||
});
|
||||
|
||||
if (!fromRange || !toRange) return null;
|
||||
|
||||
const range = document.createRange();
|
||||
const startContainer = fromRange.startContainer;
|
||||
const startOffset = fromRange.startOffset;
|
||||
const endContainer = toRange.endContainer;
|
||||
const endOffset = toRange.endOffset;
|
||||
range.setStart(startContainer, startOffset);
|
||||
range.setEnd(endContainer, endOffset);
|
||||
|
||||
return range;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user