@remit/ui 0.0.8 → 0.0.9
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/package.json
CHANGED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { describe, it } from "node:test";
|
|
3
|
+
import { isForwardableKey } from "./isolated-email-frame.js";
|
|
4
|
+
|
|
5
|
+
describe("isForwardableKey", () => {
|
|
6
|
+
it("forwards the keys that move around the app", () => {
|
|
7
|
+
for (const key of [
|
|
8
|
+
"j",
|
|
9
|
+
"k",
|
|
10
|
+
"g",
|
|
11
|
+
"x",
|
|
12
|
+
"r",
|
|
13
|
+
"#",
|
|
14
|
+
"Enter",
|
|
15
|
+
"Escape",
|
|
16
|
+
"ArrowUp",
|
|
17
|
+
"ArrowDown",
|
|
18
|
+
"Home",
|
|
19
|
+
"End",
|
|
20
|
+
]) {
|
|
21
|
+
assert.equal(isForwardableKey(key), true, key);
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("keeps destructive keys inside the message body", () => {
|
|
26
|
+
// The replayed event carries no focused control, so the app routes what it
|
|
27
|
+
// recognises straight at the message list. Backspace while reading must not
|
|
28
|
+
// become a delete of the row behind the reader.
|
|
29
|
+
assert.equal(isForwardableKey("Backspace"), false);
|
|
30
|
+
assert.equal(isForwardableKey("Delete"), false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("keeps Space inside the message body", () => {
|
|
34
|
+
// Space belongs to reading the email, not to selecting rows behind it.
|
|
35
|
+
assert.equal(isForwardableKey(" "), false);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("does not forward unknown named keys", () => {
|
|
39
|
+
assert.equal(isForwardableKey("F5"), false);
|
|
40
|
+
assert.equal(isForwardableKey("Tab"), false);
|
|
41
|
+
assert.equal(isForwardableKey("PageDown"), false);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -104,6 +104,61 @@ const useMatchMedia = (query: string): boolean => {
|
|
|
104
104
|
return matches;
|
|
105
105
|
};
|
|
106
106
|
|
|
107
|
+
/** Named (non-character) keys worth replaying: moving around and closing. */
|
|
108
|
+
const FORWARDED_NAMED_KEYS = new Set([
|
|
109
|
+
"Enter",
|
|
110
|
+
"Escape",
|
|
111
|
+
"ArrowUp",
|
|
112
|
+
"ArrowDown",
|
|
113
|
+
"ArrowLeft",
|
|
114
|
+
"ArrowRight",
|
|
115
|
+
"Home",
|
|
116
|
+
"End",
|
|
117
|
+
]);
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Which keystrokes leave the frame. An allowlist rather than a denylist: the
|
|
121
|
+
* replayed event's target is the host window, so the app's keyboard layer sees
|
|
122
|
+
* no focused control and routes everything it recognises to the message list.
|
|
123
|
+
* Forwarding indiscriminately would therefore let Backspace pressed while
|
|
124
|
+
* reading delete the message under the cursor, and Space select it.
|
|
125
|
+
*
|
|
126
|
+
* Single characters (letters, digits, punctuation) carry the app's navigation
|
|
127
|
+
* and verb bindings and are safe to replay. Space is excluded: it belongs to
|
|
128
|
+
* reading the email, not to selecting rows behind it.
|
|
129
|
+
*/
|
|
130
|
+
export const isForwardableKey = (key: string): boolean => {
|
|
131
|
+
if (key === " ") return false;
|
|
132
|
+
if (key.length === 1) return true;
|
|
133
|
+
return FORWARDED_NAMED_KEYS.has(key);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Keydown events raised inside an iframe never reach the embedding window, so
|
|
138
|
+
* once the reader clicks into the message body every app shortcut goes dead —
|
|
139
|
+
* j/k, arrows, Esc, the lot (#43). Replay the allowlisted keystrokes on the host
|
|
140
|
+
* window so the app's one keyboard layer keeps hearing them. The replay is a
|
|
141
|
+
* copy: the original event is untouched, so selecting and scrolling inside the
|
|
142
|
+
* email still behave normally. Modifier combos stay with the frame and the
|
|
143
|
+
* browser.
|
|
144
|
+
*/
|
|
145
|
+
const forwardKeyDown = (event: KeyboardEvent) => {
|
|
146
|
+
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
147
|
+
if (!isForwardableKey(event.key)) return;
|
|
148
|
+
const doc = (event.currentTarget ?? event.target) as Document | null;
|
|
149
|
+
const host = doc?.defaultView?.parent;
|
|
150
|
+
if (!host || host === doc?.defaultView) return;
|
|
151
|
+
host.dispatchEvent(
|
|
152
|
+
new KeyboardEvent("keydown", {
|
|
153
|
+
key: event.key,
|
|
154
|
+
code: event.code,
|
|
155
|
+
shiftKey: event.shiftKey,
|
|
156
|
+
bubbles: true,
|
|
157
|
+
cancelable: true,
|
|
158
|
+
}),
|
|
159
|
+
);
|
|
160
|
+
};
|
|
161
|
+
|
|
107
162
|
/**
|
|
108
163
|
* Render untrusted (sanitized) email HTML in a sandboxed iframe that fits the
|
|
109
164
|
* viewport width on mobile and isolates the email's CSS from the app chrome.
|
|
@@ -175,6 +230,7 @@ export const IsolatedEmailFrame = ({
|
|
|
175
230
|
};
|
|
176
231
|
|
|
177
232
|
let observer: ResizeObserver | undefined;
|
|
233
|
+
let keyDoc: Document | undefined;
|
|
178
234
|
const handleLoad = () => {
|
|
179
235
|
measure();
|
|
180
236
|
const doc = iframe.contentDocument;
|
|
@@ -182,11 +238,14 @@ export const IsolatedEmailFrame = ({
|
|
|
182
238
|
observer = new ResizeObserver(measure);
|
|
183
239
|
observer.observe(doc.body);
|
|
184
240
|
if (doc.documentElement) observer.observe(doc.documentElement);
|
|
241
|
+
doc.addEventListener("keydown", forwardKeyDown);
|
|
242
|
+
keyDoc = doc;
|
|
185
243
|
};
|
|
186
244
|
|
|
187
245
|
iframe.addEventListener("load", handleLoad);
|
|
188
246
|
return () => {
|
|
189
247
|
iframe.removeEventListener("load", handleLoad);
|
|
248
|
+
keyDoc?.removeEventListener("keydown", forwardKeyDown);
|
|
190
249
|
observer?.disconnect();
|
|
191
250
|
};
|
|
192
251
|
}, []);
|