@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.
- package/dist/index.d.ts +4 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/marks.d.ts +50 -0
- package/dist/marks.js +122 -0
- package/dist/marks.js.map +1 -0
- package/dist/observer.d.ts +77 -0
- package/dist/observer.js +408 -0
- package/dist/observer.js.map +1 -0
- package/dist-esm/index.js +3 -0
- package/dist-esm/index.js.map +1 -0
- package/dist-esm/marks.js +115 -0
- package/dist-esm/marks.js.map +1 -0
- package/dist-esm/observer.js +405 -0
- package/dist-esm/observer.js.map +1 -0
- package/dist-esm/package.json +1 -0
- package/package.json +35 -0
- package/src/index.ts +4 -0
- package/src/marks.test.ts +125 -0
- package/src/marks.ts +128 -0
- package/src/observer.test.ts +522 -0
- package/src/observer.ts +471 -0
package/src/observer.ts
ADDED
|
@@ -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
|
+
}
|