@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.
Files changed (92) hide show
  1. package/LICENSE.md +6 -0
  2. package/dist/bridge.js +4 -0
  3. package/dist/bridge.js.map +7 -0
  4. package/dist/effindom.v2.manifest.json +68 -0
  5. package/dist/fonts/NotoColorEmoji.ttf +0 -0
  6. package/dist/fonts/NotoEmoji-Regular.ttf +0 -0
  7. package/dist/fonts/NotoSans-Bold.ttf +0 -0
  8. package/dist/fonts/NotoSans-BoldItalic.ttf +0 -0
  9. package/dist/fonts/NotoSans-Italic.ttf +0 -0
  10. package/dist/fonts/NotoSans-Regular.ttf +0 -0
  11. package/dist/fonts/NotoSansMono-Bold.ttf +0 -0
  12. package/dist/fonts/NotoSansMono-Regular.ttf +0 -0
  13. package/dist/fonts/NotoSansSymbols2-Regular.ttf +0 -0
  14. package/dist/harness.js +2 -0
  15. package/dist/harness.js.map +7 -0
  16. package/dist/index.html +53 -0
  17. package/dist/runtime/effindom-core-v2.wasm32-simd.JQXIaRaN0-JahfIVFiSLE49WzzCENvef_2EDEm09nJs.wasm +0 -0
  18. package/dist/runtime/effindom-core-v2.wasm32-simd.y7RzpkMARiFeRkpgiqKQsAfv4Hf17NYdpni-6aLNhMs.js.symbols +10079 -0
  19. package/dist/runtime/effindom-core-v2.wasm32-simd.yhT7DGUv4soEv4W91WVZl3T7T_ecKojk5_IcnwL79a0.js +1 -0
  20. package/dist/runtime/effindom-core-v2.wasm32.JSfMkp9ertJzSZxA-_xz3yacrJUhswxlwbqbJLRIuqw.wasm +0 -0
  21. package/dist/runtime/effindom-core-v2.wasm32.xNgsQv7dCwf8Uy-PfJSoRNyk9-q1OSogUwkk5g6ZBjk.js.symbols +10088 -0
  22. package/dist/runtime/effindom-core-v2.wasm32.yhT7DGUv4soEv4W91WVZl3T7T_ecKojk5_IcnwL79a0.js +1 -0
  23. package/dist/runtime/effindom-core-v2.wasm64-simd.GkByf-CPorNOs1CORny_8JjVk8Z3piiFq92r-uw1Syc.js +1 -0
  24. package/dist/runtime/effindom-core-v2.wasm64-simd.p4P98oRu2wEWxtRRW8RHr27JhGeWvWlziZXDM_z3Nc4.js.symbols +10286 -0
  25. package/dist/runtime/effindom-core-v2.wasm64-simd.y75FYXRwhQrpaDGYbZWrohGDv0AmjTb-EjXwOjBIgnM.wasm +0 -0
  26. package/dist/runtime/effindom-core-v2.wasm64.GkByf-CPorNOs1CORny_8JjVk8Z3piiFq92r-uw1Syc.js +1 -0
  27. package/dist/runtime/effindom-core-v2.wasm64.emhE1_CJs4_zXp8wiQS_5lYpUQ0OchmXgxksi0ykaBs.js.symbols +10298 -0
  28. package/dist/runtime/effindom-core-v2.wasm64.sO-Yu70cfN8Qs3a5iEp6cbFPaiOchqcMKUzryu4npNo.wasm +0 -0
  29. package/dist/runtime/effindom-ui-v2.wasm32-simd.0Mas1XD03eYvemryTioWaZOBuBA5ij7MFlTa8CgEZWs.wasm +0 -0
  30. package/dist/runtime/effindom-ui-v2.wasm32-simd.ThSDClMnSWdwf9d89JZfYor0G1Z6OxR4lOc75rNRuD4.js.symbols +1890 -0
  31. package/dist/runtime/effindom-ui-v2.wasm32-simd.wved0xEV4EKXVNBU3Sx7giD4faxD2YII9sQ2N_wCP4I.js +2 -0
  32. package/dist/runtime/effindom-ui-v2.wasm32.H7kYg99bT9ADGh0uUvj6H9Dk1L058nVFLv_4R79IXW8.js.symbols +1900 -0
  33. package/dist/runtime/effindom-ui-v2.wasm32.tp53X7nHfG_EUq29naDyElfnqhMw2D1Tr1T-BJAYO7w.wasm +0 -0
  34. package/dist/runtime/effindom-ui-v2.wasm32.wved0xEV4EKXVNBU3Sx7giD4faxD2YII9sQ2N_wCP4I.js +2 -0
  35. package/dist/runtime/effindom-ui-v2.wasm64-simd.86tk9Z3xIpgTOykET_8Nn9iUVJnp1AzOHW4fVQRGtQE.wasm +0 -0
  36. package/dist/runtime/effindom-ui-v2.wasm64-simd.RQaXil22Chu63-vxK9oOuX8wUY044kbo190oYIbBU4M.js.symbols +1918 -0
  37. package/dist/runtime/effindom-ui-v2.wasm64-simd.ZS1KEAg0XQex-VXkfgpBHE8MIoqPF8qpaf8nOjANb_U.js +2 -0
  38. package/dist/runtime/effindom-ui-v2.wasm64.YSwpMFbr-Q1SBe0Ze8mub1u1PqsvSz3QIYuA3eaUMME.js.symbols +1924 -0
  39. package/dist/runtime/effindom-ui-v2.wasm64.ZS1KEAg0XQex-VXkfgpBHE8MIoqPF8qpaf8nOjANb_U.js +2 -0
  40. package/dist/runtime/effindom-ui-v2.wasm64.ioQ9DuM6gR_EjlfRHdF8EvNPBcKCs0PQbbY9-cjTV6Y.wasm +0 -0
  41. package/dist/runtime/icudt_minimal.962CX1q0-Nbv-OqXPaub5piYTOLumUk-nEvemcvvnpw.dat +0 -0
  42. package/package.json +62 -0
  43. package/scripts/build.sh +279 -0
  44. package/scripts/build_assets.sh +51 -0
  45. package/scripts/font_assets.sh +52 -0
  46. package/scripts/generate_manifest.py +121 -0
  47. package/scripts/stage_package_assets.sh +42 -0
  48. package/src/bridge/commit-policy.ts +10 -0
  49. package/src/bridge/events/canvas-geometry.ts +78 -0
  50. package/src/bridge/events/key-router.ts +187 -0
  51. package/src/bridge/events/pointer-router.ts +619 -0
  52. package/src/bridge/events/semantic-hit-testing.ts +27 -0
  53. package/src/bridge/events.ts +54 -0
  54. package/src/bridge/find-dialog.ts +690 -0
  55. package/src/bridge/find-session.ts +158 -0
  56. package/src/bridge/font-catalog.ts +51 -0
  57. package/src/bridge/google-fonts.ts +63 -0
  58. package/src/bridge/incremental-font-packages.ts +216 -0
  59. package/src/bridge/init.ts +77 -0
  60. package/src/bridge/interaction/editor-model.ts +371 -0
  61. package/src/bridge/interaction/editor-mutations.ts +495 -0
  62. package/src/bridge/interaction/editor-session.ts +628 -0
  63. package/src/bridge/interaction/logs.ts +23 -0
  64. package/src/bridge/interaction/text-encoding.ts +51 -0
  65. package/src/bridge/interaction.ts +86 -0
  66. package/src/bridge/local-types.ts +105 -0
  67. package/src/bridge/platform.ts +68 -0
  68. package/src/bridge/pointer-move-coalescer.ts +41 -0
  69. package/src/bridge/pull-to-refresh.ts +124 -0
  70. package/src/bridge/render-loop.ts +268 -0
  71. package/src/bridge/runtime/asset-manager.ts +202 -0
  72. package/src/bridge/runtime/find-controller.ts +269 -0
  73. package/src/bridge/runtime/font-manager.ts +691 -0
  74. package/src/bridge/runtime/open-canvas-api.ts +72 -0
  75. package/src/bridge/runtime/semantic-controller.ts +133 -0
  76. package/src/bridge/runtime/text-documents.ts +234 -0
  77. package/src/bridge/runtime.ts +315 -0
  78. package/src/bridge/touch-gesture.ts +159 -0
  79. package/src/bridge/utils/assets.ts +572 -0
  80. package/src/bridge/utils/backends.ts +163 -0
  81. package/src/bridge/utils/encoding.ts +128 -0
  82. package/src/bridge/utils/fetch.ts +147 -0
  83. package/src/bridge/utils/heap.ts +118 -0
  84. package/src/bridge.ts +93 -0
  85. package/src/clipboard.ts +139 -0
  86. package/src/core-types.ts +595 -0
  87. package/src/find-on-page.ts +284 -0
  88. package/src/harness.ts +53 -0
  89. package/src/index.ts +40 -0
  90. package/src/open-canvas.ts +108 -0
  91. package/src/runtime-config.ts +96 -0
  92. package/src/semantic.ts +905 -0
@@ -0,0 +1,690 @@
1
+ import type {
2
+ BridgeRuntime,
3
+ OpenCanvasFindMatch,
4
+ OpenCanvasFindState,
5
+ OpenCanvasResolvedFindOptions,
6
+ } from '../core-types';
7
+ import { DEFAULT_OPEN_CANVAS_FIND_OPTIONS } from './find-session';
8
+ import { isMobileBrowser, isPlatformShortcutKey } from './platform';
9
+
10
+ type FindOptionKey = keyof OpenCanvasResolvedFindOptions;
11
+ type FindIconKind = 'chevronUp' | 'chevronDown' | 'filter' | 'close';
12
+
13
+ interface FindDialogPalette {
14
+ readonly colorScheme: 'dark' | 'light';
15
+ readonly shellBackground: string;
16
+ readonly shellBorder: string;
17
+ readonly divider: string;
18
+ readonly panelBackground: string;
19
+ readonly text: string;
20
+ readonly mutedText: string;
21
+ readonly errorText: string;
22
+ readonly buttonText: string;
23
+ readonly buttonMutedText: string;
24
+ readonly buttonActiveBackground: string;
25
+ readonly buttonPlainHoverBackground: string;
26
+ readonly filterButtonBackground: string;
27
+ readonly filterButtonActiveBackground: string;
28
+ readonly optionHoverBackground: string;
29
+ readonly toggleTrackOff: string;
30
+ readonly toggleTrackOn: string;
31
+ readonly toggleThumb: string;
32
+ }
33
+
34
+ function isFindShortcut(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey'>): boolean {
35
+ return isPlatformShortcutKey(event) &&
36
+ (event.key === 'f' || event.key === 'F');
37
+ }
38
+
39
+ function isNextShortcut(event: Pick<KeyboardEvent, 'key' | 'ctrlKey' | 'metaKey' | 'altKey' | 'shiftKey'>): boolean {
40
+ return isPlatformShortcutKey(event) &&
41
+ (event.key === 'g' || event.key === 'G');
42
+ }
43
+
44
+ function createSvgIcon(kind: FindIconKind): SVGSVGElement {
45
+ const ns = 'http://www.w3.org/2000/svg';
46
+ const svg = document.createElementNS(ns, 'svg');
47
+ svg.setAttribute('viewBox', '0 0 16 16');
48
+ svg.setAttribute('fill', 'none');
49
+ svg.setAttribute('stroke', 'currentColor');
50
+ svg.setAttribute('stroke-width', kind === 'filter' ? '1.6' : '1.8');
51
+ svg.setAttribute('stroke-linecap', 'round');
52
+ svg.setAttribute('stroke-linejoin', 'round');
53
+ svg.setAttribute('aria-hidden', 'true');
54
+ svg.style.display = 'block';
55
+ svg.style.width = '16px';
56
+ svg.style.height = '16px';
57
+
58
+ const appendPath = (d: string): void => {
59
+ const path = document.createElementNS(ns, 'path');
60
+ path.setAttribute('d', d);
61
+ svg.appendChild(path);
62
+ };
63
+
64
+ switch (kind) {
65
+ case 'chevronUp':
66
+ appendPath('M4.5 10.25L8 6.75L11.5 10.25');
67
+ break;
68
+ case 'chevronDown':
69
+ appendPath('M4.5 5.75L8 9.25L11.5 5.75');
70
+ break;
71
+ case 'filter':
72
+ appendPath('M3 4.5H13');
73
+ appendPath('M5 8H11');
74
+ appendPath('M6.75 11.5H9.25');
75
+ break;
76
+ case 'close':
77
+ appendPath('M4.75 4.75L11.25 11.25');
78
+ appendPath('M11.25 4.75L4.75 11.25');
79
+ break;
80
+ }
81
+
82
+ return svg;
83
+ }
84
+
85
+ function createIconButton(
86
+ label: string,
87
+ icon: FindIconKind,
88
+ variant: 'plain' | 'filter' = 'plain',
89
+ ): HTMLButtonElement {
90
+ const button = document.createElement('button');
91
+ button.type = 'button';
92
+ button.setAttribute('aria-label', label);
93
+ button.setAttribute('data-ed-find-icon-variant', variant);
94
+ button.appendChild(createSvgIcon(icon));
95
+ button.style.width = '32px';
96
+ button.style.height = '32px';
97
+ button.style.display = 'inline-flex';
98
+ button.style.alignItems = 'center';
99
+ button.style.justifyContent = 'center';
100
+ button.style.padding = '0';
101
+ button.style.border = 'none';
102
+ button.style.borderRadius = '10px';
103
+ button.style.background = 'transparent';
104
+ button.style.outline = 'none';
105
+ button.style.appearance = 'none';
106
+ button.style.webkitAppearance = 'none';
107
+ button.style.boxShadow = 'none';
108
+ button.style.cursor = 'pointer';
109
+ button.style.userSelect = 'none';
110
+ button.style.webkitUserSelect = 'none';
111
+ button.style.flex = '0 0 auto';
112
+ button.style.transition = 'background 120ms ease, color 120ms ease, opacity 120ms ease';
113
+ button.dataset.edHovered = '0';
114
+ return button;
115
+ }
116
+
117
+ function sameMatch(left: OpenCanvasFindMatch | null, right: OpenCanvasFindMatch | null): boolean {
118
+ return left?.handle === right?.handle &&
119
+ left?.start === right?.start &&
120
+ left?.end === right?.end;
121
+ }
122
+
123
+ export class DesktopFindDialogController {
124
+ private readonly runtime: BridgeRuntime;
125
+ private readonly root: HTMLDivElement;
126
+ private readonly input: HTMLInputElement;
127
+ private readonly status: HTMLSpanElement;
128
+ private readonly advancedPanel: HTMLDivElement;
129
+ private readonly previousButton: HTMLButtonElement;
130
+ private readonly nextButton: HTMLButtonElement;
131
+ private readonly disclosureButton: HTMLButtonElement;
132
+ private readonly closeButton: HTMLButtonElement;
133
+ private readonly optionButtons: Record<FindOptionKey, HTMLButtonElement>;
134
+ private readonly optionTracks: Record<FindOptionKey, HTMLSpanElement>;
135
+ private readonly optionThumbs: Record<FindOptionKey, HTMLSpanElement>;
136
+ private readonly themeMedia: MediaQueryList | null;
137
+ private matches: OpenCanvasFindMatch[] = [];
138
+ private activeMatchIndex = -1;
139
+ private options: OpenCanvasResolvedFindOptions = { ...DEFAULT_OPEN_CANVAS_FIND_OPTIONS };
140
+ private optionsExpanded = false;
141
+
142
+ private readonly handleThemeChange = (): void => {
143
+ this.syncThemeUi();
144
+ };
145
+
146
+ public constructor(runtime: BridgeRuntime, _canvas: HTMLCanvasElement) {
147
+ this.runtime = runtime;
148
+ const parent = document.body ?? document.documentElement;
149
+ if (!(parent instanceof HTMLElement)) {
150
+ throw new Error('Expected document root for desktop Find dialog.');
151
+ }
152
+
153
+ const root = document.createElement('div');
154
+ root.setAttribute('data-ed-find-dialog', '1');
155
+ root.setAttribute('data-ed-open', '0');
156
+ root.setAttribute('role', 'dialog');
157
+ root.setAttribute('aria-label', 'Find on page');
158
+ root.style.position = 'fixed';
159
+ root.style.top = '6px';
160
+ root.style.right = '6px';
161
+ root.style.display = 'none';
162
+ root.style.flexDirection = 'column';
163
+ root.style.width = 'min(356px, calc(100vw - 12px))';
164
+ root.style.boxSizing = 'border-box';
165
+ root.style.borderRadius = '14px';
166
+ root.style.border = '1px solid transparent';
167
+ root.style.overflow = 'hidden';
168
+ root.style.pointerEvents = 'auto';
169
+ root.style.zIndex = '2147483647';
170
+ root.style.boxShadow = '0 10px 24px rgba(0, 0, 0, 0.22)';
171
+ root.style.backdropFilter = 'blur(10px)';
172
+ root.style.font = '500 13px/1.2 system-ui, sans-serif';
173
+
174
+ const topRow = document.createElement('div');
175
+ topRow.style.display = 'flex';
176
+ topRow.style.alignItems = 'center';
177
+ topRow.style.minHeight = '42px';
178
+ topRow.style.padding = '0 8px 0 12px';
179
+
180
+ const searchShell = document.createElement('div');
181
+ searchShell.style.flex = '1 1 auto';
182
+ searchShell.style.minWidth = '0';
183
+ searchShell.style.display = 'flex';
184
+ searchShell.style.alignItems = 'center';
185
+ searchShell.style.paddingRight = '6px';
186
+
187
+ const input = document.createElement('input');
188
+ input.type = 'text';
189
+ input.autocomplete = 'off';
190
+ input.spellcheck = false;
191
+ input.placeholder = 'Find text';
192
+ input.setAttribute('aria-label', 'Find query');
193
+ input.style.width = '100%';
194
+ input.style.minWidth = '0';
195
+ input.style.height = '24px';
196
+ input.style.padding = '0';
197
+ input.style.margin = '0';
198
+ input.style.border = 'none';
199
+ input.style.outline = 'none';
200
+ input.style.background = 'transparent';
201
+ input.style.font = '500 13px/1.2 system-ui, sans-serif';
202
+ searchShell.appendChild(input);
203
+
204
+ const status = document.createElement('span');
205
+ status.setAttribute('aria-live', 'polite');
206
+ status.style.minWidth = '36px';
207
+ status.style.textAlign = 'center';
208
+ status.style.font = '500 13px/1.2 system-ui, sans-serif';
209
+ status.style.padding = '0 4px 0 2px';
210
+
211
+ const divider = document.createElement('div');
212
+ divider.setAttribute('aria-hidden', 'true');
213
+ divider.style.width = '1px';
214
+ divider.style.height = '20px';
215
+ divider.style.margin = '0 6px 0 4px';
216
+ divider.style.flex = '0 0 auto';
217
+
218
+ const previousButton = createIconButton('Previous result', 'chevronUp');
219
+ const nextButton = createIconButton('Next result', 'chevronDown');
220
+ const disclosureButton = createIconButton('Show advanced find options', 'filter', 'filter');
221
+ const closeButton = createIconButton('Close find dialog', 'close');
222
+
223
+ const navigationGroup = document.createElement('div');
224
+ navigationGroup.style.display = 'flex';
225
+ navigationGroup.style.alignItems = 'center';
226
+ navigationGroup.style.gap = '2px';
227
+ navigationGroup.append(previousButton, nextButton, disclosureButton, closeButton);
228
+
229
+ topRow.append(searchShell, status, divider, navigationGroup);
230
+
231
+ const advancedPanel = document.createElement('div');
232
+ advancedPanel.style.display = 'none';
233
+ advancedPanel.style.flexDirection = 'column';
234
+ advancedPanel.style.padding = '2px 0';
235
+
236
+ const createToggleRow = (
237
+ key: FindOptionKey,
238
+ label: string,
239
+ ): { readonly button: HTMLButtonElement; readonly track: HTMLSpanElement; readonly thumb: HTMLSpanElement } => {
240
+ const button = document.createElement('button');
241
+ button.type = 'button';
242
+ button.setAttribute('data-ed-find-option', key);
243
+ button.setAttribute('aria-label', label);
244
+ button.setAttribute('role', 'switch');
245
+ button.style.width = '100%';
246
+ button.style.height = '38px';
247
+ button.style.display = 'flex';
248
+ button.style.alignItems = 'center';
249
+ button.style.justifyContent = 'space-between';
250
+ button.style.padding = '0 12px';
251
+ button.style.border = 'none';
252
+ button.style.background = 'transparent';
253
+ button.style.cursor = 'pointer';
254
+ button.style.textAlign = 'left';
255
+ button.style.font = '500 12px/1.2 system-ui, sans-serif';
256
+
257
+ const labelSpan = document.createElement('span');
258
+ labelSpan.textContent = label;
259
+ labelSpan.style.pointerEvents = 'none';
260
+ labelSpan.style.font = 'inherit';
261
+
262
+ const track = document.createElement('span');
263
+ track.setAttribute('aria-hidden', 'true');
264
+ track.style.position = 'relative';
265
+ track.style.display = 'inline-flex';
266
+ track.style.alignItems = 'center';
267
+ track.style.width = '30px';
268
+ track.style.height = '18px';
269
+ track.style.borderRadius = '999px';
270
+ track.style.transition = 'background 120ms ease';
271
+ track.style.pointerEvents = 'none';
272
+
273
+ const thumb = document.createElement('span');
274
+ thumb.style.position = 'absolute';
275
+ thumb.style.top = '2px';
276
+ thumb.style.left = '2px';
277
+ thumb.style.width = '14px';
278
+ thumb.style.height = '14px';
279
+ thumb.style.borderRadius = '999px';
280
+ thumb.style.boxShadow = '0 1px 2px rgba(0, 0, 0, 0.24)';
281
+ thumb.style.transition = 'transform 120ms ease';
282
+ thumb.style.pointerEvents = 'none';
283
+ track.appendChild(thumb);
284
+
285
+ button.append(labelSpan, track);
286
+ return { button, track, thumb };
287
+ };
288
+
289
+ const highlightAllRow = createToggleRow('highlightAll', 'Highlight all');
290
+ const matchCaseRow = createToggleRow('matchCase', 'Match case');
291
+ const matchDiacriticsRow = createToggleRow('matchDiacritics', 'Match diacritics');
292
+ const wholeWordsRow = createToggleRow('wholeWords', 'Match whole word');
293
+ const optionButtons = {
294
+ highlightAll: highlightAllRow.button,
295
+ matchCase: matchCaseRow.button,
296
+ matchDiacritics: matchDiacriticsRow.button,
297
+ wholeWords: wholeWordsRow.button,
298
+ } satisfies Record<FindOptionKey, HTMLButtonElement>;
299
+ const optionTracks = {
300
+ highlightAll: highlightAllRow.track,
301
+ matchCase: matchCaseRow.track,
302
+ matchDiacritics: matchDiacriticsRow.track,
303
+ wholeWords: wholeWordsRow.track,
304
+ } satisfies Record<FindOptionKey, HTMLSpanElement>;
305
+ const optionThumbs = {
306
+ highlightAll: highlightAllRow.thumb,
307
+ matchCase: matchCaseRow.thumb,
308
+ matchDiacritics: matchDiacriticsRow.thumb,
309
+ wholeWords: wholeWordsRow.thumb,
310
+ } satisfies Record<FindOptionKey, HTMLSpanElement>;
311
+ advancedPanel.append(
312
+ optionButtons.highlightAll,
313
+ optionButtons.matchCase,
314
+ optionButtons.matchDiacritics,
315
+ optionButtons.wholeWords,
316
+ );
317
+
318
+ root.append(topRow, advancedPanel);
319
+ parent.appendChild(root);
320
+
321
+ const preserveInputFocus = (): void => {
322
+ this.restoreInputFocus(false);
323
+ };
324
+ const setButtonHovered = (
325
+ button: HTMLButtonElement,
326
+ hovered: boolean,
327
+ ): void => {
328
+ button.dataset.edHovered = hovered ? '1' : '0';
329
+ this.syncStatusUi();
330
+ };
331
+ const bindNonBlurringButton = (
332
+ button: HTMLButtonElement,
333
+ callback: () => void,
334
+ ): void => {
335
+ button.addEventListener('pointerdown', (event) => {
336
+ event.preventDefault();
337
+ });
338
+ button.addEventListener('pointerenter', () => {
339
+ setButtonHovered(button, true);
340
+ });
341
+ button.addEventListener('pointerleave', () => {
342
+ setButtonHovered(button, false);
343
+ });
344
+ button.addEventListener('click', () => {
345
+ callback();
346
+ preserveInputFocus();
347
+ });
348
+ };
349
+
350
+ root.addEventListener('keydown', (event) => {
351
+ event.stopPropagation();
352
+ });
353
+ input.addEventListener('input', () => {
354
+ this.refreshMatches(false);
355
+ });
356
+ input.addEventListener('keydown', (event) => {
357
+ if (event.key === 'Enter') {
358
+ event.preventDefault();
359
+ this.step(event.shiftKey ? -1 : 1);
360
+ return;
361
+ }
362
+ if (event.key === 'ArrowDown' && !event.altKey && !event.ctrlKey && !event.metaKey) {
363
+ event.preventDefault();
364
+ this.step(1);
365
+ return;
366
+ }
367
+ if (event.key === 'ArrowUp' && !event.altKey && !event.ctrlKey && !event.metaKey) {
368
+ event.preventDefault();
369
+ this.step(-1);
370
+ return;
371
+ }
372
+ if (event.key === 'Escape') {
373
+ event.preventDefault();
374
+ this.hide();
375
+ }
376
+ });
377
+ bindNonBlurringButton(previousButton, () => {
378
+ this.step(-1);
379
+ });
380
+ bindNonBlurringButton(nextButton, () => {
381
+ this.step(1);
382
+ });
383
+ bindNonBlurringButton(disclosureButton, () => {
384
+ this.optionsExpanded = !this.optionsExpanded;
385
+ this.syncOptionUi();
386
+ });
387
+ bindNonBlurringButton(closeButton, () => {
388
+ this.hide();
389
+ });
390
+ for (const [key, button] of Object.entries(optionButtons) as [FindOptionKey, HTMLButtonElement][]) {
391
+ bindNonBlurringButton(button, () => {
392
+ this.options = {
393
+ ...this.options,
394
+ [key]: !this.options[key],
395
+ };
396
+ this.syncOptionUi();
397
+ this.refreshMatches(true);
398
+ });
399
+ }
400
+
401
+ this.root = root;
402
+ this.input = input;
403
+ this.status = status;
404
+ this.advancedPanel = advancedPanel;
405
+ this.previousButton = previousButton;
406
+ this.nextButton = nextButton;
407
+ this.disclosureButton = disclosureButton;
408
+ this.closeButton = closeButton;
409
+ this.optionButtons = optionButtons;
410
+ this.optionTracks = optionTracks;
411
+ this.optionThumbs = optionThumbs;
412
+ this.themeMedia = typeof window.matchMedia === 'function'
413
+ ? window.matchMedia('(prefers-color-scheme: dark)')
414
+ : null;
415
+ this.themeMedia?.addEventListener?.('change', this.handleThemeChange);
416
+ this.syncOptionUi();
417
+ this.syncThemeUi();
418
+ }
419
+
420
+ public consumeGlobalKeyEvent(event: KeyboardEvent, type: 'down' | 'up'): boolean {
421
+ if (isMobileBrowser()) {
422
+ return false;
423
+ }
424
+
425
+ if (isFindShortcut(event)) {
426
+ event.preventDefault();
427
+ event.stopPropagation();
428
+ if (type === 'down') {
429
+ this.show();
430
+ }
431
+ return true;
432
+ }
433
+
434
+ if (type === 'down' && this.isOpen() && !event.altKey && !event.ctrlKey && !event.metaKey && event.key === 'F3') {
435
+ event.preventDefault();
436
+ this.step(event.shiftKey ? -1 : 1);
437
+ return true;
438
+ }
439
+
440
+ if (type === 'down' && this.isOpen() && isNextShortcut(event)) {
441
+ event.preventDefault();
442
+ this.step(event.shiftKey ? -1 : 1);
443
+ return true;
444
+ }
445
+
446
+ if (this.containsTarget(event.target)) {
447
+ return true;
448
+ }
449
+
450
+ return false;
451
+ }
452
+
453
+ public containsTarget(target: EventTarget | null): boolean {
454
+ return target instanceof Node && this.root.contains(target);
455
+ }
456
+
457
+ public destroy(): void {
458
+ this.themeMedia?.removeEventListener?.('change', this.handleThemeChange);
459
+ this.root.remove();
460
+ }
461
+
462
+ private getPalette(): FindDialogPalette {
463
+ const darkMode = this.themeMedia?.matches ?? true;
464
+ if (darkMode) {
465
+ return {
466
+ colorScheme: 'dark',
467
+ shellBackground: 'rgba(36, 36, 36, 0.98)',
468
+ shellBorder: 'rgba(255, 255, 255, 0.08)',
469
+ divider: 'rgba(255, 255, 255, 0.12)',
470
+ panelBackground: 'rgba(0, 0, 0, 0.10)',
471
+ text: '#f5f5f5',
472
+ mutedText: '#cfcfcf',
473
+ errorText: '#fca5a5',
474
+ buttonText: '#d9d9d9',
475
+ buttonMutedText: '#7c7c7c',
476
+ buttonActiveBackground: 'rgba(255, 255, 255, 0.08)',
477
+ buttonPlainHoverBackground: 'rgba(255, 255, 255, 0.06)',
478
+ filterButtonBackground: 'transparent',
479
+ filterButtonActiveBackground: 'rgba(255, 255, 255, 0.20)',
480
+ optionHoverBackground: 'rgba(255, 255, 255, 0.03)',
481
+ toggleTrackOff: '#5a5a5a',
482
+ toggleTrackOn: '#818cf8',
483
+ toggleThumb: '#ffffff',
484
+ };
485
+ }
486
+ return {
487
+ colorScheme: 'light',
488
+ shellBackground: 'rgba(251, 251, 251, 0.98)',
489
+ shellBorder: 'rgba(15, 23, 42, 0.12)',
490
+ divider: 'rgba(15, 23, 42, 0.12)',
491
+ panelBackground: 'rgba(15, 23, 42, 0.03)',
492
+ text: '#111827',
493
+ mutedText: '#4b5563',
494
+ errorText: '#dc2626',
495
+ buttonText: '#374151',
496
+ buttonMutedText: '#9ca3af',
497
+ buttonActiveBackground: 'rgba(15, 23, 42, 0.08)',
498
+ buttonPlainHoverBackground: 'rgba(15, 23, 42, 0.05)',
499
+ filterButtonBackground: 'transparent',
500
+ filterButtonActiveBackground: 'rgba(15, 23, 42, 0.16)',
501
+ optionHoverBackground: 'rgba(15, 23, 42, 0.03)',
502
+ toggleTrackOff: '#d1d5db',
503
+ toggleTrackOn: '#818cf8',
504
+ toggleThumb: '#ffffff',
505
+ };
506
+ }
507
+
508
+ private isOpen(): boolean {
509
+ return this.root.style.display !== 'none';
510
+ }
511
+
512
+ private restoreInputFocus(selectAll: boolean): void {
513
+ const selectionStart = this.input.selectionStart ?? this.input.value.length;
514
+ const selectionEnd = this.input.selectionEnd ?? selectionStart;
515
+ queueMicrotask(() => {
516
+ if (!this.isOpen()) {
517
+ return;
518
+ }
519
+ this.input.focus({ preventScroll: true });
520
+ if (selectAll) {
521
+ this.input.select();
522
+ return;
523
+ }
524
+ this.input.setSelectionRange(selectionStart, selectionEnd);
525
+ });
526
+ }
527
+
528
+ private syncThemeUi(): void {
529
+ const palette = this.getPalette();
530
+ this.root.style.colorScheme = palette.colorScheme;
531
+ this.root.style.background = palette.shellBackground;
532
+ this.root.style.borderColor = palette.shellBorder;
533
+ this.root.style.color = palette.text;
534
+ this.advancedPanel.style.background = palette.panelBackground;
535
+ this.advancedPanel.style.borderTop = this.optionsExpanded ? `1px solid ${palette.divider}` : 'none';
536
+ this.input.style.color = palette.text;
537
+ this.input.style.caretColor = palette.text;
538
+ this.status.style.color = palette.mutedText;
539
+ this.previousButton.style.color = palette.buttonText;
540
+ this.nextButton.style.color = palette.buttonText;
541
+ this.closeButton.style.color = palette.buttonText;
542
+ this.disclosureButton.style.color = palette.buttonText;
543
+ this.previousButton.style.background = 'transparent';
544
+ this.nextButton.style.background = 'transparent';
545
+ this.closeButton.style.background = 'transparent';
546
+ this.disclosureButton.style.background = this.optionsExpanded
547
+ ? palette.filterButtonActiveBackground
548
+ : palette.filterButtonBackground;
549
+ this.syncOptionUi();
550
+ this.syncStatusUi();
551
+ }
552
+
553
+ private syncOptionUi(): void {
554
+ const palette = this.getPalette();
555
+ this.root.setAttribute('data-ed-open', this.isOpen() ? '1' : '0');
556
+ this.advancedPanel.style.display = this.optionsExpanded ? 'flex' : 'none';
557
+ this.advancedPanel.style.borderTop = this.optionsExpanded ? `1px solid ${palette.divider}` : 'none';
558
+ this.disclosureButton.setAttribute(
559
+ 'aria-label',
560
+ this.optionsExpanded ? 'Hide advanced find options' : 'Show advanced find options',
561
+ );
562
+ for (const [key, button] of Object.entries(this.optionButtons) as [FindOptionKey, HTMLButtonElement][]) {
563
+ const pressed = this.options[key];
564
+ button.setAttribute('aria-checked', pressed ? 'true' : 'false');
565
+ button.style.color = palette.text;
566
+ button.style.background = 'transparent';
567
+ const track = this.optionTracks[key];
568
+ const thumb = this.optionThumbs[key];
569
+ track.style.background = pressed ? palette.toggleTrackOn : palette.toggleTrackOff;
570
+ thumb.style.background = palette.toggleThumb;
571
+ thumb.style.transform = pressed ? 'translateX(12px)' : 'translateX(0)';
572
+ }
573
+ }
574
+
575
+ private syncStatusUi(): void {
576
+ const palette = this.getPalette();
577
+ const query = this.input.value;
578
+ if (query.length === 0) {
579
+ this.status.textContent = '';
580
+ this.status.style.color = palette.mutedText;
581
+ } else if (this.matches.length === 0 || this.activeMatchIndex < 0) {
582
+ this.status.textContent = '0/0';
583
+ this.status.style.color = palette.errorText;
584
+ } else {
585
+ this.status.textContent = `${String(this.activeMatchIndex + 1)}/${String(this.matches.length)}`;
586
+ this.status.style.color = palette.mutedText;
587
+ }
588
+ const hasMatches = this.matches.length > 0;
589
+ const syncButtonState = (button: HTMLButtonElement, variant: 'plain' | 'filter'): void => {
590
+ button.disabled = !hasMatches && button !== this.disclosureButton && button !== this.closeButton;
591
+ const enabled = !button.disabled;
592
+ const hovered = button.dataset.edHovered === '1';
593
+ button.style.opacity = enabled ? '1' : '0.45';
594
+ button.style.cursor = enabled ? 'pointer' : 'default';
595
+ if (variant === 'filter') {
596
+ if (this.optionsExpanded) {
597
+ button.style.background = palette.filterButtonActiveBackground;
598
+ } else if (enabled && hovered) {
599
+ button.style.background = palette.buttonPlainHoverBackground;
600
+ } else {
601
+ button.style.background = palette.filterButtonBackground;
602
+ }
603
+ } else {
604
+ button.style.background = enabled && hovered
605
+ ? palette.buttonPlainHoverBackground
606
+ : 'transparent';
607
+ }
608
+ button.style.color = enabled ? palette.buttonText : palette.buttonMutedText;
609
+ };
610
+ syncButtonState(this.previousButton, 'plain');
611
+ syncButtonState(this.nextButton, 'plain');
612
+ syncButtonState(this.disclosureButton, 'filter');
613
+ syncButtonState(this.closeButton, 'plain');
614
+ }
615
+
616
+ private show(): void {
617
+ this.root.style.display = 'flex';
618
+ this.root.setAttribute('data-ed-open', '1');
619
+ this.syncThemeUi();
620
+ this.refreshMatches(true);
621
+ this.restoreInputFocus(true);
622
+ }
623
+
624
+ private hide(): void {
625
+ this.root.style.display = 'none';
626
+ this.root.setAttribute('data-ed-open', '0');
627
+ this.previousButton.dataset.edHovered = '0';
628
+ this.nextButton.dataset.edHovered = '0';
629
+ this.disclosureButton.dataset.edHovered = '0';
630
+ this.closeButton.dataset.edHovered = '0';
631
+ this.runtime.openCanvasApi.setFindState(null);
632
+ }
633
+
634
+ private refreshMatches(preserveCurrentMatch: boolean): void {
635
+ const query = this.input.value;
636
+ const previousMatch =
637
+ preserveCurrentMatch && this.activeMatchIndex >= 0
638
+ ? this.matches[this.activeMatchIndex] ?? null
639
+ : null;
640
+ const results = this.runtime.openCanvasApi.findText(query, this.options);
641
+ this.options = { ...results.options };
642
+ this.matches = results.matches.map((match) => ({ ...match }));
643
+ if (this.matches.length === 0) {
644
+ this.activeMatchIndex = -1;
645
+ this.runtime.openCanvasApi.setFindState(null);
646
+ this.syncOptionUi();
647
+ this.syncStatusUi();
648
+ this.restoreInputFocus(false);
649
+ return;
650
+ }
651
+
652
+ if (previousMatch !== null) {
653
+ const preservedIndex = this.matches.findIndex((entry) => sameMatch(entry, previousMatch));
654
+ this.activeMatchIndex = preservedIndex >= 0 ? preservedIndex : 0;
655
+ } else if (this.activeMatchIndex < 0 || this.activeMatchIndex >= this.matches.length) {
656
+ this.activeMatchIndex = 0;
657
+ }
658
+
659
+ this.applyCurrentState(true);
660
+ }
661
+
662
+ private step(delta: 1 | -1): void {
663
+ if (this.matches.length === 0) {
664
+ this.refreshMatches(false);
665
+ return;
666
+ }
667
+ this.activeMatchIndex = (this.activeMatchIndex + delta + this.matches.length) % this.matches.length;
668
+ this.applyCurrentState(true);
669
+ }
670
+
671
+ private applyCurrentState(revealActive: boolean): void {
672
+ const state: OpenCanvasFindState | null =
673
+ this.matches.length === 0 || this.activeMatchIndex < 0 || this.activeMatchIndex >= this.matches.length
674
+ ? null
675
+ : {
676
+ query: this.input.value,
677
+ options: { ...this.options },
678
+ matches: this.matches.map((match) => ({ ...match })),
679
+ activeMatchIndex: this.activeMatchIndex,
680
+ };
681
+ if (state === null || !this.runtime.openCanvasApi.setFindState(state, revealActive)) {
682
+ this.matches = [];
683
+ this.activeMatchIndex = -1;
684
+ this.runtime.openCanvasApi.setFindState(null);
685
+ }
686
+ this.syncOptionUi();
687
+ this.syncStatusUi();
688
+ this.restoreInputFocus(false);
689
+ }
690
+ }