@toonstrip/element 0.1.0
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/dist/comic-strip-element.d.ts +18 -0
- package/dist/comic-strip-element.js +223 -0
- package/dist/describe.d.ts +7 -0
- package/dist/describe.js +21 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -0
- package/dist/load-pack.d.ts +15 -0
- package/dist/load-pack.js +75 -0
- package/package.json +31 -0
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ComicStripDocument } from "@toonstrip/schema";
|
|
2
|
+
export declare class ComicStripElement extends HTMLElement {
|
|
3
|
+
#private;
|
|
4
|
+
constructor();
|
|
5
|
+
static get observedAttributes(): string[];
|
|
6
|
+
connectedCallback(): void;
|
|
7
|
+
disconnectedCallback(): void;
|
|
8
|
+
attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null): void;
|
|
9
|
+
/** The strip to render, as data (validated at the boundary). */
|
|
10
|
+
get document(): ComicStripDocument | null;
|
|
11
|
+
set document(doc: ComicStripDocument | null);
|
|
12
|
+
/** Base URLs of registered asset packs, searched in the order given. */
|
|
13
|
+
get packs(): string[];
|
|
14
|
+
set packs(packs: string[]);
|
|
15
|
+
/** Column count as of the last layout pass — exposed for tests/consumers. */
|
|
16
|
+
get columns(): number;
|
|
17
|
+
}
|
|
18
|
+
export declare function registerComicStripElement(tagName?: string): void;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<comic-strip>` — the embed primitive (dev plan §5). Ported from
|
|
3
|
+
* `grues-in-comic`'s `web/src/comic-view.ts` mechanism (canvas pool,
|
|
4
|
+
* ResizeObserver relayout, devicePixelRatio backing store, IntersectionObserver
|
|
5
|
+
* visibility gating, per-panel a11y), with the turn/session/incremental-append
|
|
6
|
+
* machinery stripped out: a `ComicStripDocument` here is authored data handed
|
|
7
|
+
* over whole, not a stream of panels a game emits (dev plan §0's non-goals).
|
|
8
|
+
*/
|
|
9
|
+
import { layoutStrip, renderPanel, selectFigures } from "@toonstrip/core";
|
|
10
|
+
import { validateDocument } from "@toonstrip/schema";
|
|
11
|
+
import { describePanel } from "./describe.js";
|
|
12
|
+
import { loadCastFromPacks } from "./load-pack.js";
|
|
13
|
+
const GUTTER = 10;
|
|
14
|
+
function copyCycles(cycles) {
|
|
15
|
+
return new Map([...cycles].map(([id, cycle]) => [id, { ...cycle }]));
|
|
16
|
+
}
|
|
17
|
+
function characterIdsOf(doc) {
|
|
18
|
+
return [...new Set(doc.panels.flatMap((panel) => panel.bodies.map((body) => body.character)))];
|
|
19
|
+
}
|
|
20
|
+
function backdropIdsOf(doc) {
|
|
21
|
+
return [...new Set(doc.panels.filter((panel) => !panel.dark).map((panel) => panel.backdrop))];
|
|
22
|
+
}
|
|
23
|
+
const STYLE = `
|
|
24
|
+
:host { display: block; }
|
|
25
|
+
.strip {
|
|
26
|
+
display: grid;
|
|
27
|
+
gap: ${GUTTER}px;
|
|
28
|
+
justify-content: center;
|
|
29
|
+
}
|
|
30
|
+
.panel { display: block; background: #111; }
|
|
31
|
+
.notice { color: #b00; font: 14px sans-serif; }
|
|
32
|
+
`;
|
|
33
|
+
export class ComicStripElement extends HTMLElement {
|
|
34
|
+
#root;
|
|
35
|
+
#strip;
|
|
36
|
+
#resizeObserver;
|
|
37
|
+
#visibilityObserver = null;
|
|
38
|
+
#doc = null;
|
|
39
|
+
#packs = [];
|
|
40
|
+
#cast = null;
|
|
41
|
+
#slots = [];
|
|
42
|
+
#frozen = new WeakMap();
|
|
43
|
+
#size = { width: 400, height: 340 };
|
|
44
|
+
#columns = 1;
|
|
45
|
+
#buildToken = 0;
|
|
46
|
+
constructor() {
|
|
47
|
+
super();
|
|
48
|
+
this.#root = this.attachShadow({ mode: "open" });
|
|
49
|
+
const style = document.createElement("style");
|
|
50
|
+
style.textContent = STYLE;
|
|
51
|
+
this.#strip = document.createElement("div");
|
|
52
|
+
this.#strip.className = "strip";
|
|
53
|
+
this.#root.append(style, this.#strip);
|
|
54
|
+
this.#resizeObserver = new ResizeObserver(() => this.#relayout());
|
|
55
|
+
}
|
|
56
|
+
static get observedAttributes() {
|
|
57
|
+
return ["src"];
|
|
58
|
+
}
|
|
59
|
+
connectedCallback() {
|
|
60
|
+
this.#resizeObserver.observe(this);
|
|
61
|
+
const src = this.getAttribute("src");
|
|
62
|
+
if (src)
|
|
63
|
+
this.#loadFromSrc(src);
|
|
64
|
+
}
|
|
65
|
+
disconnectedCallback() {
|
|
66
|
+
this.#resizeObserver.disconnect();
|
|
67
|
+
this.#visibilityObserver?.disconnect();
|
|
68
|
+
}
|
|
69
|
+
attributeChangedCallback(name, oldValue, newValue) {
|
|
70
|
+
if (name === "src" && newValue && newValue !== oldValue)
|
|
71
|
+
this.#loadFromSrc(newValue);
|
|
72
|
+
}
|
|
73
|
+
/** The strip to render, as data (validated at the boundary). */
|
|
74
|
+
get document() {
|
|
75
|
+
return this.#doc;
|
|
76
|
+
}
|
|
77
|
+
set document(doc) {
|
|
78
|
+
this.#doc = doc ? validateDocument(doc) : null;
|
|
79
|
+
void this.#rebuild();
|
|
80
|
+
}
|
|
81
|
+
/** Base URLs of registered asset packs, searched in the order given. */
|
|
82
|
+
get packs() {
|
|
83
|
+
return this.#packs;
|
|
84
|
+
}
|
|
85
|
+
set packs(packs) {
|
|
86
|
+
this.#packs = [...packs];
|
|
87
|
+
void this.#rebuild();
|
|
88
|
+
}
|
|
89
|
+
async #loadFromSrc(src) {
|
|
90
|
+
try {
|
|
91
|
+
const res = await fetch(src);
|
|
92
|
+
if (!res.ok)
|
|
93
|
+
throw new Error(`fetch ${src} failed: ${res.status} ${res.statusText}`);
|
|
94
|
+
this.document = validateDocument(await res.json());
|
|
95
|
+
}
|
|
96
|
+
catch (err) {
|
|
97
|
+
this.#fail(err instanceof Error ? err.message : String(err));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
#fail(message) {
|
|
101
|
+
this.#strip.replaceChildren();
|
|
102
|
+
const notice = document.createElement("p");
|
|
103
|
+
notice.className = "notice";
|
|
104
|
+
notice.textContent = message;
|
|
105
|
+
this.#strip.append(notice);
|
|
106
|
+
console.error("comic-strip:", message);
|
|
107
|
+
}
|
|
108
|
+
async #rebuild() {
|
|
109
|
+
const token = ++this.#buildToken;
|
|
110
|
+
this.#visibilityObserver?.disconnect();
|
|
111
|
+
this.#slots = [];
|
|
112
|
+
this.#strip.replaceChildren();
|
|
113
|
+
this.#cast = null;
|
|
114
|
+
if (!this.#doc)
|
|
115
|
+
return;
|
|
116
|
+
let cast;
|
|
117
|
+
try {
|
|
118
|
+
cast = await loadCastFromPacks(this.#packs, characterIdsOf(this.#doc), backdropIdsOf(this.#doc));
|
|
119
|
+
}
|
|
120
|
+
catch (err) {
|
|
121
|
+
if (token === this.#buildToken)
|
|
122
|
+
this.#fail(err instanceof Error ? err.message : String(err));
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
if (token !== this.#buildToken)
|
|
126
|
+
return; // superseded by a later document/packs set
|
|
127
|
+
this.#cast = cast;
|
|
128
|
+
// Body selection happens once, in document order, and is frozen per panel
|
|
129
|
+
// so a resize repaint reproduces the same figures rather than re-rolling
|
|
130
|
+
// pose cycles every time (mirrors comic-view.ts's `stage`/`frozen`).
|
|
131
|
+
const cycles = new Map();
|
|
132
|
+
for (const panel of this.#doc.panels) {
|
|
133
|
+
selectFigures(panel, cast, cycles);
|
|
134
|
+
this.#frozen.set(panel, copyCycles(cycles));
|
|
135
|
+
}
|
|
136
|
+
this.#size = this.#measure();
|
|
137
|
+
this.#visibilityObserver = new IntersectionObserver((entries) => {
|
|
138
|
+
for (const entry of entries) {
|
|
139
|
+
const slot = this.#slots.find((s) => s.canvas === entry.target);
|
|
140
|
+
if (!slot)
|
|
141
|
+
continue;
|
|
142
|
+
if (entry.isIntersecting)
|
|
143
|
+
this.#paint(slot);
|
|
144
|
+
else
|
|
145
|
+
this.#release(slot);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
// No internal scroll pane here (unlike the source app's fixed-height
|
|
149
|
+
// strip) — this element assumes the host page scrolls, so visibility is
|
|
150
|
+
// relative to the viewport (root: null), not a container this owns.
|
|
151
|
+
{ root: null, rootMargin: "100% 0px" });
|
|
152
|
+
for (const panel of this.#doc.panels) {
|
|
153
|
+
const canvas = document.createElement("canvas");
|
|
154
|
+
canvas.className = "panel";
|
|
155
|
+
canvas.setAttribute("role", "img");
|
|
156
|
+
canvas.setAttribute("aria-label", describePanel(panel));
|
|
157
|
+
canvas.width = 0;
|
|
158
|
+
canvas.height = 0;
|
|
159
|
+
const slot = { canvas, panel, painted: false };
|
|
160
|
+
this.#resizeSlot(slot);
|
|
161
|
+
this.#strip.append(canvas);
|
|
162
|
+
this.#slots.push(slot);
|
|
163
|
+
this.#visibilityObserver.observe(canvas);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
#measure() {
|
|
167
|
+
const style = getComputedStyle(this);
|
|
168
|
+
const inner = (this.clientWidth || this.getBoundingClientRect().width) -
|
|
169
|
+
parseFloat(style.paddingLeft || "0") -
|
|
170
|
+
parseFloat(style.paddingRight || "0");
|
|
171
|
+
const layout = layoutStrip(inner, { gutter: GUTTER });
|
|
172
|
+
this.#strip.style.gridTemplateColumns = `repeat(${layout.columns}, ${layout.panelWidth}px)`;
|
|
173
|
+
this.#columns = layout.columns;
|
|
174
|
+
return { width: layout.panelWidth, height: layout.panelHeight };
|
|
175
|
+
}
|
|
176
|
+
#resizeSlot(slot) {
|
|
177
|
+
slot.canvas.style.width = `${this.#size.width}px`;
|
|
178
|
+
slot.canvas.style.height = `${this.#size.height}px`;
|
|
179
|
+
}
|
|
180
|
+
#paint(slot) {
|
|
181
|
+
if (!this.#cast || slot.painted)
|
|
182
|
+
return;
|
|
183
|
+
const ratio = window.devicePixelRatio || 1;
|
|
184
|
+
const { width, height } = this.#size;
|
|
185
|
+
slot.canvas.width = Math.round(width * ratio);
|
|
186
|
+
slot.canvas.height = Math.round(height * ratio);
|
|
187
|
+
const ctx = slot.canvas.getContext("2d");
|
|
188
|
+
if (!ctx)
|
|
189
|
+
return;
|
|
190
|
+
ctx.setTransform(ratio, 0, 0, ratio, 0, 0);
|
|
191
|
+
renderPanel(ctx, slot.panel, this.#cast, width, height, {
|
|
192
|
+
cycles: copyCycles(this.#frozen.get(slot.panel) ?? new Map()),
|
|
193
|
+
});
|
|
194
|
+
slot.painted = true;
|
|
195
|
+
}
|
|
196
|
+
#release(slot) {
|
|
197
|
+
if (!slot.painted)
|
|
198
|
+
return;
|
|
199
|
+
slot.canvas.width = 0;
|
|
200
|
+
slot.canvas.height = 0;
|
|
201
|
+
slot.painted = false;
|
|
202
|
+
}
|
|
203
|
+
#relayout() {
|
|
204
|
+
if (!this.#cast)
|
|
205
|
+
return;
|
|
206
|
+
this.#size = this.#measure();
|
|
207
|
+
for (const slot of this.#slots) {
|
|
208
|
+
this.#resizeSlot(slot);
|
|
209
|
+
if (slot.painted) {
|
|
210
|
+
slot.painted = false;
|
|
211
|
+
this.#paint(slot);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
/** Column count as of the last layout pass — exposed for tests/consumers. */
|
|
216
|
+
get columns() {
|
|
217
|
+
return this.#columns;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
export function registerComicStripElement(tagName = "comic-strip") {
|
|
221
|
+
if (!customElements.get(tagName))
|
|
222
|
+
customElements.define(tagName, ComicStripElement);
|
|
223
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Panel } from "@toonstrip/schema";
|
|
2
|
+
/**
|
|
3
|
+
* Alt text for a panel's canvas — a screen reader (or a search engine) gets
|
|
4
|
+
* this whether or not the panel is currently painted. Generic over the
|
|
5
|
+
* document's own `speaker`/`text` fields; no game- or role-specific labels.
|
|
6
|
+
*/
|
|
7
|
+
export declare function describePanel(panel: Panel): string;
|
package/dist/describe.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alt text for a panel's canvas — a screen reader (or a search engine) gets
|
|
3
|
+
* this whether or not the panel is currently painted. Generic over the
|
|
4
|
+
* document's own `speaker`/`text` fields; no game- or role-specific labels.
|
|
5
|
+
*/
|
|
6
|
+
export function describePanel(panel) {
|
|
7
|
+
const parts = [`${panel.camera ?? "medium"} shot`];
|
|
8
|
+
if (panel.roomTitle)
|
|
9
|
+
parts.push(panel.roomTitle);
|
|
10
|
+
for (const line of panel.speakers) {
|
|
11
|
+
if (line.speaker === "caption") {
|
|
12
|
+
parts.push(`caption: ${line.text}`);
|
|
13
|
+
continue;
|
|
14
|
+
}
|
|
15
|
+
const verb = line.balloon === "thought" ? "thinks" : "says";
|
|
16
|
+
parts.push(`${line.speaker} ${verb} "${line.text}"`);
|
|
17
|
+
}
|
|
18
|
+
if (parts.length === 1)
|
|
19
|
+
parts.push("no dialogue");
|
|
20
|
+
return parts.join("; ");
|
|
21
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ComicStripElement, registerComicStripElement } from "./comic-strip-element.js";
|
|
2
|
+
export { describePanel } from "./describe.js";
|
|
3
|
+
export { loadCastFromPacks } from "./load-pack.js";
|
|
4
|
+
import { registerComicStripElement } from "./comic-strip-element.js";
|
|
5
|
+
registerComicStripElement();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side counterpart to `@toonstrip/pack-comic-chat`'s `loadCastManifest`
|
|
3
|
+
* (fs-based, Node-only). A pack here is a URL — `pack.json` plus the
|
|
4
|
+
* character/backdrop files it points at, fetched over HTTP rather than read
|
|
5
|
+
* off disk — assembling the same `Cast` shape `@toonstrip/core`'s `renderPanel`
|
|
6
|
+
* consumes either way (see NOTES.md "M3": this loader is new work, not a port).
|
|
7
|
+
*/
|
|
8
|
+
import type { Cast } from "@toonstrip/core";
|
|
9
|
+
/**
|
|
10
|
+
* Loads exactly the characters/backdrops a document needs from a list of
|
|
11
|
+
* registered pack base URLs. Per the dev plan §6, an id declared by more than
|
|
12
|
+
* one registered pack is a hard error, not a silent first-match — collisions
|
|
13
|
+
* are a pack-authoring mistake the caller needs to know about immediately.
|
|
14
|
+
*/
|
|
15
|
+
export declare function loadCastFromPacks(packBaseUrls: readonly string[], characterIds: readonly string[], backdropIds: readonly string[]): Promise<Cast>;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/** Resolves `path` against `baseUrl`; `baseUrl` may itself be relative (e.g. "/packs/comic-chat/"). */
|
|
2
|
+
function resolveUrl(baseUrl, path) {
|
|
3
|
+
const withSlash = baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`;
|
|
4
|
+
const absoluteBase = new URL(withSlash, document.baseURI);
|
|
5
|
+
return new URL(path, absoluteBase).toString();
|
|
6
|
+
}
|
|
7
|
+
async function fetchJson(url) {
|
|
8
|
+
const res = await fetch(url);
|
|
9
|
+
if (!res.ok)
|
|
10
|
+
throw new Error(`fetch ${url} failed: ${res.status} ${res.statusText}`);
|
|
11
|
+
return (await res.json());
|
|
12
|
+
}
|
|
13
|
+
function loadImage(url) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
const img = new Image();
|
|
16
|
+
img.crossOrigin = "anonymous";
|
|
17
|
+
img.onload = () => resolve(img);
|
|
18
|
+
img.onerror = () => reject(new Error(`failed to load image ${url}`));
|
|
19
|
+
img.src = url;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Loads exactly the characters/backdrops a document needs from a list of
|
|
24
|
+
* registered pack base URLs. Per the dev plan §6, an id declared by more than
|
|
25
|
+
* one registered pack is a hard error, not a silent first-match — collisions
|
|
26
|
+
* are a pack-authoring mistake the caller needs to know about immediately.
|
|
27
|
+
*/
|
|
28
|
+
export async function loadCastFromPacks(packBaseUrls, characterIds, backdropIds) {
|
|
29
|
+
if (packBaseUrls.length === 0 && (characterIds.length > 0 || backdropIds.length > 0)) {
|
|
30
|
+
throw new Error("comic-strip: document references characters/backdrops but no packs are registered (set .packs)");
|
|
31
|
+
}
|
|
32
|
+
const packs = await Promise.all(packBaseUrls.map(async (baseUrl) => ({
|
|
33
|
+
baseUrl,
|
|
34
|
+
json: await fetchJson(resolveUrl(baseUrl, "pack.json")),
|
|
35
|
+
})));
|
|
36
|
+
const cast = {};
|
|
37
|
+
const sheets = new Map();
|
|
38
|
+
for (const id of characterIds) {
|
|
39
|
+
const matches = packs
|
|
40
|
+
.map((pack) => ({ pack, entry: pack.json.characters.find((c) => c.id === id) }))
|
|
41
|
+
.filter((m) => !!m.entry);
|
|
42
|
+
if (matches.length === 0) {
|
|
43
|
+
throw new Error(`comic-strip: no registered pack declares character "${id}"`);
|
|
44
|
+
}
|
|
45
|
+
if (matches.length > 1) {
|
|
46
|
+
throw new Error(`comic-strip: character "${id}" is declared by more than one registered pack ` +
|
|
47
|
+
`(${matches.map((m) => m.pack.baseUrl).join(", ")})`);
|
|
48
|
+
}
|
|
49
|
+
const { pack, entry } = matches[0];
|
|
50
|
+
const avatar = await fetchJson(resolveUrl(pack.baseUrl, entry.manifest));
|
|
51
|
+
const sheetUrl = resolveUrl(pack.baseUrl, entry.sheet);
|
|
52
|
+
cast[id] = { ...avatar, sheet: sheetUrl };
|
|
53
|
+
sheets.set(id, await loadImage(sheetUrl));
|
|
54
|
+
}
|
|
55
|
+
const backdrops = new Map();
|
|
56
|
+
const backdropEntries = [];
|
|
57
|
+
for (const id of backdropIds) {
|
|
58
|
+
const matches = packs
|
|
59
|
+
.map((pack) => ({ pack, entry: pack.json.backdrops.find((b) => b.id === id) }))
|
|
60
|
+
.filter((m) => !!m.entry);
|
|
61
|
+
if (matches.length === 0) {
|
|
62
|
+
throw new Error(`comic-strip: no registered pack declares backdrop "${id}"`);
|
|
63
|
+
}
|
|
64
|
+
if (matches.length > 1) {
|
|
65
|
+
throw new Error(`comic-strip: backdrop "${id}" is declared by more than one registered pack ` +
|
|
66
|
+
`(${matches.map((m) => m.pack.baseUrl).join(", ")})`);
|
|
67
|
+
}
|
|
68
|
+
const { pack, entry } = matches[0];
|
|
69
|
+
const fileUrl = resolveUrl(pack.baseUrl, entry.file);
|
|
70
|
+
backdropEntries.push({ ...entry, file: fileUrl });
|
|
71
|
+
backdrops.set(id, await loadImage(fileUrl));
|
|
72
|
+
}
|
|
73
|
+
const manifest = { version: 1, cast, backdrops: backdropEntries };
|
|
74
|
+
return { manifest, sheets, backdrops };
|
|
75
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@toonstrip/element",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"license": "MIT",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./dist/index.js",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist"
|
|
10
|
+
],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"build": "tsc -p tsconfig.json",
|
|
13
|
+
"test": "vitest run",
|
|
14
|
+
"lint": "tsc -p tsconfig.json --noEmit",
|
|
15
|
+
"verify:demo": "node scripts/verify-demo.mjs"
|
|
16
|
+
},
|
|
17
|
+
"dependencies": {
|
|
18
|
+
"@toonstrip/core": "workspace:*",
|
|
19
|
+
"@toonstrip/schema": "workspace:*"
|
|
20
|
+
},
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"typescript": "^5.6.3",
|
|
23
|
+
"vitest": "^2.1.4",
|
|
24
|
+
"jsdom": "^25.0.1",
|
|
25
|
+
"playwright": "^1.48.0",
|
|
26
|
+
"axe-core": "^4.10.0"
|
|
27
|
+
},
|
|
28
|
+
"publishConfig": {
|
|
29
|
+
"access": "public"
|
|
30
|
+
}
|
|
31
|
+
}
|