@effindomv2/runtime 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +6 -0
- package/dist/bridge.js +4 -0
- package/dist/bridge.js.map +7 -0
- package/dist/effindom.v2.manifest.json +68 -0
- package/dist/fonts/NotoColorEmoji.ttf +0 -0
- package/dist/fonts/NotoEmoji-Regular.ttf +0 -0
- package/dist/fonts/NotoSans-Bold.ttf +0 -0
- package/dist/fonts/NotoSans-BoldItalic.ttf +0 -0
- package/dist/fonts/NotoSans-Italic.ttf +0 -0
- package/dist/fonts/NotoSans-Regular.ttf +0 -0
- package/dist/fonts/NotoSansMono-Bold.ttf +0 -0
- package/dist/fonts/NotoSansMono-Regular.ttf +0 -0
- package/dist/fonts/NotoSansSymbols2-Regular.ttf +0 -0
- package/dist/harness.js +2 -0
- package/dist/harness.js.map +7 -0
- package/dist/index.html +53 -0
- package/dist/runtime/effindom-core-v2.wasm32-simd.JQXIaRaN0-JahfIVFiSLE49WzzCENvef_2EDEm09nJs.wasm +0 -0
- package/dist/runtime/effindom-core-v2.wasm32-simd.y7RzpkMARiFeRkpgiqKQsAfv4Hf17NYdpni-6aLNhMs.js.symbols +10079 -0
- package/dist/runtime/effindom-core-v2.wasm32-simd.yhT7DGUv4soEv4W91WVZl3T7T_ecKojk5_IcnwL79a0.js +1 -0
- package/dist/runtime/effindom-core-v2.wasm32.JSfMkp9ertJzSZxA-_xz3yacrJUhswxlwbqbJLRIuqw.wasm +0 -0
- package/dist/runtime/effindom-core-v2.wasm32.xNgsQv7dCwf8Uy-PfJSoRNyk9-q1OSogUwkk5g6ZBjk.js.symbols +10088 -0
- package/dist/runtime/effindom-core-v2.wasm32.yhT7DGUv4soEv4W91WVZl3T7T_ecKojk5_IcnwL79a0.js +1 -0
- package/dist/runtime/effindom-core-v2.wasm64-simd.GkByf-CPorNOs1CORny_8JjVk8Z3piiFq92r-uw1Syc.js +1 -0
- package/dist/runtime/effindom-core-v2.wasm64-simd.p4P98oRu2wEWxtRRW8RHr27JhGeWvWlziZXDM_z3Nc4.js.symbols +10286 -0
- package/dist/runtime/effindom-core-v2.wasm64-simd.y75FYXRwhQrpaDGYbZWrohGDv0AmjTb-EjXwOjBIgnM.wasm +0 -0
- package/dist/runtime/effindom-core-v2.wasm64.GkByf-CPorNOs1CORny_8JjVk8Z3piiFq92r-uw1Syc.js +1 -0
- package/dist/runtime/effindom-core-v2.wasm64.emhE1_CJs4_zXp8wiQS_5lYpUQ0OchmXgxksi0ykaBs.js.symbols +10298 -0
- package/dist/runtime/effindom-core-v2.wasm64.sO-Yu70cfN8Qs3a5iEp6cbFPaiOchqcMKUzryu4npNo.wasm +0 -0
- package/dist/runtime/effindom-ui-v2.wasm32-simd.0Mas1XD03eYvemryTioWaZOBuBA5ij7MFlTa8CgEZWs.wasm +0 -0
- package/dist/runtime/effindom-ui-v2.wasm32-simd.ThSDClMnSWdwf9d89JZfYor0G1Z6OxR4lOc75rNRuD4.js.symbols +1890 -0
- package/dist/runtime/effindom-ui-v2.wasm32-simd.wved0xEV4EKXVNBU3Sx7giD4faxD2YII9sQ2N_wCP4I.js +2 -0
- package/dist/runtime/effindom-ui-v2.wasm32.H7kYg99bT9ADGh0uUvj6H9Dk1L058nVFLv_4R79IXW8.js.symbols +1900 -0
- package/dist/runtime/effindom-ui-v2.wasm32.tp53X7nHfG_EUq29naDyElfnqhMw2D1Tr1T-BJAYO7w.wasm +0 -0
- package/dist/runtime/effindom-ui-v2.wasm32.wved0xEV4EKXVNBU3Sx7giD4faxD2YII9sQ2N_wCP4I.js +2 -0
- package/dist/runtime/effindom-ui-v2.wasm64-simd.86tk9Z3xIpgTOykET_8Nn9iUVJnp1AzOHW4fVQRGtQE.wasm +0 -0
- package/dist/runtime/effindom-ui-v2.wasm64-simd.RQaXil22Chu63-vxK9oOuX8wUY044kbo190oYIbBU4M.js.symbols +1918 -0
- package/dist/runtime/effindom-ui-v2.wasm64-simd.ZS1KEAg0XQex-VXkfgpBHE8MIoqPF8qpaf8nOjANb_U.js +2 -0
- package/dist/runtime/effindom-ui-v2.wasm64.YSwpMFbr-Q1SBe0Ze8mub1u1PqsvSz3QIYuA3eaUMME.js.symbols +1924 -0
- package/dist/runtime/effindom-ui-v2.wasm64.ZS1KEAg0XQex-VXkfgpBHE8MIoqPF8qpaf8nOjANb_U.js +2 -0
- package/dist/runtime/effindom-ui-v2.wasm64.ioQ9DuM6gR_EjlfRHdF8EvNPBcKCs0PQbbY9-cjTV6Y.wasm +0 -0
- package/dist/runtime/icudt_minimal.962CX1q0-Nbv-OqXPaub5piYTOLumUk-nEvemcvvnpw.dat +0 -0
- package/package.json +62 -0
- package/scripts/build.sh +279 -0
- package/scripts/build_assets.sh +51 -0
- package/scripts/font_assets.sh +52 -0
- package/scripts/generate_manifest.py +121 -0
- package/scripts/stage_package_assets.sh +42 -0
- package/src/bridge/commit-policy.ts +10 -0
- package/src/bridge/events/canvas-geometry.ts +78 -0
- package/src/bridge/events/key-router.ts +187 -0
- package/src/bridge/events/pointer-router.ts +619 -0
- package/src/bridge/events/semantic-hit-testing.ts +27 -0
- package/src/bridge/events.ts +54 -0
- package/src/bridge/find-dialog.ts +690 -0
- package/src/bridge/find-session.ts +158 -0
- package/src/bridge/font-catalog.ts +51 -0
- package/src/bridge/google-fonts.ts +63 -0
- package/src/bridge/incremental-font-packages.ts +216 -0
- package/src/bridge/init.ts +77 -0
- package/src/bridge/interaction/editor-model.ts +371 -0
- package/src/bridge/interaction/editor-mutations.ts +495 -0
- package/src/bridge/interaction/editor-session.ts +628 -0
- package/src/bridge/interaction/logs.ts +23 -0
- package/src/bridge/interaction/text-encoding.ts +51 -0
- package/src/bridge/interaction.ts +86 -0
- package/src/bridge/local-types.ts +105 -0
- package/src/bridge/platform.ts +68 -0
- package/src/bridge/pointer-move-coalescer.ts +41 -0
- package/src/bridge/pull-to-refresh.ts +124 -0
- package/src/bridge/render-loop.ts +268 -0
- package/src/bridge/runtime/asset-manager.ts +202 -0
- package/src/bridge/runtime/find-controller.ts +269 -0
- package/src/bridge/runtime/font-manager.ts +691 -0
- package/src/bridge/runtime/open-canvas-api.ts +72 -0
- package/src/bridge/runtime/semantic-controller.ts +133 -0
- package/src/bridge/runtime/text-documents.ts +234 -0
- package/src/bridge/runtime.ts +315 -0
- package/src/bridge/touch-gesture.ts +159 -0
- package/src/bridge/utils/assets.ts +572 -0
- package/src/bridge/utils/backends.ts +163 -0
- package/src/bridge/utils/encoding.ts +128 -0
- package/src/bridge/utils/fetch.ts +147 -0
- package/src/bridge/utils/heap.ts +118 -0
- package/src/bridge.ts +93 -0
- package/src/clipboard.ts +139 -0
- package/src/core-types.ts +595 -0
- package/src/find-on-page.ts +284 -0
- package/src/harness.ts +53 -0
- package/src/index.ts +40 -0
- package/src/open-canvas.ts +108 -0
- package/src/runtime-config.ts +96 -0
- package/src/semantic.ts +905 -0
|
@@ -0,0 +1,628 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BridgeLogs,
|
|
3
|
+
BridgeRuntime,
|
|
4
|
+
FocusEventLog,
|
|
5
|
+
SelectionChangeLog,
|
|
6
|
+
WasmHandleLike,
|
|
7
|
+
} from '../../core-types';
|
|
8
|
+
import type { BridgeInteractionState } from '../local-types';
|
|
9
|
+
import { codeUnitIndexToUtf8ByteOffset, utf8ByteLength } from './text-encoding';
|
|
10
|
+
import {
|
|
11
|
+
applyUtf8ByteReplacementEdit,
|
|
12
|
+
buildClampedTextboxEdit,
|
|
13
|
+
buildHiddenEditorWindow,
|
|
14
|
+
clearHiddenTextEditor,
|
|
15
|
+
createHiddenTextEditor,
|
|
16
|
+
type HiddenEditorWindow,
|
|
17
|
+
type HiddenTextEditor,
|
|
18
|
+
type PendingLocalReplacementEcho,
|
|
19
|
+
type PendingLocalSelectionEcho,
|
|
20
|
+
summarizeTextChange,
|
|
21
|
+
utf8ByteOffsetToCodeUnitIndex,
|
|
22
|
+
} from './editor-model';
|
|
23
|
+
import { createEditorMutationController } from './editor-mutations';
|
|
24
|
+
import { handleToBigInt } from '../utils/encoding';
|
|
25
|
+
import { writeUtf8ToHeap } from '../utils/heap';
|
|
26
|
+
|
|
27
|
+
export interface EditorSession extends BridgeInteractionState {
|
|
28
|
+
handleClipboardRead(handle: WasmHandleLike): void;
|
|
29
|
+
handleFocusChanged(handle: WasmHandleLike, isFocused: boolean): void;
|
|
30
|
+
handleRequestSemanticAnnouncement(handle: WasmHandleLike): void;
|
|
31
|
+
handleSelectionChanged(handle: WasmHandleLike, start: number, end: number): void;
|
|
32
|
+
handleTextChanged(handle: WasmHandleLike, text: string): void;
|
|
33
|
+
handleTextReplaced(handle: WasmHandleLike, start: number, end: number, text: string): void;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const CARET_BLINK_INTERVAL_MS = 500;
|
|
37
|
+
|
|
38
|
+
function currentInteractionTimeMs(): bigint {
|
|
39
|
+
return BigInt(Math.floor(performance.now()));
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function createEditorSession(
|
|
43
|
+
runtimeRef: { current: BridgeRuntime | null },
|
|
44
|
+
logs: BridgeLogs,
|
|
45
|
+
): EditorSession {
|
|
46
|
+
const textByHandle = Object.create(null) as Record<string, string>;
|
|
47
|
+
const selectionsByHandle = Object.create(null) as Record<string, { start: number; end: number }>;
|
|
48
|
+
const hiddenInput = createHiddenTextEditor(false) as HTMLInputElement;
|
|
49
|
+
const hiddenTextarea = createHiddenTextEditor(true) as HTMLTextAreaElement;
|
|
50
|
+
let activeTextHandle: bigint | null = null;
|
|
51
|
+
let lastPointerClientX: number | null = null;
|
|
52
|
+
let lastPointerClientY: number | null = null;
|
|
53
|
+
let lastPointerX = 0;
|
|
54
|
+
let lastPointerY = 0;
|
|
55
|
+
let lastPointerModifiers = 0;
|
|
56
|
+
let lastInteractivePointerHandle: bigint | null = null;
|
|
57
|
+
let capturedPointerHandle: bigint | null = null;
|
|
58
|
+
let pointerInsideCanvas = false;
|
|
59
|
+
let appSessionVersion = 0;
|
|
60
|
+
let activeTextEditable = false;
|
|
61
|
+
let activeTextMultiline = false;
|
|
62
|
+
let activeEditorWindow: HiddenEditorWindow = { text: '', docStart: 0, docEnd: 0, textStart: 0, textEnd: 0 };
|
|
63
|
+
const textByteLengthsByHandle = Object.create(null) as Record<string, number>;
|
|
64
|
+
const pendingCaretRevealByHandle = Object.create(null) as Record<string, boolean>;
|
|
65
|
+
let pendingCaretRevealFrame: number | null = null;
|
|
66
|
+
let pendingLocalReplacementEcho: PendingLocalReplacementEcho | null = null;
|
|
67
|
+
let pendingLocalSelectionEcho: PendingLocalSelectionEcho | null = null;
|
|
68
|
+
let deferredTouchFocusHandle: string | null = null;
|
|
69
|
+
let caretBlinkTimer: ReturnType<typeof setTimeout> | null = null;
|
|
70
|
+
let focusedHandle: string | null = null;
|
|
71
|
+
const pendingSemanticAnnouncements = new Set<string>();
|
|
72
|
+
window.__bridgeLogs = logs;
|
|
73
|
+
window.__bridgeTextByHandle = textByHandle;
|
|
74
|
+
window.__bridgeSelectionsByHandle = selectionsByHandle;
|
|
75
|
+
window.__bridgeActiveEditorWindow = { handle: null, ...activeEditorWindow };
|
|
76
|
+
|
|
77
|
+
const getActiveEditor = (): HiddenTextEditor => (activeTextMultiline ? hiddenTextarea : hiddenInput);
|
|
78
|
+
|
|
79
|
+
const isActiveEditorFocused = (): boolean => document.activeElement === getActiveEditor();
|
|
80
|
+
|
|
81
|
+
const queueSemanticAnnouncement = (handleKey: string): void => {
|
|
82
|
+
pendingSemanticAnnouncements.add(handleKey);
|
|
83
|
+
runtimeRef.current?.requestFrame();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const clampSelectionToText = (
|
|
87
|
+
length: number,
|
|
88
|
+
selection: { start: number; end: number },
|
|
89
|
+
): { start: number; end: number } => ({
|
|
90
|
+
start: Math.max(0, Math.min(selection.start, length)),
|
|
91
|
+
end: Math.max(0, Math.min(selection.end, length)),
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
const clearCaretBlinkTimer = (): void => {
|
|
95
|
+
if (caretBlinkTimer !== null) {
|
|
96
|
+
clearTimeout(caretBlinkTimer);
|
|
97
|
+
caretBlinkTimer = null;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const shouldRunCaretBlinkTimer = (): boolean => {
|
|
102
|
+
if (activeTextHandle === null || !isActiveEditorFocused()) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const handleKey = activeTextHandle.toString();
|
|
106
|
+
const text = textByHandle[handleKey] ?? '';
|
|
107
|
+
const textByteLength = textByteLengthsByHandle[handleKey] ?? utf8ByteLength(text);
|
|
108
|
+
const selection = selectionsByHandle[handleKey] ?? { start: textByteLength, end: textByteLength };
|
|
109
|
+
const { start, end } = clampSelectionToText(textByteLength, selection);
|
|
110
|
+
return start === end;
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
const armCaretBlinkTimer = (): void => {
|
|
114
|
+
if (caretBlinkTimer !== null) {
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
caretBlinkTimer = setTimeout(() => {
|
|
118
|
+
caretBlinkTimer = null;
|
|
119
|
+
if (!shouldRunCaretBlinkTimer()) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
runtimeRef.current?.requestFrame();
|
|
123
|
+
armCaretBlinkTimer();
|
|
124
|
+
}, CARET_BLINK_INTERVAL_MS);
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const updateCaretBlinkTimer = (resetPhase = false): void => {
|
|
128
|
+
if (!shouldRunCaretBlinkTimer()) {
|
|
129
|
+
clearCaretBlinkTimer();
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
if (resetPhase) {
|
|
133
|
+
clearCaretBlinkTimer();
|
|
134
|
+
}
|
|
135
|
+
armCaretBlinkTimer();
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const syncActiveEditorWindowDebug = (handle: string | null): void => {
|
|
139
|
+
window.__bridgeActiveEditorWindow = {
|
|
140
|
+
handle,
|
|
141
|
+
text: activeEditorWindow.text,
|
|
142
|
+
docStart: activeEditorWindow.docStart,
|
|
143
|
+
docEnd: activeEditorWindow.docEnd,
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const clearActiveEditorWindow = (): void => {
|
|
148
|
+
activeEditorWindow = { text: '', docStart: 0, docEnd: 0, textStart: 0, textEnd: 0 };
|
|
149
|
+
syncActiveEditorWindowDebug(null);
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const clearPendingCaretReveal = (): void => {
|
|
153
|
+
if (pendingCaretRevealFrame !== null) {
|
|
154
|
+
cancelAnimationFrame(pendingCaretRevealFrame);
|
|
155
|
+
pendingCaretRevealFrame = null;
|
|
156
|
+
}
|
|
157
|
+
for (const key of Object.keys(pendingCaretRevealByHandle)) {
|
|
158
|
+
delete pendingCaretRevealByHandle[key];
|
|
159
|
+
}
|
|
160
|
+
};
|
|
161
|
+
|
|
162
|
+
const updateActiveEditorWindowText = (text: string): void => {
|
|
163
|
+
activeEditorWindow = {
|
|
164
|
+
text,
|
|
165
|
+
docStart: activeEditorWindow.docStart,
|
|
166
|
+
docEnd: activeEditorWindow.docStart + utf8ByteLength(text),
|
|
167
|
+
textStart: activeEditorWindow.textStart,
|
|
168
|
+
textEnd: activeEditorWindow.textStart + text.length,
|
|
169
|
+
};
|
|
170
|
+
syncActiveEditorWindowDebug(activeTextHandle?.toString() ?? null);
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const detachBridgeTextInput = (): void => {
|
|
174
|
+
hiddenInput.blur();
|
|
175
|
+
hiddenTextarea.blur();
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const getTextboxState = (
|
|
179
|
+
handleKey: string,
|
|
180
|
+
): { isTextbox: boolean; isEditable: boolean; isMultiline: boolean } => {
|
|
181
|
+
const runtime = runtimeRef.current;
|
|
182
|
+
if (runtime === null) {
|
|
183
|
+
return { isTextbox: false, isEditable: false, isMultiline: false };
|
|
184
|
+
}
|
|
185
|
+
const node = runtime.getSemanticTree().find((entry) => entry.handle === handleKey);
|
|
186
|
+
if (node?.roleName !== 'textbox') {
|
|
187
|
+
return { isTextbox: false, isEditable: false, isMultiline: false };
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
isTextbox: true,
|
|
191
|
+
isEditable: node.state.readonly !== true,
|
|
192
|
+
isMultiline: node.state.multiline === true,
|
|
193
|
+
};
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const syncFocusedInputState = (): void => {
|
|
197
|
+
if (activeTextHandle === null) {
|
|
198
|
+
clearCaretBlinkTimer();
|
|
199
|
+
clearActiveEditorWindow();
|
|
200
|
+
detachBridgeTextInput();
|
|
201
|
+
clearHiddenTextEditor(hiddenInput);
|
|
202
|
+
clearHiddenTextEditor(hiddenTextarea);
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const activeEditor = getActiveEditor();
|
|
207
|
+
const handleKey = activeTextHandle.toString();
|
|
208
|
+
const text = textByHandle[handleKey] ?? '';
|
|
209
|
+
const textByteLength = textByteLengthsByHandle[handleKey] ?? utf8ByteLength(text);
|
|
210
|
+
textByteLengthsByHandle[handleKey] = textByteLength;
|
|
211
|
+
const selection = selectionsByHandle[handleKey] ?? { start: textByteLength, end: textByteLength };
|
|
212
|
+
const { start: startByte, end: endByte } = clampSelectionToText(textByteLength, selection);
|
|
213
|
+
const start = utf8ByteOffsetToCodeUnitIndex(text, startByte, textByteLength);
|
|
214
|
+
const end = utf8ByteOffsetToCodeUnitIndex(text, endByte, textByteLength);
|
|
215
|
+
const direction = start === end ? 'none' : (startByte < endByte ? 'forward' : 'backward');
|
|
216
|
+
const normalizedStart = Math.min(start, end);
|
|
217
|
+
const normalizedEnd = Math.max(start, end);
|
|
218
|
+
activeEditorWindow = buildHiddenEditorWindow(text, normalizedStart, normalizedEnd, textByteLength, activeEditorWindow);
|
|
219
|
+
syncActiveEditorWindowDebug(handleKey);
|
|
220
|
+
const localStart = normalizedStart - activeEditorWindow.textStart;
|
|
221
|
+
const localEnd = normalizedEnd - activeEditorWindow.textStart;
|
|
222
|
+
if (activeEditor.value !== activeEditorWindow.text) {
|
|
223
|
+
activeEditor.value = activeEditorWindow.text;
|
|
224
|
+
}
|
|
225
|
+
activeEditor.readOnly = !activeTextEditable;
|
|
226
|
+
activeEditor.setSelectionRange(localStart, localEnd, direction);
|
|
227
|
+
updateCaretBlinkTimer();
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
const mutationController = createEditorMutationController({
|
|
231
|
+
runtimeRef,
|
|
232
|
+
textByHandle,
|
|
233
|
+
selectionsByHandle,
|
|
234
|
+
textByteLengthsByHandle,
|
|
235
|
+
getActiveEditor,
|
|
236
|
+
getActiveEditorWindow: () => activeEditorWindow,
|
|
237
|
+
getActiveTextEditable: () => activeTextEditable,
|
|
238
|
+
getActiveTextHandle: () => activeTextHandle,
|
|
239
|
+
isActiveEditorFocused,
|
|
240
|
+
setPendingLocalReplacementEcho: (value) => {
|
|
241
|
+
pendingLocalReplacementEcho = value;
|
|
242
|
+
},
|
|
243
|
+
setPendingLocalSelectionEcho: (value) => {
|
|
244
|
+
pendingLocalSelectionEcho = value;
|
|
245
|
+
},
|
|
246
|
+
syncFocusedInputState,
|
|
247
|
+
updateActiveEditorWindowText,
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
const clearPendingTextMutations = (): void => {
|
|
251
|
+
mutationController.clearPendingTextMutations();
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
const focusHiddenEditorNow = (): void => {
|
|
255
|
+
getActiveEditor().focus({ preventScroll: true });
|
|
256
|
+
syncFocusedInputState();
|
|
257
|
+
updateCaretBlinkTimer(true);
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const refocusActiveTextInput = (): void => {
|
|
261
|
+
if (activeTextHandle === null) {
|
|
262
|
+
if (deferredTouchFocusHandle === null) {
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const textboxState = getTextboxState(deferredTouchFocusHandle);
|
|
266
|
+
if (!textboxState.isTextbox) {
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
269
|
+
activeTextHandle = handleToBigInt(deferredTouchFocusHandle);
|
|
270
|
+
activeTextEditable = textboxState.isEditable;
|
|
271
|
+
activeTextMultiline = textboxState.isMultiline;
|
|
272
|
+
syncFocusedInputState();
|
|
273
|
+
focusHiddenEditorNow();
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
focusHiddenEditorNow();
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
const beginTouchTextFocusDeferral = (handle: bigint): void => {
|
|
280
|
+
deferredTouchFocusHandle = handle.toString();
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
const cancelTouchTextFocusDeferral = (): void => {
|
|
284
|
+
deferredTouchFocusHandle = null;
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const commitTouchTextFocusDeferral = (handle: bigint): void => {
|
|
288
|
+
const handleKey = handle.toString();
|
|
289
|
+
if (deferredTouchFocusHandle !== handleKey) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
deferredTouchFocusHandle = null;
|
|
293
|
+
if (activeTextHandle?.toString() !== handleKey) {
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
if (!isActiveEditorFocused()) {
|
|
297
|
+
focusHiddenEditorNow();
|
|
298
|
+
}
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const scheduleCaretRevealReplay = (handleKey: string): void => {
|
|
302
|
+
if (!pendingCaretRevealByHandle[handleKey] || activeTextHandle?.toString() !== handleKey) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
if (pendingCaretRevealFrame !== null) {
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
pendingCaretRevealFrame = requestAnimationFrame(() => {
|
|
309
|
+
pendingCaretRevealFrame = null;
|
|
310
|
+
if (!pendingCaretRevealByHandle[handleKey] || activeTextHandle?.toString() !== handleKey) {
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
delete pendingCaretRevealByHandle[handleKey];
|
|
314
|
+
const runtime = runtimeRef.current;
|
|
315
|
+
if (runtime === null) {
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
const text = textByHandle[handleKey] ?? '';
|
|
319
|
+
const textByteLength = utf8ByteLength(text);
|
|
320
|
+
const selection = selectionsByHandle[handleKey] ?? { start: textByteLength, end: textByteLength };
|
|
321
|
+
const { start, end } = clampSelectionToText(textByteLength, selection);
|
|
322
|
+
runtime.ui._ui_set_text_selection_range(handleToBigInt(handleKey), start, end);
|
|
323
|
+
});
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
const clearRecordMap = <T>(record: Record<string, T>): void => {
|
|
327
|
+
for (const key of Object.keys(record)) {
|
|
328
|
+
delete record[key];
|
|
329
|
+
}
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const resetAppSession = (): void => {
|
|
333
|
+
appSessionVersion += 1;
|
|
334
|
+
clearRecordMap(textByHandle);
|
|
335
|
+
clearRecordMap(textByteLengthsByHandle);
|
|
336
|
+
clearRecordMap(selectionsByHandle);
|
|
337
|
+
clearPendingCaretReveal();
|
|
338
|
+
mutationController.reset();
|
|
339
|
+
pendingLocalReplacementEcho = null;
|
|
340
|
+
pendingLocalSelectionEcho = null;
|
|
341
|
+
deferredTouchFocusHandle = null;
|
|
342
|
+
clearCaretBlinkTimer();
|
|
343
|
+
activeTextHandle = null;
|
|
344
|
+
activeTextEditable = false;
|
|
345
|
+
activeTextMultiline = false;
|
|
346
|
+
clearActiveEditorWindow();
|
|
347
|
+
focusedHandle = null;
|
|
348
|
+
pendingSemanticAnnouncements.clear();
|
|
349
|
+
lastInteractivePointerHandle = null;
|
|
350
|
+
capturedPointerHandle = null;
|
|
351
|
+
hiddenInput.value = '';
|
|
352
|
+
hiddenInput.setSelectionRange(0, 0, 'none');
|
|
353
|
+
hiddenTextarea.value = '';
|
|
354
|
+
hiddenTextarea.setSelectionRange(0, 0, 'none');
|
|
355
|
+
detachBridgeTextInput();
|
|
356
|
+
};
|
|
357
|
+
mutationController.attachHiddenEditorListeners(hiddenInput);
|
|
358
|
+
mutationController.attachHiddenEditorListeners(hiddenTextarea);
|
|
359
|
+
|
|
360
|
+
const handleFocusChanged = (handle: WasmHandleLike, isFocused: boolean): void => {
|
|
361
|
+
const handleKey = handle.toString();
|
|
362
|
+
const entry: FocusEventLog = { handle: handleKey, isFocused };
|
|
363
|
+
logs.focusEvents.push(entry);
|
|
364
|
+
if (isFocused) {
|
|
365
|
+
focusedHandle = handleKey;
|
|
366
|
+
} else if (focusedHandle === handleKey) {
|
|
367
|
+
focusedHandle = null;
|
|
368
|
+
}
|
|
369
|
+
if (isFocused) {
|
|
370
|
+
const textboxState = getTextboxState(handleKey);
|
|
371
|
+
if (!textboxState.isTextbox) {
|
|
372
|
+
deferredTouchFocusHandle = null;
|
|
373
|
+
delete pendingCaretRevealByHandle[handleKey];
|
|
374
|
+
activeTextHandle = null;
|
|
375
|
+
activeTextEditable = false;
|
|
376
|
+
activeTextMultiline = false;
|
|
377
|
+
syncFocusedInputState();
|
|
378
|
+
updateCaretBlinkTimer();
|
|
379
|
+
queueSemanticAnnouncement(handleKey);
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
activeTextHandle = handleToBigInt(handle);
|
|
383
|
+
activeTextEditable = textboxState.isEditable;
|
|
384
|
+
activeTextMultiline = textboxState.isMultiline;
|
|
385
|
+
syncFocusedInputState();
|
|
386
|
+
if (deferredTouchFocusHandle === handleKey) {
|
|
387
|
+
updateCaretBlinkTimer();
|
|
388
|
+
queueSemanticAnnouncement(handleKey);
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
391
|
+
window.setTimeout(() => {
|
|
392
|
+
if (activeTextHandle !== null && activeTextHandle.toString() === handleKey) {
|
|
393
|
+
focusHiddenEditorNow();
|
|
394
|
+
}
|
|
395
|
+
}, 0);
|
|
396
|
+
} else if (activeTextHandle !== null && activeTextHandle.toString() === handleKey) {
|
|
397
|
+
if (deferredTouchFocusHandle === handleKey) {
|
|
398
|
+
updateCaretBlinkTimer();
|
|
399
|
+
return;
|
|
400
|
+
}
|
|
401
|
+
deferredTouchFocusHandle = null;
|
|
402
|
+
runtimeRef.current?.flushPendingCommit();
|
|
403
|
+
delete pendingCaretRevealByHandle[handleKey];
|
|
404
|
+
pendingLocalReplacementEcho = null;
|
|
405
|
+
pendingLocalSelectionEcho = null;
|
|
406
|
+
clearPendingTextMutations();
|
|
407
|
+
activeTextHandle = null;
|
|
408
|
+
activeTextEditable = false;
|
|
409
|
+
activeTextMultiline = false;
|
|
410
|
+
syncFocusedInputState();
|
|
411
|
+
}
|
|
412
|
+
updateCaretBlinkTimer();
|
|
413
|
+
if (isFocused) {
|
|
414
|
+
queueSemanticAnnouncement(handleKey);
|
|
415
|
+
}
|
|
416
|
+
};
|
|
417
|
+
|
|
418
|
+
const handleTextChanged = (handle: WasmHandleLike, text: string): void => {
|
|
419
|
+
const handleKey = handle.toString();
|
|
420
|
+
if (pendingLocalReplacementEcho !== null && pendingLocalReplacementEcho.handle === handleKey) {
|
|
421
|
+
pendingLocalReplacementEcho = null;
|
|
422
|
+
}
|
|
423
|
+
textByHandle[handleKey] = text;
|
|
424
|
+
textByteLengthsByHandle[handleKey] = utf8ByteLength(text);
|
|
425
|
+
logs.textChanges.push(summarizeTextChange(handleKey, text));
|
|
426
|
+
if (activeTextHandle !== null && activeTextHandle.toString() === handleKey) {
|
|
427
|
+
pendingCaretRevealByHandle[handleKey] = true;
|
|
428
|
+
if (!isActiveEditorFocused()) {
|
|
429
|
+
focusHiddenEditorNow();
|
|
430
|
+
updateCaretBlinkTimer(true);
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
syncFocusedInputState();
|
|
434
|
+
updateCaretBlinkTimer(true);
|
|
435
|
+
}
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
const handleRequestSemanticAnnouncement = (handle: WasmHandleLike): void => {
|
|
439
|
+
queueSemanticAnnouncement(handle.toString());
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const handleTextReplaced = (handle: WasmHandleLike, start: number, end: number, text: string): void => {
|
|
443
|
+
const handleKey = handle.toString();
|
|
444
|
+
const previousText = textByHandle[handleKey] ?? '';
|
|
445
|
+
const previousTextByteLength = textByteLengthsByHandle[handleKey] ?? utf8ByteLength(previousText);
|
|
446
|
+
const isLocalEcho = pendingLocalReplacementEcho !== null
|
|
447
|
+
&& pendingLocalReplacementEcho.handle === handleKey
|
|
448
|
+
&& pendingLocalReplacementEcho.start === start
|
|
449
|
+
&& pendingLocalReplacementEcho.end === end
|
|
450
|
+
&& pendingLocalReplacementEcho.text === text
|
|
451
|
+
&& activeTextHandle !== null
|
|
452
|
+
&& activeTextHandle.toString() === handleKey
|
|
453
|
+
&& isActiveEditorFocused();
|
|
454
|
+
const nextText = isLocalEcho
|
|
455
|
+
? previousText
|
|
456
|
+
: applyUtf8ByteReplacementEdit(previousText, start, end, text);
|
|
457
|
+
if (!isLocalEcho) {
|
|
458
|
+
textByHandle[handleKey] = nextText;
|
|
459
|
+
textByteLengthsByHandle[handleKey] = previousTextByteLength - (end - start) + utf8ByteLength(text);
|
|
460
|
+
}
|
|
461
|
+
if (isLocalEcho) {
|
|
462
|
+
pendingLocalReplacementEcho = null;
|
|
463
|
+
}
|
|
464
|
+
logs.textChanges.push(summarizeTextChange(handleKey, nextText));
|
|
465
|
+
if (activeTextHandle !== null && activeTextHandle.toString() === handleKey) {
|
|
466
|
+
pendingCaretRevealByHandle[handleKey] = true;
|
|
467
|
+
if (!isActiveEditorFocused()) {
|
|
468
|
+
focusHiddenEditorNow();
|
|
469
|
+
updateCaretBlinkTimer(true);
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (!isLocalEcho) {
|
|
473
|
+
syncFocusedInputState();
|
|
474
|
+
}
|
|
475
|
+
updateCaretBlinkTimer(true);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
|
|
479
|
+
const handleSelectionChanged = (handle: WasmHandleLike, start: number, end: number): void => {
|
|
480
|
+
const handleKey = handle.toString();
|
|
481
|
+
const isLocalEcho = pendingLocalSelectionEcho !== null
|
|
482
|
+
&& pendingLocalSelectionEcho.handle === handleKey
|
|
483
|
+
&& pendingLocalSelectionEcho.start === start
|
|
484
|
+
&& pendingLocalSelectionEcho.end === end
|
|
485
|
+
&& activeTextHandle !== null
|
|
486
|
+
&& activeTextHandle.toString() === handleKey
|
|
487
|
+
&& isActiveEditorFocused();
|
|
488
|
+
selectionsByHandle[handleKey] = { start, end };
|
|
489
|
+
if (pendingLocalSelectionEcho !== null && pendingLocalSelectionEcho.handle === handleKey) {
|
|
490
|
+
pendingLocalSelectionEcho = null;
|
|
491
|
+
}
|
|
492
|
+
const entry: SelectionChangeLog = { handle: handleKey, start, end };
|
|
493
|
+
logs.selectionChanges.push(entry);
|
|
494
|
+
if (activeTextHandle !== null && activeTextHandle.toString() === handleKey) {
|
|
495
|
+
if (!isActiveEditorFocused()) {
|
|
496
|
+
focusHiddenEditorNow();
|
|
497
|
+
updateCaretBlinkTimer(true);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (!isLocalEcho) {
|
|
501
|
+
syncFocusedInputState();
|
|
502
|
+
}
|
|
503
|
+
updateCaretBlinkTimer(true);
|
|
504
|
+
scheduleCaretRevealReplay(handleKey);
|
|
505
|
+
}
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
const handleClipboardRead = (handle: WasmHandleLike): void => {
|
|
509
|
+
const runtime = runtimeRef.current;
|
|
510
|
+
if (runtime === null) {
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
const handleValue = handleToBigInt(handle);
|
|
514
|
+
const requestSessionVersion = appSessionVersion;
|
|
515
|
+
logs.clipboardReadRequests.push(handleValue.toString());
|
|
516
|
+
void navigator.clipboard.readText().then((text) => {
|
|
517
|
+
if (requestSessionVersion !== appSessionVersion) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
const handleKey = handleValue.toString();
|
|
521
|
+
const currentText = textByHandle[handleKey] ?? '';
|
|
522
|
+
const currentTextByteLength = textByteLengthsByHandle[handleKey] ?? utf8ByteLength(currentText);
|
|
523
|
+
const selection = selectionsByHandle[handleKey] ?? { start: currentTextByteLength, end: currentTextByteLength };
|
|
524
|
+
const rangeStart = Math.max(0, Math.min(selection.start, selection.end));
|
|
525
|
+
const rangeEnd = Math.max(rangeStart, Math.min(currentTextByteLength, Math.max(selection.start, selection.end)));
|
|
526
|
+
const clampedEdit = buildClampedTextboxEdit(
|
|
527
|
+
currentText,
|
|
528
|
+
rangeStart,
|
|
529
|
+
rangeEnd,
|
|
530
|
+
text,
|
|
531
|
+
rangeStart + utf8ByteLength(text),
|
|
532
|
+
);
|
|
533
|
+
textByHandle[handleKey] = clampedEdit.fullNextText;
|
|
534
|
+
textByteLengthsByHandle[handleKey] = utf8ByteLength(clampedEdit.fullNextText);
|
|
535
|
+
selectionsByHandle[handleKey] = { start: clampedEdit.caretByte, end: clampedEdit.caretByte };
|
|
536
|
+
if (activeTextHandle !== null && activeTextHandle.toString() === handleKey) {
|
|
537
|
+
syncFocusedInputState();
|
|
538
|
+
}
|
|
539
|
+
if (clampedEdit.replacement === null) {
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
runtime.ui._ui_set_interaction_time(currentInteractionTimeMs());
|
|
543
|
+
const replacementStartByte = codeUnitIndexToUtf8ByteOffset(currentText, clampedEdit.replacement.start);
|
|
544
|
+
const replacementEndByte = codeUnitIndexToUtf8ByteOffset(currentText, clampedEdit.replacement.end);
|
|
545
|
+
pendingLocalReplacementEcho = {
|
|
546
|
+
handle: handleKey,
|
|
547
|
+
start: replacementStartByte,
|
|
548
|
+
end: replacementEndByte,
|
|
549
|
+
text: clampedEdit.replacement.insertedText,
|
|
550
|
+
};
|
|
551
|
+
pendingLocalSelectionEcho = {
|
|
552
|
+
handle: handleKey,
|
|
553
|
+
start: clampedEdit.caretByte,
|
|
554
|
+
end: clampedEdit.caretByte,
|
|
555
|
+
};
|
|
556
|
+
const heapString = writeUtf8ToHeap(runtime.ui, clampedEdit.replacement.insertedText);
|
|
557
|
+
try {
|
|
558
|
+
runtime.ui._ui_replace_text_range(
|
|
559
|
+
handleValue,
|
|
560
|
+
replacementStartByte,
|
|
561
|
+
replacementEndByte,
|
|
562
|
+
heapString.ptr,
|
|
563
|
+
heapString.len,
|
|
564
|
+
clampedEdit.caretByte,
|
|
565
|
+
);
|
|
566
|
+
} finally {
|
|
567
|
+
heapString.dispose();
|
|
568
|
+
}
|
|
569
|
+
runtime.commitFrame();
|
|
570
|
+
}).catch(() => undefined);
|
|
571
|
+
};
|
|
572
|
+
|
|
573
|
+
return {
|
|
574
|
+
logs,
|
|
575
|
+
textByHandle,
|
|
576
|
+
selectionsByHandle,
|
|
577
|
+
hasPendingTextMutations: mutationController.hasPendingTextMutations,
|
|
578
|
+
materializePendingTextMutations: mutationController.materializePendingTextMutations,
|
|
579
|
+
getActiveTextEditable: () => activeTextEditable,
|
|
580
|
+
getActiveTextHandle: () => activeTextHandle,
|
|
581
|
+
getActiveTextMultiline: () => activeTextMultiline,
|
|
582
|
+
getCapturedPointerHandle: () => capturedPointerHandle,
|
|
583
|
+
getLastPointerClientPosition: () => ({ x: lastPointerClientX, y: lastPointerClientY }),
|
|
584
|
+
getLastPointerPosition: () => ({ x: lastPointerX, y: lastPointerY }),
|
|
585
|
+
getLastPointerModifiers: () => lastPointerModifiers,
|
|
586
|
+
getLastInteractivePointerHandle: () => lastInteractivePointerHandle,
|
|
587
|
+
isActiveTextInputFocused: isActiveEditorFocused,
|
|
588
|
+
isPointerInsideCanvas: () => pointerInsideCanvas,
|
|
589
|
+
applyActiveTextDeletion: mutationController.applyActiveTextDeletion,
|
|
590
|
+
beginTouchTextFocusDeferral,
|
|
591
|
+
cancelTouchTextFocusDeferral,
|
|
592
|
+
commitTouchTextFocusDeferral,
|
|
593
|
+
refocusActiveTextInput,
|
|
594
|
+
resetAppSession,
|
|
595
|
+
consumePendingSemanticAnnouncements: () => {
|
|
596
|
+
const handles = Array.from(pendingSemanticAnnouncements.values());
|
|
597
|
+
pendingSemanticAnnouncements.clear();
|
|
598
|
+
return handles;
|
|
599
|
+
},
|
|
600
|
+
getFocusedHandle: () => focusedHandle,
|
|
601
|
+
setCapturedPointerHandle: (handle: bigint | null) => {
|
|
602
|
+
capturedPointerHandle = handle;
|
|
603
|
+
},
|
|
604
|
+
setLastPointerClientPosition: (x: number, y: number) => {
|
|
605
|
+
lastPointerClientX = x;
|
|
606
|
+
lastPointerClientY = y;
|
|
607
|
+
},
|
|
608
|
+
setLastPointerModifiers: (modifiers: number) => {
|
|
609
|
+
lastPointerModifiers = modifiers;
|
|
610
|
+
},
|
|
611
|
+
setLastPointerPosition: (x: number, y: number) => {
|
|
612
|
+
lastPointerX = x;
|
|
613
|
+
lastPointerY = y;
|
|
614
|
+
},
|
|
615
|
+
setLastInteractivePointerHandle: (handle: bigint | null) => {
|
|
616
|
+
lastInteractivePointerHandle = handle;
|
|
617
|
+
},
|
|
618
|
+
setPointerInsideCanvas: (flag: boolean) => {
|
|
619
|
+
pointerInsideCanvas = flag;
|
|
620
|
+
},
|
|
621
|
+
handleClipboardRead,
|
|
622
|
+
handleFocusChanged,
|
|
623
|
+
handleRequestSemanticAnnouncement,
|
|
624
|
+
handleSelectionChanged,
|
|
625
|
+
handleTextChanged,
|
|
626
|
+
handleTextReplaced,
|
|
627
|
+
};
|
|
628
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
BridgeLogs,
|
|
3
|
+
FocusEventLog,
|
|
4
|
+
PointerEventLog,
|
|
5
|
+
ScrollEventLog,
|
|
6
|
+
SelectionChangeLog,
|
|
7
|
+
TextChangeLog,
|
|
8
|
+
} from '../../core-types';
|
|
9
|
+
|
|
10
|
+
export function createBridgeLogs(): BridgeLogs {
|
|
11
|
+
return {
|
|
12
|
+
pointerEvents: [] as PointerEventLog[],
|
|
13
|
+
focusEvents: [] as FocusEventLog[],
|
|
14
|
+
textChanges: [] as TextChangeLog[],
|
|
15
|
+
selectionChanges: [] as SelectionChangeLog[],
|
|
16
|
+
crossSelectionChanges: [] as { areaHandle: string; text: string }[],
|
|
17
|
+
clipboardWrites: [] as string[],
|
|
18
|
+
clipboardReadRequests: [] as string[],
|
|
19
|
+
scrollEvents: [] as ScrollEventLog[],
|
|
20
|
+
missingFontCoverageRequests: [] as { fontId: number; coverageKind: number; sampleText: string }[],
|
|
21
|
+
incrementalFontPackageRequests: [] as { primaryFontId: number; coverageKind: number; packageId: string; segmentIds: readonly string[]; sampleText: string }[],
|
|
22
|
+
} satisfies BridgeLogs;
|
|
23
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export function utf8ByteLengthForCodePoint(codePoint: number): number {
|
|
2
|
+
if (codePoint <= 0x7f) {
|
|
3
|
+
return 1;
|
|
4
|
+
}
|
|
5
|
+
if (codePoint <= 0x7ff) {
|
|
6
|
+
return 2;
|
|
7
|
+
}
|
|
8
|
+
if (codePoint <= 0xffff) {
|
|
9
|
+
return 3;
|
|
10
|
+
}
|
|
11
|
+
return 4;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function advanceCodeUnitIndex(text: string, index: number): number {
|
|
15
|
+
const codePoint = text.codePointAt(index) ?? 0;
|
|
16
|
+
return index + (codePoint > 0xffff ? 2 : 1);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function retreatCodeUnitIndex(text: string, index: number): number {
|
|
20
|
+
const clampedIndex = Math.max(0, Math.min(index, text.length));
|
|
21
|
+
if (clampedIndex <= 0) {
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
const previousIndex = clampedIndex - 1;
|
|
25
|
+
const previousUnit = text.charCodeAt(previousIndex);
|
|
26
|
+
if (
|
|
27
|
+
previousIndex > 0 &&
|
|
28
|
+
previousUnit >= 0xdc00 &&
|
|
29
|
+
previousUnit <= 0xdfff
|
|
30
|
+
) {
|
|
31
|
+
const leadUnit = text.charCodeAt(previousIndex - 1);
|
|
32
|
+
if (leadUnit >= 0xd800 && leadUnit <= 0xdbff) {
|
|
33
|
+
return previousIndex - 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return previousIndex;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function codeUnitIndexToUtf8ByteOffset(text: string, index: number): number {
|
|
40
|
+
const clampedIndex = Math.max(0, Math.min(index, text.length));
|
|
41
|
+
let byteOffset = 0;
|
|
42
|
+
for (let current = 0; current < clampedIndex; current = advanceCodeUnitIndex(text, current)) {
|
|
43
|
+
const codePoint = text.codePointAt(current) ?? 0;
|
|
44
|
+
byteOffset += utf8ByteLengthForCodePoint(codePoint);
|
|
45
|
+
}
|
|
46
|
+
return byteOffset;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function utf8ByteLength(text: string): number {
|
|
50
|
+
return codeUnitIndexToUtf8ByteOffset(text, text.length);
|
|
51
|
+
}
|