@worthy-ventures/metaglotta-observer 1.0.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.
@@ -0,0 +1,471 @@
1
+ import type { TranslateProps } from '@worthy-ventures/metaglotta-runtime';
2
+ import { isMarked, mark, readMarks, type MarkedKey } from './marks.js';
3
+
4
+ /**
5
+ * Watches the page for marked text, and turns a click on it back into a key.
6
+ *
7
+ * Three jobs, and they are separate on purpose:
8
+ *
9
+ * 1. MARK - every string the runtime produces gets its key appended in invisible characters.
10
+ * This is the `decorate` hook, and it is the only thing that touches the runtime.
11
+ * 2. SCAN - as marked text arrives in the DOM, read the keys out, take the marks back OUT of
12
+ * the node so nothing can copy them into a clipboard, and remember which element the keys
13
+ * belong to.
14
+ * 3. POINT - while the modifier is held, outline whatever is under the cursor and swallow the
15
+ * click, so ALT+clicking a "Delete" button opens the dialog rather than deleting anything.
16
+ *
17
+ * Authoring only. Nothing in a production build should ever import this.
18
+ */
19
+
20
+ export type ModifierName = 'Alt' | 'Control' | 'Shift' | 'Meta';
21
+
22
+ export type KeyPosition = {
23
+ key: string;
24
+ ns: string;
25
+ position: { x: number; y: number; width: number; height: number };
26
+ };
27
+
28
+ export type ObserverOptions = {
29
+ /** What to do with an armed click. The target is the element the keys were found on. */
30
+ onClick: (keys: MarkedKey[], target: HTMLElement) => void;
31
+ /** Held to arm it. All of them, if more than one. Default: Alt. */
32
+ keys?: ModifierName[];
33
+ /** Attributes whose value may be a translation. */
34
+ attributes?: string[];
35
+ /** Where to watch. Default: the whole document. */
36
+ root?: Element | Document;
37
+ /** A subtree to leave alone entirely - the editing dialog's own, for one. */
38
+ ignore?: (element: Element) => boolean;
39
+ highlightColor?: string;
40
+ highlightWidth?: number;
41
+ };
42
+
43
+ const MODIFIER_FLAG: Record<ModifierName, 'altKey' | 'ctrlKey' | 'shiftKey' | 'metaKey'> = {
44
+ Alt: 'altKey',
45
+ Control: 'ctrlKey',
46
+ Shift: 'shiftKey',
47
+ Meta: 'metaKey',
48
+ };
49
+
50
+ /** Where a translation plausibly lands other than in text. */
51
+ const DEFAULT_ATTRIBUTES = ['title', 'placeholder', 'aria-label', 'alt', 'value', 'label'];
52
+
53
+ /** Never worth walking into: their text is not for reading. */
54
+ const SKIP_TAGS = new Set(['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEMPLATE', 'TEXTAREA']);
55
+
56
+ const HIGHLIGHT_CLASS = 'mg-observer-highlight';
57
+
58
+ /**
59
+ * How many observers are watching, so a second one can say so.
60
+ *
61
+ * Two on the same document silently break each other: marks are taken OUT of a node as it is
62
+ * scanned, so whichever observer's mutation callback runs first absorbs them and the other
63
+ * registers nothing at all. ALT+click then does nothing, with no error to explain it.
64
+ *
65
+ * One page only ever has one. Where this bites is a hot reload, or a test that forgot to stop
66
+ * the previous instance - both of which look like the feature is broken rather than
67
+ * double-installed.
68
+ */
69
+ let watching = 0;
70
+
71
+ type Registered = {
72
+ element: HTMLElement;
73
+ /** Per node, because one element can hold several marked nodes and lose them separately. */
74
+ nodes: Map<Node, MarkedKey[]>;
75
+ outline?: HTMLElement;
76
+ };
77
+
78
+ export function createObserver(options: ObserverOptions) {
79
+ const armWith = options.keys ?? ['Alt'];
80
+ const attributes = options.attributes ?? DEFAULT_ATTRIBUTES;
81
+ const root = options.root ?? document;
82
+ /**
83
+ * Tech Blue, the brand accent.
84
+ *
85
+ * An outline is one of the few places the light blue is right: it sits on top of the page
86
+ * rather than under text, so its 2.14:1 against white does not matter - what matters is
87
+ * that it reads as ours. Pass highlightColor to override it on a page it disappears into.
88
+ */
89
+ const highlightColor = options.highlightColor ?? '#38bdf8';
90
+ const highlightWidth = options.highlightWidth ?? 2;
91
+
92
+ const registry = new Map<HTMLElement, Registered>();
93
+ const held = new Set<ModifierName>();
94
+
95
+ let mutations: MutationObserver | undefined;
96
+ let listeners: (() => void)[] = [];
97
+ let cursor: { x: number; y: number } | undefined;
98
+ let outlined: HTMLElement | undefined;
99
+
100
+ const ignored = (element: Element): boolean => Boolean(options.ignore?.(element)) || SKIP_TAGS.has(element.tagName);
101
+
102
+ const withinIgnored = (node: Node): boolean => {
103
+ let at: Node | null = node;
104
+ while (at) {
105
+ if (at.nodeType === Node.ELEMENT_NODE && ignored(at as Element)) return true;
106
+ at = at.parentNode;
107
+ }
108
+ return false;
109
+ };
110
+
111
+ // ---- 2. scan ----------------------------------------------------------------------
112
+
113
+ function register(element: HTMLElement, node: Node, keys: MarkedKey[]): void {
114
+ let entry = registry.get(element);
115
+ if (!entry) {
116
+ entry = { element, nodes: new Map() };
117
+ registry.set(element, entry);
118
+ }
119
+ entry.nodes.set(node, keys);
120
+ }
121
+
122
+ /**
123
+ * Reads the keys out and puts the visible text back.
124
+ *
125
+ * Stripping is not tidiness. Marks left in the DOM are copied by Ctrl+C, submitted in
126
+ * form values, and compared against by application code that has no idea they are there -
127
+ * and because they are invisible, the resulting bug looks like magic. Writing the node
128
+ * back triggers another mutation, which is harmless: the text is no longer marked, so the
129
+ * next pass skips it.
130
+ */
131
+ function absorb(node: Text | Attr): void {
132
+ const raw = node.nodeValue;
133
+ if (!raw || !isMarked(raw)) return;
134
+
135
+ const owner = node.nodeType === Node.ATTRIBUTE_NODE ? (node as Attr).ownerElement : node.parentElement;
136
+ if (!owner || withinIgnored(owner)) return;
137
+
138
+ const { text, keys } = readMarks(raw);
139
+ node.nodeValue = text;
140
+ if (keys.length) register(owner as HTMLElement, node, keys);
141
+ }
142
+
143
+ function scan(node: Node): void {
144
+ if (node.nodeType === Node.TEXT_NODE) {
145
+ absorb(node as Text);
146
+ return;
147
+ }
148
+ if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_NODE) return;
149
+ if (node.nodeType === Node.ELEMENT_NODE && ignored(node as Element)) return;
150
+
151
+ const elements = [node as Element, ...Array.from((node as Element).querySelectorAll?.('*') ?? [])];
152
+ for (const element of elements) {
153
+ if (element.nodeType !== Node.ELEMENT_NODE || ignored(element)) continue;
154
+ for (const name of attributes) {
155
+ const attribute = element.getAttributeNode?.(name);
156
+ if (attribute) absorb(attribute);
157
+ }
158
+ }
159
+
160
+ const walker = document.createTreeWalker(node, NodeFilter.SHOW_TEXT, {
161
+ acceptNode: text =>
162
+ isMarked(text.nodeValue ?? '') && !SKIP_TAGS.has(text.parentElement?.tagName ?? '')
163
+ ? NodeFilter.FILTER_ACCEPT
164
+ : NodeFilter.FILTER_REJECT,
165
+ });
166
+ const found: Text[] = [];
167
+ while (walker.nextNode()) found.push(walker.currentNode as Text);
168
+ for (const text of found) absorb(text);
169
+ }
170
+
171
+ /**
172
+ * Forgets what has left the page.
173
+ *
174
+ * Without this the registry holds every element the page has ever rendered - a leak that
175
+ * also makes findPositions() report boxes for text nobody can see.
176
+ */
177
+ function forget(): void {
178
+ for (const [element, entry] of registry) {
179
+ if (!element.isConnected) {
180
+ unoutline(element);
181
+ registry.delete(element);
182
+ continue;
183
+ }
184
+ for (const node of entry.nodes.keys()) {
185
+ const stillThere = node.nodeType === Node.ATTRIBUTE_NODE ? (node as Attr).ownerElement?.isConnected : node.isConnected;
186
+ if (!stillThere) entry.nodes.delete(node);
187
+ }
188
+ if (entry.nodes.size === 0) {
189
+ unoutline(element);
190
+ registry.delete(element);
191
+ }
192
+ }
193
+ }
194
+
195
+ // ---- 3. point ---------------------------------------------------------------------
196
+
197
+ function armed(): boolean {
198
+ return armWith.every(key => held.has(key));
199
+ }
200
+
201
+ function trackModifiers(event: MouseEvent | KeyboardEvent): void {
202
+ for (const name of Object.keys(MODIFIER_FLAG) as ModifierName[]) {
203
+ if (event[MODIFIER_FLAG[name]]) held.add(name);
204
+ else held.delete(name);
205
+ }
206
+ }
207
+
208
+ function registeredAt(x: number, y: number): HTMLElement | undefined {
209
+ for (const candidate of document.elementsFromPoint(x, y)) {
210
+ let at: Element | null = candidate;
211
+ while (at) {
212
+ if (ignored(at)) return undefined;
213
+ if (registry.has(at as HTMLElement)) return at as HTMLElement;
214
+ at = at.parentElement;
215
+ }
216
+ }
217
+ return undefined;
218
+ }
219
+
220
+ function outline(element: HTMLElement | undefined): void {
221
+ if (outlined === element) {
222
+ if (element) position(element);
223
+ return;
224
+ }
225
+ if (outlined) unoutline(outlined);
226
+ outlined = element;
227
+ if (element) position(element);
228
+ }
229
+
230
+ function position(element: HTMLElement): void {
231
+ const entry = registry.get(element);
232
+ if (!entry || !element.isConnected) return;
233
+
234
+ if (!entry.outline) {
235
+ const box = document.createElement('div');
236
+ box.className = HIGHLIGHT_CLASS;
237
+ Object.assign(box.style, {
238
+ position: 'fixed',
239
+ boxSizing: 'content-box',
240
+ pointerEvents: 'none',
241
+ zIndex: String(Number.MAX_SAFE_INTEGER),
242
+ borderStyle: 'solid',
243
+ borderRadius: '4px',
244
+ borderColor: highlightColor,
245
+ borderWidth: `${highlightWidth}px`,
246
+ contain: 'layout',
247
+ });
248
+ document.body.appendChild(box);
249
+ entry.outline = box;
250
+ }
251
+
252
+ const shape = element.getBoundingClientRect();
253
+ Object.assign(entry.outline.style, {
254
+ top: `${shape.top - highlightWidth}px`,
255
+ left: `${shape.left - highlightWidth}px`,
256
+ width: `${shape.width}px`,
257
+ height: `${shape.height}px`,
258
+ });
259
+ }
260
+
261
+ function unoutline(element: HTMLElement): void {
262
+ const entry = registry.get(element);
263
+ entry?.outline?.remove();
264
+ if (entry) entry.outline = undefined;
265
+ if (outlined === element) outlined = undefined;
266
+ }
267
+
268
+ function refresh(): void {
269
+ outline(armed() && cursor ? registeredAt(cursor.x, cursor.y) : undefined);
270
+ }
271
+
272
+ /**
273
+ * Swallows the page's own interaction while armed.
274
+ *
275
+ * ALT+clicking a "Delete patient" button has to open the dialog and nothing else, so the
276
+ * event is stopped in the capture phase - before the application's own listener - and on
277
+ * every mouse event, not only click: a hover handler that opens a menu would otherwise
278
+ * fire and cover the thing being clicked.
279
+ */
280
+ function swallow(event: MouseEvent): void {
281
+ trackModifiers(event);
282
+ if (!armed()) return;
283
+ const target = event.target as Element | null;
284
+ if (target && withinIgnored(target)) return;
285
+ event.stopPropagation();
286
+ event.preventDefault();
287
+ }
288
+
289
+ function onClick(event: MouseEvent): void {
290
+ swallow(event);
291
+ cursor = { x: event.clientX, y: event.clientY };
292
+ if (!armed()) return;
293
+
294
+ const element = registeredAt(event.clientX, event.clientY);
295
+ if (!element) return;
296
+
297
+ const keys = [...(registry.get(element)?.nodes.values() ?? [])].flat();
298
+ if (!keys.length) return;
299
+
300
+ outline(undefined);
301
+ options.onClick(keys, element);
302
+ }
303
+
304
+ function listen(): void {
305
+ const on = <K extends keyof DocumentEventMap>(type: K, handler: (event: DocumentEventMap[K]) => void, passive = false) => {
306
+ const wrapped = handler as EventListener;
307
+ document.addEventListener(type, wrapped, { capture: true, passive });
308
+ listeners.push(() => document.removeEventListener(type, wrapped, { capture: true }));
309
+ };
310
+
311
+ on('keydown', event => {
312
+ trackModifiers(event);
313
+ refresh();
314
+ });
315
+ on('keyup', event => {
316
+ trackModifiers(event);
317
+ refresh();
318
+ });
319
+ on('mousemove', event => {
320
+ trackModifiers(event);
321
+ cursor = { x: event.clientX, y: event.clientY };
322
+ refresh();
323
+ }, true);
324
+ on('scroll', () => refresh(), true);
325
+ on('click', onClick);
326
+ for (const type of ['mousedown', 'mouseup', 'mouseover', 'mouseout', 'mouseenter', 'mouseleave'] as const) {
327
+ on(type, swallow);
328
+ }
329
+ // A window that loses focus never sees the keyup, so the modifier would stay held.
330
+ const onBlur = () => {
331
+ held.clear();
332
+ refresh();
333
+ };
334
+ window.addEventListener('blur', onBlur);
335
+ listeners.push(() => window.removeEventListener('blur', onBlur));
336
+ }
337
+
338
+ // ---- the public surface -----------------------------------------------------------
339
+
340
+ return {
341
+ /**
342
+ * Pass as `decorate` to the runtime. Everything else follows from this.
343
+ *
344
+ * The namespace comes from the runtime, not from `props`: props hold what the caller
345
+ * asked with, which for most templates is nothing at all, while this is where the
346
+ * string was actually resolved from. Marking with the former is what made the dialog
347
+ * open on the default namespace with nothing in it.
348
+ */
349
+ mark: (result: string, props: TranslateProps, namespace: string): string =>
350
+ mark(result, { key: props.key, ns: namespace || undefined, defaultValue: props.defaultValue }),
351
+
352
+ run(): void {
353
+ if (mutations) return;
354
+
355
+ watching += 1;
356
+ if (watching > 1) {
357
+ console.warn(
358
+ `Metaglotta: ${watching} in-context observers are running at once. They will take each other's marks and ALT+click will stop finding keys - stop the previous one.`
359
+ );
360
+ }
361
+
362
+ scan(root as Node);
363
+ listen();
364
+
365
+ mutations = new MutationObserver(records => {
366
+ let anythingRemoved = false;
367
+ for (const record of records) {
368
+ if (record.type === 'characterData') scan(record.target);
369
+ if (record.type === 'attributes') scan(record.target);
370
+ if (record.type === 'childList') {
371
+ record.addedNodes.forEach(node => scan(node));
372
+ anythingRemoved ||= record.removedNodes.length > 0;
373
+ }
374
+ }
375
+ // Swept by what is still connected rather than by what was reported: a node
376
+ // moved and a node deleted look the same in a mutation record, and only the
377
+ // DOM knows which happened.
378
+ if (anythingRemoved) forget();
379
+ });
380
+
381
+ mutations.observe(root as Node, {
382
+ childList: true,
383
+ subtree: true,
384
+ characterData: true,
385
+ attributes: true,
386
+ attributeFilter: attributes,
387
+ });
388
+ },
389
+
390
+ stop(): void {
391
+ if (mutations) watching -= 1;
392
+ mutations?.disconnect();
393
+ mutations = undefined;
394
+ for (const off of listeners) off();
395
+ listeners = [];
396
+ for (const element of [...registry.keys()]) unoutline(element);
397
+ registry.clear();
398
+ held.clear();
399
+ cursor = undefined;
400
+ },
401
+
402
+ /**
403
+ * Where a key is on screen, in viewport coordinates.
404
+ *
405
+ * This is what a screenshot's boxes are drawn from, so the order matters: sorted by
406
+ * position in the document, because the dialog labels them in the order it gets them
407
+ * and a reader expects the first box to be the first occurrence.
408
+ */
409
+ findPositions(key?: string, ns?: string): KeyPosition[] {
410
+ const matching = [...registry.values()].filter(entry => [...entry.nodes.values()].flat().some(found => matches(found, key, ns)));
411
+
412
+ matching.sort((a, b) =>
413
+ a.element.compareDocumentPosition(b.element) & Node.DOCUMENT_POSITION_FOLLOWING ? -1 : 1
414
+ );
415
+
416
+ const positions: KeyPosition[] = [];
417
+ for (const entry of matching) {
418
+ const shape = entry.element.getBoundingClientRect();
419
+ for (const found of [...entry.nodes.values()].flat()) {
420
+ if (!matches(found, key, ns)) continue;
421
+ positions.push({
422
+ key: found.key,
423
+ ns: found.ns ?? '',
424
+ position: { x: shape.x, y: shape.y, width: shape.width, height: shape.height },
425
+ });
426
+ }
427
+ }
428
+ return positions;
429
+ },
430
+
431
+ /** Outlines every place a key appears. Returns how to put it back. */
432
+ highlight(key?: string, ns?: string): { unhighlight: () => void } {
433
+ const elements = [...registry.values()]
434
+ .filter(entry => [...entry.nodes.values()].flat().some(found => matches(found, key, ns)))
435
+ .map(entry => entry.element);
436
+
437
+ for (const element of elements) position(element);
438
+ return {
439
+ unhighlight: () => {
440
+ for (const element of elements) unoutline(element);
441
+ },
442
+ };
443
+ },
444
+
445
+ /**
446
+ * Hides the outlines and hands back how to restore them.
447
+ *
448
+ * For screenshots: the outlines are fixed-position divs on the body, so they would be
449
+ * captured over the very text being photographed.
450
+ */
451
+ hideOutlines(): () => void {
452
+ const hidden = [...registry.values()].map(entry => entry.outline).filter((box): box is HTMLElement => Boolean(box));
453
+ for (const box of hidden) box.style.visibility = 'hidden';
454
+ return () => {
455
+ for (const box of hidden) box.style.visibility = '';
456
+ };
457
+ },
458
+
459
+ /** Test seam. */
460
+ registeredCount: (): number => registry.size,
461
+ };
462
+ }
463
+
464
+ export type Observer = ReturnType<typeof createObserver>;
465
+
466
+ /** Undefined on either side means "any", which is how the dialog asks for everything. */
467
+ function matches(found: MarkedKey, key?: string, ns?: string): boolean {
468
+ if (key !== undefined && found.key !== key) return false;
469
+ if (ns !== undefined && (found.ns ?? '') !== ns) return false;
470
+ return true;
471
+ }