You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
170 lines
5.6 KiB
170 lines
5.6 KiB
import { EmbedBlot, Scope } from 'parchment'; |
|
import TextBlot from './text.js'; |
|
class Cursor extends EmbedBlot { |
|
static blotName = 'cursor'; |
|
static className = 'ql-cursor'; |
|
static tagName = 'span'; |
|
static CONTENTS = '\uFEFF'; // Zero width no break space |
|
|
|
static value() { |
|
return undefined; |
|
} |
|
constructor(scroll, domNode, selection) { |
|
super(scroll, domNode); |
|
this.selection = selection; |
|
this.textNode = document.createTextNode(Cursor.CONTENTS); |
|
this.domNode.appendChild(this.textNode); |
|
this.savedLength = 0; |
|
} |
|
detach() { |
|
// super.detach() will also clear domNode.__blot |
|
if (this.parent != null) this.parent.removeChild(this); |
|
} |
|
format(name, value) { |
|
if (this.savedLength !== 0) { |
|
super.format(name, value); |
|
return; |
|
} |
|
// TODO: Fix this next time the file is edited. |
|
// eslint-disable-next-line @typescript-eslint/no-this-alias |
|
let target = this; |
|
let index = 0; |
|
while (target != null && target.statics.scope !== Scope.BLOCK_BLOT) { |
|
index += target.offset(target.parent); |
|
target = target.parent; |
|
} |
|
if (target != null) { |
|
this.savedLength = Cursor.CONTENTS.length; |
|
// @ts-expect-error TODO: allow empty context in Parchment |
|
target.optimize(); |
|
target.formatAt(index, Cursor.CONTENTS.length, name, value); |
|
this.savedLength = 0; |
|
} |
|
} |
|
index(node, offset) { |
|
if (node === this.textNode) return 0; |
|
return super.index(node, offset); |
|
} |
|
length() { |
|
return this.savedLength; |
|
} |
|
position() { |
|
return [this.textNode, this.textNode.data.length]; |
|
} |
|
remove() { |
|
super.remove(); |
|
// @ts-expect-error Fix me later |
|
this.parent = null; |
|
} |
|
restore() { |
|
if (this.selection.composing || this.parent == null) return null; |
|
const range = this.selection.getNativeRange(); |
|
// Browser may push down styles/nodes inside the cursor blot. |
|
// https://dvcs.w3.org/hg/editing/raw-file/tip/editing.html#push-down-values |
|
while (this.domNode.lastChild != null && this.domNode.lastChild !== this.textNode) { |
|
// @ts-expect-error Fix me later |
|
this.domNode.parentNode.insertBefore(this.domNode.lastChild, this.domNode); |
|
} |
|
const prevTextBlot = this.prev instanceof TextBlot ? this.prev : null; |
|
const prevTextLength = prevTextBlot ? prevTextBlot.length() : 0; |
|
const nextTextBlot = this.next instanceof TextBlot ? this.next : null; |
|
// @ts-expect-error TODO: make TextBlot.text public |
|
const nextText = nextTextBlot ? nextTextBlot.text : ''; |
|
const { |
|
textNode |
|
} = this; |
|
// take text from inside this blot and reset it |
|
const newText = textNode.data.split(Cursor.CONTENTS).join(''); |
|
textNode.data = Cursor.CONTENTS; |
|
|
|
// proactively merge TextBlots around cursor so that optimization |
|
// doesn't lose the cursor. the reason we are here in cursor.restore |
|
// could be that the user clicked in prevTextBlot or nextTextBlot, or |
|
// the user typed something. |
|
let mergedTextBlot; |
|
if (prevTextBlot) { |
|
mergedTextBlot = prevTextBlot; |
|
if (newText || nextTextBlot) { |
|
prevTextBlot.insertAt(prevTextBlot.length(), newText + nextText); |
|
if (nextTextBlot) { |
|
nextTextBlot.remove(); |
|
} |
|
} |
|
} else if (nextTextBlot) { |
|
mergedTextBlot = nextTextBlot; |
|
nextTextBlot.insertAt(0, newText); |
|
} else { |
|
const newTextNode = document.createTextNode(newText); |
|
mergedTextBlot = this.scroll.create(newTextNode); |
|
this.parent.insertBefore(mergedTextBlot, this); |
|
} |
|
this.remove(); |
|
if (range) { |
|
// calculate selection to restore |
|
const remapOffset = (node, offset) => { |
|
if (prevTextBlot && node === prevTextBlot.domNode) { |
|
return offset; |
|
} |
|
if (node === textNode) { |
|
return prevTextLength + offset - 1; |
|
} |
|
if (nextTextBlot && node === nextTextBlot.domNode) { |
|
return prevTextLength + newText.length + offset; |
|
} |
|
return null; |
|
}; |
|
const start = remapOffset(range.start.node, range.start.offset); |
|
const end = remapOffset(range.end.node, range.end.offset); |
|
if (start !== null && end !== null) { |
|
return { |
|
startNode: mergedTextBlot.domNode, |
|
startOffset: start, |
|
endNode: mergedTextBlot.domNode, |
|
endOffset: end |
|
}; |
|
} |
|
} |
|
return null; |
|
} |
|
update(mutations, context) { |
|
if (mutations.some(mutation => { |
|
return mutation.type === 'characterData' && mutation.target === this.textNode; |
|
})) { |
|
const range = this.restore(); |
|
if (range) context.range = range; |
|
} |
|
} |
|
|
|
// Avoid .ql-cursor being a descendant of `<a/>`. |
|
// The reason is Safari pushes down `<a/>` on text insertion. |
|
// That will cause DOM nodes not sync with the model. |
|
// |
|
// For example ({I} is the caret), given the markup: |
|
// <a><span class="ql-cursor">\uFEFF{I}</span></a> |
|
// When typing a char "x", `<a/>` will be pushed down inside the `<span>` first: |
|
// <span class="ql-cursor"><a>\uFEFF{I}</a></span> |
|
// And then "x" will be inserted after `<a/>`: |
|
// <span class="ql-cursor"><a>\uFEFF</a>d{I}</span> |
|
optimize(context) { |
|
// @ts-expect-error Fix me later |
|
super.optimize(context); |
|
let { |
|
parent |
|
} = this; |
|
while (parent) { |
|
if (parent.domNode.tagName === 'A') { |
|
this.savedLength = Cursor.CONTENTS.length; |
|
// @ts-expect-error TODO: make isolate generic |
|
parent.isolate(this.offset(parent), this.length()).unwrap(); |
|
this.savedLength = 0; |
|
break; |
|
} |
|
parent = parent.parent; |
|
} |
|
} |
|
value() { |
|
return ''; |
|
} |
|
} |
|
export default Cursor; |
|
//# sourceMappingURL=cursor.js.map
|