@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/marks.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Carrying a key inside the string it produced.
|
|
3
|
+
*
|
|
4
|
+
* The problem: a click lands on a DOM node, and nothing about that node says which
|
|
5
|
+
* translation key rendered it. The string may have been interpolated into a text node,
|
|
6
|
+
* concatenated with another, put in a `title` attribute, or handed to a chart library - 938
|
|
7
|
+
* call sites in these applications do the last one, and no amount of framework integration
|
|
8
|
+
* can follow a string somebody put in a canvas.
|
|
9
|
+
*
|
|
10
|
+
* So the key travels WITH the string, written in characters that occupy no space: a zero-width
|
|
11
|
+
* non-joiner for 0 and a zero-width joiner for 1. Appended to the rendered text, they are
|
|
12
|
+
* invisible, they survive concatenation and interpolation, and they can be read back out of
|
|
13
|
+
* `textContent` wherever the string ended up.
|
|
14
|
+
*
|
|
15
|
+
* Two things make that practical rather than merely clever:
|
|
16
|
+
*
|
|
17
|
+
* - Keys are INTERNED. Encoding `{"k":"appointment_payment_amount_mismatch","n":"reception"}`
|
|
18
|
+
* would add ~500 invisible characters to a string of thirty, and the DOM would be mostly
|
|
19
|
+
* marks. Instead each distinct key gets a number, and only the number is encoded - one to
|
|
20
|
+
* three digits. The table lives in this module for the life of the page, which is exactly
|
|
21
|
+
* as long as the marks in the DOM do.
|
|
22
|
+
* - Each byte is written as NINE characters, eight bits and a trailing zero. That makes an
|
|
23
|
+
* encoded run a multiple of nine, which is how a run of marks is told apart from the odd
|
|
24
|
+
* zero-width character that turns up in real text (Persian and Hindi both use them, and
|
|
25
|
+
* emoji sequences are full of joiners).
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** 0 and 1. Zero-width, so they render as nothing at all. */
|
|
29
|
+
const ZERO = '';
|
|
30
|
+
const ONE = '';
|
|
31
|
+
|
|
32
|
+
/** Between two encoded numbers, when two marked strings ended up in one node. */
|
|
33
|
+
const SEPARATOR = '\n';
|
|
34
|
+
|
|
35
|
+
const BITS_PER_BYTE = 9;
|
|
36
|
+
|
|
37
|
+
const RUN = new RegExp(`(?:[${ZERO}${ONE}]{${BITS_PER_BYTE}})+`, 'g');
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Keys seen this page, so only an index has to be encoded.
|
|
41
|
+
*
|
|
42
|
+
* Module-level and never pruned, which is right: an entry costs a string, and a mark in the
|
|
43
|
+
* DOM is only meaningful while the page that wrote it is still open.
|
|
44
|
+
*/
|
|
45
|
+
const interned: string[] = [];
|
|
46
|
+
|
|
47
|
+
function intern(value: string): number {
|
|
48
|
+
const existing = interned.indexOf(value);
|
|
49
|
+
if (existing !== -1) return existing;
|
|
50
|
+
interned.push(value);
|
|
51
|
+
return interned.length - 1;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* ASCII only, deliberately.
|
|
56
|
+
*
|
|
57
|
+
* What gets encoded is never the key itself - it is an index and a separator, so digits and
|
|
58
|
+
* one newline. That means no TextEncoder, which jsdom does not always provide, and no
|
|
59
|
+
* multi-byte handling to get wrong. The key's own characters live in the interning table,
|
|
60
|
+
* where they are just a JavaScript string.
|
|
61
|
+
*/
|
|
62
|
+
function encodeText(text: string): string {
|
|
63
|
+
let bits = '';
|
|
64
|
+
for (let at = 0; at < text.length; at += 1) {
|
|
65
|
+
bits += (text.charCodeAt(at) & 0x7f).toString(2).padStart(8, '0') + '0';
|
|
66
|
+
}
|
|
67
|
+
return [...bits].map(bit => (bit === '1' ? ONE : ZERO)).join('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function decodeText(marks: string): string {
|
|
71
|
+
let text = '';
|
|
72
|
+
for (let at = 0; at + BITS_PER_BYTE <= marks.length; at += BITS_PER_BYTE) {
|
|
73
|
+
const bits = [...marks.slice(at, at + 8)].map(character => (character === ONE ? '1' : '0')).join('');
|
|
74
|
+
text += String.fromCharCode(parseInt(bits, 2));
|
|
75
|
+
}
|
|
76
|
+
return text;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type MarkedKey = { key: string; ns?: string; defaultValue?: string };
|
|
80
|
+
|
|
81
|
+
/** What a marked string carries: an index into the table, or several. */
|
|
82
|
+
export function mark(text: string, key: MarkedKey): string {
|
|
83
|
+
return text + encodeText(String(intern(JSON.stringify([key.key, key.ns ?? '', key.defaultValue ?? '']))) + SEPARATOR);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Whether a string is worth decoding, cheaply enough to run on every text node. */
|
|
87
|
+
export function isMarked(text: string): boolean {
|
|
88
|
+
return text.includes(ZERO) || text.includes(ONE);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The text as a person sees it, with the marks taken out. */
|
|
92
|
+
export function unmark(text: string): string {
|
|
93
|
+
return text.replace(RUN, '');
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The keys a string carries, and the string without them.
|
|
98
|
+
*
|
|
99
|
+
* Anything that does not decode to a known index is ignored rather than reported: real text
|
|
100
|
+
* contains zero-width characters for its own reasons, and a run that happens to be a multiple
|
|
101
|
+
* of nine is a coincidence this must survive rather than a message.
|
|
102
|
+
*/
|
|
103
|
+
export function readMarks(text: string): { text: string; keys: MarkedKey[] } {
|
|
104
|
+
const keys: MarkedKey[] = [];
|
|
105
|
+
|
|
106
|
+
for (const run of text.match(RUN) ?? []) {
|
|
107
|
+
for (const encoded of decodeText(run).split(SEPARATOR)) {
|
|
108
|
+
if (!encoded) continue;
|
|
109
|
+
const index = Number(encoded);
|
|
110
|
+
if (!Number.isInteger(index) || index < 0 || index >= interned.length) continue;
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const [key, ns, defaultValue] = JSON.parse(interned[index]!) as [string, string, string];
|
|
114
|
+
keys.push({ key, ns: ns || undefined, defaultValue: defaultValue || undefined });
|
|
115
|
+
} catch {
|
|
116
|
+
// An entry this module wrote cannot fail to parse; if it somehow does, the
|
|
117
|
+
// right answer is one fewer key rather than a broken page.
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { text: unmark(text), keys };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Test seam: the table is a module global by design, and lives as long as the page. */
|
|
126
|
+
export function forgetInterned(): void {
|
|
127
|
+
interned.length = 0;
|
|
128
|
+
}
|
|
@@ -0,0 +1,522 @@
|
|
|
1
|
+
import { forgetInterned } from './marks.js';
|
|
2
|
+
import { createObserver, type Observer } from './observer.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The observer, against a real DOM.
|
|
6
|
+
*
|
|
7
|
+
* jsdom does the useful half here - text nodes, attributes, MutationObserver, TreeWalker are
|
|
8
|
+
* all real. What it does not do is lay anything out, so getBoundingClientRect returns zeros
|
|
9
|
+
* and elementsFromPoint returns nothing; those two are stubbed per test, and every assertion
|
|
10
|
+
* that depends on them says so.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
type Clicked = { keys: { key: string; ns?: string; defaultValue?: string }[]; target: HTMLElement };
|
|
14
|
+
|
|
15
|
+
let observer: Observer | undefined;
|
|
16
|
+
let clicks: Clicked[];
|
|
17
|
+
|
|
18
|
+
/** A macrotask: MutationObserver callbacks are microtasks, one hop is enough. */
|
|
19
|
+
const settle = () => new Promise(resolve => setTimeout(resolve, 0));
|
|
20
|
+
|
|
21
|
+
function start(options: Partial<Parameters<typeof createObserver>[0]> = {}): Observer {
|
|
22
|
+
clicks = [];
|
|
23
|
+
observer = createObserver({
|
|
24
|
+
onClick: (keys, target) => clicks.push({ keys, target }),
|
|
25
|
+
...options,
|
|
26
|
+
});
|
|
27
|
+
observer.run();
|
|
28
|
+
return observer;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* What the runtime would have produced for this key.
|
|
33
|
+
*
|
|
34
|
+
* The namespace is a separate argument because that is how the runtime supplies it - it is
|
|
35
|
+
* where the string was resolved from, which is not something the caller's props know.
|
|
36
|
+
*/
|
|
37
|
+
function rendered(text: string, key: string, ns = ''): string {
|
|
38
|
+
return observer!.mark(text, { key } as never, ns);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
document.body.innerHTML = '';
|
|
43
|
+
forgetInterned();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
observer?.stop();
|
|
48
|
+
observer = undefined;
|
|
49
|
+
document.body.innerHTML = '';
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Two observers on one document take each other's marks: the first to scan a node strips
|
|
54
|
+
* them, so the second registers nothing and ALT+click quietly stops working. It cannot happen
|
|
55
|
+
* with one bootstrap; it happens constantly with a hot reload or a test that forgot to stop.
|
|
56
|
+
*/
|
|
57
|
+
describe('more than one at a time', () => {
|
|
58
|
+
it('says so rather than failing silently', () => {
|
|
59
|
+
const warnings: unknown[] = [];
|
|
60
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(message => warnings.push(message));
|
|
61
|
+
const first = createObserver({ onClick: () => undefined });
|
|
62
|
+
const second = createObserver({ onClick: () => undefined });
|
|
63
|
+
try {
|
|
64
|
+
first.run();
|
|
65
|
+
expect(warnings).toHaveLength(0);
|
|
66
|
+
|
|
67
|
+
second.run();
|
|
68
|
+
expect(String(warnings[0])).toContain('observers are running at once');
|
|
69
|
+
} finally {
|
|
70
|
+
first.stop();
|
|
71
|
+
second.stop();
|
|
72
|
+
warn.mockRestore();
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('counts down again, so a stopped one is not held against the next', () => {
|
|
77
|
+
const warnings: unknown[] = [];
|
|
78
|
+
const warn = jest.spyOn(console, 'warn').mockImplementation(message => warnings.push(message));
|
|
79
|
+
try {
|
|
80
|
+
const first = createObserver({ onClick: () => undefined });
|
|
81
|
+
first.run();
|
|
82
|
+
first.stop();
|
|
83
|
+
|
|
84
|
+
const second = createObserver({ onClick: () => undefined });
|
|
85
|
+
second.run();
|
|
86
|
+
second.stop();
|
|
87
|
+
|
|
88
|
+
expect(warnings).toHaveLength(0);
|
|
89
|
+
} finally {
|
|
90
|
+
warn.mockRestore();
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe('scanning', () => {
|
|
96
|
+
it('finds a key in text that was already on the page', async () => {
|
|
97
|
+
const o = createObserver({ onClick: () => undefined });
|
|
98
|
+
const span = document.createElement('span');
|
|
99
|
+
span.textContent = o.mark('Hello', { key: 'greeting' } as never, '');
|
|
100
|
+
document.body.appendChild(span);
|
|
101
|
+
|
|
102
|
+
o.run();
|
|
103
|
+
try {
|
|
104
|
+
expect(o.registeredCount()).toBe(1);
|
|
105
|
+
expect(o.findPositions('greeting')).toHaveLength(1);
|
|
106
|
+
} finally {
|
|
107
|
+
o.stop();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it('finds a key in text added afterwards', async () => {
|
|
112
|
+
const o = start();
|
|
113
|
+
const span = document.createElement('span');
|
|
114
|
+
span.textContent = rendered('Hello', 'greeting');
|
|
115
|
+
document.body.appendChild(span);
|
|
116
|
+
|
|
117
|
+
await settle();
|
|
118
|
+
|
|
119
|
+
expect(o.registeredCount()).toBe(1);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The marks must not stay in the DOM. They are copied by Ctrl+C, submitted inside form
|
|
124
|
+
* values, and compared against by code that cannot see them - and an invisible character
|
|
125
|
+
* causing `if (text === 'Save')` to fail is about as hard a bug as there is.
|
|
126
|
+
*/
|
|
127
|
+
it('takes the marks back out of the node', async () => {
|
|
128
|
+
start();
|
|
129
|
+
const span = document.createElement('span');
|
|
130
|
+
span.textContent = rendered('Hello', 'greeting');
|
|
131
|
+
document.body.appendChild(span);
|
|
132
|
+
|
|
133
|
+
await settle();
|
|
134
|
+
|
|
135
|
+
expect(span.textContent).toBe('Hello');
|
|
136
|
+
expect(document.body.textContent).toBe('Hello');
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
it('reads a key out of an attribute', async () => {
|
|
140
|
+
const o = start();
|
|
141
|
+
const input = document.createElement('input');
|
|
142
|
+
input.setAttribute('placeholder', rendered('Your name', 'name_placeholder'));
|
|
143
|
+
document.body.appendChild(input);
|
|
144
|
+
|
|
145
|
+
await settle();
|
|
146
|
+
|
|
147
|
+
expect(input.getAttribute('placeholder')).toBe('Your name');
|
|
148
|
+
expect(o.findPositions('name_placeholder')).toHaveLength(1);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('registers both keys when one element holds two', async () => {
|
|
152
|
+
const o = start();
|
|
153
|
+
const span = document.createElement('span');
|
|
154
|
+
span.append(document.createTextNode(rendered('Hello', 'greeting')), document.createTextNode(rendered('Ada', 'name')));
|
|
155
|
+
document.body.appendChild(span);
|
|
156
|
+
|
|
157
|
+
await settle();
|
|
158
|
+
|
|
159
|
+
expect(o.registeredCount()).toBe(1);
|
|
160
|
+
expect(o.findPositions('greeting')).toHaveLength(1);
|
|
161
|
+
expect(o.findPositions('name')).toHaveLength(1);
|
|
162
|
+
expect(o.findPositions()).toHaveLength(2);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('leaves unmarked text alone', async () => {
|
|
166
|
+
const o = start();
|
|
167
|
+
document.body.innerHTML = '<span>Just text</span>';
|
|
168
|
+
|
|
169
|
+
await settle();
|
|
170
|
+
|
|
171
|
+
expect(o.registeredCount()).toBe(0);
|
|
172
|
+
expect(document.body.textContent).toBe('Just text');
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it('does not walk into a subtree it was told to ignore', async () => {
|
|
176
|
+
const dialog = document.createElement('div');
|
|
177
|
+
dialog.id = 'the-dialog';
|
|
178
|
+
const o = start({ ignore: element => element.id === 'the-dialog' });
|
|
179
|
+
|
|
180
|
+
dialog.textContent = rendered('Hello', 'greeting');
|
|
181
|
+
document.body.appendChild(dialog);
|
|
182
|
+
await settle();
|
|
183
|
+
|
|
184
|
+
expect(o.registeredCount()).toBe(0);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('ignores text inside a script tag', async () => {
|
|
188
|
+
const o = start();
|
|
189
|
+
const script = document.createElement('script');
|
|
190
|
+
// Not executable: jsdom runs a bare <script>, and the marked text is not JavaScript.
|
|
191
|
+
script.type = 'text/plain';
|
|
192
|
+
script.textContent = rendered('Hello', 'greeting');
|
|
193
|
+
document.body.appendChild(script);
|
|
194
|
+
|
|
195
|
+
await settle();
|
|
196
|
+
|
|
197
|
+
expect(o.registeredCount()).toBe(0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
/** Stripping writes the node, which is itself a mutation. It must not feed itself. */
|
|
201
|
+
it('settles rather than looping when it strips a node', async () => {
|
|
202
|
+
const o = start();
|
|
203
|
+
const span = document.createElement('span');
|
|
204
|
+
span.textContent = rendered('Hello', 'greeting');
|
|
205
|
+
document.body.appendChild(span);
|
|
206
|
+
|
|
207
|
+
await settle();
|
|
208
|
+
await settle();
|
|
209
|
+
await settle();
|
|
210
|
+
|
|
211
|
+
expect(span.textContent).toBe('Hello');
|
|
212
|
+
expect(o.registeredCount()).toBe(1);
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
it('picks up text that replaced other text', async () => {
|
|
216
|
+
const o = start();
|
|
217
|
+
const span = document.createElement('span');
|
|
218
|
+
span.textContent = rendered('Hello', 'greeting');
|
|
219
|
+
document.body.appendChild(span);
|
|
220
|
+
await settle();
|
|
221
|
+
|
|
222
|
+
span.textContent = rendered('Γεια', 'greeting');
|
|
223
|
+
await settle();
|
|
224
|
+
|
|
225
|
+
expect(span.textContent).toBe('Γεια');
|
|
226
|
+
expect(o.findPositions('greeting')).toHaveLength(1);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
describe('forgetting', () => {
|
|
231
|
+
it('drops an element that left the page', async () => {
|
|
232
|
+
const o = start();
|
|
233
|
+
const span = document.createElement('span');
|
|
234
|
+
span.textContent = rendered('Hello', 'greeting');
|
|
235
|
+
document.body.appendChild(span);
|
|
236
|
+
await settle();
|
|
237
|
+
expect(o.registeredCount()).toBe(1);
|
|
238
|
+
|
|
239
|
+
span.remove();
|
|
240
|
+
await settle();
|
|
241
|
+
|
|
242
|
+
expect(o.registeredCount()).toBe(0);
|
|
243
|
+
expect(o.findPositions('greeting')).toHaveLength(0);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('keeps an element that was only moved', async () => {
|
|
247
|
+
const o = start();
|
|
248
|
+
const holder = document.createElement('div');
|
|
249
|
+
const span = document.createElement('span');
|
|
250
|
+
span.textContent = rendered('Hello', 'greeting');
|
|
251
|
+
holder.appendChild(span);
|
|
252
|
+
document.body.appendChild(holder);
|
|
253
|
+
await settle();
|
|
254
|
+
|
|
255
|
+
const elsewhere = document.createElement('div');
|
|
256
|
+
document.body.appendChild(elsewhere);
|
|
257
|
+
elsewhere.appendChild(span);
|
|
258
|
+
await settle();
|
|
259
|
+
|
|
260
|
+
expect(o.registeredCount()).toBe(1);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('forgets everything when stopped', async () => {
|
|
264
|
+
const o = start();
|
|
265
|
+
const span = document.createElement('span');
|
|
266
|
+
span.textContent = rendered('Hello', 'greeting');
|
|
267
|
+
document.body.appendChild(span);
|
|
268
|
+
await settle();
|
|
269
|
+
|
|
270
|
+
o.stop();
|
|
271
|
+
|
|
272
|
+
expect(o.registeredCount()).toBe(0);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
describe('pointing', () => {
|
|
277
|
+
/** jsdom lays nothing out, so what is under the cursor has to be declared. */
|
|
278
|
+
function pointAt(element: Element | undefined): void {
|
|
279
|
+
document.elementsFromPoint = () => (element ? [element] : []);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function span(text: string, key: string): HTMLElement {
|
|
283
|
+
const el = document.createElement('span');
|
|
284
|
+
el.textContent = rendered(text, key);
|
|
285
|
+
document.body.appendChild(el);
|
|
286
|
+
return el;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
async function armedOver(element: Element): Promise<void> {
|
|
290
|
+
pointAt(element);
|
|
291
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true, bubbles: true }));
|
|
292
|
+
document.dispatchEvent(new MouseEvent('mousemove', { altKey: true, clientX: 5, clientY: 5, bubbles: true }));
|
|
293
|
+
await settle();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
it('reports the keys when an armed click lands on marked text', async () => {
|
|
297
|
+
start();
|
|
298
|
+
const el = span('Hello', 'greeting');
|
|
299
|
+
await settle();
|
|
300
|
+
await armedOver(el);
|
|
301
|
+
|
|
302
|
+
el.dispatchEvent(new MouseEvent('click', { altKey: true, clientX: 5, clientY: 5, bubbles: true, cancelable: true }));
|
|
303
|
+
|
|
304
|
+
expect(clicks).toHaveLength(1);
|
|
305
|
+
expect(clicks[0]!.keys.map(k => k.key)).toEqual(['greeting']);
|
|
306
|
+
expect(clicks[0]!.target).toBe(el);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('does nothing on a plain click', async () => {
|
|
310
|
+
start();
|
|
311
|
+
const el = span('Hello', 'greeting');
|
|
312
|
+
await settle();
|
|
313
|
+
pointAt(el);
|
|
314
|
+
|
|
315
|
+
el.dispatchEvent(new MouseEvent('click', { clientX: 5, clientY: 5, bubbles: true, cancelable: true }));
|
|
316
|
+
|
|
317
|
+
expect(clicks).toHaveLength(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* The whole point of swallowing: ALT+clicking "Delete patient" must open the dialog and
|
|
322
|
+
* delete nothing. The application's own listener has to never see the event.
|
|
323
|
+
*/
|
|
324
|
+
it('keeps an armed click away from the page’s own handler', async () => {
|
|
325
|
+
start();
|
|
326
|
+
const el = span('Delete patient', 'delete_patient');
|
|
327
|
+
await settle();
|
|
328
|
+
let handled = 0;
|
|
329
|
+
el.addEventListener('click', () => (handled += 1));
|
|
330
|
+
await armedOver(el);
|
|
331
|
+
|
|
332
|
+
const event = new MouseEvent('click', { altKey: true, clientX: 5, clientY: 5, bubbles: true, cancelable: true });
|
|
333
|
+
el.dispatchEvent(event);
|
|
334
|
+
|
|
335
|
+
expect(handled).toBe(0);
|
|
336
|
+
expect(event.defaultPrevented).toBe(true);
|
|
337
|
+
expect(clicks).toHaveLength(1);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('lets a plain click through to the page', async () => {
|
|
341
|
+
start();
|
|
342
|
+
const el = span('Delete patient', 'delete_patient');
|
|
343
|
+
await settle();
|
|
344
|
+
let handled = 0;
|
|
345
|
+
el.addEventListener('click', () => (handled += 1));
|
|
346
|
+
pointAt(el);
|
|
347
|
+
|
|
348
|
+
el.dispatchEvent(new MouseEvent('click', { clientX: 5, clientY: 5, bubbles: true, cancelable: true }));
|
|
349
|
+
|
|
350
|
+
expect(handled).toBe(1);
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
it('does not swallow clicks inside a subtree it was told to ignore', async () => {
|
|
354
|
+
const dialog = document.createElement('div');
|
|
355
|
+
dialog.id = 'the-dialog';
|
|
356
|
+
const button = document.createElement('button');
|
|
357
|
+
dialog.appendChild(button);
|
|
358
|
+
document.body.appendChild(dialog);
|
|
359
|
+
|
|
360
|
+
start({ ignore: element => element.id === 'the-dialog' });
|
|
361
|
+
let handled = 0;
|
|
362
|
+
button.addEventListener('click', () => (handled += 1));
|
|
363
|
+
|
|
364
|
+
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt', altKey: true, bubbles: true }));
|
|
365
|
+
button.dispatchEvent(new MouseEvent('click', { altKey: true, bubbles: true, cancelable: true }));
|
|
366
|
+
|
|
367
|
+
expect(handled).toBe(1);
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
it('ignores an armed click on unmarked text', async () => {
|
|
371
|
+
start();
|
|
372
|
+
const plain = document.createElement('span');
|
|
373
|
+
plain.textContent = 'Just text';
|
|
374
|
+
document.body.appendChild(plain);
|
|
375
|
+
await settle();
|
|
376
|
+
await armedOver(plain);
|
|
377
|
+
|
|
378
|
+
plain.dispatchEvent(new MouseEvent('click', { altKey: true, clientX: 5, clientY: 5, bubbles: true, cancelable: true }));
|
|
379
|
+
|
|
380
|
+
expect(clicks).toHaveLength(0);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it('finds the marked ancestor when the click lands on a child', async () => {
|
|
384
|
+
start();
|
|
385
|
+
const outer = document.createElement('div');
|
|
386
|
+
outer.textContent = rendered('Hello', 'greeting');
|
|
387
|
+
const inner = document.createElement('b');
|
|
388
|
+
outer.appendChild(inner);
|
|
389
|
+
document.body.appendChild(outer);
|
|
390
|
+
await settle();
|
|
391
|
+
await armedOver(inner);
|
|
392
|
+
|
|
393
|
+
inner.dispatchEvent(new MouseEvent('click', { altKey: true, clientX: 5, clientY: 5, bubbles: true, cancelable: true }));
|
|
394
|
+
|
|
395
|
+
expect(clicks).toHaveLength(1);
|
|
396
|
+
expect(clicks[0]!.target).toBe(outer);
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
/** A window that loses focus never sees the keyup, so the modifier would stay held. */
|
|
400
|
+
it('disarms when the window loses focus', async () => {
|
|
401
|
+
start();
|
|
402
|
+
const el = span('Hello', 'greeting');
|
|
403
|
+
await settle();
|
|
404
|
+
await armedOver(el);
|
|
405
|
+
|
|
406
|
+
window.dispatchEvent(new Event('blur'));
|
|
407
|
+
el.dispatchEvent(new MouseEvent('click', { altKey: true, clientX: 5, clientY: 5, bubbles: true, cancelable: true }));
|
|
408
|
+
|
|
409
|
+
// altKey on the event re-arms it before the check, which is correct: the modifier IS
|
|
410
|
+
// down. What blur must not do is leave a stale outline behind.
|
|
411
|
+
expect(document.querySelectorAll('.mg-observer-highlight')).toHaveLength(0);
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
it('stops listening when stopped', async () => {
|
|
415
|
+
const o = start();
|
|
416
|
+
const el = span('Hello', 'greeting');
|
|
417
|
+
await settle();
|
|
418
|
+
await armedOver(el);
|
|
419
|
+
o.stop();
|
|
420
|
+
|
|
421
|
+
el.dispatchEvent(new MouseEvent('click', { altKey: true, clientX: 5, clientY: 5, bubbles: true, cancelable: true }));
|
|
422
|
+
|
|
423
|
+
expect(clicks).toHaveLength(0);
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('outlines what is under the cursor while armed, and removes it after', async () => {
|
|
427
|
+
start();
|
|
428
|
+
const el = span('Hello', 'greeting');
|
|
429
|
+
await settle();
|
|
430
|
+
|
|
431
|
+
await armedOver(el);
|
|
432
|
+
expect(document.querySelectorAll('.mg-observer-highlight')).toHaveLength(1);
|
|
433
|
+
|
|
434
|
+
pointAt(undefined);
|
|
435
|
+
document.dispatchEvent(new KeyboardEvent('keyup', { key: 'Alt', altKey: false, bubbles: true }));
|
|
436
|
+
await settle();
|
|
437
|
+
|
|
438
|
+
expect(document.querySelectorAll('.mg-observer-highlight')).toHaveLength(0);
|
|
439
|
+
});
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
describe('positions and highlights', () => {
|
|
443
|
+
function laidOut(element: HTMLElement, box: { x: number; y: number; width: number; height: number }): void {
|
|
444
|
+
element.getBoundingClientRect = () => ({ ...box, top: box.y, left: box.x, right: box.x + box.width, bottom: box.y + box.height, toJSON: () => ({}) }) as DOMRect;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
it('reports a box per place the key appears, in document order', async () => {
|
|
448
|
+
const o = start();
|
|
449
|
+
const first = document.createElement('span');
|
|
450
|
+
const second = document.createElement('span');
|
|
451
|
+
first.textContent = rendered('Hello', 'greeting');
|
|
452
|
+
second.textContent = rendered('Hello again', 'greeting');
|
|
453
|
+
document.body.append(first, second);
|
|
454
|
+
await settle();
|
|
455
|
+
|
|
456
|
+
laidOut(first, { x: 10, y: 20, width: 30, height: 40 });
|
|
457
|
+
laidOut(second, { x: 50, y: 60, width: 70, height: 80 });
|
|
458
|
+
|
|
459
|
+
const positions = o.findPositions('greeting');
|
|
460
|
+
|
|
461
|
+
expect(positions).toHaveLength(2);
|
|
462
|
+
expect(positions[0]!.position).toEqual({ x: 10, y: 20, width: 30, height: 40 });
|
|
463
|
+
expect(positions[1]!.position).toEqual({ x: 50, y: 60, width: 70, height: 80 });
|
|
464
|
+
});
|
|
465
|
+
|
|
466
|
+
it('reports the namespace it was marked with', async () => {
|
|
467
|
+
const o = start();
|
|
468
|
+
const el = document.createElement('span');
|
|
469
|
+
el.textContent = rendered('Sign in', 'title', 'login');
|
|
470
|
+
document.body.appendChild(el);
|
|
471
|
+
await settle();
|
|
472
|
+
|
|
473
|
+
expect(o.findPositions('title', 'login')).toHaveLength(1);
|
|
474
|
+
expect(o.findPositions('title', 'other')).toHaveLength(0);
|
|
475
|
+
expect(o.findPositions('title')[0]!.ns).toBe('login');
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
it('names no key when asked for none, which is how the gallery asks for everything', async () => {
|
|
479
|
+
const o = start();
|
|
480
|
+
const el = document.createElement('span');
|
|
481
|
+
el.append(document.createTextNode(rendered('a', 'first')), document.createTextNode(rendered('b', 'second')));
|
|
482
|
+
document.body.appendChild(el);
|
|
483
|
+
await settle();
|
|
484
|
+
|
|
485
|
+
expect(o.findPositions().map(p => p.key).sort()).toEqual(['first', 'second']);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it('outlines every place a key appears, and puts it back', async () => {
|
|
489
|
+
const o = start();
|
|
490
|
+
const first = document.createElement('span');
|
|
491
|
+
const second = document.createElement('span');
|
|
492
|
+
first.textContent = rendered('Hello', 'greeting');
|
|
493
|
+
second.textContent = rendered('Hello again', 'greeting');
|
|
494
|
+
document.body.append(first, second);
|
|
495
|
+
await settle();
|
|
496
|
+
|
|
497
|
+
const shown = o.highlight('greeting');
|
|
498
|
+
expect(document.querySelectorAll('.mg-observer-highlight')).toHaveLength(2);
|
|
499
|
+
|
|
500
|
+
shown.unhighlight();
|
|
501
|
+
expect(document.querySelectorAll('.mg-observer-highlight')).toHaveLength(0);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The outlines are fixed-position divs on the body, so a screenshot would capture them
|
|
506
|
+
* over the very text being photographed.
|
|
507
|
+
*/
|
|
508
|
+
it('hides the outlines for a screenshot and restores them', async () => {
|
|
509
|
+
const o = start();
|
|
510
|
+
const el = document.createElement('span');
|
|
511
|
+
el.textContent = rendered('Hello', 'greeting');
|
|
512
|
+
document.body.appendChild(el);
|
|
513
|
+
await settle();
|
|
514
|
+
o.highlight('greeting');
|
|
515
|
+
|
|
516
|
+
const restore = o.hideOutlines();
|
|
517
|
+
expect([...document.querySelectorAll<HTMLElement>('.mg-observer-highlight')].every(box => box.style.visibility === 'hidden')).toBe(true);
|
|
518
|
+
|
|
519
|
+
restore();
|
|
520
|
+
expect([...document.querySelectorAll<HTMLElement>('.mg-observer-highlight')].every(box => box.style.visibility === '')).toBe(true);
|
|
521
|
+
});
|
|
522
|
+
});
|