diff --git a/common/web/keyboard-processor/src/index.ts b/common/web/keyboard-processor/src/index.ts
index ad7aa49523..f800691cfa 100644
--- a/common/web/keyboard-processor/src/index.ts
+++ b/common/web/keyboard-processor/src/index.ts
@@ -39,6 +39,7 @@ export { default as KeyMapping } from "./text/keyMapping.js";
export { default as OutputTarget } from "./text/outputTarget.js";
export * from "./text/outputTarget.js";
export { default as RuleBehavior } from "./text/ruleBehavior.js";
+export * from "./text/stringDivergence.js";
export * from "./text/systemStores.js";
export * from "@keymanapp/web-utils";
diff --git a/common/web/keyboard-processor/src/text/outputTarget.ts b/common/web/keyboard-processor/src/text/outputTarget.ts
index 306dc2170d..1ef67b1f7b 100644
--- a/common/web/keyboard-processor/src/text/outputTarget.ts
+++ b/common/web/keyboard-processor/src/text/outputTarget.ts
@@ -1,6 +1,7 @@
///
import { extendString } from "@keymanapp/web-utils";
+import { searchStringDivergence } from "./stringDivergence.js";
extendString();
@@ -120,85 +121,24 @@ export default abstract class OutputTarget {
* @param from An output target (preferably a Mock) representing the prior state of the input/output system.
*/
buildTransformFrom(original: OutputTarget): Transform {
- let to = this.getText();
- let from = original.getText();
+ const toLeft = this.getTextBeforeCaret();
+ const fromLeft = original.getTextBeforeCaret();
- let fromCaret = original.getDeadkeyCaret();
- let toCaret = this.getDeadkeyCaret();
+ const leftDivergenceIndex = searchStringDivergence(fromLeft, toLeft, false)[0];
+ const deletedLeft = fromLeft.substring(leftDivergenceIndex)._kmwLength();
+ // No need for our specialized variant here.
+ const insertedText = toLeft.substring(leftDivergenceIndex);
- // Step 1: Determine the number of left-deletions.
- let maxSMPLeftMatch = fromCaret < toCaret ? fromCaret : toCaret;
+ const toRight = this.getTextAfterCaret();
+ const fromRight = original.getTextAfterCaret();
+ const rightDivergence1 = searchStringDivergence(fromRight, toRight, true)[0];
- // We need the corresponding non-SMP caret location in order to binary-search efficiently.
- // (Examining code units is much more computationally efficient.)
- let maxLeftMatch = to._kmwCodePointToCodeUnit(maxSMPLeftMatch);
+ // Right insertions aren't supported, but right deletions will matter in some scenarios.
+ // In particular, once we allow right-deletion for pred-text suggestions applied with the
+ // caret mid-word..
+ const deletedRight = fromRight.substring(0, rightDivergence1 + 1)._kmwLength();
- // 1.1: use a non-SMP-aware binary search to determine the divergence point.
- let start = 0;
- let end = maxLeftMatch; // the index AFTER the last possible matching char.
-
- // This search is O(maxLeftMatch). 1/2 + 1/4 + 1/8 + ... converges to = 1.
- while(start < end) {
- let mid = Math.floor((end+start+1) / 2); // round up (compare more)
- let fromLeft = from.substr(start, mid-start);
- let toLeft = to.substr(start, mid-start);
-
- if(fromLeft == toLeft) {
- start = mid;
- } else {
- end = mid - 1;
- }
- }
-
- // At the loop's end: `end` now holds the non-SMP-aware divergence point.
- // The 'caret' is after the last matching code unit.
-
- // 1.2: detect a possible surrogate-pair split scenario, correcting for it
- // (by moving the split before the high-surrogate) if detected.
-
- // If the split location is precisely on either end of the context, we can't
- // have split a surrogate pair.
- if(end > 0 && end < maxLeftMatch) {
- let potentialHigh = from.charCodeAt(end-1);
- let potentialFromLow = from.charCodeAt(end);
- let potentialToLow = to.charCodeAt(end);
-
- // if potentialHigh is a possible high surrogate...
- if(potentialHigh >= 0xD800 && potentialHigh <= 0xDBFF) {
- // and at least one potential 'low' is a possible low surrogate...
- let flag = potentialFromLow >= 0xDC00 && potentialFromLow <= 0xDFFF;
- flag = flag || (potentialToLow >= 0XDC00 && potentialToLow <= 0xDFFF);
-
- // Correct the split location, moving it 'before' the high surrogate.
- if(flag) {
- end = end - 1;
- }
- }
- }
-
- // 1.3: take substring from start to the split point; determine SMP-aware length.
- // This yields the SMP-aware divergence index, which gives the number of left-deletes.
- let newCaret = from._kmwCodeUnitToCodePoint(end);
- let deletedLeft = fromCaret - newCaret;
-
- // Step 2: Determine the other properties.
- // Since the 'after' OutputTarget's caret indicates the end of any inserted text, we
- // can easily calculate the rest.
- let insertedLength = toCaret - newCaret;
- let delta = to._kmwSubstr(newCaret, insertedLength);
-
- let undeletedRight = to._kmwLength() - toCaret;
- let originalRight = from._kmwLength() - fromCaret;
- let deletedRight = originalRight - undeletedRight;
-
- // May occur when reverting a suggestion that had been applied mid-word.
- if(deletedRight < 0) {
- // Restores deleteRight characters.
- delta = delta + to._kmwSubstr(toCaret, -deletedRight);
- deletedRight = 0;
- }
-
- return new TextTransform(delta, deletedLeft, deletedRight);
+ return new TextTransform(insertedText, deletedLeft, deletedRight);
}
buildTranscriptionFrom(original: OutputTarget, keyEvent: KeyEvent, readonly: boolean, alternates?: Alternate[]): Transcription {
diff --git a/common/web/keyboard-processor/src/text/stringDivergence.ts b/common/web/keyboard-processor/src/text/stringDivergence.ts
new file mode 100644
index 0000000000..33213f7fdb
--- /dev/null
+++ b/common/web/keyboard-processor/src/text/stringDivergence.ts
@@ -0,0 +1,68 @@
+/**
+ * Returns the index for the code point divergence point in code unit coordinates.
+ * @param str1
+ * @param str2
+ * @param commonRight If false or undefined, asserts a common prefix to the strings. If true, asserts a common suffix.
+ * @returns The code unit indices within each string for the start of the code point not common to both.
+ */
+export function searchStringDivergence(str1: string, str2: string, commonRight?: boolean): [number, number] {
+ let maxInterval = Math.min(str1.length, str2.length) - 1;
+ const commonLeft = !commonRight;
+
+ let index: number;
+ let end: number;
+
+ /**
+ * Index shift per loop iteration.
+ */
+ let inc: number;
+ /**
+ * Difference in index for comparison between strings.
+ * Mostly matters when assuming a common right-hand side.
+ */
+ let offset: number;
+
+ if(commonLeft) {
+ index = 0;
+ end = maxInterval;
+ inc = 1;
+ offset = 0;
+ } else {
+ index = str1.length - 1;
+ end = index - maxInterval;
+ inc = -1;
+ offset = str2.length - str1.length;
+ }
+
+ for(; commonLeft ? index <= end: index >= end; index += inc) {
+ if(str1.charAt(index) != str2.charAt(index + offset)) {
+ break;
+ }
+ }
+
+ // `index` corresponds to the first char that is different _in the direction indicated by inc_.
+
+ // if commonLeft, high surrogate; if commonRight, low surrogate.
+ const commonPotentialSurrogate = str1.charCodeAt(index - inc);
+ // Opposite surrogate type from the previous variable.
+ const divergentChar1 = str1.charCodeAt(index);
+ const divergentChar2 = str2.charCodeAt(index + offset);
+
+ const isHigh = (charCode: number) => charCode >= 0xD800 && charCode <= 0xDBFF;
+ const isLow = (charCode: number) => charCode >= 0xDC00 && charCode <= 0xDFFF;
+ const commonChecker = commonLeft ? isHigh : isLow;
+ const divergentChecker = commonLeft ? isLow : isHigh;
+
+ // If the last common char qualifies as a direction-appropriate SMP surrogate...
+ if(commonChecker(commonPotentialSurrogate)) {
+ // And one of the two divergent chars is a qualifying match - a surrogate
+ // of the opposite type...
+ if(divergentChecker(divergentChar1) || divergentChecker(divergentChar2)) {
+ // Our current index would split a surrogate pair; decrement the index to
+ // preserve the pair.
+ return [index - inc, index - inc + offset];
+ }
+ }
+
+ return [index, index + offset];
+}
\ No newline at end of file
diff --git a/common/web/keyboard-processor/tests/node/transcriptions.js b/common/web/keyboard-processor/tests/node/transcriptions.js
index 3d8519ec12..5e02cc724e 100644
--- a/common/web/keyboard-processor/tests/node/transcriptions.js
+++ b/common/web/keyboard-processor/tests/node/transcriptions.js
@@ -1,20 +1,129 @@
import { assert } from 'chai';
-import { Mock } from '@keymanapp/keyboard-processor';
+import { Mock, searchStringDivergence } from '@keymanapp/keyboard-processor';
import { extendString } from '@keymanapp/web-utils';
extendString(); // Ensure KMW's string-extension functionality is available.
String.kmwEnableSupplementaryPlane(false);
+const toSupplementaryPairString = function(code){
+ var H = Math.floor((code - 0x10000) / 0x400) + 0xD800;
+ var L = (code - 0x10000) % 0x400 + 0xDC00;
+
+ return String.fromCharCode(H, L);
+}
+// A unicode-coding like alias for use in constructing SMP strings.
+const u = toSupplementaryPairString;
+
+/**
+ * Returns the "Mathematical Sans-Serif Small" SMP encoding for
+ * a passed-in lowercase char between 'a' and 'z', inclusive.
+ * @param {*} char
+ * @returns
+ */
+const ss = (char) => {
+ const charCodeOffset = char.charCodeAt(0) - 'a'.charCodeAt(0);
+ return u(0x1d5ba + charCodeOffset);
+}
+
+describe("String divergence calculations", function() {
+ describe("Common prefix", () => {
+ it("BMP text", () => {
+ const result1 = searchStringDivergence("apple", "applause", false);
+ assert.deepEqual(result1, [4, 4]);
+
+ const result2 = searchStringDivergence("applesauce", "applause", false);
+ assert.deepEqual(result2, [4, 4]);
+
+ const result3 = searchStringDivergence("applesauce", "applesauce", false);
+ assert.deepEqual(result3, [10, 10]);
+ });
+
+ it("SMP text", () => {
+ const smp_ify = (str) => str.split('').map(ss).join('');
+
+ const result1 = searchStringDivergence(
+ smp_ify('apple'),
+ smp_ify('applause'),
+ false
+ );
+
+ // 2 per SMP char; is in code-unit... units.
+ // Will avoid splitting code points, though.
+ assert.deepEqual(result1, [8, 8]);
+
+ const result2 = searchStringDivergence(
+ smp_ify('applesauce'),
+ smp_ify('applause'),
+ false
+ );
+
+ assert.deepEqual(result2, [8, 8]);
+
+ const result3 = searchStringDivergence(
+ smp_ify('applesauce'),
+ smp_ify('applesauce'),
+ false
+ );
+
+ assert.deepEqual(result3, [20, 20]);
+ });
+ });
+
+ describe("Common suffix", () => {
+ it("BMP text", () => {
+ // att|endance
+ // transc|endance
+ const result1 = searchStringDivergence("attendance", "transcendance", true);
+ assert.deepEqual(result1, [2, 5]);
+
+ // transcend|ance
+ // happenst|ance
+ const result2 = searchStringDivergence("transcendance", "happenstance", true);
+ assert.deepEqual(result2, [8, 7]);
+
+ // And if the two are equal...
+ const result3 = searchStringDivergence("post-caret text", "post-caret text", true);
+ assert.deepEqual(result3, [-1, -1]);
+ });
+
+ it("SMP text", () => {
+ const smp_ify = (str) => str.split('').map(ss).join('');
+
+ // att|endance
+ // trans|endance
+ const result1 = searchStringDivergence(
+ smp_ify("attendance"),
+ smp_ify("transcendance"),
+ true
+ );
+
+ // 2 per SMP char; is in code-unit... units.
+ // Will avoid splitting code points; is odd b/c we get the index of the LAST char of the pair.
+ assert.deepEqual(result1, [5, 11]);
+
+ // transcend|ance
+ // happenst|ance
+ const result2 = searchStringDivergence(
+ smp_ify("transcendance"),
+ smp_ify("happenstance"),
+ true
+ );
+ assert.deepEqual(result2, [17, 15]);
+
+ // And if the two are equal...
+ const result3 = searchStringDivergence(
+ smp_ify("post-caret text"),
+ smp_ify("post-caret text"),
+ true
+ );
+ assert.deepEqual(result3, [-1, -1]);
+ });
+ })
+});
+
describe("Transcriptions and Transforms", function() {
- var toSupplementaryPairString = function(code){
- var H = Math.floor((code - 0x10000) / 0x400) + 0xD800;
- var L = (code - 0x10000) % 0x400 + 0xDC00;
-
- return String.fromCharCode(H, L);
- }
-
// Built in-line via function. Looks functionally equivalent to "apple", but with SMP characters.
let u = toSupplementaryPairString;
let smpApple = u(0x1d5ba)+u(0x1d5c9)+u(0x1d5c9)+u(0x1d5c5)+u(0x1d5be);
@@ -298,7 +407,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels.
}
});
- it.only('from targets with existing selection', () => {
+ it('from targets with existing selection', () => {
// | |
const target = new Mock("testing testing one two three");
target.setSelection(8, 20)
@@ -313,7 +422,7 @@ but not himself.`; // Sheev Palpatine, in the Star Wars prequels.
});
});
- it.only('to targets with existing selection', () => {
+ it('to targets with existing selection', () => {
// | |
const target = new Mock("testing testing one two three");
target.setSelection(8, 20)
diff --git a/web/src/app/browser/src/keymanEngine.ts b/web/src/app/browser/src/keymanEngine.ts
index 5e11fa4809..6b95cb49a0 100644
--- a/web/src/app/browser/src/keymanEngine.ts
+++ b/web/src/app/browser/src/keymanEngine.ts
@@ -205,9 +205,6 @@ export default class KeymanEngine extends KeymanEngineBase