| 1 | export interface ComposerNativeEventSnapshot { |
| 2 | readonly eventCount: number; |
| 3 | readonly value: string; |
| 4 | readonly selection: ComposerEditorSelection | null; |
| 5 | } |
| 6 | |
| 7 | interface ComposerEditorSelection { |
| 8 | readonly start: number; |
| 9 | readonly end: number; |
| 10 | } |
| 11 | |
| 12 | export function acknowledgeComposerNativeEvent( |
| 13 | mostRecentEventCount: number, |
| 14 | incomingEventCount: number, |
| 15 | ): number | null { |
| 16 | if (!Number.isSafeInteger(incomingEventCount) || incomingEventCount < mostRecentEventCount) { |
| 17 | return null; |
| 18 | } |
| 19 | return incomingEventCount; |
| 20 | } |
| 21 | |
| 22 | export function resolveComposerControlledEventCount( |
| 23 | value: string, |
| 24 | selection: ComposerEditorSelection | null, |
| 25 | mostRecentEventCount: number, |
| 26 | snapshots: ReadonlyArray<ComposerNativeEventSnapshot>, |
| 27 | ): number { |
| 28 | let newestValueEventCount: number | null = null; |
| 29 | for (let index = snapshots.length - 1; index >= 0; index -= 1) { |
| 30 | const snapshot = snapshots[index]; |
| 31 | if (snapshot?.value !== value) continue; |
| 32 | |
| 33 | newestValueEventCount ??= snapshot.eventCount; |
| 34 | if (selection === null || snapshotSelectionMatches(snapshot, selection)) { |
| 35 | return snapshot.eventCount; |
| 36 | } |
| 37 | } |
| 38 | |
| 39 | // A value emitted by native paired with a different selection is an |
| 40 | // intermediate React render. Keep it behind the native revision so it |
| 41 | // cannot move the caret while newer keystrokes are being processed. |
| 42 | if (newestValueEventCount !== null && mostRecentEventCount > 0) { |
| 43 | return Math.min(newestValueEventCount, mostRecentEventCount - 1); |
| 44 | } |
| 45 | |
| 46 | return mostRecentEventCount; |
| 47 | } |
| 48 | |
| 49 | // A snapshot without a selection describes a state the editor applied itself |
| 50 | // (an assumed controlled document, where the native side may have bounded the |
| 51 | // caret). Revision stamping treats it as matching any controlled selection so |
| 52 | // a parent caret move on the assumed value stays at the assumed revision and |
| 53 | // passes the editor's staleness guard. Echo detection must not reuse this |
| 54 | // wildcard: an echo payload serializes `selection: null`, which would drop |
| 55 | // that caret move instead of applying it. |
| 56 | function snapshotSelectionMatches( |
| 57 | snapshot: ComposerNativeEventSnapshot, |
| 58 | selection: ComposerEditorSelection, |
| 59 | ): boolean { |
| 60 | if (snapshot.selection === null) return true; |
| 61 | return snapshot.selection.start === selection.start && snapshot.selection.end === selection.end; |
| 62 | } |
| 63 | |
| 64 | export function isComposerNativeEcho( |
| 65 | value: string, |
| 66 | selection: ComposerEditorSelection | null, |
| 67 | eventCount: number, |
| 68 | snapshots: ReadonlyArray<ComposerNativeEventSnapshot>, |
| 69 | ): boolean { |
| 70 | for (let index = snapshots.length - 1; index >= 0; index -= 1) { |
| 71 | const snapshot = snapshots[index]; |
| 72 | if ( |
| 73 | snapshot !== undefined && |
| 74 | snapshot.eventCount === eventCount && |
| 75 | snapshot.value === value && |
| 76 | (selection === null || |
| 77 | (snapshot.selection !== null && |
| 78 | snapshot.selection.start === selection.start && |
| 79 | snapshot.selection.end === selection.end)) |
| 80 | ) { |
| 81 | return true; |
| 82 | } |
| 83 | } |
| 84 | return false; |
| 85 | } |
| 86 | |
| 87 | /** |
| 88 | * Records that a parent-driven controlled document was handed to the native |
| 89 | * editor. From that point the acknowledged snapshot history describes a |
| 90 | * superseded native state, so it is replaced with the assumed applied state; |
| 91 | * a later parent update back to a previously acknowledged value must classify |
| 92 | * as a fresh edit, not as a native echo the editor would drop. Native events |
| 93 | * that raced past the controlled revision stay authoritative and are kept. |
| 94 | */ |
| 95 | export function assumeComposerControlledState( |
| 96 | snapshots: ReadonlyArray<ComposerNativeEventSnapshot>, |
| 97 | eventCount: number, |
| 98 | value: string, |
| 99 | ): ComposerNativeEventSnapshot[] { |
| 100 | return [ |
| 101 | { eventCount, value, selection: null }, |
| 102 | ...snapshots.filter((snapshot) => snapshot.eventCount > eventCount), |
| 103 | ]; |
| 104 | } |
| 105 | |
| 106 | export function pruneAcknowledgedComposerNativeEvents( |
| 107 | snapshots: ReadonlyArray<ComposerNativeEventSnapshot>, |
| 108 | acknowledgedEventCount: number, |
| 109 | ): ComposerNativeEventSnapshot[] { |
| 110 | // The newest acknowledged snapshot must survive pruning: it is what lets a |
| 111 | // later, unrelated re-render classify the settled composer state as a native |
| 112 | // echo instead of a parent-driven edit that would re-control the caret (and |
| 113 | // reset the keyboard's autocorrect context on iOS). |
| 114 | let latestAcknowledgedIndex = -1; |
| 115 | for (let index = snapshots.length - 1; index >= 0; index -= 1) { |
| 116 | const snapshot = snapshots[index]; |
| 117 | if (snapshot !== undefined && snapshot.eventCount <= acknowledgedEventCount) { |
| 118 | latestAcknowledgedIndex = index; |
| 119 | break; |
| 120 | } |
| 121 | } |
| 122 | return snapshots.filter( |
| 123 | (snapshot, index) => |
| 124 | index === latestAcknowledgedIndex || snapshot.eventCount > acknowledgedEventCount, |
| 125 | ); |
| 126 | } |