mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-06 17:05:34 +00:00
Relates to #5853 and others. Selection management was not working properly with the various OutputTargets: 1. When there is a non-empty selection, rules have no context -- it's like new text. 2. Backspace over a selection deletes just the selection. 3. Typing a character replaces the selection, of course, and collapses the caret to the end of the new text. 4. `hasSelection` is a very strange name for `OutputTarget` descendants. It doesn't mean "has an active selection" but rather, kinda means "supports selection internally". 5. Added `isSelectionEmpty` which is used for some of the new selection rules above. Note that the `touchAlias` OutputTarget class does not currently support selection. I hope we can deprecate `touchAlias` with the use of `inputMode` (#3030) in the future, rather than adding support for selection.
277 lines
No EOL
8 KiB
TypeScript
277 lines
No EOL
8 KiB
TypeScript
namespace com.keyman.dom.targets {
|
|
class SelectionCaret {
|
|
node: Node;
|
|
offset: number;
|
|
|
|
constructor(node, offset) {
|
|
this.node = node;
|
|
this.offset = offset;
|
|
}
|
|
}
|
|
|
|
class SelectionRange {
|
|
start: SelectionCaret;
|
|
end: SelectionCaret;
|
|
|
|
constructor(start, end) {
|
|
this.start = start;
|
|
this.end = end;
|
|
}
|
|
}
|
|
|
|
export class ContentEditable extends OutputTarget {
|
|
root: HTMLElement;
|
|
|
|
constructor(ele: HTMLElement) {
|
|
if(ele.isContentEditable) {
|
|
super();
|
|
this.root = ele;
|
|
} else {
|
|
throw "Specified element is not already content-editable!";
|
|
}
|
|
}
|
|
|
|
get isSynthetic(): boolean {
|
|
return false;
|
|
}
|
|
|
|
getElement(): HTMLElement {
|
|
return this.root;
|
|
}
|
|
|
|
isSelectionEmpty(): boolean {
|
|
if(!this.hasSelection()) {
|
|
return true;
|
|
}
|
|
|
|
return this.root.ownerDocument.getSelection().isCollapsed;
|
|
}
|
|
|
|
hasSelection(): boolean {
|
|
let Lsel = this.root.ownerDocument.getSelection();
|
|
|
|
// We can't completely rely on this.root.contains because of a weird IE 11 bug.
|
|
// Apparently, the text node contains the HTMLElement?
|
|
var ie11ParentChild = function(parent, child) {
|
|
// It's explicitly a text node bug.
|
|
if(child.nodeType != 3) {
|
|
return null;
|
|
}
|
|
let code = child.compareDocumentPosition(parent);
|
|
|
|
return (code & 8) != 0; // Yep. Text node contains its root.
|
|
}
|
|
|
|
if(this.root != Lsel.anchorNode && !this.root.contains(Lsel.anchorNode) && !ie11ParentChild(this.root, Lsel.anchorNode)) {
|
|
return false;
|
|
}
|
|
|
|
if(this.root != Lsel.focusNode && !this.root.contains(Lsel.focusNode) && !ie11ParentChild(this.root, Lsel.anchorNode)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
clearSelection(): void {
|
|
if(this.hasSelection()) {
|
|
let Lsel = this.root.ownerDocument.getSelection();
|
|
|
|
if(!Lsel.isCollapsed) {
|
|
Lsel.deleteFromDocument(); // I2134, I2192
|
|
}
|
|
} else {
|
|
console.warn("Attempted to clear an unowned Selection!");
|
|
}
|
|
}
|
|
|
|
invalidateSelection(): void { /* No cache maintenance needed here, partly because
|
|
* it's impossible to cache a Selection; it mutates.
|
|
*/ }
|
|
|
|
getCarets(): SelectionRange {
|
|
let Lsel = this.root.ownerDocument.getSelection();
|
|
let code = Lsel.anchorNode.compareDocumentPosition(Lsel.focusNode);
|
|
|
|
if(Lsel.isCollapsed) {
|
|
let caret = new SelectionCaret(Lsel.anchorNode, Lsel.anchorOffset);
|
|
return new SelectionRange(caret, caret);
|
|
} else {
|
|
let anchor = new SelectionCaret(Lsel.anchorNode, Lsel.anchorOffset);
|
|
let focus = new SelectionCaret(Lsel.focusNode, Lsel.focusOffset);
|
|
|
|
if(anchor.node == focus.node) {
|
|
code = (focus.offset - anchor.offset > 0) ? 2 : 4;
|
|
}
|
|
|
|
if(code & 2) {
|
|
return new SelectionRange(anchor, focus);
|
|
} else { // Default
|
|
// can test against code & 4 to ensure Focus is before anchor, though.
|
|
return new SelectionRange(focus, anchor);
|
|
}
|
|
}
|
|
}
|
|
|
|
getDeadkeyCaret(): number {
|
|
return this.getTextBeforeCaret().kmwLength();
|
|
}
|
|
|
|
getTextBeforeCaret(): string {
|
|
if(!this.hasSelection()) {
|
|
return;
|
|
}
|
|
|
|
let caret = this.getCarets().start;
|
|
|
|
if(caret.node.nodeType != 3) {
|
|
return ''; // Must be a text node to provide a context.
|
|
}
|
|
|
|
return caret.node.textContent.substr(0, caret.offset);
|
|
}
|
|
|
|
getTextAfterCaret(): string {
|
|
if(!this.hasSelection()) {
|
|
return;
|
|
}
|
|
|
|
let caret = this.getCarets().end;
|
|
|
|
if(caret.node.nodeType != 3) {
|
|
return ''; // Must be a text node to provide a context.
|
|
}
|
|
|
|
return caret.node.textContent.substr(caret.offset);
|
|
}
|
|
|
|
getText(): string {
|
|
return this.root.innerText;
|
|
}
|
|
|
|
deleteCharsBeforeCaret(dn: number) {
|
|
if(!this.hasSelection() || dn <= 0) {
|
|
return;
|
|
}
|
|
|
|
let start = this.getCarets().start;
|
|
|
|
// Bounds-check on the number of chars to delete.
|
|
if(dn > start.offset) {
|
|
dn = start.offset;
|
|
}
|
|
|
|
if(start.node.nodeType != 3) {
|
|
console.warn("Deletion of characters requested without available context!");
|
|
return; // No context to delete characters from.
|
|
}
|
|
|
|
let range = this.root.ownerDocument.createRange();
|
|
let dnOffset = start.offset - start.node.nodeValue.substr(0, start.offset)._kmwSubstr(-dn).length;
|
|
|
|
range.setStart(start.node, dnOffset);
|
|
range.setEnd(start.node, start.offset);
|
|
|
|
this.adjustDeadkeys(-dn);
|
|
range.deleteContents();
|
|
// No need to reposition the caret - the DOM will auto-move the selection accordingly, since
|
|
// we didn't use the selection to delete anything.
|
|
}
|
|
|
|
insertTextBeforeCaret(s: string) {
|
|
if(!this.hasSelection()) {
|
|
return;
|
|
}
|
|
|
|
let start = this.getCarets().start;
|
|
let delta = s._kmwLength();
|
|
let Lsel = this.root.ownerDocument.getSelection();
|
|
|
|
if(delta == 0) {
|
|
return;
|
|
}
|
|
|
|
this.adjustDeadkeys(delta);
|
|
|
|
// While Selection.extend() is really nice for this, IE doesn't support it whatsoever.
|
|
// However, IE (11, at least) DOES support setting selections via ranges, so we can still
|
|
// manage the caret properly.
|
|
let finalCaret = this.root.ownerDocument.createRange();
|
|
|
|
if(start.node.nodeType == 3) {
|
|
let textStart = <Text> start.node;
|
|
textStart.insertData(start.offset, s);
|
|
finalCaret.setStart(textStart, start.offset + s.length);
|
|
} else {
|
|
// Create a new text node - empty control
|
|
var n = start.node.ownerDocument.createTextNode(s);
|
|
|
|
let range = this.root.ownerDocument.createRange();
|
|
range.setStart(start.node, start.offset);
|
|
range.collapse(true);
|
|
range.insertNode(n);
|
|
finalCaret.setStart(n, s.length);
|
|
}
|
|
|
|
finalCaret.collapse(true);
|
|
Lsel.removeAllRanges();
|
|
try {
|
|
Lsel.addRange(finalCaret);
|
|
} catch(e) {
|
|
// Chrome (through 4.0 at least) throws an exception because it has not synchronised its content with the selection.
|
|
// scrollIntoView synchronises the content for selection
|
|
start.node.parentElement.scrollIntoView();
|
|
Lsel.addRange(finalCaret);
|
|
}
|
|
Lsel.collapseToEnd();
|
|
}
|
|
|
|
handleNewlineAtCaret(): void {
|
|
// TODO: Implement.
|
|
//
|
|
// As it turns out, we never had an implementation for handling newline inputs from the OSK for this element type.
|
|
// At least this way, it's more explicit.
|
|
//
|
|
// Note: consult "// Create a new text node - empty control" case in insertTextBeforeCaret -
|
|
// this helps to handle the browser-default implementation of newline handling. In particular,
|
|
// entry of the first character after a newline.
|
|
//
|
|
// If raw newlines are entered into the HTML, but as with usual HTML, they're interpreted as excess whitespace and
|
|
// have no effect. We need to add DOM elements for a functional newline.
|
|
}
|
|
|
|
protected setTextAfterCaret(s: string) {
|
|
if(!this.hasSelection()) {
|
|
return;
|
|
}
|
|
|
|
let caret = this.getCarets().end;
|
|
let delta = s._kmwLength();
|
|
let Lsel = this.root.ownerDocument.getSelection();
|
|
|
|
if(delta == 0) {
|
|
return;
|
|
}
|
|
|
|
// This is designed explicitly for use in direct-setting operations; deadkeys
|
|
// will be handled after this method.
|
|
|
|
if(caret.node.nodeType == 3) {
|
|
let textStart = <Text> caret.node;
|
|
textStart.replaceData(caret.offset, textStart.length, s);
|
|
} else {
|
|
// Create a new text node - empty control
|
|
var n = caret.node.ownerDocument.createTextNode(s);
|
|
|
|
let range = this.root.ownerDocument.createRange();
|
|
range.setStart(caret.node, caret.offset);
|
|
range.collapse(true);
|
|
range.insertNode(n);
|
|
}
|
|
}
|
|
|
|
doInputEvent() {
|
|
this.dispatchInputEventOn(this.root);
|
|
}
|
|
}
|
|
} |