Files
AFFiNE-Mirror/blocksuite/framework/block-std/src/range/range-manager.ts
T
2024-12-20 15:38:06 +08:00

255 lines
6.9 KiB
TypeScript

import { INLINE_ROOT_ATTR, type InlineRootElement } from '@blocksuite/inline';
import { LifeCycleWatcher } from '../extension/index.js';
import type { 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('text', {
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;
}
}