@remit/ui 0.0.122 → 0.0.123
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 +1 -1
- package/src/components/app-shell-slotted.tsx +5 -1
- package/src/components/compose-body.language.test.ts +236 -0
- package/src/components/compose-body.stories.tsx +26 -0
- package/src/components/compose-form-shell.render.test.ts +59 -0
- package/src/components/compose-form-shell.tsx +36 -10
- package/src/components/email-frame-css.ts +110 -29
- package/src/components/isolated-email-frame.render.test.ts +174 -36
- package/src/components/isolated-email-frame.stories.tsx +518 -33
- package/src/components/isolated-email-frame.tsx +58 -135
- package/src/components/message-body-view.stories.tsx +200 -2
- package/src/components/message-body-view.tsx +82 -33
- package/src/components/mobile-reading-pane.tsx +2 -1
- package/src/components/reading-pane.stories.tsx +138 -3
- package/src/components/reading-pane.tsx +35 -26
- package/src/components/resizable.tsx +42 -0
- package/src/components/slide-panel.tsx +7 -3
- package/src/index.ts +8 -1
- package/src/lib/compose-language.test.ts +17 -0
- package/src/lib/compose-language.ts +17 -6
- package/src/lib/detect-compose-language.test.ts +18 -0
- package/src/lib/email-layout-clamp.test.ts +30 -0
- package/src/lib/email-layout-clamp.ts +17 -0
- package/src/lib/email-sanitizer.test.ts +153 -0
- package/src/lib/email-sanitizer.ts +165 -0
- package/src/lib/keymap.test.ts +16 -0
- package/src/lib/keymap.ts +16 -4
package/package.json
CHANGED
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
ResizableHandle,
|
|
10
10
|
ResizablePanel,
|
|
11
11
|
ResizablePanelGroup,
|
|
12
|
+
WholePixelWidth,
|
|
12
13
|
} from "./resizable.js";
|
|
13
14
|
|
|
14
15
|
/* ------------------------------------------------------------------ */
|
|
@@ -250,7 +251,10 @@ export function AppShellSlotted({
|
|
|
250
251
|
minSize={29}
|
|
251
252
|
className="min-w-0"
|
|
252
253
|
>
|
|
253
|
-
{
|
|
254
|
+
{/* The email inside this pane is laid out against the pane's own
|
|
255
|
+
width, so the pane is the last place a fractional box is
|
|
256
|
+
allowed. */}
|
|
257
|
+
<WholePixelWidth className="h-full">{reading}</WholePixelWidth>
|
|
254
258
|
</ResizablePanel>
|
|
255
259
|
</>
|
|
256
260
|
)}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The language of a message a Dutch writer types on an English browser, all the
|
|
3
|
+
* way through the surface: the chip, the tag on the writing surface, and the
|
|
4
|
+
* dictionary the checker is opened for.
|
|
5
|
+
*
|
|
6
|
+
* The account that has never been to the language setting is the ordinary case,
|
|
7
|
+
* and what it falls back on has to be a set detection can choose inside — a set
|
|
8
|
+
* of one is detection switched off, and the message then goes out tagged `en`
|
|
9
|
+
* with every Dutch word underlined.
|
|
10
|
+
*
|
|
11
|
+
* React is imported after the jsdom globals are installed so its DOM bindings
|
|
12
|
+
* bind to jsdom's prototypes.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import assert from "node:assert/strict";
|
|
16
|
+
import { after, afterEach, before, beforeEach, describe, it } from "node:test";
|
|
17
|
+
import type { JSDOM } from "jsdom";
|
|
18
|
+
import type {
|
|
19
|
+
act as reactAct,
|
|
20
|
+
createElement as reactCreateElement,
|
|
21
|
+
} from "react";
|
|
22
|
+
import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
|
|
23
|
+
import { defaultComposeLanguages } from "../lib/compose-language.js";
|
|
24
|
+
import type { ComposeBody as ComposeBodyType } from "./compose-body.js";
|
|
25
|
+
import type {
|
|
26
|
+
SpellcheckOptions,
|
|
27
|
+
SpellProvider,
|
|
28
|
+
} from "./rich-text-spellcheck.js";
|
|
29
|
+
|
|
30
|
+
let dom: JSDOM;
|
|
31
|
+
let container: HTMLElement;
|
|
32
|
+
let root: Root;
|
|
33
|
+
let act: typeof reactAct;
|
|
34
|
+
let createElement: typeof reactCreateElement;
|
|
35
|
+
let createRoot: typeof reactCreateRoot;
|
|
36
|
+
let ComposeBody: typeof ComposeBodyType;
|
|
37
|
+
|
|
38
|
+
/** What the published image stages, per `REMIT_SPELLCHECK_LANGUAGES`. */
|
|
39
|
+
const BUILT = ["en", "en-GB", "nl"];
|
|
40
|
+
|
|
41
|
+
/** An English browser, which is what a Dutch writer routinely reads mail on. */
|
|
42
|
+
const BROWSER = ["en-US", "en"];
|
|
43
|
+
|
|
44
|
+
const DUTCH = "OK nou dank je wel hoor flapsigaar";
|
|
45
|
+
|
|
46
|
+
class Marks {
|
|
47
|
+
readonly ranges: Range[] = [];
|
|
48
|
+
add(range: Range): void {
|
|
49
|
+
this.ranges.push(range);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const recordingSpellcheck = (): {
|
|
54
|
+
options: SpellcheckOptions;
|
|
55
|
+
asked: string[];
|
|
56
|
+
} => {
|
|
57
|
+
const asked: string[] = [];
|
|
58
|
+
const options: SpellcheckOptions = {
|
|
59
|
+
provider: (language) => {
|
|
60
|
+
asked.push(language);
|
|
61
|
+
const provider: SpellProvider = {
|
|
62
|
+
language,
|
|
63
|
+
onStatus: (listener) => {
|
|
64
|
+
listener({ state: "ready", language });
|
|
65
|
+
return () => {};
|
|
66
|
+
},
|
|
67
|
+
check: (request) =>
|
|
68
|
+
Promise.resolve({
|
|
69
|
+
requestId: request.requestId,
|
|
70
|
+
revision: request.revision,
|
|
71
|
+
findings: [],
|
|
72
|
+
}),
|
|
73
|
+
suggest: (request) =>
|
|
74
|
+
Promise.resolve({
|
|
75
|
+
requestId: request.requestId,
|
|
76
|
+
word: request.word,
|
|
77
|
+
suggestions: [],
|
|
78
|
+
}),
|
|
79
|
+
close: () => {},
|
|
80
|
+
};
|
|
81
|
+
return Promise.resolve(provider);
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
return { options, asked };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
const settle = async (): Promise<void> => {
|
|
88
|
+
await act(async () => {
|
|
89
|
+
await Promise.resolve();
|
|
90
|
+
});
|
|
91
|
+
await act(async () => {
|
|
92
|
+
await Promise.resolve();
|
|
93
|
+
});
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
/** Past the detection debounce, which is what the chip waits on. */
|
|
97
|
+
const detected = async (): Promise<void> => {
|
|
98
|
+
await act(async () => {
|
|
99
|
+
await new Promise((resolve) => setTimeout(resolve, 600));
|
|
100
|
+
});
|
|
101
|
+
await settle();
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const editable = (): HTMLElement => {
|
|
105
|
+
const surface = container.querySelector<HTMLElement>(
|
|
106
|
+
"[data-testid=compose-body]",
|
|
107
|
+
);
|
|
108
|
+
if (!surface) throw new Error("the writing surface is not mounted");
|
|
109
|
+
return surface;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const chip = (): HTMLElement => {
|
|
113
|
+
const control = container.querySelector<HTMLElement>(
|
|
114
|
+
"[data-testid=compose-language-chip]",
|
|
115
|
+
);
|
|
116
|
+
if (!control) throw new Error("the language chip is not mounted");
|
|
117
|
+
return control;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
before(async () => {
|
|
121
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
122
|
+
dom = new JSDOMCtor(
|
|
123
|
+
"<!doctype html><html><body><div id=root></div></body></html>",
|
|
124
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
125
|
+
);
|
|
126
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
127
|
+
globalThis.document = dom.window.document;
|
|
128
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
129
|
+
globalThis.Element = dom.window.Element;
|
|
130
|
+
globalThis.Node = dom.window.Node;
|
|
131
|
+
globalThis.Event = dom.window.Event;
|
|
132
|
+
globalThis.MouseEvent = dom.window.MouseEvent;
|
|
133
|
+
globalThis.DOMParser = dom.window.DOMParser;
|
|
134
|
+
globalThis.MutationObserver = dom.window.MutationObserver;
|
|
135
|
+
globalThis.Range = dom.window.Range;
|
|
136
|
+
globalThis.AbortController = dom.window.AbortController;
|
|
137
|
+
globalThis.AbortSignal = dom.window.AbortSignal;
|
|
138
|
+
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
|
139
|
+
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(
|
|
140
|
+
dom.window,
|
|
141
|
+
);
|
|
142
|
+
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(
|
|
143
|
+
dom.window,
|
|
144
|
+
);
|
|
145
|
+
Object.defineProperty(globalThis, "navigator", {
|
|
146
|
+
value: dom.window.navigator,
|
|
147
|
+
configurable: true,
|
|
148
|
+
});
|
|
149
|
+
// The marks are drawn through the CSS Custom Highlight registry, which jsdom
|
|
150
|
+
// has neither half of, and without it no checker is opened at all.
|
|
151
|
+
Object.defineProperty(globalThis, "CSS", {
|
|
152
|
+
value: { highlights: new Map<string, Marks>() },
|
|
153
|
+
configurable: true,
|
|
154
|
+
});
|
|
155
|
+
Object.defineProperty(globalThis, "Highlight", {
|
|
156
|
+
value: Marks,
|
|
157
|
+
configurable: true,
|
|
158
|
+
});
|
|
159
|
+
(
|
|
160
|
+
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
|
|
161
|
+
).IS_REACT_ACT_ENVIRONMENT = true;
|
|
162
|
+
|
|
163
|
+
({ act, createElement } = await import("react"));
|
|
164
|
+
({ createRoot } = await import("react-dom/client"));
|
|
165
|
+
({ ComposeBody } = await import("./compose-body.js"));
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
beforeEach(() => {
|
|
169
|
+
container = dom.window.document.createElement("div");
|
|
170
|
+
dom.window.document.body.append(container);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
afterEach(async () => {
|
|
174
|
+
await act(async () => {
|
|
175
|
+
root.unmount();
|
|
176
|
+
});
|
|
177
|
+
container.remove();
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
after(() => {
|
|
181
|
+
dom.window.close();
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const mount = async (
|
|
185
|
+
text: string,
|
|
186
|
+
languages: readonly string[],
|
|
187
|
+
spellcheck?: SpellcheckOptions,
|
|
188
|
+
): Promise<void> => {
|
|
189
|
+
await act(async () => {
|
|
190
|
+
root = createRoot(container);
|
|
191
|
+
root.render(
|
|
192
|
+
createElement(ComposeBody, {
|
|
193
|
+
mode: "rich",
|
|
194
|
+
onModeChange: () => undefined,
|
|
195
|
+
initialHtml: `<p>${text}</p>`,
|
|
196
|
+
initialText: text,
|
|
197
|
+
onChange: () => undefined,
|
|
198
|
+
onConversionError: () => undefined,
|
|
199
|
+
onLanguageChange: () => undefined,
|
|
200
|
+
languages,
|
|
201
|
+
spellcheck,
|
|
202
|
+
}),
|
|
203
|
+
);
|
|
204
|
+
});
|
|
205
|
+
await settle();
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
describe("the language a message is written in", () => {
|
|
209
|
+
it("reads Dutch off the body of an account that never chose a language", async () => {
|
|
210
|
+
const { options, asked } = recordingSpellcheck();
|
|
211
|
+
|
|
212
|
+
await mount(DUTCH, defaultComposeLanguages(BROWSER, BUILT), options);
|
|
213
|
+
await detected();
|
|
214
|
+
|
|
215
|
+
assert.equal(chip().dataset.language, "nl");
|
|
216
|
+
assert.equal(chip().dataset.languageSource, "detected");
|
|
217
|
+
assert.equal(chip().textContent, "NL");
|
|
218
|
+
assert.equal(editable().getAttribute("lang"), "nl");
|
|
219
|
+
assert.equal(
|
|
220
|
+
asked.at(-1),
|
|
221
|
+
"nl",
|
|
222
|
+
"the checker follows the language, so the underlines are Dutch ones",
|
|
223
|
+
);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it("stays on the account default while the body is English", async () => {
|
|
227
|
+
await mount(
|
|
228
|
+
"Thanks a lot, I will send you a new proposal tomorrow.",
|
|
229
|
+
defaultComposeLanguages(BROWSER, BUILT),
|
|
230
|
+
);
|
|
231
|
+
await detected();
|
|
232
|
+
|
|
233
|
+
assert.equal(chip().dataset.language, "en");
|
|
234
|
+
assert.equal(editable().getAttribute("lang"), "en");
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { useState } from "react";
|
|
3
3
|
import { expect, fn, userEvent, waitFor, within } from "storybook/test";
|
|
4
|
+
import { defaultComposeLanguages } from "../lib/compose-language.js";
|
|
4
5
|
import { ComposeBody, type ConversionFailure } from "./compose-body.js";
|
|
5
6
|
import type { RichTextValue } from "./rich-text-value.js";
|
|
6
7
|
|
|
@@ -411,6 +412,31 @@ export const DutchIsDetected: Story = {
|
|
|
411
412
|
},
|
|
412
413
|
};
|
|
413
414
|
|
|
415
|
+
/**
|
|
416
|
+
* The account that has never opened the language setting, read on an English
|
|
417
|
+
* browser. Its candidate set is the browser's answer and the dictionaries this
|
|
418
|
+
* build carries — a set of one would be detection switched off, and a Dutch
|
|
419
|
+
* message would keep the English tag and the English underlines.
|
|
420
|
+
*/
|
|
421
|
+
export const UnconfiguredAccountStillReadsDutch: Story = {
|
|
422
|
+
name: "Dutch on an English browser, nothing configured",
|
|
423
|
+
args: {
|
|
424
|
+
initialHtml: DUTCH_DOCUMENT,
|
|
425
|
+
languages: defaultComposeLanguages(["en-US", "en"], ["en", "en-GB", "nl"]),
|
|
426
|
+
},
|
|
427
|
+
play: async ({ canvasElement }) => {
|
|
428
|
+
await waitFor(
|
|
429
|
+
async () => {
|
|
430
|
+
await expect(chipOf(canvasElement)).toHaveTextContent("NL");
|
|
431
|
+
},
|
|
432
|
+
{ timeout: 5000 },
|
|
433
|
+
);
|
|
434
|
+
await expect(
|
|
435
|
+
canvasElement.querySelector("[data-testid=compose-body]"),
|
|
436
|
+
).toHaveAttribute("lang", "nl");
|
|
437
|
+
},
|
|
438
|
+
};
|
|
439
|
+
|
|
414
440
|
/** Under twenty characters detection is a coin toss, so the account default stands. */
|
|
415
441
|
export const TooShortHoldsTheDefault: Story = {
|
|
416
442
|
name: "Nine characters hold the default",
|
|
@@ -45,6 +45,65 @@ describe("ComposeFormShell", () => {
|
|
|
45
45
|
assert.doesNotMatch(html, /pb-2/);
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it("scrolls the body region when it fills the height it is given", () => {
|
|
49
|
+
const html = renderToString(
|
|
50
|
+
createElement(ComposeFormShell, {
|
|
51
|
+
layout: "fill",
|
|
52
|
+
header: createElement("div", null, "H"),
|
|
53
|
+
actionBar: createElement("div", null, "B"),
|
|
54
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
55
|
+
children: createElement("div", null, "BODY"),
|
|
56
|
+
}),
|
|
57
|
+
);
|
|
58
|
+
assert.match(html, /overflow-auto/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("scrolls nothing when it takes the height of what is written in it", () => {
|
|
62
|
+
const html = renderToString(
|
|
63
|
+
createElement(ComposeFormShell, {
|
|
64
|
+
layout: "flow",
|
|
65
|
+
header: createElement("div", null, "H"),
|
|
66
|
+
quoted: createElement("div", null, "QUOTED"),
|
|
67
|
+
actionBar: createElement("div", null, "B"),
|
|
68
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
69
|
+
children: createElement("div", null, "BODY"),
|
|
70
|
+
}),
|
|
71
|
+
);
|
|
72
|
+
// A surface opened as a block of a page it did not bring: a scroller here
|
|
73
|
+
// is a second track in that page's column, with the caret inside it.
|
|
74
|
+
assert.doesNotMatch(html, /overflow-/);
|
|
75
|
+
assert.doesNotMatch(html, /h-full/);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("keeps the action bar on the bottom edge while the surface grows", () => {
|
|
79
|
+
const html = renderToString(
|
|
80
|
+
createElement(ComposeFormShell, {
|
|
81
|
+
layout: "flow",
|
|
82
|
+
header: createElement("div", null, "H"),
|
|
83
|
+
actionBar: createElement("div", null, "B"),
|
|
84
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
85
|
+
children: createElement("div", null, "BODY"),
|
|
86
|
+
}),
|
|
87
|
+
);
|
|
88
|
+
// A draft of a few paragraphs is taller than a phone, and the bar is the
|
|
89
|
+
// last thing in the surface: without this Send goes off the bottom as it
|
|
90
|
+
// is written.
|
|
91
|
+
assert.match(html, /sticky bottom-0/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("leaves the action bar in the column when the surface fills its pane", () => {
|
|
95
|
+
const html = renderToString(
|
|
96
|
+
createElement(ComposeFormShell, {
|
|
97
|
+
layout: "fill",
|
|
98
|
+
header: createElement("div", null, "H"),
|
|
99
|
+
actionBar: createElement("div", null, "B"),
|
|
100
|
+
// biome-ignore lint/correctness/noChildrenProp: React 19 types require children in props object when using createElement
|
|
101
|
+
children: createElement("div", null, "BODY"),
|
|
102
|
+
}),
|
|
103
|
+
);
|
|
104
|
+
assert.doesNotMatch(html, /sticky/);
|
|
105
|
+
});
|
|
106
|
+
|
|
48
107
|
it("exposes mode labels for every compose mode", () => {
|
|
49
108
|
assert.equal(composeModeLabels.new, "New Message");
|
|
50
109
|
assert.equal(composeModeLabels.reply, "Reply");
|
|
@@ -9,6 +9,15 @@ export const composeModeLabels: Record<ComposeMode, string> = {
|
|
|
9
9
|
forward: "Forward",
|
|
10
10
|
};
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* How the surface gets its height. `fill` takes the one its container hands it
|
|
14
|
+
* and scrolls the body inside that, so the action bar is pinned to the bottom
|
|
15
|
+
* edge — a window's shape. `flow` takes the height of what is written in it and
|
|
16
|
+
* has no scroller of its own, so the surface grows downward and whatever it
|
|
17
|
+
* sits in is the only thing that scrolls.
|
|
18
|
+
*/
|
|
19
|
+
export type ComposeShellLayout = "fill" | "flow";
|
|
20
|
+
|
|
12
21
|
export interface ComposeFormShellProps {
|
|
13
22
|
/** Optional banner above the header (e.g. SMTP-missing notice). */
|
|
14
23
|
banner?: ReactNode;
|
|
@@ -20,17 +29,26 @@ export interface ComposeFormShellProps {
|
|
|
20
29
|
quoted?: ReactNode;
|
|
21
30
|
/** The ComposeActionBar. */
|
|
22
31
|
actionBar: ReactNode;
|
|
32
|
+
layout?: ComposeShellLayout;
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
/**
|
|
26
|
-
* Presentational compose layout: banner / header /
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
36
|
+
* Presentational compose layout: banner / header / body+quote / action bar.
|
|
37
|
+
*
|
|
38
|
+
* In `fill` the body region is a column so the editor can claim the space the
|
|
39
|
+
* quote and the action bar leave — a body slot shorter than the region would
|
|
40
|
+
* otherwise leave dead, unclickable canvas under it.
|
|
41
|
+
*
|
|
42
|
+
* In `flow` nothing here scrolls. A composer that is a block of the page it was
|
|
43
|
+
* opened on must not bring a second scroller into that pane: two tracks in one
|
|
44
|
+
* column, with the caret in the inner one, is the reader guessing which of them
|
|
45
|
+
* a wheel gesture belongs to.
|
|
30
46
|
*
|
|
31
|
-
*
|
|
32
|
-
* the action bar
|
|
33
|
-
*
|
|
47
|
+
* A surface that grows with the writing grows past the screen on a phone after
|
|
48
|
+
* a few paragraphs, and the action bar is the last thing in it — so in `flow`
|
|
49
|
+
* the bar rides the bottom edge of whatever scrolls the page while the surface
|
|
50
|
+
* is on it. Send is reachable at every length of draft, and lands back in the
|
|
51
|
+
* column when the end of the composer comes into view.
|
|
34
52
|
*/
|
|
35
53
|
export function ComposeFormShell({
|
|
36
54
|
banner,
|
|
@@ -38,19 +56,27 @@ export function ComposeFormShell({
|
|
|
38
56
|
children,
|
|
39
57
|
quoted,
|
|
40
58
|
actionBar,
|
|
59
|
+
layout = "fill",
|
|
41
60
|
}: ComposeFormShellProps) {
|
|
61
|
+
const fills = layout === "fill";
|
|
42
62
|
return (
|
|
43
|
-
<div className="flex h-full min-h-0 flex-col">
|
|
63
|
+
<div className={fills ? "flex h-full min-h-0 flex-col" : "flex flex-col"}>
|
|
44
64
|
{banner}
|
|
45
65
|
{header}
|
|
46
66
|
<div
|
|
47
|
-
className=
|
|
67
|
+
className={
|
|
68
|
+
fills ? "flex min-h-0 flex-1 flex-col overflow-auto" : "flex flex-col"
|
|
69
|
+
}
|
|
48
70
|
data-testid="compose-body-area"
|
|
49
71
|
>
|
|
50
72
|
{children}
|
|
51
73
|
{quoted && <div className="shrink-0 px-3 pb-2">{quoted}</div>}
|
|
52
74
|
</div>
|
|
53
|
-
{
|
|
75
|
+
{fills ? (
|
|
76
|
+
actionBar
|
|
77
|
+
) : (
|
|
78
|
+
<div className="sticky bottom-0 z-10 bg-canvas">{actionBar}</div>
|
|
79
|
+
)}
|
|
54
80
|
</div>
|
|
55
81
|
);
|
|
56
82
|
}
|
|
@@ -24,23 +24,79 @@ const FONT_STACK =
|
|
|
24
24
|
* iframe boundary. Keep in sync with `tokens.css`; the unit test pins these
|
|
25
25
|
* resolved values so token drift fails loudly.
|
|
26
26
|
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* `canvas` is the reading pane's own ground, not `surface`: whenever the app
|
|
28
|
+
* supplies the background the frame must be indistinguishable from the pane
|
|
29
|
+
* around it, or the email reads as a lighter rectangle sitting inside the pane.
|
|
30
|
+
*
|
|
31
|
+
* --fg light: oklch(0.3 0.025 235) dark: oklch(0.88 0.02 90)
|
|
32
|
+
* --canvas light: oklch(0.96 0.015 90) dark: oklch(0.22 0.025 220)
|
|
33
|
+
* --accent light: oklch(0.55 0.14 150) dark: oklch(0.78 0.16 150)
|
|
30
34
|
*/
|
|
31
|
-
const
|
|
35
|
+
const FRAME_TOKENS = {
|
|
32
36
|
light: {
|
|
33
37
|
fg: "oklch(0.3 0.025 235)",
|
|
34
|
-
|
|
38
|
+
canvas: "oklch(0.96 0.015 90)",
|
|
35
39
|
accent: "oklch(0.55 0.14 150)",
|
|
36
40
|
},
|
|
37
41
|
dark: {
|
|
38
42
|
fg: "oklch(0.88 0.02 90)",
|
|
39
|
-
|
|
43
|
+
canvas: "oklch(0.22 0.025 220)",
|
|
40
44
|
accent: "oklch(0.78 0.16 150)",
|
|
41
45
|
},
|
|
42
46
|
} as const;
|
|
43
47
|
|
|
48
|
+
/**
|
|
49
|
+
* What the mail declares about its own presentation. Both facts are read off
|
|
50
|
+
* the raw author markup by the sanitizer, and both decide whether the app
|
|
51
|
+
* supplies something or stands back:
|
|
52
|
+
*
|
|
53
|
+
* - `background` — the mail paints its own ground, so it renders as authored.
|
|
54
|
+
* When it declares none, the app's ground is the reading pane's own colour.
|
|
55
|
+
* - `spacing` — the mail lays out its own padding or margin. When it declares
|
|
56
|
+
* none, the app injects breathing room INSIDE the background, so the ground
|
|
57
|
+
* still runs edge to edge and only the text is inset.
|
|
58
|
+
*/
|
|
59
|
+
export interface AuthorDeclarations {
|
|
60
|
+
background: boolean;
|
|
61
|
+
spacing: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const NOTHING_DECLARED: AuthorDeclarations = {
|
|
65
|
+
background: false,
|
|
66
|
+
spacing: false,
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The inset between the email's own ground and its text, for mail that lays out
|
|
71
|
+
* none of its own. Matches the reading pane's desktop gutter, so a bare message
|
|
72
|
+
* reads with the same rhythm as the chrome above it.
|
|
73
|
+
*/
|
|
74
|
+
const CONTENT_INSET = "16px";
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Breathing room for a mail that brings none, painted inside the element that
|
|
78
|
+
* carries the background rather than around it. Emitted LAST in the document so
|
|
79
|
+
* it beats the layout clamp's `padding: 0` reset; `border-box` keeps the padded
|
|
80
|
+
* body inside the frame instead of pushing 32px of content past its right edge.
|
|
81
|
+
*/
|
|
82
|
+
export const generateContentInsetCSS = (): string =>
|
|
83
|
+
`html{padding:0}body{padding:${CONTENT_INSET};box-sizing:border-box}`;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The document is its own scrollport. The frame is exactly as wide as the pane
|
|
87
|
+
* and is never widened to fit the mail, so content that genuinely cannot wrap —
|
|
88
|
+
* a fixed-width table, an image with its own `min-width`, a `pre` the author
|
|
89
|
+
* pinned — has to be reachable from inside the document rather than by handing
|
|
90
|
+
* the app a wider box to hold.
|
|
91
|
+
*
|
|
92
|
+
* `body` only becomes a scroll container once `html` stops being `visible`:
|
|
93
|
+
* overflow set on the body propagates to the viewport otherwise, which would put
|
|
94
|
+
* the scrollbar back on the frame itself. Pinning `html` first is what keeps the
|
|
95
|
+
* scroll where the content is.
|
|
96
|
+
*/
|
|
97
|
+
export const generateScrollportCSS = (): string =>
|
|
98
|
+
"html{overflow:hidden}body{overflow-x:auto}";
|
|
99
|
+
|
|
44
100
|
/**
|
|
45
101
|
* Detects whether a (sanitized) email opts into dark rendering — either via a
|
|
46
102
|
* `prefers-color-scheme: dark` media query or an explicit `color-scheme: dark`
|
|
@@ -65,7 +121,7 @@ export const VIEWPORT_META =
|
|
|
65
121
|
* letting font-size / weight variations through, and re-themes links.
|
|
66
122
|
*/
|
|
67
123
|
export const generatePlainEmailBaseCSS = (isDark: boolean): string => {
|
|
68
|
-
const t = isDark ?
|
|
124
|
+
const t = isDark ? FRAME_TOKENS.dark : FRAME_TOKENS.light;
|
|
69
125
|
return `
|
|
70
126
|
/* Plain-email base: UI font-stack + theme-aware colors (#424) */
|
|
71
127
|
html, body {
|
|
@@ -73,7 +129,7 @@ html, body {
|
|
|
73
129
|
font-size: 14px;
|
|
74
130
|
line-height: 1.6;
|
|
75
131
|
color: ${t.fg};
|
|
76
|
-
background-color: ${t.
|
|
132
|
+
background-color: ${t.canvas};
|
|
77
133
|
margin: 0;
|
|
78
134
|
padding: 0;
|
|
79
135
|
}
|
|
@@ -90,48 +146,73 @@ a, a:visited {
|
|
|
90
146
|
`;
|
|
91
147
|
};
|
|
92
148
|
|
|
149
|
+
const SMART_INVERT = "invert(0.92) hue-rotate(180deg)";
|
|
150
|
+
const RE_INVERT_MEDIA = `img,picture,video,svg,canvas,[style*='background-image'],[background]{filter:${SMART_INVERT}}`;
|
|
151
|
+
|
|
93
152
|
/**
|
|
94
|
-
* Framed-email base CSS, mirroring K-9 Mail's dark-reading strategy
|
|
95
|
-
*
|
|
153
|
+
* Framed-email base CSS, mirroring K-9 Mail's dark-reading strategy, split
|
|
154
|
+
* first on whether the mail brought a ground of its own.
|
|
155
|
+
*
|
|
156
|
+
* The mail declared one — render it as authored: white canvas in light, and in
|
|
157
|
+
* dark either the author's own dark design or the smart-invert that darkens a
|
|
158
|
+
* white-assuming email into the pane.
|
|
96
159
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
*
|
|
160
|
+
* The mail declared none — the app supplies the ground, and an app-supplied
|
|
161
|
+
* ground is the reading pane's own colour, so the email is one surface with the
|
|
162
|
+
* pane rather than a rectangle inside it. In dark the invert moves off `html`
|
|
163
|
+
* onto `body`: the author's colours still darken, but the canvas behind them is
|
|
164
|
+
* the pane's and is never inverted into a white-turned-charcoal slab.
|
|
101
165
|
*
|
|
102
166
|
* `invert(0.92)` (not 1.0) lands on soft charcoal + light-grey like K-9 rather
|
|
103
|
-
* than pure black/white;
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* composes. The margin reset zeroes the UA body margin.
|
|
167
|
+
* than pure black/white; `hue-rotate(180deg)` keeps blues blue (links); the
|
|
168
|
+
* media-element rule re-inverts images / logos / photos back to their natural
|
|
169
|
+
* colours. No `!important` — this is a default canvas, so the email's own
|
|
170
|
+
* background still composes. The margin reset zeroes the UA body margin.
|
|
108
171
|
*/
|
|
109
172
|
export const generateFramedEmailBaseCSS = (
|
|
110
173
|
isDark: boolean,
|
|
111
174
|
optsIntoDark: boolean,
|
|
175
|
+
hasAuthorBackground: boolean,
|
|
112
176
|
): string => {
|
|
177
|
+
const canvas = isDark ? FRAME_TOKENS.dark.canvas : FRAME_TOKENS.light.canvas;
|
|
178
|
+
if (!hasAuthorBackground) {
|
|
179
|
+
if (!isDark)
|
|
180
|
+
return `html,body{margin:0;background-color:${canvas};color-scheme:light}`;
|
|
181
|
+
if (optsIntoDark)
|
|
182
|
+
return `html,body{margin:0;background-color:${canvas};color-scheme:dark light}`;
|
|
183
|
+
return `html{margin:0;background-color:${canvas}}body{margin:0;filter:${SMART_INVERT}}${RE_INVERT_MEDIA}`;
|
|
184
|
+
}
|
|
113
185
|
if (!isDark)
|
|
114
186
|
return "html,body{margin:0;background-color:#ffffff;color-scheme:light}";
|
|
115
|
-
if (optsIntoDark)
|
|
116
|
-
|
|
187
|
+
if (optsIntoDark)
|
|
188
|
+
return `html,body{margin:0;background-color:${canvas};color-scheme:dark light}`;
|
|
189
|
+
return `html{margin:0;background-color:#ffffff;filter:${SMART_INVERT}}body{margin:0}${RE_INVERT_MEDIA}`;
|
|
117
190
|
};
|
|
118
191
|
|
|
119
192
|
export type EmailFrameVariant = "plain" | "framed";
|
|
120
193
|
|
|
121
194
|
/**
|
|
122
195
|
* Assemble the full srcDoc for an isolated email frame: the viewport meta, the
|
|
123
|
-
* treatment's base CSS,
|
|
124
|
-
*
|
|
125
|
-
*
|
|
196
|
+
* treatment's base CSS, the (already sanitized, layout-clamped) email HTML, and
|
|
197
|
+
* last — where they outrank the clamp's own resets — the scrollport and, for
|
|
198
|
+
* mail that lays out none of its own, the content inset. This is the single
|
|
199
|
+
* place the colour / font / dark-mode / spacing decision is applied; callers
|
|
200
|
+
* pass treatment, theme and what the mail declares, never raw CSS.
|
|
126
201
|
*/
|
|
127
202
|
export const buildEmailSrcDoc = (
|
|
128
203
|
html: string,
|
|
129
204
|
variant: EmailFrameVariant,
|
|
130
205
|
isDark: boolean,
|
|
206
|
+
declares: AuthorDeclarations = NOTHING_DECLARED,
|
|
131
207
|
): string => {
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
208
|
+
const base =
|
|
209
|
+
variant === "plain"
|
|
210
|
+
? generatePlainEmailBaseCSS(isDark)
|
|
211
|
+
: generateFramedEmailBaseCSS(
|
|
212
|
+
isDark,
|
|
213
|
+
DARK_OPT_IN_RE.test(html),
|
|
214
|
+
declares.background,
|
|
215
|
+
);
|
|
216
|
+
const inset = declares.spacing ? "" : generateContentInsetCSS();
|
|
217
|
+
return `${VIEWPORT_META}<style>${base}</style>${html}<style>${generateScrollportCSS()}${inset}</style>`;
|
|
137
218
|
};
|