@johnmorrisdotca/kazoku-ui 0.3.0 → 0.7.3
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/CHANGELOG.md +71 -0
- package/README.md +85 -5
- package/dist/address-state/address.d.ts +76 -0
- package/dist/address-state/address.js +171 -0
- package/dist/address-state/index.d.ts +1 -0
- package/dist/address-state/index.js +2 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/language-picker/define.d.ts +1 -0
- package/dist/language-picker/define.js +9 -0
- package/dist/language-picker/element.d.ts +39 -0
- package/dist/language-picker/element.js +194 -0
- package/dist/language-picker/index.d.ts +4 -0
- package/dist/language-picker/index.js +5 -0
- package/dist/language-picker/languages.d.ts +38 -0
- package/dist/language-picker/languages.js +100 -0
- package/dist/language-picker/styles.d.ts +1 -0
- package/dist/language-picker/styles.js +3 -0
- package/dist/language-picker/words.d.ts +8 -0
- package/dist/language-picker/words.js +7 -0
- package/dist/save-image/define.d.ts +1 -0
- package/dist/save-image/define.js +9 -0
- package/dist/save-image/element.d.ts +39 -0
- package/dist/save-image/element.js +186 -0
- package/dist/save-image/format.d.ts +20 -0
- package/dist/save-image/format.js +37 -0
- package/dist/save-image/index.d.ts +6 -0
- package/dist/save-image/index.js +8 -0
- package/dist/save-image/render.d.ts +55 -0
- package/dist/save-image/render.js +188 -0
- package/dist/save-image/styles.d.ts +1 -0
- package/dist/save-image/styles.js +9 -0
- package/dist/save-image/words.d.ts +12 -0
- package/dist/save-image/words.js +7 -0
- package/dist/shared/adopt.d.ts +4 -0
- package/dist/shared/adopt.js +34 -0
- package/dist/shared/base.d.ts +1 -0
- package/dist/shared/base.js +40 -0
- package/dist/shared/scheme.d.ts +1 -0
- package/dist/shared/scheme.js +19 -0
- package/dist/shared/store.d.ts +2 -0
- package/dist/shared/store.js +17 -0
- package/dist/shared/target.d.ts +2 -0
- package/dist/shared/target.js +11 -0
- package/dist/theme-switch/define.d.ts +1 -0
- package/dist/theme-switch/define.js +9 -0
- package/dist/theme-switch/element.d.ts +39 -0
- package/dist/theme-switch/element.js +203 -0
- package/dist/theme-switch/index.d.ts +4 -0
- package/dist/theme-switch/index.js +5 -0
- package/dist/theme-switch/styles.d.ts +1 -0
- package/dist/theme-switch/styles.js +8 -0
- package/dist/theme-switch/theme.d.ts +29 -0
- package/dist/theme-switch/theme.js +43 -0
- package/dist/theme-switch/words.d.ts +11 -0
- package/dist/theme-switch/words.js +7 -0
- package/dist/view-controls/controller.d.ts +6 -1
- package/dist/view-controls/controller.js +26 -3
- package/dist/view-controls/element.d.ts +1 -1
- package/dist/view-controls/element.js +54 -24
- package/package.json +67 -4
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// <kazoku-language-picker>: a button for each language the page gives, each in its own name. It writes the choice as `lang` on a target (the page's <html> by default),
|
|
2
|
+
// keeps it in the address and, if asked, in localStorage, and tells the page with a `kazoku-languagechange` event. It translates nothing: the page reads the event and says its words.
|
|
3
|
+
import { createAddressState } from "../address-state/address.js";
|
|
4
|
+
import { adoptStyles } from "../shared/adopt.js";
|
|
5
|
+
import { watchScheme } from "../shared/scheme.js";
|
|
6
|
+
import { localStore, remember } from "../shared/store.js";
|
|
7
|
+
import { resolveTarget } from "../shared/target.js";
|
|
8
|
+
import { EVENT, TAG } from "./index.js";
|
|
9
|
+
import { applyLanguage, LANGUAGE_ADDRESS_KEY, LANGUAGE_STORAGE_KEY, languageName, matchLanguage, parseLanguages, startingLanguage } from "./languages.js";
|
|
10
|
+
import { STYLES } from "./styles.js";
|
|
11
|
+
import { languageOf, wordsFor } from "./words.js";
|
|
12
|
+
export { EVENT, TAG };
|
|
13
|
+
/**
|
|
14
|
+
* Attributes: `languages` (the codes, `en ja` by default), `value` (the language to start in), `target` (a CSS selector for the element that gets `lang`; the page's <html>
|
|
15
|
+
* by default), `address` (keep the choice in `?lang=`, or the key `address-key` names), `storage` (also remember it in localStorage under that key; a bare `storage` uses
|
|
16
|
+
* `kazoku-lang`), `theme` (`light` or `dark`: its own look). The start is read, in this order, from the address, the store, `value`, the reader's languages
|
|
17
|
+
* (`navigator.languages`), the target's own `lang`, then the first code.
|
|
18
|
+
*/
|
|
19
|
+
export class LanguagePickerElement extends HTMLElement {
|
|
20
|
+
static observedAttributes = ["languages", "value", "target"];
|
|
21
|
+
#root;
|
|
22
|
+
#list = [];
|
|
23
|
+
#lang = "";
|
|
24
|
+
#target;
|
|
25
|
+
#names = {};
|
|
26
|
+
#words = {};
|
|
27
|
+
#ready = false;
|
|
28
|
+
#styled = false;
|
|
29
|
+
#address = createAddressState({ delay: 0 });
|
|
30
|
+
#stop = [];
|
|
31
|
+
#watching;
|
|
32
|
+
constructor() {
|
|
33
|
+
super();
|
|
34
|
+
this.#root = this.attachShadow({ mode: "open" });
|
|
35
|
+
}
|
|
36
|
+
/** The language now chosen. Setting it applies it (event reason `set`); a code that is not in `languages` is ignored. */
|
|
37
|
+
get value() {
|
|
38
|
+
return this.#lang;
|
|
39
|
+
}
|
|
40
|
+
set value(code) {
|
|
41
|
+
const match = matchLanguage(code, this.#list);
|
|
42
|
+
if (match !== undefined)
|
|
43
|
+
this.#choose(match, "set");
|
|
44
|
+
}
|
|
45
|
+
/** The codes the picker offers. */
|
|
46
|
+
get languages() {
|
|
47
|
+
return this.#list;
|
|
48
|
+
}
|
|
49
|
+
get target() {
|
|
50
|
+
return this.#target;
|
|
51
|
+
}
|
|
52
|
+
set target(element) {
|
|
53
|
+
this.#aim(element);
|
|
54
|
+
}
|
|
55
|
+
/** Your own names over the family's, for example `{ fr: "Français (Canada)" }`. */
|
|
56
|
+
set names(names) {
|
|
57
|
+
this.#names = names;
|
|
58
|
+
this.#render();
|
|
59
|
+
}
|
|
60
|
+
/** Your own words over the language's, for example `{ group: "Choose a language" }`. */
|
|
61
|
+
set words(words) {
|
|
62
|
+
this.#words = words;
|
|
63
|
+
this.#render();
|
|
64
|
+
}
|
|
65
|
+
connectedCallback() {
|
|
66
|
+
if (!this.#styled) {
|
|
67
|
+
adoptStyles(this.#root, STYLES, this.nonce);
|
|
68
|
+
this.#styled = true;
|
|
69
|
+
}
|
|
70
|
+
this.#stop.push(watchScheme(this));
|
|
71
|
+
this.#list = parseLanguages(this.getAttribute("languages"));
|
|
72
|
+
this.#aim(resolveTarget(this, this.getAttribute("target")));
|
|
73
|
+
this.#lang = startingLanguage({
|
|
74
|
+
list: this.#list,
|
|
75
|
+
...(this.hasAttribute("address") ? { search: globalThis.location?.search ?? "", addressKey: this.#addressKey() } : {}),
|
|
76
|
+
...(this.hasAttribute("storage") ? { storage: localStore(), storageKey: this.#storageKey() } : {}),
|
|
77
|
+
value: this.getAttribute("value"),
|
|
78
|
+
preferred: globalThis.navigator?.languages ?? [],
|
|
79
|
+
current: this.#target?.getAttribute("lang") ?? null,
|
|
80
|
+
});
|
|
81
|
+
if (this.#target !== undefined)
|
|
82
|
+
applyLanguage(this.#target, this.#lang);
|
|
83
|
+
this.#render();
|
|
84
|
+
this.#ready = true;
|
|
85
|
+
// Said once the page has had its turn to listen; `value` can be read at any time.
|
|
86
|
+
queueMicrotask(() => {
|
|
87
|
+
if (this.isConnected)
|
|
88
|
+
this.#dispatch(undefined, "init");
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
disconnectedCallback() {
|
|
92
|
+
for (const stop of this.#stop.splice(0))
|
|
93
|
+
stop();
|
|
94
|
+
this.#watching?.disconnect();
|
|
95
|
+
this.#watching = undefined;
|
|
96
|
+
this.#ready = false;
|
|
97
|
+
}
|
|
98
|
+
attributeChangedCallback(name, _old, value) {
|
|
99
|
+
if (!this.#ready)
|
|
100
|
+
return;
|
|
101
|
+
if (name === "languages") {
|
|
102
|
+
this.#list = parseLanguages(value);
|
|
103
|
+
const kept = matchLanguage(this.#lang, this.#list);
|
|
104
|
+
if (kept === undefined)
|
|
105
|
+
this.#choose(this.#list[0], "set");
|
|
106
|
+
else
|
|
107
|
+
this.#render();
|
|
108
|
+
}
|
|
109
|
+
else if (name === "value") {
|
|
110
|
+
const match = matchLanguage(value, this.#list);
|
|
111
|
+
if (match !== undefined)
|
|
112
|
+
this.#choose(match, "set");
|
|
113
|
+
}
|
|
114
|
+
else if (name === "target") {
|
|
115
|
+
this.#aim(resolveTarget(this, value));
|
|
116
|
+
if (this.#target !== undefined)
|
|
117
|
+
applyLanguage(this.#target, this.#lang);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
#addressKey() {
|
|
121
|
+
return this.getAttribute("address-key") || LANGUAGE_ADDRESS_KEY;
|
|
122
|
+
}
|
|
123
|
+
#storageKey() {
|
|
124
|
+
return this.getAttribute("storage") || LANGUAGE_STORAGE_KEY;
|
|
125
|
+
}
|
|
126
|
+
#aim(element) {
|
|
127
|
+
if (element === this.#target)
|
|
128
|
+
return;
|
|
129
|
+
this.#watching?.disconnect();
|
|
130
|
+
this.#target = element;
|
|
131
|
+
if (element === undefined)
|
|
132
|
+
return;
|
|
133
|
+
// Something else writing the target's lang (a page's own script, another picker) moves this one with it, when it is one of ours.
|
|
134
|
+
this.#watching = new MutationObserver(() => {
|
|
135
|
+
const held = matchLanguage(element.getAttribute("lang"), this.#list);
|
|
136
|
+
if (held !== undefined && held !== this.#lang) {
|
|
137
|
+
const previous = this.#lang;
|
|
138
|
+
this.#lang = held;
|
|
139
|
+
this.#render();
|
|
140
|
+
this.#dispatch(previous, "external");
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
this.#watching.observe(element, { attributes: true, attributeFilter: ["lang"] });
|
|
144
|
+
}
|
|
145
|
+
#choose(code, reason) {
|
|
146
|
+
const previous = this.#lang;
|
|
147
|
+
if (this.#target !== undefined)
|
|
148
|
+
applyLanguage(this.#target, code);
|
|
149
|
+
this.#lang = code;
|
|
150
|
+
this.#render();
|
|
151
|
+
if (this.hasAttribute("address"))
|
|
152
|
+
this.#address.set(this.#addressKey(), code);
|
|
153
|
+
if (this.hasAttribute("storage"))
|
|
154
|
+
remember(this.#storageKey(), code);
|
|
155
|
+
if (code !== previous || reason === "user")
|
|
156
|
+
this.#dispatch(previous, reason);
|
|
157
|
+
}
|
|
158
|
+
#render() {
|
|
159
|
+
const words = wordsFor(languageOf(this.#lang), this.#words);
|
|
160
|
+
let group = this.#root.querySelector(".group");
|
|
161
|
+
if (group === null) {
|
|
162
|
+
group = this.ownerDocument.createElement("div");
|
|
163
|
+
group.className = "group";
|
|
164
|
+
group.setAttribute("part", "group");
|
|
165
|
+
group.setAttribute("role", "group");
|
|
166
|
+
this.#root.append(group);
|
|
167
|
+
}
|
|
168
|
+
group.setAttribute("aria-label", words.group);
|
|
169
|
+
group.setAttribute("lang", languageOf(this.#lang));
|
|
170
|
+
const have = [...group.querySelectorAll("button")];
|
|
171
|
+
if (have.length !== this.#list.length || have.some((button, index) => button.dataset.v !== this.#list[index])) {
|
|
172
|
+
group.replaceChildren(...this.#list.map((code) => {
|
|
173
|
+
const button = this.ownerDocument.createElement("button");
|
|
174
|
+
button.type = "button";
|
|
175
|
+
button.dataset.v = code;
|
|
176
|
+
button.lang = code;
|
|
177
|
+
button.setAttribute("part", "button");
|
|
178
|
+
button.addEventListener("click", () => this.#choose(code, "user"));
|
|
179
|
+
return button;
|
|
180
|
+
}));
|
|
181
|
+
}
|
|
182
|
+
for (const button of group.querySelectorAll("button")) {
|
|
183
|
+
const code = button.dataset.v;
|
|
184
|
+
button.textContent = languageName(code, this.#names);
|
|
185
|
+
button.setAttribute("aria-pressed", String(code === this.#lang));
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
#dispatch(previous, reason) {
|
|
189
|
+
if (this.#target === undefined)
|
|
190
|
+
return;
|
|
191
|
+
const detail = { lang: this.#lang, previous, reason, target: this.#target };
|
|
192
|
+
this.dispatchEvent(new CustomEvent(EVENT, { detail, bubbles: true, composed: true }));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { applyLanguage, DEFAULT_LANGUAGES, LANGUAGE_ADDRESS_KEY, LANGUAGE_ATTRIBUTE, LANGUAGE_STORAGE_KEY, languageName, matchLanguage, normaliseCode, OWN_NAMES, parseLanguages, startingLanguage, type StartingLanguage } from "./languages.ts";
|
|
2
|
+
export { languageOf, WORDS, wordsFor, type Language, type PickerWords } from "./words.ts";
|
|
3
|
+
export declare const EVENT = "kazoku-languagechange";
|
|
4
|
+
export declare const TAG = "kazoku-language-picker";
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// The language picker without the element: the codes, each language's own name, which one to start in and how one is applied. Nothing here touches the DOM until `applyLanguage` is called with an element.
|
|
2
|
+
export { applyLanguage, DEFAULT_LANGUAGES, LANGUAGE_ADDRESS_KEY, LANGUAGE_ATTRIBUTE, LANGUAGE_STORAGE_KEY, languageName, matchLanguage, normaliseCode, OWN_NAMES, parseLanguages, startingLanguage } from "./languages.js";
|
|
3
|
+
export { languageOf, WORDS, wordsFor } from "./words.js";
|
|
4
|
+
export const EVENT = "kazoku-languagechange";
|
|
5
|
+
export const TAG = "kazoku-language-picker";
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/** The codes when a page gives none. */
|
|
2
|
+
export declare const DEFAULT_LANGUAGES: readonly string[];
|
|
3
|
+
/** The attribute the choice is written to on the target. */
|
|
4
|
+
export declare const LANGUAGE_ATTRIBUTE = "lang";
|
|
5
|
+
/** The address parameter, with `address`. */
|
|
6
|
+
export declare const LANGUAGE_ADDRESS_KEY = "lang";
|
|
7
|
+
/** The localStorage key, with a bare `storage`. */
|
|
8
|
+
export declare const LANGUAGE_STORAGE_KEY = "kazoku-lang";
|
|
9
|
+
/** Each language's own name, for the codes the family uses. Any other code gets its name from `Intl.DisplayNames`, else the code itself. */
|
|
10
|
+
export declare const OWN_NAMES: Readonly<Record<string, string>>;
|
|
11
|
+
/** A code as the page may write it ("EN", "ja_JP", " fr ") in the form this part compares: lower case, dashes. `undefined` if it is not a language code. */
|
|
12
|
+
export declare function normaliseCode(value: string | null | undefined): string | undefined;
|
|
13
|
+
/** The codes of `languages="en ja fr"`: valid ones, once each, in the order given. Nothing usable gives `en` and `ja`. */
|
|
14
|
+
export declare function parseLanguages(value: string | null | undefined): string[];
|
|
15
|
+
/** The entry of `list` that a language tag means: the tag itself, else its main language ("ja-JP" is "ja"). `undefined` if the list has neither. */
|
|
16
|
+
export declare function matchLanguage(tag: string | null | undefined, list: readonly string[]): string | undefined;
|
|
17
|
+
/** A language's own name: yours (`names`), the family's table, `Intl.DisplayNames` in that language, else the code. */
|
|
18
|
+
export declare function languageName(code: string, names?: Readonly<Record<string, string>>): string;
|
|
19
|
+
/** What may say which language to start in, strongest first. Each is a language tag or `undefined`. */
|
|
20
|
+
export interface StartingLanguage {
|
|
21
|
+
list: readonly string[];
|
|
22
|
+
/** The address' search string such as `?lang=ja`, read with `addressKey`. */
|
|
23
|
+
search?: string;
|
|
24
|
+
addressKey?: string;
|
|
25
|
+
/** A localStorage-like object and the key in it. A store that throws is the same as an empty one. */
|
|
26
|
+
storage?: Pick<Storage, "getItem">;
|
|
27
|
+
storageKey?: string;
|
|
28
|
+
/** The page's own start (the `value` attribute). */
|
|
29
|
+
value?: string | null;
|
|
30
|
+
/** The reader's languages, most wanted first (`navigator.languages`). */
|
|
31
|
+
preferred?: readonly string[];
|
|
32
|
+
/** What the target holds now. */
|
|
33
|
+
current?: string | null;
|
|
34
|
+
}
|
|
35
|
+
/** The language to start in: the address, the store, `value`, the reader's languages, what the page holds now, then the first of the list. */
|
|
36
|
+
export declare function startingLanguage(from: StartingLanguage): string;
|
|
37
|
+
/** Writes the choice as `lang` on the target. */
|
|
38
|
+
export declare function applyLanguage(target: Element, code: string): void;
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// The language choice without the element: the list of codes a page gives, each language's own name, which one to start in, and how one is applied.
|
|
2
|
+
/** The codes when a page gives none. */
|
|
3
|
+
export const DEFAULT_LANGUAGES = ["en", "ja"];
|
|
4
|
+
/** The attribute the choice is written to on the target. */
|
|
5
|
+
export const LANGUAGE_ATTRIBUTE = "lang";
|
|
6
|
+
/** The address parameter, with `address`. */
|
|
7
|
+
export const LANGUAGE_ADDRESS_KEY = "lang";
|
|
8
|
+
/** The localStorage key, with a bare `storage`. */
|
|
9
|
+
export const LANGUAGE_STORAGE_KEY = "kazoku-lang";
|
|
10
|
+
/** Each language's own name, for the codes the family uses. Any other code gets its name from `Intl.DisplayNames`, else the code itself. */
|
|
11
|
+
export const OWN_NAMES = {
|
|
12
|
+
en: "English",
|
|
13
|
+
ja: "日本語",
|
|
14
|
+
fr: "Français",
|
|
15
|
+
de: "Deutsch",
|
|
16
|
+
es: "Español",
|
|
17
|
+
pt: "Português",
|
|
18
|
+
it: "Italiano",
|
|
19
|
+
nl: "Nederlands",
|
|
20
|
+
sv: "Svenska",
|
|
21
|
+
pl: "Polski",
|
|
22
|
+
ru: "Русский",
|
|
23
|
+
ko: "한국어",
|
|
24
|
+
zh: "中文",
|
|
25
|
+
"zh-hans": "简体中文",
|
|
26
|
+
"zh-hant": "繁體中文",
|
|
27
|
+
};
|
|
28
|
+
const CODE = /^[a-z]{2,3}(-[a-z0-9]{2,8})*$/;
|
|
29
|
+
/** A code as the page may write it ("EN", "ja_JP", " fr ") in the form this part compares: lower case, dashes. `undefined` if it is not a language code. */
|
|
30
|
+
export function normaliseCode(value) {
|
|
31
|
+
const code = (value ?? "").trim().toLowerCase().replace(/_/g, "-");
|
|
32
|
+
return CODE.test(code) ? code : undefined;
|
|
33
|
+
}
|
|
34
|
+
/** The codes of `languages="en ja fr"`: valid ones, once each, in the order given. Nothing usable gives `en` and `ja`. */
|
|
35
|
+
export function parseLanguages(value) {
|
|
36
|
+
const codes = [];
|
|
37
|
+
for (const word of (value ?? "").split(/[\s,]+/)) {
|
|
38
|
+
const code = normaliseCode(word);
|
|
39
|
+
if (code !== undefined && !codes.includes(code))
|
|
40
|
+
codes.push(code);
|
|
41
|
+
}
|
|
42
|
+
return codes.length > 0 ? codes : [...DEFAULT_LANGUAGES];
|
|
43
|
+
}
|
|
44
|
+
/** The entry of `list` that a language tag means: the tag itself, else its main language ("ja-JP" is "ja"). `undefined` if the list has neither. */
|
|
45
|
+
export function matchLanguage(tag, list) {
|
|
46
|
+
const code = normaliseCode(tag);
|
|
47
|
+
if (code === undefined)
|
|
48
|
+
return undefined;
|
|
49
|
+
if (list.includes(code))
|
|
50
|
+
return code;
|
|
51
|
+
const main = code.split("-")[0];
|
|
52
|
+
return list.find((entry) => entry === main) ?? list.find((entry) => entry.split("-")[0] === main);
|
|
53
|
+
}
|
|
54
|
+
/** A language's own name: yours (`names`), the family's table, `Intl.DisplayNames` in that language, else the code. */
|
|
55
|
+
export function languageName(code, names = {}) {
|
|
56
|
+
const own = names[code] ?? OWN_NAMES[code];
|
|
57
|
+
if (own !== undefined)
|
|
58
|
+
return own;
|
|
59
|
+
try {
|
|
60
|
+
const name = new Intl.DisplayNames([code], { type: "language" }).of(code);
|
|
61
|
+
if (name !== undefined && name !== code)
|
|
62
|
+
return name.charAt(0).toLocaleUpperCase(code) + name.slice(1);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
/* a browser that does not know the code */
|
|
66
|
+
}
|
|
67
|
+
return code;
|
|
68
|
+
}
|
|
69
|
+
/** The language to start in: the address, the store, `value`, the reader's languages, what the page holds now, then the first of the list. */
|
|
70
|
+
export function startingLanguage(from) {
|
|
71
|
+
const { list } = from;
|
|
72
|
+
if (from.search !== undefined) {
|
|
73
|
+
const asked = matchLanguage(new URLSearchParams(from.search).get(from.addressKey ?? LANGUAGE_ADDRESS_KEY), list);
|
|
74
|
+
if (asked !== undefined)
|
|
75
|
+
return asked;
|
|
76
|
+
}
|
|
77
|
+
if (from.storage !== undefined) {
|
|
78
|
+
try {
|
|
79
|
+
const kept = matchLanguage(from.storage.getItem(from.storageKey ?? LANGUAGE_STORAGE_KEY), list);
|
|
80
|
+
if (kept !== undefined)
|
|
81
|
+
return kept;
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* a browser that keeps nothing */
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const own = matchLanguage(from.value, list);
|
|
88
|
+
if (own !== undefined)
|
|
89
|
+
return own;
|
|
90
|
+
for (const tag of from.preferred ?? []) {
|
|
91
|
+
const wanted = matchLanguage(tag, list);
|
|
92
|
+
if (wanted !== undefined)
|
|
93
|
+
return wanted;
|
|
94
|
+
}
|
|
95
|
+
return matchLanguage(from.current, list) ?? list[0];
|
|
96
|
+
}
|
|
97
|
+
/** Writes the choice as `lang` on the target. */
|
|
98
|
+
export function applyLanguage(target, code) {
|
|
99
|
+
target.setAttribute(LANGUAGE_ATTRIBUTE, code);
|
|
100
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare const STYLES = "\n:host {\n --_ink: var(--kazoku-ink, #1b1b1f);\n --_surface: var(--kazoku-surface, #ffffff);\n --_line: var(--kazoku-line, #c9ccd3);\n --_accent: var(--kazoku-accent, #2457d6);\n display: inline-block;\n box-sizing: border-box;\n color: var(--_ink);\n font: 600 0.9rem/1.2 system-ui, -apple-system, \"Hiragino Sans\", \"Noto Sans JP\", \"Segoe UI\", sans-serif;\n color-scheme: light dark;\n}\n@media (prefers-color-scheme: dark) {\n :host(:not([theme=\"light\"]):not([data-scheme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n }\n}\n:host([theme=\"dark\"]), :host([data-scheme=\"dark\"]:not([theme=\"light\"])) {\n --_ink: var(--kazoku-ink, #f1f2f6);\n --_surface: var(--kazoku-surface, #1c1d22);\n --_line: var(--kazoku-line, #4a4d58);\n --_accent: var(--kazoku-accent, #8fb0ff);\n color-scheme: dark;\n}\n:host([theme=\"light\"]) { color-scheme: light; }\n[hidden] { display: none !important; }\n.vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }\n.group { display: inline-flex; align-items: center; flex-wrap: wrap; gap: 4px; }\nbutton { -webkit-appearance: none; appearance: none; box-sizing: border-box; display: inline-flex; align-items: center; justify-content: center; gap: 6px; min-width: 48px; min-height: 48px; margin: 0; padding: 0 14px; border: 1px solid var(--_line); border-radius: 24px; background: var(--_surface); color: var(--_ink); font: inherit; cursor: pointer; white-space: nowrap; }\nbutton:hover:not(:disabled) { border-color: var(--_ink); }\nbutton:focus-visible { outline: 2px solid var(--_ink); outline-offset: 2px; }\nbutton[aria-pressed=\"true\"] { background: var(--_ink); color: var(--_surface); border-color: var(--_ink); }\nbutton:disabled { opacity: 0.4; cursor: default; }\nsvg { width: 18px; height: 18px; fill: currentColor; flex: none; }\n";
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export interface PickerWords {
|
|
2
|
+
group: string;
|
|
3
|
+
}
|
|
4
|
+
export type Language = "en" | "ja";
|
|
5
|
+
export declare const WORDS: Record<Language, PickerWords>;
|
|
6
|
+
/** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
|
|
7
|
+
export declare const languageOf: (lang: string | null | undefined) => Language;
|
|
8
|
+
export declare const wordsFor: (language: Language, overrides?: Partial<PickerWords>) => PickerWords;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export const WORDS = {
|
|
2
|
+
en: { group: "Language" },
|
|
3
|
+
ja: { group: "言語" },
|
|
4
|
+
};
|
|
5
|
+
/** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
|
|
6
|
+
export const languageOf = (lang) => (/^ja\b/i.test(lang ?? "") ? "ja" : "en");
|
|
7
|
+
export const wordsFor = (language, overrides = {}) => ({ ...WORDS[language], ...overrides });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function defineSaveImage(registry?: CustomElementRegistry): boolean;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Importing this registers <kazoku-save-image>, unless the tag is already taken (the first definition wins; see view-controls/define.ts for why a taken tag is left alone).
|
|
2
|
+
import { SaveImageElement, TAG } from "./element.js";
|
|
3
|
+
export function defineSaveImage(registry = customElements) {
|
|
4
|
+
if (registry.get(TAG) !== undefined)
|
|
5
|
+
return false;
|
|
6
|
+
registry.define(TAG, SaveImageElement);
|
|
7
|
+
return true;
|
|
8
|
+
}
|
|
9
|
+
defineSaveImage();
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { EVENT, ERROR_EVENT, TAG } from "./index.ts";
|
|
2
|
+
import { type Format } from "./format.ts";
|
|
3
|
+
import { type Picture } from "./render.ts";
|
|
4
|
+
import { type SaveWords } from "./words.ts";
|
|
5
|
+
export { EVENT, ERROR_EVENT, TAG };
|
|
6
|
+
/** What the `kazoku-save` event carries in `detail`. */
|
|
7
|
+
export interface SaveDetail extends Picture {
|
|
8
|
+
/** The file name offered to the browser, with the extension. */
|
|
9
|
+
filename: string;
|
|
10
|
+
/** `user` (a button) or `api` (`save()` called by the page). */
|
|
11
|
+
reason: "user" | "api";
|
|
12
|
+
}
|
|
13
|
+
/** What the `kazoku-saveerror` event carries in `detail`. */
|
|
14
|
+
export interface SaveErrorDetail {
|
|
15
|
+
format: Format;
|
|
16
|
+
error: Error;
|
|
17
|
+
reason: "user" | "api";
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Attributes: `for` (the id of the canvas, SVG or element to save; or set `element.target`), `formats` (`png svg`, the buttons, in order; one the target cannot give is left out),
|
|
21
|
+
* `name` (the file name without its extension; `image` by default), `scale` (PNG pixels for each CSS pixel of an SVG or element; 2 by default; a canvas is saved at its own size),
|
|
22
|
+
* `background` (a CSS colour behind a PNG; transparent without it), `manual` (make the picture and send the event, but do not download), `no-inline-styles` (leave the computed styles out of what is saved),
|
|
23
|
+
* `lang` (`en` or `ja`), `theme` (`light` or `dark`: its own look, otherwise the page's).
|
|
24
|
+
*/
|
|
25
|
+
export declare class SaveImageElement extends HTMLElement {
|
|
26
|
+
#private;
|
|
27
|
+
static observedAttributes: string[];
|
|
28
|
+
constructor();
|
|
29
|
+
/** The element that is saved: the one `for` names, or the one you set. */
|
|
30
|
+
get target(): Element | undefined;
|
|
31
|
+
set target(element: Element | undefined);
|
|
32
|
+
/** Your own labels over the language's, for example `{ png: "Download PNG" }`. */
|
|
33
|
+
set words(words: Partial<SaveWords>);
|
|
34
|
+
/** Saves the target in a format (the first button's by default) and resolves with the picture; rejects with the error the event reports. */
|
|
35
|
+
save(format?: Format): Promise<SaveDetail>;
|
|
36
|
+
connectedCallback(): void;
|
|
37
|
+
disconnectedCallback(): void;
|
|
38
|
+
attributeChangedCallback(name: string): void;
|
|
39
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// <kazoku-save-image>: Save PNG and Save SVG buttons for a canvas, an SVG or an element on the page. It never draws the scene: it saves what the target shows, hands the picture to the
|
|
2
|
+
// browser as a download, and tells the page with a `kazoku-save` event (or `kazoku-saveerror`).
|
|
3
|
+
import { adoptStyles } from "../shared/adopt.js";
|
|
4
|
+
import { watchScheme } from "../shared/scheme.js";
|
|
5
|
+
import { EVENT, ERROR_EVENT, TAG } from "./index.js";
|
|
6
|
+
import { filenameFor, formatsFor, parseFormats } from "./format.js";
|
|
7
|
+
import { downloadBlob, kindOf, renderImage } from "./render.js";
|
|
8
|
+
import { STYLES } from "./styles.js";
|
|
9
|
+
import { languageOf, wordsFor } from "./words.js";
|
|
10
|
+
export { EVENT, ERROR_EVENT, TAG };
|
|
11
|
+
const icon = `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M11 3h2v9.2l3.3-3.3 1.4 1.4L12 16.1l-5.7-5.8 1.4-1.4 3.3 3.3zM5 18h14v2H5z"/></svg>`;
|
|
12
|
+
/**
|
|
13
|
+
* Attributes: `for` (the id of the canvas, SVG or element to save; or set `element.target`), `formats` (`png svg`, the buttons, in order; one the target cannot give is left out),
|
|
14
|
+
* `name` (the file name without its extension; `image` by default), `scale` (PNG pixels for each CSS pixel of an SVG or element; 2 by default; a canvas is saved at its own size),
|
|
15
|
+
* `background` (a CSS colour behind a PNG; transparent without it), `manual` (make the picture and send the event, but do not download), `no-inline-styles` (leave the computed styles out of what is saved),
|
|
16
|
+
* `lang` (`en` or `ja`), `theme` (`light` or `dark`: its own look, otherwise the page's).
|
|
17
|
+
*/
|
|
18
|
+
export class SaveImageElement extends HTMLElement {
|
|
19
|
+
static observedAttributes = ["for", "formats", "lang"];
|
|
20
|
+
#root;
|
|
21
|
+
#target;
|
|
22
|
+
#words = {};
|
|
23
|
+
#ready = false;
|
|
24
|
+
#styled = false;
|
|
25
|
+
#busy = false;
|
|
26
|
+
#stop = [];
|
|
27
|
+
#clear;
|
|
28
|
+
constructor() {
|
|
29
|
+
super();
|
|
30
|
+
this.#root = this.attachShadow({ mode: "open" });
|
|
31
|
+
}
|
|
32
|
+
/** The element that is saved: the one `for` names, or the one you set. */
|
|
33
|
+
get target() {
|
|
34
|
+
return this.#target;
|
|
35
|
+
}
|
|
36
|
+
set target(element) {
|
|
37
|
+
this.#target = element;
|
|
38
|
+
this.#render();
|
|
39
|
+
}
|
|
40
|
+
/** Your own labels over the language's, for example `{ png: "Download PNG" }`. */
|
|
41
|
+
set words(words) {
|
|
42
|
+
this.#words = words;
|
|
43
|
+
this.#render();
|
|
44
|
+
}
|
|
45
|
+
/** Saves the target in a format (the first button's by default) and resolves with the picture; rejects with the error the event reports. */
|
|
46
|
+
async save(format = this.#formats()[0] ?? "png") {
|
|
47
|
+
return this.#save(format, "api");
|
|
48
|
+
}
|
|
49
|
+
connectedCallback() {
|
|
50
|
+
if (!this.#styled) {
|
|
51
|
+
adoptStyles(this.#root, STYLES, this.nonce);
|
|
52
|
+
this.#styled = true;
|
|
53
|
+
}
|
|
54
|
+
this.#stop.push(watchScheme(this));
|
|
55
|
+
this.#resolveTarget();
|
|
56
|
+
if (this.#target === undefined && this.ownerDocument.readyState === "loading")
|
|
57
|
+
this.ownerDocument.addEventListener("DOMContentLoaded", () => this.#resolveTarget(), { once: true });
|
|
58
|
+
this.#ready = true;
|
|
59
|
+
this.#render();
|
|
60
|
+
}
|
|
61
|
+
disconnectedCallback() {
|
|
62
|
+
for (const stop of this.#stop.splice(0))
|
|
63
|
+
stop();
|
|
64
|
+
globalThis.clearTimeout(this.#clear);
|
|
65
|
+
this.#ready = false;
|
|
66
|
+
}
|
|
67
|
+
attributeChangedCallback(name) {
|
|
68
|
+
if (!this.#ready)
|
|
69
|
+
return;
|
|
70
|
+
if (name === "for")
|
|
71
|
+
this.#resolveTarget();
|
|
72
|
+
else
|
|
73
|
+
this.#render();
|
|
74
|
+
}
|
|
75
|
+
#resolveTarget() {
|
|
76
|
+
const id = this.getAttribute("for");
|
|
77
|
+
if (id === null)
|
|
78
|
+
return;
|
|
79
|
+
const root = this.getRootNode();
|
|
80
|
+
const found = root.getElementById?.(id) ?? this.ownerDocument.getElementById(id);
|
|
81
|
+
if (found !== null && found !== undefined) {
|
|
82
|
+
this.#target = found;
|
|
83
|
+
this.#render();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** The formats the buttons offer: those asked for that the target can give (all of them while there is no target yet). */
|
|
87
|
+
#formats() {
|
|
88
|
+
const asked = parseFormats(this.getAttribute("formats"));
|
|
89
|
+
if (this.#target === undefined)
|
|
90
|
+
return asked;
|
|
91
|
+
const can = formatsFor(kindOf(this.#target));
|
|
92
|
+
return asked.filter((format) => can.includes(format));
|
|
93
|
+
}
|
|
94
|
+
#render() {
|
|
95
|
+
if (!this.#ready)
|
|
96
|
+
return;
|
|
97
|
+
const language = languageOf(this.getAttribute("lang") ?? this.closest("[lang]")?.getAttribute("lang") ?? this.ownerDocument.documentElement.lang);
|
|
98
|
+
const words = wordsFor(language, this.#words);
|
|
99
|
+
const formats = this.#formats();
|
|
100
|
+
let group = this.#root.querySelector(".group");
|
|
101
|
+
if (group === null) {
|
|
102
|
+
group = this.ownerDocument.createElement("div");
|
|
103
|
+
group.className = "group";
|
|
104
|
+
group.setAttribute("part", "group");
|
|
105
|
+
group.setAttribute("role", "group");
|
|
106
|
+
const status = this.ownerDocument.createElement("span");
|
|
107
|
+
status.className = "vh";
|
|
108
|
+
status.setAttribute("role", "status");
|
|
109
|
+
this.#root.append(group, status);
|
|
110
|
+
}
|
|
111
|
+
group.setAttribute("aria-label", words.group);
|
|
112
|
+
group.setAttribute("lang", language);
|
|
113
|
+
const have = [...group.querySelectorAll("button")];
|
|
114
|
+
if (have.length !== formats.length || have.some((button, index) => button.dataset.v !== formats[index])) {
|
|
115
|
+
group.replaceChildren(...formats.map((format) => {
|
|
116
|
+
const button = this.ownerDocument.createElement("button");
|
|
117
|
+
button.type = "button";
|
|
118
|
+
button.dataset.v = format;
|
|
119
|
+
button.setAttribute("part", "button");
|
|
120
|
+
button.innerHTML = `${icon}<span class="t"></span>`;
|
|
121
|
+
button.addEventListener("click", () => {
|
|
122
|
+
if (!this.#busy)
|
|
123
|
+
void this.#save(format, "user").catch(() => undefined);
|
|
124
|
+
});
|
|
125
|
+
return button;
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
for (const button of group.querySelectorAll("button"))
|
|
129
|
+
button.querySelector(".t").textContent = words[button.dataset.v];
|
|
130
|
+
}
|
|
131
|
+
async #save(format, reason) {
|
|
132
|
+
const words = wordsFor(languageOf(this.getAttribute("lang") ?? this.closest("[lang]")?.getAttribute("lang") ?? this.ownerDocument.documentElement.lang), this.#words);
|
|
133
|
+
const say = (text) => {
|
|
134
|
+
const status = this.#root.querySelector(".vh");
|
|
135
|
+
if (status !== null)
|
|
136
|
+
status.textContent = text;
|
|
137
|
+
globalThis.clearTimeout(this.#clear);
|
|
138
|
+
this.#clear = globalThis.setTimeout(() => status !== null && (status.textContent = ""), 3000);
|
|
139
|
+
};
|
|
140
|
+
try {
|
|
141
|
+
if (this.#target === undefined)
|
|
142
|
+
this.#resolveTarget();
|
|
143
|
+
if (this.#target === undefined)
|
|
144
|
+
throw new Error("there is nothing to save: `for` names no element");
|
|
145
|
+
if (this.#busy)
|
|
146
|
+
throw new Error("a picture is already being made");
|
|
147
|
+
this.#busy = true;
|
|
148
|
+
this.#busyButtons(true);
|
|
149
|
+
const scale = Number(this.getAttribute("scale"));
|
|
150
|
+
const background = this.getAttribute("background") ?? undefined;
|
|
151
|
+
const picture = await renderImage(this.#target, {
|
|
152
|
+
format,
|
|
153
|
+
scale: Number.isFinite(scale) && scale > 0 ? scale : 2,
|
|
154
|
+
...(background !== undefined ? { background } : {}),
|
|
155
|
+
inlineStyles: !this.hasAttribute("no-inline-styles"),
|
|
156
|
+
});
|
|
157
|
+
const filename = filenameFor(this.getAttribute("name"), format);
|
|
158
|
+
if (!this.hasAttribute("manual"))
|
|
159
|
+
downloadBlob(picture.blob, filename);
|
|
160
|
+
const detail = { ...picture, filename, reason };
|
|
161
|
+
say(words.saved);
|
|
162
|
+
this.dispatchEvent(new CustomEvent(EVENT, { detail, bubbles: true, composed: true }));
|
|
163
|
+
return detail;
|
|
164
|
+
}
|
|
165
|
+
catch (caught) {
|
|
166
|
+
const error = caught instanceof Error ? caught : new Error(String(caught));
|
|
167
|
+
say(words.failed);
|
|
168
|
+
this.dispatchEvent(new CustomEvent(ERROR_EVENT, { detail: { format, error, reason }, bubbles: true, composed: true }));
|
|
169
|
+
throw error;
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
if (this.#busy) {
|
|
173
|
+
this.#busy = false;
|
|
174
|
+
this.#busyButtons(false);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
#busyButtons(busy) {
|
|
179
|
+
for (const button of this.#root.querySelectorAll("button")) {
|
|
180
|
+
if (busy)
|
|
181
|
+
button.setAttribute("aria-busy", "true");
|
|
182
|
+
else
|
|
183
|
+
button.removeAttribute("aria-busy");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const FORMATS: readonly ["png", "svg"];
|
|
2
|
+
export type Format = (typeof FORMATS)[number];
|
|
3
|
+
/** What a target is: a canvas, an SVG, or any other element (drawn through an SVG `foreignObject`). */
|
|
4
|
+
export type TargetKind = "canvas" | "svg" | "element";
|
|
5
|
+
export declare const MIME: Record<Format, string>;
|
|
6
|
+
/** The most a picture may be on one side, and in all: past these a canvas fails in some browsers, so the scale is lowered instead. */
|
|
7
|
+
export declare const MAX_SIDE = 8192;
|
|
8
|
+
export declare const MAX_PIXELS = 36000000;
|
|
9
|
+
/** The formats of `formats="png svg"`: the known ones, once each, in the order given. Nothing usable gives `png` and `svg`. */
|
|
10
|
+
export declare function parseFormats(value: string | null | undefined): Format[];
|
|
11
|
+
/** Which formats a kind of target can give: an SVG both, a canvas and any other element a PNG. */
|
|
12
|
+
export declare const formatsFor: (kind: TargetKind) => readonly Format[];
|
|
13
|
+
/** A file name that is safe on any system, with the format's extension: no path, no control or reserved characters, at most 80 characters of name. */
|
|
14
|
+
export declare function filenameFor(name: string | null | undefined, format: Format): string;
|
|
15
|
+
/** The pixels of a PNG of a target `width` by `height` CSS pixels at `scale`: whole pixels, and the scale lowered so that no side passes MAX_SIDE and the whole stays within MAX_PIXELS. */
|
|
16
|
+
export declare function pixelSize(width: number, height: number, scale: number): {
|
|
17
|
+
width: number;
|
|
18
|
+
height: number;
|
|
19
|
+
scale: number;
|
|
20
|
+
};
|