quill
Version:
Your powerful, rich text editor
374 lines • 13.7 kB
JavaScript
import { LeafBlot, Scope } from 'parchment';
import { cloneDeep, isEqual } from 'lodash-es';
import Emitter from './emitter.js';
import logger from './logger.js';
const debug = logger('quill:selection');
export class Range {
constructor(index) {
let length = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
this.index = index;
this.length = length;
}
}
class Selection {
constructor(scroll, emitter) {
this.emitter = emitter;
this.scroll = scroll;
this.composing = false;
this.mouseDown = false;
this.root = this.scroll.domNode;
// @ts-expect-error
this.cursor = this.scroll.create('cursor', this);
// savedRange is last non-null range
this.savedRange = new Range(0, 0);
this.lastRange = this.savedRange;
this.lastNative = null;
this.handleComposition();
this.handleDragging();
this.emitter.listenDOM('selectionchange', document, () => {
if (!this.mouseDown && !this.composing) {
setTimeout(this.update.bind(this, Emitter.sources.USER), 1);
}
});
this.emitter.on(Emitter.events.SCROLL_BEFORE_UPDATE, () => {
if (!this.hasFocus()) return;
const native = this.getNativeRange();
if (native == null) return;
if (native.start.node === this.cursor.textNode) return; // cursor.restore() will handle
this.emitter.once(Emitter.events.SCROLL_UPDATE, (source, mutations) => {
try {
if (this.root.contains(native.start.node) && this.root.contains(native.end.node)) {
this.setNativeRange(native.start.node, native.start.offset, native.end.node, native.end.offset);
}
const triggeredByTyping = mutations.some(mutation => mutation.type === 'characterData' || mutation.type === 'childList' || mutation.type === 'attributes' && mutation.target === this.root);
this.update(triggeredByTyping ? Emitter.sources.SILENT : source);
} catch (ignored) {
// ignore
}
});
});
this.emitter.on(Emitter.events.SCROLL_OPTIMIZE, (mutations, context) => {
if (context.range) {
const {
startNode,
startOffset,
endNode,
endOffset
} = context.range;
this.setNativeRange(startNode, startOffset, endNode, endOffset);
this.update(Emitter.sources.SILENT);
}
});
this.update(Emitter.sources.SILENT);
}
handleComposition() {
this.emitter.on(Emitter.events.COMPOSITION_BEFORE_START, () => {
this.composing = true;
});
this.emitter.on(Emitter.events.COMPOSITION_END, () => {
this.composing = false;
if (this.cursor.parent) {
const range = this.cursor.restore();
if (!range) return;
setTimeout(() => {
this.setNativeRange(range.startNode, range.startOffset, range.endNode, range.endOffset);
}, 1);
}
});
}
handleDragging() {
this.emitter.listenDOM('mousedown', document.body, () => {
this.mouseDown = true;
});
this.emitter.listenDOM('mouseup', document.body, () => {
this.mouseDown = false;
this.update(Emitter.sources.USER);
});
}
focus() {
if (this.hasFocus()) return;
this.root.focus({
preventScroll: true
});
this.setRange(this.savedRange);
}
format(format, value) {
this.scroll.update();
const nativeRange = this.getNativeRange();
if (nativeRange == null || !nativeRange.native.collapsed || this.scroll.query(format, Scope.BLOCK)) return;
if (nativeRange.start.node !== this.cursor.textNode) {
const blot = this.scroll.find(nativeRange.start.node, false);
if (blot == null) return;
// TODO Give blot ability to not split
if (blot instanceof LeafBlot) {
const after = blot.split(nativeRange.start.offset);
blot.parent.insertBefore(this.cursor, after);
} else {
// @ts-expect-error TODO: nativeRange.start.node doesn't seem to match function signature
blot.insertBefore(this.cursor, nativeRange.start.node); // Should never happen
}
this.cursor.attach();
}
this.cursor.format(format, value);
this.scroll.optimize();
this.setNativeRange(this.cursor.textNode, this.cursor.textNode.data.length);
this.update();
}
getBounds(index) {
let length = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : 0;
const scrollLength = this.scroll.length();
index = Math.min(index, scrollLength - 1);
length = Math.min(index + length, scrollLength - 1) - index;
let node;
let [leaf, offset] = this.scroll.leaf(index);
if (leaf == null) return null;
if (length > 0 && offset === leaf.length()) {
const [next] = this.scroll.leaf(index + 1);
if (next) {
const [line] = this.scroll.line(index);
const [nextLine] = this.scroll.line(index + 1);
if (line === nextLine) {
leaf = next;
offset = 0;
}
}
}
[node, offset] = leaf.position(offset, true);
const range = document.createRange();
if (length > 0) {
range.setStart(node, offset);
[leaf, offset] = this.scroll.leaf(index + length);
if (leaf == null) return null;
[node, offset] = leaf.position(offset, true);
range.setEnd(node, offset);
return range.getBoundingClientRect();
}
let side = 'left';
let rect;
if (node instanceof Text) {
// Return null if the text node is empty because it is
// not able to get a useful client rect:
// https://github.com/w3c/csswg-drafts/issues/2514.
// Empty text nodes are most likely caused by TextBlot#optimize()
// not getting called when editor content changes.
if (!node.data.length) {
return null;
}
if (offset < node.data.length) {
range.setStart(node, offset);
range.setEnd(node, offset + 1);
} else {
range.setStart(node, offset - 1);
range.setEnd(node, offset);
side = 'right';
}
rect = range.getBoundingClientRect();
} else {
if (!(leaf.domNode instanceof Element)) return null;
rect = leaf.domNode.getBoundingClientRect();
if (offset > 0) side = 'right';
}
return {
bottom: rect.top + rect.height,
height: rect.height,
left: rect[side],
right: rect[side],
top: rect.top,
width: 0
};
}
getNativeRange() {
const selection = document.getSelection();
if (selection == null || selection.rangeCount <= 0) return null;
const nativeRange = selection.getRangeAt(0);
if (nativeRange == null) return null;
const range = this.normalizeNative(nativeRange);
debug.info('getNativeRange', range);
return range;
}
getRange() {
const root = this.scroll.domNode;
if ('isConnected' in root && !root.isConnected) {
// document.getSelection() forces layout on Blink, so we trend to
// not calling it.
return [null, null];
}
const normalized = this.getNativeRange();
if (normalized == null) return [null, null];
const range = this.normalizedToRange(normalized);
return [range, normalized];
}
hasFocus() {
return document.activeElement === this.root || document.activeElement != null && contains(this.root, document.activeElement);
}
normalizedToRange(range) {
const positions = [[range.start.node, range.start.offset]];
if (!range.native.collapsed) {
positions.push([range.end.node, range.end.offset]);
}
const indexes = positions.map(position => {
const [node, offset] = position;
const blot = this.scroll.find(node, true);
// @ts-expect-error Fix me later
const index = blot.offset(this.scroll);
if (offset === 0) {
return index;
}
if (blot instanceof LeafBlot) {
return index + blot.index(node, offset);
}
// @ts-expect-error Fix me later
return index + blot.length();
});
const end = Math.min(Math.max(...indexes), this.scroll.length() - 1);
const start = Math.min(end, ...indexes);
return new Range(start, end - start);
}
normalizeNative(nativeRange) {
if (!contains(this.root, nativeRange.startContainer) || !nativeRange.collapsed && !contains(this.root, nativeRange.endContainer)) {
return null;
}
const range = {
start: {
node: nativeRange.startContainer,
offset: nativeRange.startOffset
},
end: {
node: nativeRange.endContainer,
offset: nativeRange.endOffset
},
native: nativeRange
};
[range.start, range.end].forEach(position => {
let {
node,
offset
} = position;
while (!(node instanceof Text) && node.childNodes.length > 0) {
if (node.childNodes.length > offset) {
node = node.childNodes[offset];
offset = 0;
} else if (node.childNodes.length === offset) {
// @ts-expect-error Fix me later
node = node.lastChild;
if (node instanceof Text) {
offset = node.data.length;
} else if (node.childNodes.length > 0) {
// Container case
offset = node.childNodes.length;
} else {
// Embed case
offset = node.childNodes.length + 1;
}
} else {
break;
}
}
position.node = node;
position.offset = offset;
});
return range;
}
rangeToNative(range) {
const scrollLength = this.scroll.length();
const getPosition = (index, inclusive) => {
index = Math.min(scrollLength - 1, index);
const [leaf, leafOffset] = this.scroll.leaf(index);
return leaf ? leaf.position(leafOffset, inclusive) : [null, -1];
};
return [...getPosition(range.index, false), ...getPosition(range.index + range.length, true)];
}
setNativeRange(startNode, startOffset) {
let endNode = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : startNode;
let endOffset = arguments.length > 3 && arguments[3] !== undefined ? arguments[3] : startOffset;
let force = arguments.length > 4 && arguments[4] !== undefined ? arguments[4] : false;
debug.info('setNativeRange', startNode, startOffset, endNode, endOffset);
if (startNode != null && (this.root.parentNode == null || startNode.parentNode == null ||
// @ts-expect-error Fix me later
endNode.parentNode == null)) {
return;
}
const selection = document.getSelection();
if (selection == null) return;
if (startNode != null) {
if (!this.hasFocus()) this.root.focus({
preventScroll: true
});
const {
native
} = this.getNativeRange() || {};
if (native == null || force || startNode !== native.startContainer || startOffset !== native.startOffset || endNode !== native.endContainer || endOffset !== native.endOffset) {
if (startNode instanceof Element && startNode.tagName === 'BR') {
// @ts-expect-error Fix me later
startOffset = Array.from(startNode.parentNode.childNodes).indexOf(startNode);
startNode = startNode.parentNode;
}
if (endNode instanceof Element && endNode.tagName === 'BR') {
// @ts-expect-error Fix me later
endOffset = Array.from(endNode.parentNode.childNodes).indexOf(endNode);
endNode = endNode.parentNode;
}
const range = document.createRange();
// @ts-expect-error Fix me later
range.setStart(startNode, startOffset);
// @ts-expect-error Fix me later
range.setEnd(endNode, endOffset);
selection.removeAllRanges();
selection.addRange(range);
}
} else {
selection.removeAllRanges();
this.root.blur();
}
}
setRange(range) {
let force = arguments.length > 1 && arguments[1] !== undefined ? arguments[1] : false;
let source = arguments.length > 2 && arguments[2] !== undefined ? arguments[2] : Emitter.sources.API;
if (typeof force === 'string') {
source = force;
force = false;
}
debug.info('setRange', range);
if (range != null) {
const args = this.rangeToNative(range);
this.setNativeRange(...args, force);
} else {
this.setNativeRange(null);
}
this.update(source);
}
update() {
let source = arguments.length > 0 && arguments[0] !== undefined ? arguments[0] : Emitter.sources.USER;
const oldRange = this.lastRange;
const [lastRange, nativeRange] = this.getRange();
this.lastRange = lastRange;
this.lastNative = nativeRange;
if (this.lastRange != null) {
this.savedRange = this.lastRange;
}
if (!isEqual(oldRange, this.lastRange)) {
if (!this.composing && nativeRange != null && nativeRange.native.collapsed && nativeRange.start.node !== this.cursor.textNode) {
const range = this.cursor.restore();
if (range) {
this.setNativeRange(range.startNode, range.startOffset, range.endNode, range.endOffset);
}
}
const args = [Emitter.events.SELECTION_CHANGE, cloneDeep(this.lastRange), cloneDeep(oldRange), source];
this.emitter.emit(Emitter.events.EDITOR_CHANGE, ...args);
if (source !== Emitter.sources.SILENT) {
this.emitter.emit(...args);
}
}
}
}
function contains(parent, descendant) {
try {
// Firefox inserts inaccessible nodes around video elements
descendant.parentNode; // eslint-disable-line @typescript-eslint/no-unused-expressions
} catch (e) {
return false;
}
return parent.contains(descendant);
}
export default Selection;
//# sourceMappingURL=selection.js.map