forked from FINAKON/HelpProject
1. Initial Commit - a boiler plate code and POC to realize the concept of context sensitive help 2. Frontend code written in ReactJS 3. Backend code written in Java, Spring Boot Framework 4. Frontend Start: pre-requisites : node, npm npm run dev ==> to start the frontend vite server 5. Backend Start: pre-requisites : java, mvn mvn spring-boot:run ==> to start the backend server 6. Visit http://localhost:5173/ for basic demo of help, press F1 in textboxes 7. Visit http://localhost:5173/editor and enter "admin123" to add/modify texts. Happy Coding !!! Thank you, Bhargava.
356 lines
12 KiB
JavaScript
356 lines
12 KiB
JavaScript
import Parchment from 'parchment';
|
|
import clone from 'clone';
|
|
import equal from 'deep-equal';
|
|
import Emitter from './emitter';
|
|
import logger from './logger';
|
|
|
|
let debug = logger('quill:selection');
|
|
|
|
|
|
class Range {
|
|
constructor(index, length = 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;
|
|
this.cursor = Parchment.create('cursor', this);
|
|
// savedRange is last non-null range
|
|
this.lastRange = this.savedRange = new Range(0, 0);
|
|
this.handleComposition();
|
|
this.handleDragging();
|
|
this.emitter.listenDOM('selectionchange', document, () => {
|
|
if (!this.mouseDown) {
|
|
setTimeout(this.update.bind(this, Emitter.sources.USER), 1);
|
|
}
|
|
});
|
|
this.emitter.on(Emitter.events.EDITOR_CHANGE, (type, delta) => {
|
|
if (type === Emitter.events.TEXT_CHANGE && delta.length() > 0) {
|
|
this.update(Emitter.sources.SILENT);
|
|
}
|
|
});
|
|
this.emitter.on(Emitter.events.SCROLL_BEFORE_UPDATE, () => {
|
|
if (!this.hasFocus()) return;
|
|
let native = this.getNativeRange();
|
|
if (native == null) return;
|
|
if (native.start.node === this.cursor.textNode) return; // cursor.restore() will handle
|
|
// TODO unclear if this has negative side effects
|
|
this.emitter.once(Emitter.events.SCROLL_UPDATE, () => {
|
|
try {
|
|
this.setNativeRange(native.start.node, native.start.offset, native.end.node, native.end.offset);
|
|
} catch (ignored) {}
|
|
});
|
|
});
|
|
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);
|
|
}
|
|
|
|
handleComposition() {
|
|
this.root.addEventListener('compositionstart', () => {
|
|
this.composing = true;
|
|
});
|
|
this.root.addEventListener('compositionend', () => {
|
|
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();
|
|
this.setRange(this.savedRange);
|
|
}
|
|
|
|
format(format, value) {
|
|
if (this.scroll.whitelist != null && !this.scroll.whitelist[format]) return;
|
|
this.scroll.update();
|
|
let nativeRange = this.getNativeRange();
|
|
if (nativeRange == null || !nativeRange.native.collapsed || Parchment.query(format, Parchment.Scope.BLOCK)) return;
|
|
if (nativeRange.start.node !== this.cursor.textNode) {
|
|
let blot = Parchment.find(nativeRange.start.node, false);
|
|
if (blot == null) return;
|
|
// TODO Give blot ability to not split
|
|
if (blot instanceof Parchment.Leaf) {
|
|
let after = blot.split(nativeRange.start.offset);
|
|
blot.parent.insertBefore(this.cursor, after);
|
|
} else {
|
|
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, length = 0) {
|
|
let scrollLength = this.scroll.length();
|
|
index = Math.min(index, scrollLength - 1);
|
|
length = Math.min(index + length, scrollLength - 1) - index;
|
|
let node, [leaf, offset] = this.scroll.leaf(index);
|
|
if (leaf == null) return null;
|
|
[node, offset] = leaf.position(offset, true);
|
|
let 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();
|
|
} else {
|
|
let side = 'left';
|
|
let rect;
|
|
if (node instanceof Text) {
|
|
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 {
|
|
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() {
|
|
let selection = document.getSelection();
|
|
if (selection == null || selection.rangeCount <= 0) return null;
|
|
let nativeRange = selection.getRangeAt(0);
|
|
if (nativeRange == null) return null;
|
|
let range = this.normalizeNative(nativeRange);
|
|
debug.info('getNativeRange', range);
|
|
return range;
|
|
}
|
|
|
|
getRange() {
|
|
let normalized = this.getNativeRange();
|
|
if (normalized == null) return [null, null];
|
|
let range = this.normalizedToRange(normalized);
|
|
return [range, normalized];
|
|
}
|
|
|
|
hasFocus() {
|
|
return document.activeElement === this.root;
|
|
}
|
|
|
|
normalizedToRange(range) {
|
|
let positions = [[range.start.node, range.start.offset]];
|
|
if (!range.native.collapsed) {
|
|
positions.push([range.end.node, range.end.offset]);
|
|
}
|
|
let indexes = positions.map((position) => {
|
|
let [node, offset] = position;
|
|
let blot = Parchment.find(node, true);
|
|
let index = blot.offset(this.scroll);
|
|
if (offset === 0) {
|
|
return index;
|
|
} else if (blot instanceof Parchment.Container) {
|
|
return index + blot.length();
|
|
} else {
|
|
return index + blot.index(node, offset);
|
|
}
|
|
});
|
|
let end = Math.min(Math.max(...indexes), this.scroll.length() - 1);
|
|
let 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;
|
|
}
|
|
let range = {
|
|
start: { node: nativeRange.startContainer, offset: nativeRange.startOffset },
|
|
end: { node: nativeRange.endContainer, offset: nativeRange.endOffset },
|
|
native: nativeRange
|
|
};
|
|
[range.start, range.end].forEach(function(position) {
|
|
let node = position.node, offset = position.offset;
|
|
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) {
|
|
node = node.lastChild;
|
|
offset = node instanceof Text ? node.data.length : node.childNodes.length + 1;
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
position.node = node, position.offset = offset;
|
|
});
|
|
return range;
|
|
}
|
|
|
|
rangeToNative(range) {
|
|
let indexes = range.collapsed ? [range.index] : [range.index, range.index + range.length];
|
|
let args = [];
|
|
let scrollLength = this.scroll.length();
|
|
indexes.forEach((index, i) => {
|
|
index = Math.min(scrollLength - 1, index);
|
|
let node, [leaf, offset] = this.scroll.leaf(index);
|
|
[node, offset] = leaf.position(offset, i !== 0);
|
|
args.push(node, offset);
|
|
});
|
|
if (args.length < 2) {
|
|
args = args.concat(args);
|
|
}
|
|
return args;
|
|
}
|
|
|
|
scrollIntoView(scrollingContainer) {
|
|
let range = this.lastRange;
|
|
if (range == null) return;
|
|
let bounds = this.getBounds(range.index, range.length);
|
|
if (bounds == null) return;
|
|
let limit = this.scroll.length()-1;
|
|
let [first, ] = this.scroll.line(Math.min(range.index, limit));
|
|
let last = first;
|
|
if (range.length > 0) {
|
|
[last, ] = this.scroll.line(Math.min(range.index + range.length, limit));
|
|
}
|
|
if (first == null || last == null) return;
|
|
let scrollBounds = scrollingContainer.getBoundingClientRect();
|
|
if (bounds.top < scrollBounds.top) {
|
|
scrollingContainer.scrollTop -= (scrollBounds.top - bounds.top);
|
|
} else if (bounds.bottom > scrollBounds.bottom) {
|
|
scrollingContainer.scrollTop += (bounds.bottom - scrollBounds.bottom);
|
|
}
|
|
}
|
|
|
|
setNativeRange(startNode, startOffset, endNode = startNode, endOffset = startOffset, force = false) {
|
|
debug.info('setNativeRange', startNode, startOffset, endNode, endOffset);
|
|
if (startNode != null && (this.root.parentNode == null || startNode.parentNode == null || endNode.parentNode == null)) {
|
|
return;
|
|
}
|
|
let selection = document.getSelection();
|
|
if (selection == null) return;
|
|
if (startNode != null) {
|
|
if (!this.hasFocus()) this.root.focus();
|
|
let native = (this.getNativeRange() || {}).native;
|
|
if (native == null || force ||
|
|
startNode !== native.startContainer ||
|
|
startOffset !== native.startOffset ||
|
|
endNode !== native.endContainer ||
|
|
endOffset !== native.endOffset) {
|
|
|
|
if (startNode.tagName == "BR") {
|
|
startOffset = [].indexOf.call(startNode.parentNode.childNodes, startNode);
|
|
startNode = startNode.parentNode;
|
|
}
|
|
if (endNode.tagName == "BR") {
|
|
endOffset = [].indexOf.call(endNode.parentNode.childNodes, endNode);
|
|
endNode = endNode.parentNode;
|
|
}
|
|
let range = document.createRange();
|
|
range.setStart(startNode, startOffset);
|
|
range.setEnd(endNode, endOffset);
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
} else {
|
|
selection.removeAllRanges();
|
|
this.root.blur();
|
|
document.body.focus(); // root.blur() not enough on IE11+Travis+SauceLabs (but not local VMs)
|
|
}
|
|
}
|
|
|
|
setRange(range, force = false, source = Emitter.sources.API) {
|
|
if (typeof force === 'string') {
|
|
source = force;
|
|
force = false;
|
|
}
|
|
debug.info('setRange', range);
|
|
if (range != null) {
|
|
let args = this.rangeToNative(range);
|
|
this.setNativeRange(...args, force);
|
|
} else {
|
|
this.setNativeRange(null);
|
|
}
|
|
this.update(source);
|
|
}
|
|
|
|
update(source = Emitter.sources.USER) {
|
|
let oldRange = this.lastRange;
|
|
let [lastRange, nativeRange] = this.getRange();
|
|
this.lastRange = lastRange;
|
|
if (this.lastRange != null) {
|
|
this.savedRange = this.lastRange;
|
|
}
|
|
if (!equal(oldRange, this.lastRange)) {
|
|
if (!this.composing && nativeRange != null && nativeRange.native.collapsed && nativeRange.start.node !== this.cursor.textNode) {
|
|
this.cursor.restore();
|
|
}
|
|
let args = [Emitter.events.SELECTION_CHANGE, clone(this.lastRange), clone(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;
|
|
} catch (e) {
|
|
return false;
|
|
}
|
|
// IE11 has bug with Text nodes
|
|
// https://connect.microsoft.com/IE/feedback/details/780874/node-contains-is-incorrect
|
|
if (descendant instanceof Text) {
|
|
descendant = descendant.parentNode;
|
|
}
|
|
return parent.contains(descendant);
|
|
}
|
|
|
|
|
|
export { Range, Selection as default };
|