spiegel-keyman/web/source/dom/targets/designIFrame.ts
Marc Durdin cacbbee5cc fix(web): properly handle selection
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.
2022-02-23 15:12:58 +11:00

350 lines
No EOL
10 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;
}
}
class StyleCommand {
cmd: string;
stateType: number;
cache: string|boolean;
constructor(c: string, s:number) {
this.cmd = c;
this.stateType = s;
}
}
export class DesignIFrame extends OutputTarget {
root: HTMLIFrameElement;
doc: Document;
docRoot: HTMLElement;
commandCache: StyleCommand[];
constructor(ele: HTMLIFrameElement) {
super();
this.root = ele;
if(ele.contentWindow && ele.contentWindow.document && ele.contentWindow.document.designMode == 'on') {
this.doc = ele.contentWindow.document;
this.docRoot = ele.contentWindow.document.documentElement;
} else {
throw "Specified IFrame is not in design-mode!";
}
}
get isSynthetic(): boolean {
return false;
}
getElement(): HTMLIFrameElement {
return this.root;
}
focus(): void {
this.doc.defaultView.focus(); // I3363 (Build 301)
}
isSelectionEmpty(): boolean {
if(!this.hasSelection()) {
return true;
}
return this.doc.getSelection().isCollapsed;
}
hasSelection(): boolean {
let Lsel = this.doc.getSelection();
let outerSel = document.getSelection();
// If the outer doc's selection matches, we're active.
if(outerSel.anchorNode == Lsel.anchorNode && outerSel.focusNode == Lsel.focusNode) {
return true;
} else {
// Problem: for testing, we can't enforce the ideal (ie: first) condition.
// Technically, the IFrame _will_ always have its own internal selection, though... so... it kinda works?
return true;
}
}
clearSelection(): void {
if(this.hasSelection()) {
let Lsel = this.doc.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.doc.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.docRoot.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.doc.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.doc.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 = this.doc.createTextNode(s);
let range = this.doc.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.doc.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);
}
}
/**
* Function saveProperties
* Scope Private
* Description Build and create list of styles that can be applied in iframes
*/
saveProperties() {
// Formerly _CacheCommands.
var _CacheableCommands=[
new StyleCommand('backcolor',1), new StyleCommand('fontname',1), new StyleCommand('fontsize',1),
new StyleCommand('forecolor',1), new StyleCommand('bold',0), new StyleCommand('italic',0),
new StyleCommand('strikethrough',0), new StyleCommand('subscript',0),
new StyleCommand('superscript',0), new StyleCommand('underline',0)
];
if(this.doc.defaultView) {
_CacheableCommands.push(new StyleCommand('hilitecolor',1));
}
for(var n=0; n < _CacheableCommands.length; n++) { // I1511 - array prototype extended
let cmd = _CacheableCommands[n];
//KeymanWeb._Debug('Command:'+_CacheableCommands[n][0]);
if(cmd.stateType == 1) {
cmd.cache = this.doc.queryCommandValue(cmd.cmd);
} else {
cmd.cache = this.doc.queryCommandState(cmd.cmd);
}
}
this.commandCache = _CacheableCommands;
}
/**
* Function restoreProperties
* Scope Private
* Description Restore styles in IFRAMEs (??)
*/
restoreProperties(_func?: () => void): void {
// Formerly _CacheCommandsReset.
if(!this.commandCache) {
console.error("No command cache exists to restore!");
}
for(var n=0; n < this.commandCache.length; n++) { // I1511 - array prototype extended
let cmd = this.commandCache[n];
//KeymanWeb._Debug('ResetCacheCommand:'+_CacheableCommands[n][0]+'='+_CacheableCommands[n][2]);
if(cmd.stateType == 1) {
if(this.doc.queryCommandValue(cmd.cmd) != cmd.cache) {
if(_func) {
_func();
}
this.doc.execCommand(cmd.cmd, false, <string> cmd.cache);
}
} else if(this.doc.queryCommandState(cmd.cmd) != cmd.cache) {
if(_func) {
_func();
}
//KeymanWeb._Debug('executing command '+_CacheableCommand[n][0]);
this.doc.execCommand(cmd.cmd, false, null);
}
}
}
doInputEvent() {
// Root = the iframe, the outermost component and the one we were originally told to attach to.
this.dispatchInputEventOn(this.root);
}
}
}