@johnmorrisdotca/kazoku-ui 0.2.1 → 0.7.2

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +72 -0
  2. package/README.md +100 -6
  3. package/dist/address-state/address.d.ts +76 -0
  4. package/dist/address-state/address.js +171 -0
  5. package/dist/address-state/index.d.ts +1 -0
  6. package/dist/address-state/index.js +2 -0
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.js +1 -1
  9. package/dist/language-picker/define.d.ts +1 -0
  10. package/dist/language-picker/define.js +9 -0
  11. package/dist/language-picker/element.d.ts +39 -0
  12. package/dist/language-picker/element.js +194 -0
  13. package/dist/language-picker/index.d.ts +4 -0
  14. package/dist/language-picker/index.js +5 -0
  15. package/dist/language-picker/languages.d.ts +38 -0
  16. package/dist/language-picker/languages.js +100 -0
  17. package/dist/language-picker/styles.d.ts +1 -0
  18. package/dist/language-picker/styles.js +3 -0
  19. package/dist/language-picker/words.d.ts +8 -0
  20. package/dist/language-picker/words.js +7 -0
  21. package/dist/save-image/define.d.ts +1 -0
  22. package/dist/save-image/define.js +9 -0
  23. package/dist/save-image/element.d.ts +39 -0
  24. package/dist/save-image/element.js +186 -0
  25. package/dist/save-image/format.d.ts +20 -0
  26. package/dist/save-image/format.js +37 -0
  27. package/dist/save-image/index.d.ts +6 -0
  28. package/dist/save-image/index.js +8 -0
  29. package/dist/save-image/render.d.ts +55 -0
  30. package/dist/save-image/render.js +188 -0
  31. package/dist/save-image/styles.d.ts +1 -0
  32. package/dist/save-image/styles.js +9 -0
  33. package/dist/save-image/words.d.ts +12 -0
  34. package/dist/save-image/words.js +7 -0
  35. package/dist/shared/adopt.d.ts +4 -0
  36. package/dist/shared/adopt.js +34 -0
  37. package/dist/shared/base.d.ts +1 -0
  38. package/dist/shared/base.js +40 -0
  39. package/dist/shared/scheme.d.ts +1 -0
  40. package/dist/shared/scheme.js +19 -0
  41. package/dist/shared/store.d.ts +2 -0
  42. package/dist/shared/store.js +17 -0
  43. package/dist/shared/target.d.ts +2 -0
  44. package/dist/shared/target.js +11 -0
  45. package/dist/theme-switch/define.d.ts +1 -0
  46. package/dist/theme-switch/define.js +9 -0
  47. package/dist/theme-switch/element.d.ts +39 -0
  48. package/dist/theme-switch/element.js +203 -0
  49. package/dist/theme-switch/index.d.ts +4 -0
  50. package/dist/theme-switch/index.js +5 -0
  51. package/dist/theme-switch/styles.d.ts +1 -0
  52. package/dist/theme-switch/styles.js +8 -0
  53. package/dist/theme-switch/theme.d.ts +29 -0
  54. package/dist/theme-switch/theme.js +43 -0
  55. package/dist/theme-switch/words.d.ts +11 -0
  56. package/dist/theme-switch/words.js +7 -0
  57. package/dist/view-controls/controller.d.ts +22 -2
  58. package/dist/view-controls/controller.js +50 -9
  59. package/dist/view-controls/element.d.ts +7 -2
  60. package/dist/view-controls/element.js +84 -26
  61. package/dist/view-controls/math.d.ts +2 -2
  62. package/dist/view-controls/math.js +4 -3
  63. package/package.json +67 -4
@@ -0,0 +1,203 @@
1
+ // <kazoku-theme-switch>: Light, Dark and Auto as a row of buttons. It writes the choice as `data-theme` on a target (the page's <html> by default; `auto` removes it),
2
+ // keeps it in the address and, if asked, in localStorage, and tells the page with a `kazoku-themechange` event. It draws nothing of the page: the page's CSS reads `data-theme`.
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 { STYLES } from "./styles.js";
10
+ import { applyTheme, parseModes, parseTheme, readTheme, resolveTheme, THEME_ADDRESS_KEY, THEME_STORAGE_KEY, themeOf } from "./theme.js";
11
+ import { languageOf, wordsFor } from "./words.js";
12
+ export { EVENT, TAG };
13
+ const icon = (body) => `<svg viewBox="0 0 24 24" aria-hidden="true">${body}</svg>`;
14
+ const ICON = {
15
+ light: icon('<path d="M12 7a5 5 0 1 0 0 10 5 5 0 0 0 0-10zM11 1h2v3h-2zM11 20h2v3h-2zM1 11h3v2H1zM20 11h3v2h-3zM4.2 5.6l1.4-1.4 2.1 2.1-1.4 1.4zM16.3 17.7l1.4-1.4 2.1 2.1-1.4 1.4zM5.6 19.8l-1.4-1.4 2.1-2.1 1.4 1.4zM17.7 7.7l-1.4-1.4 2.1-2.1 1.4 1.4z"/>'),
16
+ dark: icon('<path d="M20 14.5A8.5 8.5 0 0 1 9.5 4 8.5 8.5 0 1 0 20 14.5z"/>'),
17
+ auto: icon('<circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 3.5a8.5 8.5 0 0 0 0 17z"/>'),
18
+ };
19
+ /**
20
+ * Attributes: `value` (`light`, `dark` or `auto`; the choice), `target` (a CSS selector for the element that gets `data-theme`; the page's <html> by default),
21
+ * `modes` (which buttons: `light dark` leaves Auto out), `address` (keep the choice in `?theme=`, or the key `address-key` names), `storage` (also remember it in
22
+ * localStorage under that key; a bare `storage` uses `kazoku-theme`), `lang` (`en` or `ja`), `theme` (`light` or `dark`: its own look, otherwise the page's).
23
+ * The choice is read, in this order, from the address, the store, `value`, then what the target already holds.
24
+ */
25
+ export class ThemeSwitchElement extends HTMLElement {
26
+ static observedAttributes = ["value", "target", "modes", "lang"];
27
+ #root;
28
+ #choice = "auto";
29
+ #target;
30
+ #words = {};
31
+ #ready = false;
32
+ #styled = false;
33
+ #address = createAddressState({ delay: 0 });
34
+ #stop = [];
35
+ #watching;
36
+ constructor() {
37
+ super();
38
+ this.#root = this.attachShadow({ mode: "open" });
39
+ }
40
+ /** The choice now: `light`, `dark` or `auto`. Setting it applies it (event reason `set`). */
41
+ get value() {
42
+ return this.#choice;
43
+ }
44
+ set value(next) {
45
+ const theme = parseTheme(next);
46
+ if (theme !== undefined)
47
+ this.#choose(theme, "set");
48
+ }
49
+ /** The colours showing now: `light` or `dark`. */
50
+ get scheme() {
51
+ return resolveTheme(this.#choice, this.#prefersDark());
52
+ }
53
+ /** The element the choice is written on. */
54
+ get target() {
55
+ return this.#target;
56
+ }
57
+ set target(element) {
58
+ this.#aim(element);
59
+ }
60
+ /** Your own labels over the language's, for example `{ auto: "System" }`. */
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.#resolveTarget();
72
+ const target = this.#target;
73
+ const remembered = readTheme({
74
+ ...(this.hasAttribute("address") ? { search: globalThis.location?.search ?? "", addressKey: this.#addressKey() } : {}),
75
+ ...(this.hasAttribute("storage") ? { storage: localStore(), storageKey: this.#storageKey() } : {}),
76
+ });
77
+ this.#choice = remembered ?? parseTheme(this.getAttribute("value")) ?? (target !== undefined && target.hasAttribute("data-theme") ? themeOf(target) : "auto");
78
+ if (target !== undefined)
79
+ applyTheme(target, this.#choice);
80
+ this.#render();
81
+ const media = globalThis.matchMedia?.("(prefers-color-scheme: dark)");
82
+ const onSystem = () => {
83
+ if (this.#choice === "auto")
84
+ this.#dispatch("system");
85
+ };
86
+ media?.addEventListener("change", onSystem);
87
+ this.#stop.push(() => media?.removeEventListener("change", onSystem));
88
+ this.#ready = true;
89
+ // Said once the page has had its turn to listen; `value` can be read at any time.
90
+ queueMicrotask(() => {
91
+ if (this.isConnected)
92
+ this.#dispatch("init");
93
+ });
94
+ }
95
+ disconnectedCallback() {
96
+ for (const stop of this.#stop.splice(0))
97
+ stop();
98
+ this.#watching?.disconnect();
99
+ this.#watching = undefined;
100
+ this.#ready = false;
101
+ }
102
+ attributeChangedCallback(name, _old, value) {
103
+ if (!this.#ready)
104
+ return;
105
+ if (name === "value") {
106
+ const theme = parseTheme(value);
107
+ if (theme !== undefined)
108
+ this.#choose(theme, "set");
109
+ }
110
+ else if (name === "target") {
111
+ this.#resolveTarget();
112
+ if (this.#target !== undefined)
113
+ applyTheme(this.#target, this.#choice);
114
+ }
115
+ else
116
+ this.#render();
117
+ }
118
+ #addressKey() {
119
+ return this.getAttribute("address-key") || THEME_ADDRESS_KEY;
120
+ }
121
+ #storageKey() {
122
+ return this.getAttribute("storage") || THEME_STORAGE_KEY;
123
+ }
124
+ #prefersDark() {
125
+ return globalThis.matchMedia?.("(prefers-color-scheme: dark)").matches === true;
126
+ }
127
+ #resolveTarget() {
128
+ this.#aim(resolveTarget(this, this.getAttribute("target")));
129
+ }
130
+ #aim(element) {
131
+ if (element === this.#target)
132
+ return;
133
+ this.#watching?.disconnect();
134
+ this.#target = element;
135
+ if (element === undefined)
136
+ return;
137
+ // Something else writing the target's data-theme (a page's own button, another switch) moves this one with it.
138
+ this.#watching = new MutationObserver(() => {
139
+ const held = themeOf(element);
140
+ if (held !== this.#choice) {
141
+ this.#choice = held;
142
+ this.#render();
143
+ this.#dispatch("external");
144
+ }
145
+ });
146
+ this.#watching.observe(element, { attributes: true, attributeFilter: ["data-theme"] });
147
+ }
148
+ #choose(theme, reason) {
149
+ if (this.#target !== undefined)
150
+ applyTheme(this.#target, theme);
151
+ const changed = theme !== this.#choice;
152
+ this.#choice = theme;
153
+ this.#render();
154
+ if (this.hasAttribute("address"))
155
+ this.#address.set(this.#addressKey(), theme === "auto" ? null : theme);
156
+ if (this.hasAttribute("storage")) {
157
+ remember(this.#storageKey(), theme);
158
+ }
159
+ if (changed || reason === "user")
160
+ this.#dispatch(reason);
161
+ }
162
+ #language() {
163
+ return languageOf(this.getAttribute("lang") ?? this.closest("[lang]")?.getAttribute("lang") ?? this.ownerDocument.documentElement.lang);
164
+ }
165
+ #render() {
166
+ const language = this.#language();
167
+ const words = wordsFor(language, this.#words);
168
+ const modes = parseModes(this.getAttribute("modes"));
169
+ let group = this.#root.querySelector(".group");
170
+ if (group === null) {
171
+ group = this.ownerDocument.createElement("div");
172
+ group.className = "group";
173
+ group.setAttribute("part", "group");
174
+ group.setAttribute("role", "group");
175
+ this.#root.append(group);
176
+ }
177
+ group.setAttribute("aria-label", words.group);
178
+ group.setAttribute("lang", language);
179
+ const have = [...group.querySelectorAll("button")];
180
+ if (have.length !== modes.length || have.some((button, index) => button.dataset.v !== modes[index])) {
181
+ group.replaceChildren(...modes.map((mode) => {
182
+ const button = this.ownerDocument.createElement("button");
183
+ button.type = "button";
184
+ button.dataset.v = mode;
185
+ button.setAttribute("part", "button");
186
+ button.innerHTML = `${ICON[mode]}<span class="t"></span>`;
187
+ button.addEventListener("click", () => this.#choose(mode, "user"));
188
+ return button;
189
+ }));
190
+ }
191
+ for (const button of group.querySelectorAll("button")) {
192
+ const mode = button.dataset.v;
193
+ button.querySelector(".t").textContent = words[mode];
194
+ button.setAttribute("aria-pressed", String(mode === this.#choice));
195
+ }
196
+ }
197
+ #dispatch(reason) {
198
+ if (this.#target === undefined)
199
+ return;
200
+ const detail = { choice: this.#choice, scheme: this.scheme, reason, target: this.#target };
201
+ this.dispatchEvent(new CustomEvent(EVENT, { detail, bubbles: true, composed: true }));
202
+ }
203
+ }
@@ -0,0 +1,4 @@
1
+ export { applyTheme, parseModes, parseTheme, readTheme, resolveTheme, THEME_ADDRESS_KEY, THEME_ATTRIBUTE, THEME_STORAGE_KEY, themeOf, THEMES, type RememberedTheme, type Scheme, type Theme } from "./theme.ts";
2
+ export { languageOf, WORDS, wordsFor, type Language, type ThemeWords } from "./words.ts";
3
+ export declare const EVENT = "kazoku-themechange";
4
+ export declare const TAG = "kazoku-theme-switch";
@@ -0,0 +1,5 @@
1
+ // The theme switch without the element: the choices, how one is applied and where one is remembered. Nothing here touches the DOM until `applyTheme` is called with an element.
2
+ export { applyTheme, parseModes, parseTheme, readTheme, resolveTheme, THEME_ADDRESS_KEY, THEME_ATTRIBUTE, THEME_STORAGE_KEY, themeOf, THEMES } from "./theme.js";
3
+ export { languageOf, WORDS, wordsFor } from "./words.js";
4
+ export const EVENT = "kazoku-themechange";
5
+ export const TAG = "kazoku-theme-switch";
@@ -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\n@media (max-width: 640px) {\n .t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }\n button { padding: 0; }\n}\n";
@@ -0,0 +1,8 @@
1
+ import { BASE_STYLES } from "../shared/base.js";
2
+ // Light, Dark and Auto as one row of buttons. On a phone the words are left to a screen reader and the icons stay.
3
+ export const STYLES = `${BASE_STYLES}
4
+ @media (max-width: 640px) {
5
+ .t { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
6
+ button { padding: 0; }
7
+ }
8
+ `;
@@ -0,0 +1,29 @@
1
+ export declare const THEMES: readonly ["light", "dark", "auto"];
2
+ export type Theme = (typeof THEMES)[number];
3
+ export type Scheme = "light" | "dark";
4
+ /** The attribute the choice is written to on the target. `auto` removes it, so the page's own `prefers-color-scheme` rules decide. */
5
+ export declare const THEME_ATTRIBUTE = "data-theme";
6
+ /** The address parameter, with `address`. */
7
+ export declare const THEME_ADDRESS_KEY = "theme";
8
+ /** The localStorage key, with a bare `storage`. */
9
+ export declare const THEME_STORAGE_KEY = "kazoku-theme";
10
+ export declare const parseTheme: (value: unknown) => Theme | undefined;
11
+ /** The colours a choice gives: light and dark are themselves, auto is the device's. */
12
+ export declare const resolveTheme: (choice: Theme, prefersDark: boolean) => Scheme;
13
+ /** The choices a switch offers, from `modes="light dark"`: the known ones, once each, in the order light, dark, auto. Nothing usable gives all three. */
14
+ export declare function parseModes(value: string | null | undefined): Theme[];
15
+ /** What a target holds now: `light` or `dark` when forced, `auto` when it says nothing or something else. */
16
+ export declare const themeOf: (target: Element) => Theme;
17
+ /** Writes the choice on the target (`data-theme="light"`, `"dark"`, or no attribute for auto). */
18
+ export declare function applyTheme(target: Element, choice: Theme): void;
19
+ /** Where a remembered choice may be read from. A page that keeps nothing passes neither. */
20
+ export interface RememberedTheme {
21
+ /** An address' search string such as `?theme=dark`, read with `addressKey`. */
22
+ search?: string;
23
+ addressKey?: string;
24
+ /** A localStorage-like object and the key in it. A store that throws is the same as an empty one. */
25
+ storage?: Pick<Storage, "getItem">;
26
+ storageKey?: string;
27
+ }
28
+ /** The remembered choice: the address first, then the store. `undefined` when neither holds a valid one. */
29
+ export declare function readTheme(from: RememberedTheme): Theme | undefined;
@@ -0,0 +1,43 @@
1
+ // The theme choice without the element: the three choices, what each means, and how a choice is applied to a page. Nothing here touches the DOM until a function is called with an element.
2
+ export const THEMES = ["light", "dark", "auto"];
3
+ /** The attribute the choice is written to on the target. `auto` removes it, so the page's own `prefers-color-scheme` rules decide. */
4
+ export const THEME_ATTRIBUTE = "data-theme";
5
+ /** The address parameter, with `address`. */
6
+ export const THEME_ADDRESS_KEY = "theme";
7
+ /** The localStorage key, with a bare `storage`. */
8
+ export const THEME_STORAGE_KEY = "kazoku-theme";
9
+ export const parseTheme = (value) => THEMES.includes(value) ? value : undefined;
10
+ /** The colours a choice gives: light and dark are themselves, auto is the device's. */
11
+ export const resolveTheme = (choice, prefersDark) => (choice === "auto" ? (prefersDark ? "dark" : "light") : choice);
12
+ /** The choices a switch offers, from `modes="light dark"`: the known ones, once each, in the order light, dark, auto. Nothing usable gives all three. */
13
+ export function parseModes(value) {
14
+ const named = new Set((value ?? "").split(/[\s,]+/).map((word) => parseTheme(word.toLowerCase())));
15
+ const kept = THEMES.filter((theme) => named.has(theme));
16
+ return kept.length > 0 ? kept : [...THEMES];
17
+ }
18
+ /** What a target holds now: `light` or `dark` when forced, `auto` when it says nothing or something else. */
19
+ export const themeOf = (target) => parseTheme(target.getAttribute(THEME_ATTRIBUTE)) ?? "auto";
20
+ /** Writes the choice on the target (`data-theme="light"`, `"dark"`, or no attribute for auto). */
21
+ export function applyTheme(target, choice) {
22
+ if (choice === "auto")
23
+ target.removeAttribute(THEME_ATTRIBUTE);
24
+ else
25
+ target.setAttribute(THEME_ATTRIBUTE, choice);
26
+ }
27
+ /** The remembered choice: the address first, then the store. `undefined` when neither holds a valid one. */
28
+ export function readTheme(from) {
29
+ if (from.search !== undefined) {
30
+ const asked = parseTheme(new URLSearchParams(from.search).get(from.addressKey ?? THEME_ADDRESS_KEY));
31
+ if (asked !== undefined)
32
+ return asked;
33
+ }
34
+ if (from.storage !== undefined) {
35
+ try {
36
+ return parseTheme(from.storage.getItem(from.storageKey ?? THEME_STORAGE_KEY));
37
+ }
38
+ catch {
39
+ /* a browser that keeps nothing */
40
+ }
41
+ }
42
+ return undefined;
43
+ }
@@ -0,0 +1,11 @@
1
+ export interface ThemeWords {
2
+ group: string;
3
+ light: string;
4
+ dark: string;
5
+ auto: string;
6
+ }
7
+ export type Language = "en" | "ja";
8
+ export declare const WORDS: Record<Language, ThemeWords>;
9
+ /** The language to use for a `lang` value such as "ja", "ja-JP" or "en-CA": Japanese for ja, English for anything else. */
10
+ export declare const languageOf: (lang: string | null | undefined) => Language;
11
+ export declare const wordsFor: (language: Language, overrides?: Partial<ThemeWords>) => ThemeWords;
@@ -0,0 +1,7 @@
1
+ export const WORDS = {
2
+ en: { group: "Colour theme", light: "Light", dark: "Dark", auto: "Auto" },
3
+ ja: { group: "テーマ", light: "ライト", dark: "ダーク", auto: "自動" },
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 });
@@ -14,6 +14,8 @@ export interface ChangeInfo {
14
14
  }
15
15
  /** What `bind` leaves alone by default: the page's own controls inside the view. */
16
16
  export declare const DEFAULT_IGNORE = "button, a, input, select, textarea, label, [data-kazoku-ignore]";
17
+ /** Which way Arrow Up tips the view: like a drag up (`"drag"`, the default) or the way the wheel turned up does (`"wheel"`, the other way). */
18
+ export type KeyTilt = "drag" | "wheel";
17
19
  export interface Clock {
18
20
  request(callback: (now: number) => void): number;
19
21
  cancel(id: number): void;
@@ -31,6 +33,14 @@ export interface ViewControllerOptions {
31
33
  fitScale?: number;
32
34
  /** A CSS selector for what inside the view `bind` leaves alone: a press on a match is not captured, so its click goes through. An empty string leaves nothing alone. */
33
35
  ignore?: string;
36
+ /** The point the zoom buttons and keys zoom about, in content coordinates: CSS pixels from the view's middle when it is at Fit (zoom 1, no pan). It moves with the picture, so the point stays where it is on screen while the view zooms. Left out, they zoom about the view's middle. */
37
+ anchor?: Point;
38
+ /** Which way Arrow Up tips the view: `"drag"` (the default, as a drag up) or `"wheel"` (as a wheel turned up). */
39
+ keyTilt?: KeyTilt;
40
+ /** True to stop the automatic turn when the reader presses the view, uses the wheel on it or presses a key it handles. False (the default) lets it carry on. Buttons and sliders never stop it. */
41
+ stopTurn?: boolean;
42
+ /** Round the tilt of a drag to a multiple of this many degrees; 0 (the default) leaves it unrounded, 1 gives whole degrees. */
43
+ tiltStep?: number;
34
44
  /** The view's size in CSS pixels, for holding the pan; `bind` gives it from the element when this is left out. */
35
45
  size?: () => Size | undefined;
36
46
  onChange?: (state: ViewState, info: ChangeInfo) => void;
@@ -44,6 +54,11 @@ export interface ViewController {
44
54
  readonly limits: Limits;
45
55
  /** What a pointer press leaves alone inside the view (see `ViewControllerOptions.ignore`). Can be changed at any time. */
46
56
  ignore: string;
57
+ /** The point the buttons and keys zoom about (see `ViewControllerOptions.anchor`); `undefined` for the view's middle. Can be changed at any time. */
58
+ anchor: Point | undefined;
59
+ keyTilt: KeyTilt;
60
+ stopTurn: boolean;
61
+ tiltStep: number;
47
62
  /** What zoom 1 means to the page (see `ViewControllerOptions.fitScale`). */
48
63
  readonly fitScale: number;
49
64
  /** The `state.zoom` the view opens at: 1 for "fit", else the starting scale divided by `fitScale`. The address leaves a zoom at this value out. */
@@ -53,15 +68,20 @@ export interface ViewController {
53
68
  /** Start at "fit" or at a scale relative to natural size, and go there now. */
54
69
  setStartZoom(zoom: "fit" | number): void;
55
70
  set(next: Partial<ViewState>, final?: boolean, reason?: string): void;
56
- play(): void;
71
+ /** Turn by itself. With `seconds`, stop by itself (as `pause`) after that many seconds of turning; time spent suspended does not count. `toggle` and a bare `play()` turn without a limit. */
72
+ play(seconds?: number): void;
57
73
  pause(): void;
58
74
  toggle(): void;
75
+ /** Stop the frame loop while nothing can see the view (it is off screen, the tab is hidden): no frames are asked for and no "auto" change is sent. `state.turning` stays true. Counts, so suspends nest. */
76
+ suspendTurn(): void;
77
+ /** Undo one `suspendTurn`; the turn goes on from where it was when the last one is undone. */
78
+ resumeTurn(): void;
59
79
  /** Hold the automatic turn still while a hand (a slider being dragged) has the angle; `releaseTurn` lets it carry on from where the hand left it. Counts, so holds nest. */
60
80
  holdTurn(): void;
61
81
  releaseTurn(): void;
62
82
  front(): void;
63
83
  fit(): void;
64
- /** Zoom to `next` about the point `at` (client pixels; the view's middle when left out). */
84
+ /** Zoom to `next` about the point `at` (client pixels); left out, about the `anchor`, else the view's middle. */
65
85
  zoomAt(next: number, at?: Point, final?: boolean): void;
66
86
  zoomBy(factor: number, at?: Point): void;
67
87
  /** Move the view by whole fractions of its size: `panBy(1, 0)` is a quarter of its width to the right. */
@@ -1,6 +1,7 @@
1
1
  import { clamp, clampPan, DEFAULT_LIMITS, dragTurn, PAN_STEP, pinchTo, wheelZoomFactor, wrapYaw, ZOOMED, zoomAbout } from "./math.js";
2
2
  /** What `bind` leaves alone by default: the page's own controls inside the view. */
3
3
  export const DEFAULT_IGNORE = "button, a, input, select, textarea, label, [data-kazoku-ignore]";
4
+ const HANDLED_KEYS = ["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "+", "=", "-", "_", "0", "Home"];
4
5
  const positive = (value) => (typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined);
5
6
  const browserClock = () => ({ request: (callback) => globalThis.requestAnimationFrame(callback), cancel: (id) => globalThis.cancelAnimationFrame(id) });
6
7
  export function createViewController(options = {}) {
@@ -11,6 +12,9 @@ export function createViewController(options = {}) {
11
12
  let frame = 0;
12
13
  let last = 0;
13
14
  let holds = 0;
15
+ let suspends = 0;
16
+ let limit = 0;
17
+ let turned = 0;
14
18
  let fitScale = positive(options.fitScale) ?? 1;
15
19
  let start = positive(options.zoom) ?? "fit";
16
20
  let atStart = true;
@@ -51,15 +55,17 @@ export function createViewController(options = {}) {
51
55
  const r = bound.getBoundingClientRect();
52
56
  return { x: r.left + r.width / 2, y: r.top + r.height / 2 };
53
57
  };
54
- /** Client pixels to pixels from the view's middle. */
58
+ /** Client pixels to pixels from the view's middle; with none, the anchor's place on screen now, else the middle. */
55
59
  const fromMiddle = (at) => {
56
- if (at === undefined)
57
- return { x: 0, y: 0 };
60
+ if (at === undefined) {
61
+ const a = controller.anchor;
62
+ return a === undefined ? { x: 0, y: 0 } : { x: state.panX + state.zoom * a.x, y: state.panY + state.zoom * a.y };
63
+ }
58
64
  const c = centre();
59
65
  return { x: at.x - c.x, y: at.y - c.y };
60
66
  };
61
67
  const tick = (now) => {
62
- if (!state.turning)
68
+ if (!state.turning || suspends > 0)
63
69
  return;
64
70
  frame = clock.request(tick);
65
71
  if (holds > 0) {
@@ -71,7 +77,10 @@ export function createViewController(options = {}) {
71
77
  if (dt === 0)
72
78
  return;
73
79
  state.yaw = wrapYaw(state.yaw + controller.speed * dt);
80
+ turned += dt;
74
81
  emit(false, "auto");
82
+ if (limit > 0 && turned >= limit)
83
+ controller.pause();
75
84
  };
76
85
  const controller = {
77
86
  state,
@@ -79,6 +88,10 @@ export function createViewController(options = {}) {
79
88
  speed: options.speed ?? 30,
80
89
  limits,
81
90
  ignore: options.ignore ?? DEFAULT_IGNORE,
91
+ anchor: options.anchor,
92
+ keyTilt: options.keyTilt ?? "drag",
93
+ stopTurn: options.stopTurn ?? false,
94
+ tiltStep: options.tiltStep ?? 0,
82
95
  get fitScale() {
83
96
  return fitScale;
84
97
  },
@@ -118,12 +131,15 @@ export function createViewController(options = {}) {
118
131
  }
119
132
  emit(final, reason);
120
133
  },
121
- play() {
134
+ play(seconds) {
122
135
  if (state.turning || !controller.turnable)
123
136
  return;
124
137
  state.turning = true;
125
138
  last = 0;
126
- frame = clock.request(tick);
139
+ turned = 0;
140
+ limit = positive(seconds) ?? 0;
141
+ if (suspends === 0)
142
+ frame = clock.request(tick);
127
143
  emit(true, "play");
128
144
  },
129
145
  pause() {
@@ -139,6 +155,20 @@ export function createViewController(options = {}) {
139
155
  else
140
156
  controller.play();
141
157
  },
158
+ suspendTurn() {
159
+ suspends += 1;
160
+ if (suspends === 1)
161
+ clock.cancel(frame);
162
+ },
163
+ resumeTurn() {
164
+ if (suspends === 0)
165
+ return;
166
+ suspends -= 1;
167
+ if (suspends > 0 || !state.turning)
168
+ return;
169
+ last = 0;
170
+ frame = clock.request(tick);
171
+ },
142
172
  holdTurn() {
143
173
  holds += 1;
144
174
  },
@@ -199,6 +229,10 @@ export function createViewController(options = {}) {
199
229
  holding = false;
200
230
  };
201
231
  const canTurn = () => controller.turnable;
232
+ const grab = () => {
233
+ if (controller.stopTurn)
234
+ controller.pause();
235
+ };
202
236
  const ignored = (e) => {
203
237
  if (controller.ignore === "")
204
238
  return false;
@@ -226,6 +260,7 @@ export function createViewController(options = {}) {
226
260
  return;
227
261
  if (ignored(e))
228
262
  return;
263
+ grab();
229
264
  pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
230
265
  try {
231
266
  view.setPointerCapture(e.pointerId);
@@ -273,7 +308,7 @@ export function createViewController(options = {}) {
273
308
  return;
274
309
  const r = view.getBoundingClientRect();
275
310
  // Up and down tips it for a mouse; on a finger the browser keeps the vertical drag to scroll the page, and the tilt slider tips it.
276
- take({ ...camera(), ...dragTurn(gesture.camera, dx, dy, { width: r.width, height: r.height }, e.pointerType !== "touch", limits) });
311
+ take({ ...camera(), ...dragTurn(gesture.camera, dx, dy, { width: r.width, height: r.height }, e.pointerType !== "touch", limits, controller.tiltStep) });
277
312
  emit(false, "drag");
278
313
  }
279
314
  };
@@ -310,6 +345,7 @@ export function createViewController(options = {}) {
310
345
  if (e.ctrlKey || e.metaKey) {
311
346
  // A trackpad's pinch arrives as Ctrl and the wheel; Command and the wheel does the same for a mouse.
312
347
  e.preventDefault();
348
+ grab();
313
349
  controller.zoomAt(state.zoom * wheelZoomFactor(e.deltaY, e.deltaMode), { x: e.clientX, y: e.clientY });
314
350
  return;
315
351
  }
@@ -321,6 +357,7 @@ export function createViewController(options = {}) {
321
357
  e.preventDefault();
322
358
  if (!canTurn())
323
359
  return;
360
+ grab();
324
361
  if (horizontal || e.shiftKey)
325
362
  controller.set({ yaw: state.yaw + (e.deltaX || e.deltaY) * 0.3 }, true, "wheel");
326
363
  else
@@ -329,6 +366,10 @@ export function createViewController(options = {}) {
329
366
  const onKey = (e) => {
330
367
  if (e.ctrlKey || e.metaKey || e.altKey)
331
368
  return;
369
+ if (!HANDLED_KEYS.includes(e.key))
370
+ return;
371
+ grab();
372
+ const up = controller.keyTilt === "wheel" ? -5 : 5;
332
373
  const big = e.shiftKey ? 45 : 15;
333
374
  const turn = (next) => {
334
375
  if (canTurn())
@@ -342,10 +383,10 @@ export function createViewController(options = {}) {
342
383
  turn({ yaw: state.yaw + big });
343
384
  break;
344
385
  case "ArrowUp":
345
- turn({ tilt: state.tilt + 5 });
386
+ turn({ tilt: state.tilt + up });
346
387
  break;
347
388
  case "ArrowDown":
348
- turn({ tilt: state.tilt - 5 });
389
+ turn({ tilt: state.tilt - up });
349
390
  break;
350
391
  case "+":
351
392
  case "=":
@@ -1,5 +1,6 @@
1
1
  import { type ViewController, type ViewState } from "./controller.ts";
2
2
  import { EVENT, TAG } from "./index.ts";
3
+ import { type Point } from "./math.ts";
3
4
  import { type ViewWords } from "./words.ts";
4
5
  export { EVENT, TAG };
5
6
  /** What the `kazoku-viewchange` event carries in `detail`. */
@@ -10,8 +11,9 @@ export interface ViewChangeDetail extends ViewState {
10
11
  /**
11
12
  * Attributes: `for` (the id of the view to bind), `lang` (en or ja; else the page's), `yaw`, `tilt` (the starting view), `zoom` (`fit`, the default, or a scale where 1
12
13
  * is the scene's natural size), `fit-scale` (the page's scale at Fit: what zoom 1 means), `ignore` (a selector for what inside the view a pointer press leaves alone;
13
- * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `speed` (degrees a second),
14
- * `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
14
+ * buttons, links and form controls by default), `autoplay` (turn by itself from the start, unless the reader prefers reduced motion), `autoplay-for` (stop that turn by itself after this many seconds of turning), `speed` (degrees a second),
15
+ * `anchor` (`x,y`: the point the zoom buttons and keys zoom about, in pixels from the view's middle at Fit), `key-tilt` (`drag`, the default, or `wheel`: which way Arrow Up tips),
16
+ * `stop-turn` (a press, the wheel or a key on the view stops the automatic turn), `tilt-step` (round a drag's tilt to a multiple of this many degrees), `flat` (the view cannot turn), `tilt-max`, `zoom-min`, `zoom-max`, `address` (keep the choices in the page's address), `address-keys` (`yaw:a,tilt:b`), `theme` (light
15
17
  * or dark), `overlay` (sit over the bottom of a positioned parent).
16
18
  */
17
19
  export declare class ViewControlsElement extends HTMLElement {
@@ -32,6 +34,9 @@ export declare class ViewControlsElement extends HTMLElement {
32
34
  front(): void;
33
35
  fit(): void;
34
36
  set(next: Partial<ViewState>): void;
37
+ /** The point the zoom buttons and keys zoom about: `{ x, y }` in CSS pixels from the view's middle when it is at Fit, moving with the picture. `undefined` zooms about the view's middle. The `anchor` attribute (`"x,y"`) sets the same. */
38
+ get anchor(): Point | undefined;
39
+ set anchor(point: Point | undefined | null);
35
40
  /** What zoom 1 means to the page: the scene is drawn at `state.zoom * fitScale`. */
36
41
  get fitScale(): number;
37
42
  setFitScale(scale: number): void;