@remit/ui 0.0.100 → 0.0.102
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 +2 -1
- package/src/components/button.tsx +4 -0
- package/src/components/compose-language-chip.stories.tsx +136 -0
- package/src/components/compose-language-chip.tsx +151 -0
- package/src/components/compose-language-setting.stories.tsx +82 -0
- package/src/components/compose-language-setting.tsx +106 -0
- package/src/components/compose-mode-toggle.tsx +35 -0
- package/src/components/plain-text-editor.mount.test.ts +238 -0
- package/src/components/plain-text-editor.stories.tsx +237 -0
- package/src/components/plain-text-editor.tsx +190 -0
- package/src/components/rich-text-document.ts +111 -3
- package/src/components/rich-text-editor.stories.tsx +100 -0
- package/src/components/rich-text-editor.tsx +12 -1
- package/src/components/rich-text-formatting.test.ts +153 -0
- package/src/components/rich-text-markdown.test.ts +131 -0
- package/src/components/rich-text-markdown.ts +155 -0
- package/src/components/rich-text-toolbar.tsx +72 -46
- package/src/components/rich-text-value.ts +11 -1
- package/src/components/use-compose-language.ts +86 -0
- package/src/index.ts +16 -0
- package/src/lib/adopted-html.test.ts +9 -0
- package/src/lib/adopted-html.ts +13 -1
- package/src/lib/compose-language.test.ts +193 -0
- package/src/lib/compose-language.ts +206 -0
- package/src/lib/detect-compose-language.test.ts +51 -0
- package/src/lib/detect-compose-language.ts +42 -0
- package/src/rich-text.ts +32 -3
- package/src/tokens.css +4 -0
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's language: which BCP 47 tag a message is being written in,
|
|
3
|
+
* what a browser will and will not do with it, and how it travels on the
|
|
4
|
+
* message.
|
|
5
|
+
*
|
|
6
|
+
* A page cannot choose the browser's spellcheck dictionary. Chrome and Safari
|
|
7
|
+
* ignore `lang` outright; Firefox reads it but only picks among dictionaries
|
|
8
|
+
* the user already installed. Nothing here claims otherwise — what the tag buys
|
|
9
|
+
* is a Firefox dictionary when one is present, a screen-reader voice, and a
|
|
10
|
+
* message the recipient's client can read the language off. See issue #686.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* A language the composer offers, and the ISO 639-3 code `franc-min` knows it
|
|
15
|
+
* by. The two are separate alphabets: the tag is what goes on the document and
|
|
16
|
+
* on the wire, the code is what the detector's trigram tables are keyed on.
|
|
17
|
+
*
|
|
18
|
+
* The list is bounded by what `franc-min` can detect — a language it has no
|
|
19
|
+
* table for could be picked by hand but never detected, which is a menu entry
|
|
20
|
+
* that behaves differently from its neighbours for no reason a user can see.
|
|
21
|
+
*/
|
|
22
|
+
export interface ComposeLanguageOption {
|
|
23
|
+
tag: string;
|
|
24
|
+
detectionCode: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const COMPOSE_LANGUAGE_OPTIONS: readonly ComposeLanguageOption[] = [
|
|
28
|
+
{ tag: "ar", detectionCode: "arb" },
|
|
29
|
+
{ tag: "az", detectionCode: "azj" },
|
|
30
|
+
{ tag: "be", detectionCode: "bel" },
|
|
31
|
+
{ tag: "bg", detectionCode: "bul" },
|
|
32
|
+
{ tag: "bs", detectionCode: "bos" },
|
|
33
|
+
{ tag: "cs", detectionCode: "ces" },
|
|
34
|
+
{ tag: "de", detectionCode: "deu" },
|
|
35
|
+
{ tag: "en", detectionCode: "eng" },
|
|
36
|
+
{ tag: "es", detectionCode: "spa" },
|
|
37
|
+
{ tag: "fa", detectionCode: "pes" },
|
|
38
|
+
{ tag: "fr", detectionCode: "fra" },
|
|
39
|
+
{ tag: "ha", detectionCode: "hau" },
|
|
40
|
+
{ tag: "hi", detectionCode: "hin" },
|
|
41
|
+
{ tag: "hr", detectionCode: "hrv" },
|
|
42
|
+
{ tag: "hu", detectionCode: "hun" },
|
|
43
|
+
{ tag: "id", detectionCode: "ind" },
|
|
44
|
+
{ tag: "ig", detectionCode: "ibo" },
|
|
45
|
+
{ tag: "it", detectionCode: "ita" },
|
|
46
|
+
{ tag: "jv", detectionCode: "jav" },
|
|
47
|
+
{ tag: "kk", detectionCode: "kaz" },
|
|
48
|
+
{ tag: "mr", detectionCode: "mar" },
|
|
49
|
+
{ tag: "ms", detectionCode: "zlm" },
|
|
50
|
+
{ tag: "ne", detectionCode: "npi" },
|
|
51
|
+
{ tag: "nl", detectionCode: "nld" },
|
|
52
|
+
{ tag: "pl", detectionCode: "pol" },
|
|
53
|
+
{ tag: "ps", detectionCode: "pbu" },
|
|
54
|
+
{ tag: "pt", detectionCode: "por" },
|
|
55
|
+
{ tag: "ro", detectionCode: "ron" },
|
|
56
|
+
{ tag: "ru", detectionCode: "rus" },
|
|
57
|
+
{ tag: "so", detectionCode: "som" },
|
|
58
|
+
{ tag: "sr", detectionCode: "srp" },
|
|
59
|
+
{ tag: "su", detectionCode: "sun" },
|
|
60
|
+
{ tag: "sv", detectionCode: "swe" },
|
|
61
|
+
{ tag: "sw", detectionCode: "swh" },
|
|
62
|
+
{ tag: "tl", detectionCode: "tgl" },
|
|
63
|
+
{ tag: "tr", detectionCode: "tur" },
|
|
64
|
+
{ tag: "uk", detectionCode: "ukr" },
|
|
65
|
+
{ tag: "ur", detectionCode: "urd" },
|
|
66
|
+
{ tag: "vi", detectionCode: "vie" },
|
|
67
|
+
{ tag: "yo", detectionCode: "yor" },
|
|
68
|
+
{ tag: "zu", detectionCode: "zul" },
|
|
69
|
+
];
|
|
70
|
+
|
|
71
|
+
/** The language subtag of a BCP 47 tag: `en` out of `en-GB`, lower-cased. */
|
|
72
|
+
export const primaryLanguageSubtag = (tag: string): string =>
|
|
73
|
+
(tag.split("-")[0] ?? "").toLowerCase();
|
|
74
|
+
|
|
75
|
+
const byPrimarySubtag = new Map(
|
|
76
|
+
COMPOSE_LANGUAGE_OPTIONS.map((option) => [option.tag, option]),
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* The detector code for a tag, or null when nothing can detect it. A region
|
|
81
|
+
* resolves through its language — no trigram table separates `en-GB` from
|
|
82
|
+
* `en-US`, and pretending one does would put a tag on the message the text
|
|
83
|
+
* never supported.
|
|
84
|
+
*/
|
|
85
|
+
export const detectionCodeFor = (tag: string): string | null =>
|
|
86
|
+
byPrimarySubtag.get(primaryLanguageSubtag(tag))?.detectionCode ?? null;
|
|
87
|
+
|
|
88
|
+
const WELL_FORMED_TAG = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/i;
|
|
89
|
+
|
|
90
|
+
/** The tag as the chip shows it: two or three letters, upper case. */
|
|
91
|
+
export const languageChipLabel = (tag: string): string =>
|
|
92
|
+
primaryLanguageSubtag(tag).toUpperCase();
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The language's own name for itself — `Nederlands`, not `Dutch`. A menu of
|
|
96
|
+
* languages is read by someone who reads that language, and an endonym is the
|
|
97
|
+
* entry they recognise without translating it first. Falls back to the tag when
|
|
98
|
+
* the platform has no name for it.
|
|
99
|
+
*/
|
|
100
|
+
export const languageLabel = (tag: string): string => {
|
|
101
|
+
// `Intl.DisplayNames` throws a RangeError on anything that is not a
|
|
102
|
+
// well-formed tag, and a stored setting is text a user can have edited.
|
|
103
|
+
if (!WELL_FORMED_TAG.test(tag)) return tag;
|
|
104
|
+
const names = new Intl.DisplayNames([tag], {
|
|
105
|
+
type: "language",
|
|
106
|
+
fallback: "none",
|
|
107
|
+
});
|
|
108
|
+
const named = names.of(tag);
|
|
109
|
+
if (!named) return tag;
|
|
110
|
+
return named.charAt(0).toLocaleUpperCase(tag) + named.slice(1);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The languages an account writes in when it has not said. The browser already
|
|
115
|
+
* holds an ordered answer; `en` follows it, so a browser set to one language
|
|
116
|
+
* nothing can detect still leaves the chip something to show.
|
|
117
|
+
*/
|
|
118
|
+
export const defaultComposeLanguages = (
|
|
119
|
+
locales: readonly string[],
|
|
120
|
+
): string[] => {
|
|
121
|
+
const known = locales.filter((locale) => detectionCodeFor(locale) !== null);
|
|
122
|
+
const tags = known.map(primaryLanguageSubtag);
|
|
123
|
+
const unique = [...new Set(tags)];
|
|
124
|
+
if (!unique.includes("en")) unique.push("en");
|
|
125
|
+
return unique;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The one sentence at the foot of the language menu naming where the browser
|
|
130
|
+
* keeps the setting that does fix spelling. A page cannot link to
|
|
131
|
+
* `chrome://settings/languages`, so it is text; and reading the user agent to
|
|
132
|
+
* name where a setting lives is not feature detection, because there is nothing
|
|
133
|
+
* to detect.
|
|
134
|
+
*/
|
|
135
|
+
export const browserSpellcheckHelp = (userAgent: string): string => {
|
|
136
|
+
if (/iPhone|iPad|iPod/.test(userAgent)) {
|
|
137
|
+
return "On iPhone and iPad the keyboard decides this. Add the language under Settings, then General, then Keyboard.";
|
|
138
|
+
}
|
|
139
|
+
if (/Edg\//.test(userAgent)) {
|
|
140
|
+
return "Edge checks every language you add under Settings, then Languages. Adding one there checks it alongside the others.";
|
|
141
|
+
}
|
|
142
|
+
if (/Firefox\//.test(userAgent)) {
|
|
143
|
+
return "Firefox uses this setting. Right-click the message to add a dictionary for it.";
|
|
144
|
+
}
|
|
145
|
+
if (/Chrome\/|Chromium\//.test(userAgent)) {
|
|
146
|
+
return "Chrome checks every language you add under Settings, then Languages. Adding one there checks it alongside the others.";
|
|
147
|
+
}
|
|
148
|
+
if (/Safari\//.test(userAgent)) {
|
|
149
|
+
return "macOS decides this under Keyboard, then Text Input, then Spelling. Automatic by Language covers every language enabled there.";
|
|
150
|
+
}
|
|
151
|
+
return "Your browser decides which dictionaries it checks. Add this language in its own language settings to have it spellchecked.";
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const parseHtml = (html: string): Document =>
|
|
155
|
+
new DOMParser().parseFromString(html, "text/html");
|
|
156
|
+
|
|
157
|
+
const singleRootElement = (body: HTMLElement): Element | null => {
|
|
158
|
+
const elements = [...body.children];
|
|
159
|
+
if (elements.length !== 1) return null;
|
|
160
|
+
const only = elements[0];
|
|
161
|
+
if (!only) return null;
|
|
162
|
+
const hasStrayText = [...body.childNodes].some(
|
|
163
|
+
(node) => node !== only && (node.textContent ?? "").trim() !== "",
|
|
164
|
+
);
|
|
165
|
+
return hasStrayText ? null : only;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The outgoing HTML under one `<div lang>`, so the recipient's client reads the
|
|
170
|
+
* language off the message rather than guessing it.
|
|
171
|
+
*
|
|
172
|
+
* Re-wrapping is idempotent: a document that already sits under a language
|
|
173
|
+
* wrapper has that wrapper's tag replaced. Autosave runs this on every keystroke
|
|
174
|
+
* batch, and a draft that gained a `<div>` per save would arrive nested forty
|
|
175
|
+
* deep.
|
|
176
|
+
*/
|
|
177
|
+
export const wrapWithLanguage = (html: string, tag: string): string => {
|
|
178
|
+
if (html === "") return "";
|
|
179
|
+
const document = parseHtml(html);
|
|
180
|
+
const existing = singleRootElement(document.body);
|
|
181
|
+
if (existing?.tagName === "DIV" && existing.hasAttribute("lang")) {
|
|
182
|
+
existing.setAttribute("lang", tag);
|
|
183
|
+
return document.body.innerHTML;
|
|
184
|
+
}
|
|
185
|
+
// Built through the DOM rather than by string concatenation: a stored
|
|
186
|
+
// setting is text a user can have edited, and a tag carrying a quote would
|
|
187
|
+
// otherwise close the attribute and put markup into the message.
|
|
188
|
+
const wrapper = document.createElement("div");
|
|
189
|
+
wrapper.setAttribute("lang", tag);
|
|
190
|
+
while (document.body.firstChild) wrapper.append(document.body.firstChild);
|
|
191
|
+
document.body.append(wrapper);
|
|
192
|
+
return document.body.innerHTML;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
/** What `wrapWithLanguage` wrote, taken back apart for the editor to reopen on. */
|
|
196
|
+
export const unwrapLanguage = (
|
|
197
|
+
html: string,
|
|
198
|
+
): { html: string; language: string | null } => {
|
|
199
|
+
if (html === "") return { html, language: null };
|
|
200
|
+
const document = parseHtml(html);
|
|
201
|
+
const root = singleRootElement(document.body);
|
|
202
|
+
if (root?.tagName !== "DIV" || !root.hasAttribute("lang")) {
|
|
203
|
+
return { html, language: null };
|
|
204
|
+
}
|
|
205
|
+
return { html: root.innerHTML, language: root.getAttribute("lang") };
|
|
206
|
+
};
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Detection restricted to the account's own languages. The restriction is the
|
|
3
|
+
* whole reason a 53 kB trigram table is good enough: `franc` scores 87.1% on
|
|
4
|
+
* one sentence over all 414 languages and 97.3% over six.
|
|
5
|
+
*/
|
|
6
|
+
import assert from "node:assert/strict";
|
|
7
|
+
import { describe, it } from "node:test";
|
|
8
|
+
import { detectComposeLanguage } from "./detect-compose-language.js";
|
|
9
|
+
|
|
10
|
+
const DUTCH =
|
|
11
|
+
"Beste Anna, de vergadering van donderdag gaat niet door. Ik stuur je morgen een nieuw voorstel.";
|
|
12
|
+
const ENGLISH =
|
|
13
|
+
"Hi Anna, Thursday's meeting is off. I will send you a new proposal tomorrow.";
|
|
14
|
+
const GERMAN =
|
|
15
|
+
"Hallo Anna, die Besprechung am Donnerstag fällt aus. Ich schicke dir morgen einen neuen Vorschlag.";
|
|
16
|
+
const FRENCH =
|
|
17
|
+
"Bonjour Anna, la réunion de jeudi est annulée. Je vous enverrai une nouvelle proposition demain.";
|
|
18
|
+
|
|
19
|
+
const CANDIDATES = ["nl", "en", "de"];
|
|
20
|
+
|
|
21
|
+
describe("detectComposeLanguage", () => {
|
|
22
|
+
it("picks each candidate out of the others", () => {
|
|
23
|
+
assert.equal(detectComposeLanguage(DUTCH, CANDIDATES), "nl");
|
|
24
|
+
assert.equal(detectComposeLanguage(ENGLISH, CANDIDATES), "en");
|
|
25
|
+
assert.equal(detectComposeLanguage(GERMAN, CANDIDATES), "de");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("stays inside the candidate set", () => {
|
|
29
|
+
const detected = detectComposeLanguage(FRENCH, CANDIDATES);
|
|
30
|
+
assert.ok(detected === null || CANDIDATES.includes(detected));
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("declines a greeting line", () => {
|
|
34
|
+
assert.equal(detectComposeLanguage("Hoi Anna,", CANDIDATES), null);
|
|
35
|
+
assert.equal(detectComposeLanguage(" ", CANDIDATES), null);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("declines when there is nothing to choose between", () => {
|
|
39
|
+
assert.equal(detectComposeLanguage(DUTCH, ["nl"]), null);
|
|
40
|
+
assert.equal(detectComposeLanguage(DUTCH, []), null);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("ignores a configured language nothing can detect", () => {
|
|
44
|
+
assert.equal(detectComposeLanguage(DUTCH, ["nl", "ja"]), null);
|
|
45
|
+
assert.equal(detectComposeLanguage(DUTCH, ["nl", "ja", "en"]), "nl");
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("resolves a regional tag through its language", () => {
|
|
49
|
+
assert.equal(detectComposeLanguage(ENGLISH, ["nl", "en-GB"]), "en-GB");
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { franc } from "franc-min";
|
|
2
|
+
import { detectionCodeFor } from "./compose-language.js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Below this, detection is a coin toss — `franc-min` scores 90.7% at 20 to 45
|
|
6
|
+
* characters and drops off a cliff under that. A greeting line holds the
|
|
7
|
+
* account default instead.
|
|
8
|
+
*/
|
|
9
|
+
export const DETECTION_MIN_LENGTH = 20;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The language of a body, chosen inside the account's own candidate set.
|
|
13
|
+
*
|
|
14
|
+
* The restriction is what makes a 53 kB trigram table good enough: `franc`
|
|
15
|
+
* scores 87.1% on one sentence over all 414 languages and 97.3% over six.
|
|
16
|
+
* Returns null when the text is too short, when there is nothing to choose
|
|
17
|
+
* between, or when the detector declines to answer.
|
|
18
|
+
*
|
|
19
|
+
* Kept apart from the hook that calls it so `franc-min` stays out of every
|
|
20
|
+
* chunk that only wants a language tag, and so this is testable as the pure
|
|
21
|
+
* function it is.
|
|
22
|
+
*/
|
|
23
|
+
export const detectComposeLanguage = (
|
|
24
|
+
text: string,
|
|
25
|
+
candidates: readonly string[],
|
|
26
|
+
): string | null => {
|
|
27
|
+
const trimmed = text.trim();
|
|
28
|
+
if (trimmed.length < DETECTION_MIN_LENGTH) return null;
|
|
29
|
+
|
|
30
|
+
const tagByCode = new Map<string, string>();
|
|
31
|
+
for (const tag of candidates) {
|
|
32
|
+
const code = detectionCodeFor(tag);
|
|
33
|
+
if (code && !tagByCode.has(code)) tagByCode.set(code, tag);
|
|
34
|
+
}
|
|
35
|
+
if (tagByCode.size < 2) return null;
|
|
36
|
+
|
|
37
|
+
const detected = franc(trimmed, {
|
|
38
|
+
only: [...tagByCode.keys()],
|
|
39
|
+
minLength: DETECTION_MIN_LENGTH,
|
|
40
|
+
});
|
|
41
|
+
return tagByCode.get(detected) ?? null;
|
|
42
|
+
};
|
package/src/rich-text.ts
CHANGED
|
@@ -1,13 +1,42 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
3
|
-
*
|
|
4
|
-
* chunk already imports `@remit/ui`, which is every screen.
|
|
2
|
+
* The compose writing surfaces and nothing else. Their own entry point so an
|
|
3
|
+
* app can load them on demand: reached through the package barrel they would
|
|
4
|
+
* land in whichever chunk already imports `@remit/ui`, which is every screen.
|
|
5
5
|
*/
|
|
6
|
+
|
|
7
|
+
export {
|
|
8
|
+
ComposeLanguageChip,
|
|
9
|
+
type ComposeLanguageChipProps,
|
|
10
|
+
} from "./components/compose-language-chip.js";
|
|
11
|
+
export {
|
|
12
|
+
type ComposeBodyMode,
|
|
13
|
+
ComposeModeToggle,
|
|
14
|
+
type ComposeModeToggleProps,
|
|
15
|
+
} from "./components/compose-mode-toggle.js";
|
|
16
|
+
export {
|
|
17
|
+
PlainTextEditor,
|
|
18
|
+
type PlainTextEditorProps,
|
|
19
|
+
} from "./components/plain-text-editor.js";
|
|
20
|
+
export {
|
|
21
|
+
htmlToMarkdown,
|
|
22
|
+
markdownToHtml,
|
|
23
|
+
} from "./components/rich-text-document.js";
|
|
6
24
|
export {
|
|
7
25
|
RichTextEditor,
|
|
8
26
|
type RichTextEditorProps,
|
|
9
27
|
} from "./components/rich-text-editor.js";
|
|
28
|
+
export { COMPOSE_TRANSFORMERS } from "./components/rich-text-markdown.js";
|
|
10
29
|
export {
|
|
11
30
|
EMPTY_RICH_TEXT,
|
|
12
31
|
type RichTextValue,
|
|
13
32
|
} from "./components/rich-text-value.js";
|
|
33
|
+
export {
|
|
34
|
+
type ComposeLanguageControl,
|
|
35
|
+
type ComposeLanguageSource,
|
|
36
|
+
type ComposeLanguageState,
|
|
37
|
+
useComposeLanguage,
|
|
38
|
+
} from "./components/use-compose-language.js";
|
|
39
|
+
export {
|
|
40
|
+
DETECTION_MIN_LENGTH,
|
|
41
|
+
detectComposeLanguage,
|
|
42
|
+
} from "./lib/detect-compose-language.js";
|
package/src/tokens.css
CHANGED
|
@@ -38,6 +38,10 @@
|
|
|
38
38
|
--font-ui: var(--font-geist);
|
|
39
39
|
--font-sans: var(--font-ui);
|
|
40
40
|
|
|
41
|
+
/* The plain compose surface promises the characters that will be sent, and a
|
|
42
|
+
pipe table in a proportional face is unaligned bars. Nothing downloaded. */
|
|
43
|
+
--font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
|
44
|
+
|
|
41
45
|
/* surfaces & text */
|
|
42
46
|
--color-canvas: var(--canvas);
|
|
43
47
|
--color-surface: var(--surface);
|