@jjlmoya/utils-tabletop 1.26.0 → 1.27.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.
Files changed (40) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -0
  3. package/src/entries.ts +2 -0
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +1 -1
  6. package/src/tests/tool_validation.test.ts +1 -1
  7. package/src/tool/token-stamp-studio/bibliography.astro +16 -0
  8. package/src/tool/token-stamp-studio/bibliography.ts +12 -0
  9. package/src/tool/token-stamp-studio/component.astro +91 -0
  10. package/src/tool/token-stamp-studio/controller.ts +179 -0
  11. package/src/tool/token-stamp-studio/dom-views.ts +266 -0
  12. package/src/tool/token-stamp-studio/entry.ts +27 -0
  13. package/src/tool/token-stamp-studio/evaluator.ts +6 -0
  14. package/src/tool/token-stamp-studio/i18n/de.ts +132 -0
  15. package/src/tool/token-stamp-studio/i18n/en.ts +132 -0
  16. package/src/tool/token-stamp-studio/i18n/es.ts +132 -0
  17. package/src/tool/token-stamp-studio/i18n/fr.ts +132 -0
  18. package/src/tool/token-stamp-studio/i18n/id.ts +132 -0
  19. package/src/tool/token-stamp-studio/i18n/it.ts +132 -0
  20. package/src/tool/token-stamp-studio/i18n/ja.ts +132 -0
  21. package/src/tool/token-stamp-studio/i18n/ko.ts +132 -0
  22. package/src/tool/token-stamp-studio/i18n/nl.ts +132 -0
  23. package/src/tool/token-stamp-studio/i18n/pl.ts +132 -0
  24. package/src/tool/token-stamp-studio/i18n/pt.ts +132 -0
  25. package/src/tool/token-stamp-studio/i18n/ru.ts +132 -0
  26. package/src/tool/token-stamp-studio/i18n/sv.ts +132 -0
  27. package/src/tool/token-stamp-studio/i18n/tr.ts +132 -0
  28. package/src/tool/token-stamp-studio/i18n/zh.ts +132 -0
  29. package/src/tool/token-stamp-studio/index.ts +11 -0
  30. package/src/tool/token-stamp-studio/logic.test.ts +36 -0
  31. package/src/tool/token-stamp-studio/logic.ts +127 -0
  32. package/src/tool/token-stamp-studio/seo.astro +16 -0
  33. package/src/tool/token-stamp-studio/storage.ts +69 -0
  34. package/src/tool/token-stamp-studio/store.ts +139 -0
  35. package/src/tool/token-stamp-studio/token-stamp-studio.css +646 -0
  36. package/src/tool/token-stamp-studio/types.ts +65 -0
  37. package/src/tool/token-stamp-studio/ui.ts +50 -0
  38. package/src/tool/token-stamp-studio/workspace-media.ts +36 -0
  39. package/src/tool/token-stamp-studio/workspace.ts +83 -0
  40. package/src/tools.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-tabletop",
3
- "version": "1.26.0",
3
+ "version": "1.27.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -2,6 +2,7 @@ import { diceRollerSimulator } from '../tool/dice-roller-simulator/entry';
2
2
  import { boardGameTimer } from '../tool/board-game-timer/entry';
3
3
  import { dungeonMapGenerator } from '../tool/dungeon-map-generator/entry';
4
4
  import { encounterDifficultyCalculator } from '../tool/encounter-difficulty-calculator/entry';
5
+ import { tokenStampStudio } from '../tool/token-stamp-studio/entry';
5
6
  import type { CategoryLocaleContent, KnownLocale } from '../types';
6
7
 
7
8
  export const tabletopCategory = {
@@ -11,6 +12,7 @@ export const tabletopCategory = {
11
12
  boardGameTimer,
12
13
  dungeonMapGenerator,
13
14
  encounterDifficultyCalculator,
15
+ tokenStampStudio,
14
16
  ],
15
17
  i18n: {
16
18
  de: () => import('./i18n/de').then((m) => m.content),
package/src/entries.ts CHANGED
@@ -35,6 +35,7 @@ import { hiddenRoleDealer } from './tool/hidden-role-dealer/entry';
35
35
  import { scatterDirectionSelector } from './tool/scatter-direction-selector/entry';
36
36
  import { dungeonMapGenerator } from './tool/dungeon-map-generator/entry';
37
37
  import { encounterDifficultyCalculator } from './tool/encounter-difficulty-calculator/entry';
38
+ import { tokenStampStudio } from './tool/token-stamp-studio/entry';
38
39
 
39
40
  export const ALL_ENTRIES = [
40
41
  diceRollerSimulator,
@@ -50,5 +51,6 @@ export const ALL_ENTRIES = [
50
51
  scatterDirectionSelector,
51
52
  dungeonMapGenerator,
52
53
  encounterDifficultyCalculator,
54
+ tokenStampStudio,
53
55
  ];
54
56
 
package/src/index.ts CHANGED
@@ -7,6 +7,7 @@ export { fantasyRunesTranslator, FANTASY_RUNES_TRANSLATOR_TOOL } from './tool/fa
7
7
  export { investigationBoard, INVESTIGATION_BOARD_TOOL } from './tool/investigation-board';
8
8
  export { lunarTideTracker, LUNAR_TIDE_TRACKER_TOOL } from './tool/lunar-tide-tracker';
9
9
  export { dungeonMapGenerator, DUNGEON_MAP_GENERATOR_TOOL } from './tool/dungeon-map-generator';
10
+ export { tokenStampStudio, TOKEN_STAMP_STUDIO_TOOL } from './tool/token-stamp-studio';
10
11
 
11
12
 
12
13
  export type {
@@ -22,7 +22,7 @@ describe('Locale Completeness Validation', () => {
22
22
  });
23
23
 
24
24
  it('all tools registered', () => {
25
- expect(ALL_TOOLS.length).toBe(13);
25
+ expect(ALL_TOOLS.length).toBe(14);
26
26
  });
27
27
 
28
28
 
@@ -5,7 +5,7 @@ import { tabletopCategory } from '../data';
5
5
  describe('Tool Validation Suite', () => {
6
6
  describe('Library Registration', () => {
7
7
  it('should have tools in ALL_TOOLS', () => {
8
- expect(ALL_TOOLS.length).toBe(13);
8
+ expect(ALL_TOOLS.length).toBe(14);
9
9
  });
10
10
 
11
11
 
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { Bibliography as SharedBibliography } from '@jjlmoya/utils-shared';
3
+ import { tokenStampStudio } from './index';
4
+ import type { KnownLocale } from '../../types';
5
+
6
+ interface Props {
7
+ locale?: KnownLocale;
8
+ }
9
+
10
+ const { locale = 'en' } = Astro.props as Props;
11
+ const loader = tokenStampStudio.i18n[locale] || tokenStampStudio.i18n.en;
12
+ const content = await loader?.();
13
+ if (!content) return null;
14
+ ---
15
+
16
+ {content && <SharedBibliography links={content.bibliography} />}
@@ -0,0 +1,12 @@
1
+ import type { BibliographyEntry } from '../../types';
2
+
3
+ export const bibliography: BibliographyEntry[] = [
4
+ {
5
+ name: 'Foundry Virtual Tabletop, Tokens',
6
+ url: 'https://foundryvtt.com/article/tokens/',
7
+ },
8
+ {
9
+ name: 'Foundry Virtual Tabletop, Actors',
10
+ url: 'https://foundryvtt.com/article/actors/',
11
+ },
12
+ ];
@@ -0,0 +1,91 @@
1
+ ---
2
+ import './token-stamp-studio.css';
3
+ import type { TokenStampUI } from './ui';
4
+
5
+ const { ui } = Astro.props as { ui: TokenStampUI };
6
+ ---
7
+
8
+ <div class="token-stamp-studio" data-token-stamp-studio data-ui={JSON.stringify(ui)}>
9
+ <div class="foundry-shell">
10
+ <section class="marker-library marker-library-header" aria-label={ui.savedMarkers}>
11
+ <div class="marker-library-grid" data-marker-library></div>
12
+ </section>
13
+ <div class="foundry-layout">
14
+ <section class="foundry-stage-panel" aria-label={ui.stageLabel}>
15
+ <label class="marker-name-field marker-name-stage"><input data-marker-name type="text" maxlength="48" placeholder={ui.markerNamePlaceholder} aria-label={ui.markerName} /></label>
16
+ <div class="token-stage" data-drop-zone>
17
+ <canvas data-token-canvas aria-label={ui.stageLabel}></canvas>
18
+ <p class="stage-caption"><span>{ui.positionHint}</span><span data-current-image>{ui.noImage}</span></p>
19
+ </div>
20
+ <div class="stage-actions">
21
+ <label class="button button-primary" for="token-image-input">{ui.chooseImage}</label>
22
+ <input id="token-image-input" data-image-input type="file" accept="image/png,image/jpeg,image/webp,image/gif" hidden />
23
+ <button class="button button-quiet" type="button" data-clear-image>{ui.clearImage}</button>
24
+ <button class="button button-quiet" type="button" data-copy>{ui.copy}</button>
25
+ <button class="button button-primary" type="button" data-download>{ui.download}</button>
26
+ </div>
27
+ <p class="export-hint">{ui.exportHint}</p>
28
+ </section>
29
+
30
+ <aside class="foundry-inspector" aria-label={ui.tokenDetails}>
31
+ <section class="inspector-block inspector-frames">
32
+ <div class="block-heading"><div><p class="eyebrow">01</p><h3>{ui.frameLegend}</h3></div><button class="micro-action" type="button" data-random-frame>{ui.randomFrame}</button></div>
33
+ <p class="block-hint">{ui.frameHint}</p>
34
+ <div class="frame-gallery" data-frame-gallery></div>
35
+ </section>
36
+
37
+ <section class="inspector-block">
38
+ <div class="block-heading"><div><p class="eyebrow">02</p><h3>{ui.surfaceLegend}</h3></div><button class="micro-action" type="button" data-random-colors>{ui.randomColors}</button></div>
39
+ <div class="color-grid">
40
+ <label class="color-control"><span>{ui.backgroundLabel}</span><input data-color="background" type="color" value="#1d2430" /></label>
41
+ <label class="color-control"><span>{ui.borderLabel}</span><input data-color="border" type="color" value="#d89555" /></label>
42
+ <label class="color-control"><span>{ui.textLabel}</span><input data-color="text" type="color" value="#fff4d6" /></label>
43
+ <label class="color-control"><span>{ui.overlayLabel}</span><input data-color="overlay" type="color" value="#e05b4f" /></label>
44
+ </div>
45
+ <label class="range-control"><span>{ui.borderWidthLabel}<output data-border-width-output>16</output></span><input data-border-width type="range" min="4" max="34" step="1" value="16" /></label>
46
+ <label class="range-control"><span>{ui.opacityLabel}<output data-border-opacity-output>100%</output></span><input data-border-opacity type="range" min="0.2" max="1" step="0.05" value="1" /></label>
47
+ <label class="range-control"><span>{ui.overlayOpacityLabel}<output data-overlay-opacity-output>0%</output></span><input data-overlay-opacity type="range" min="0" max="0.8" step="0.05" value="0" /></label>
48
+ </section>
49
+
50
+ <section class="inspector-block">
51
+ <div class="block-heading"><div><p class="eyebrow">03</p><h3>{ui.imageLegend}</h3></div></div>
52
+ <label class="range-control"><span>{ui.imageZoomLabel}<output data-image-zoom-output>100%</output></span><input data-image-zoom type="range" min="0.7" max="1.8" step="0.05" value="1" /></label>
53
+ <label class="range-control"><span>{ui.scaleLabel}<output data-scale-output>100%</output></span><input data-scale type="range" min="0.75" max="1.5" step="0.05" value="1" /></label>
54
+ </section>
55
+
56
+ <section class="inspector-block">
57
+ <div class="block-heading"><div><p class="eyebrow">04</p><h3>{ui.textLegend}</h3></div></div>
58
+ <p class="block-hint" data-text-hint>{ui.textHint}</p>
59
+ <div class="text-layer-list" data-text-layers></div>
60
+ <div class="text-editor">
61
+ <input data-text-input type="text" maxlength="24" placeholder={ui.textPlaceholder} aria-label={ui.textPlaceholder} />
62
+ <div class="editor-row"><label class="range-control"><span>{ui.textSizeLabel}<output data-text-size-output>54</output></span><input data-text-size type="range" min="22" max="86" step="1" value="54" /></label></div>
63
+ <div class="align-actions" aria-label={ui.alignmentLabel}>
64
+ <button type="button" data-align="left" aria-label={ui.alignLeft} title={ui.alignLeft}>↤</button><button type="button" data-align="center" aria-label={ui.alignCenter} title={ui.alignCenter}>↔</button><button type="button" data-align="right" aria-label={ui.alignRight} title={ui.alignRight}>↦</button>
65
+ </div>
66
+ <div class="editor-actions"><button class="button button-quiet" type="button" data-add-text>{ui.addText}</button><button class="button button-danger" type="button" data-remove-text>{ui.removeText}</button></div>
67
+ </div>
68
+ </section>
69
+
70
+ </aside>
71
+ </div>
72
+
73
+ <details class="batch-advanced">
74
+ <summary>{ui.batchLabel}</summary>
75
+ <section class="batch-dock" aria-labelledby="batch-title">
76
+ <div><h3 id="batch-title">{ui.batchHint}</h3><p data-batch-status>{ui.batchHint}</p></div>
77
+ <div class="batch-actions"><label class="button button-quiet" for="token-batch-input">{ui.chooseBatch}</label><input id="token-batch-input" data-batch-input type="file" accept="image/png,image/jpeg,image/webp,image/gif" multiple hidden /><button class="button button-primary" type="button" data-batch-download disabled>{ui.downloadBatch}</button></div>
78
+ </section>
79
+ </details>
80
+ </div>
81
+ </div>
82
+
83
+ <script>
84
+ import { mountTokenStampStudio } from './controller';
85
+
86
+ const root = document.querySelector<HTMLElement>('[data-token-stamp-studio]');
87
+ if (root) {
88
+ const ui = JSON.parse(root.dataset.ui ?? '{}');
89
+ void mountTokenStampStudio(root, ui);
90
+ }
91
+ </script>
@@ -0,0 +1,179 @@
1
+ import { createTextLayer, FRAME_PRESETS, getRandomPalette, hitTestText, updateTextLayer, clamp } from './logic';
2
+ import { pointerToCanvas } from './dom-views';
3
+ import { attachBatchEvents, attachFileEvents, attachWorkspaceEvents } from './workspace';
4
+ import { StudioStore } from './store';
5
+ import type { TokenStampUI } from './ui';
6
+ import type { TextAlign, TokenStampState } from './types';
7
+
8
+ export interface StudioElements {
9
+ root: HTMLElement;
10
+ canvas: HTMLCanvasElement;
11
+ frames: HTMLElement;
12
+ textLayers: HTMLElement;
13
+ imageInput: HTMLInputElement;
14
+ batchInput: HTMLInputElement;
15
+ textInput: HTMLInputElement;
16
+ textSize: HTMLInputElement;
17
+ imageZoom: HTMLInputElement;
18
+ scale: HTMLInputElement;
19
+ borderWidth: HTMLInputElement;
20
+ borderOpacity: HTMLInputElement;
21
+ overlayOpacity: HTMLInputElement;
22
+ currentImage: HTMLElement;
23
+ batchStatus: HTMLElement;
24
+ library: HTMLElement;
25
+ markerName: HTMLInputElement;
26
+ copy: HTMLButtonElement;
27
+ download: HTMLButtonElement;
28
+ batchDownload: HTMLButtonElement;
29
+ }
30
+
31
+ function getElement<T extends Element>(root: HTMLElement, selector: string): T {
32
+ const element = root.querySelector<T>(selector);
33
+ if (!element) throw new Error(`Token stamp studio element missing: ${selector}`);
34
+ return element;
35
+ }
36
+
37
+ function getElements(root: HTMLElement): StudioElements {
38
+ return {
39
+ root,
40
+ canvas: getElement(root, '[data-token-canvas]'),
41
+ frames: getElement(root, '[data-frame-gallery]'),
42
+ textLayers: getElement(root, '[data-text-layers]'),
43
+ imageInput: getElement(root, '[data-image-input]'),
44
+ batchInput: getElement(root, '[data-batch-input]'),
45
+ textInput: getElement(root, '[data-text-input]'),
46
+ textSize: getElement(root, '[data-text-size]'),
47
+ imageZoom: getElement(root, '[data-image-zoom]'),
48
+ scale: getElement(root, '[data-scale]'),
49
+ borderWidth: getElement(root, '[data-border-width]'),
50
+ borderOpacity: getElement(root, '[data-border-opacity]'),
51
+ overlayOpacity: getElement(root, '[data-overlay-opacity]'),
52
+ currentImage: getElement(root, '[data-current-image]'),
53
+ batchStatus: getElement(root, '[data-batch-status]'),
54
+ library: getElement(root, '[data-marker-library]'),
55
+ markerName: getElement<HTMLInputElement>(root, '[data-marker-name]'),
56
+ copy: getElement<HTMLButtonElement>(root, '[data-copy]'),
57
+ download: getElement(root, '[data-download]'),
58
+ batchDownload: getElement(root, '[data-batch-download]'),
59
+ };
60
+ }
61
+
62
+ function getColor(root: HTMLElement, name: string): HTMLInputElement {
63
+ return getElement<HTMLInputElement>(root, `[data-color="${name}"]`);
64
+ }
65
+
66
+ function attachFrameEvents(elements: StudioElements, getState: () => TokenStampState, setState: (state: TokenStampState) => void): void {
67
+ elements.frames.addEventListener('click', (event) => {
68
+ const target = event.target as HTMLElement;
69
+ const button = target.closest<HTMLButtonElement>('[data-frame-id]');
70
+ if (button?.dataset.frameId) setState({ ...getState(), frameId: button.dataset.frameId });
71
+ });
72
+ }
73
+
74
+ function attachColorEvents(elements: StudioElements, getState: () => TokenStampState, setState: (state: TokenStampState) => void): void {
75
+ const colors: Array<[string, keyof TokenStampState]> = [['background', 'background'], ['border', 'border'], ['text', 'textColor'], ['overlay', 'overlay']];
76
+ colors.forEach(([name, key]) => getColor(elements.root, name).addEventListener('input', (event) => {
77
+ setState({ ...getState(), [key]: (event.target as HTMLInputElement).value } as TokenStampState);
78
+ }));
79
+ }
80
+
81
+ function attachRangeEvents(elements: StudioElements, getState: () => TokenStampState, setState: (state: TokenStampState) => void): void {
82
+ const ranges: Array<[HTMLInputElement, keyof TokenStampState]> = [
83
+ [elements.imageZoom, 'imageZoom'], [elements.scale, 'scale'], [elements.borderWidth, 'borderWidth'], [elements.borderOpacity, 'borderOpacity'], [elements.overlayOpacity, 'overlayOpacity'],
84
+ ];
85
+ ranges.forEach(([input, key]) => input.addEventListener('input', (event) => setState({ ...getState(), [key]: Number((event.target as HTMLInputElement).value) } as TokenStampState)));
86
+ }
87
+
88
+ function updateDraftText(value: string, getState: () => TokenStampState, setState: (state: TokenStampState) => void): void {
89
+ const current = getState();
90
+ if (!current.selectedTextId && value.trim()) {
91
+ const text = { ...createTextLayer(current.texts.length + 1), text: value };
92
+ setState({ ...current, texts: [...current.texts, text], selectedTextId: text.id });
93
+ return;
94
+ }
95
+ setState({ ...current, texts: updateTextLayer(current.texts, current.selectedTextId, { text: value }) });
96
+ }
97
+
98
+ function attachTextEvents(elements: StudioElements, getState: () => TokenStampState, setState: (state: TokenStampState) => void, ui: TokenStampUI): void {
99
+ elements.textLayers.addEventListener('click', (event) => {
100
+ const button = (event.target as HTMLElement).closest<HTMLButtonElement>('[data-text-id]');
101
+ if (button?.dataset.textId) setState({ ...getState(), selectedTextId: button.dataset.textId });
102
+ });
103
+ elements.textInput.addEventListener('input', (event) => updateDraftText((event.target as HTMLInputElement).value, getState, setState));
104
+ elements.textSize.addEventListener('input', (event) => setState({ ...getState(), texts: updateTextLayer(getState().texts, getState().selectedTextId, { size: Number((event.target as HTMLInputElement).value) }) }));
105
+ elements.root.querySelectorAll<HTMLButtonElement>('[data-align]').forEach((button) => button.addEventListener('click', () => {
106
+ const align = button.dataset.align as TextAlign;
107
+ setState({ ...getState(), texts: updateTextLayer(getState().texts, getState().selectedTextId, { align }) });
108
+ }));
109
+ getElement<HTMLButtonElement>(elements.root, '[data-add-text]').addEventListener('click', () => {
110
+ const text = createTextLayer(getState().texts.length + 1);
111
+ setState({ ...getState(), texts: [...getState().texts, text], selectedTextId: text.id });
112
+ });
113
+ getElement<HTMLButtonElement>(elements.root, '[data-remove-text]').addEventListener('click', () => {
114
+ const current = getState();
115
+ const texts = current.texts.filter((text) => text.id !== current.selectedTextId);
116
+ setState({ ...current, texts, selectedTextId: texts[0]?.id ?? null });
117
+ });
118
+ getElement<HTMLElement>(elements.root, '[data-text-hint]').textContent = ui.textHint;
119
+ }
120
+
121
+ function attachCanvasEvents(elements: StudioElements, getState: () => TokenStampState, setState: (state: TokenStampState) => void): void {
122
+ let drag: { mode: 'image' | 'text'; start: { x: number; y: number }; imageX: number; imageY: number; textX: number; textY: number } | null = null;
123
+ elements.canvas.addEventListener('pointerdown', (event) => {
124
+ const point = pointerToCanvas(elements.canvas, event);
125
+ const state = getState();
126
+ const text = state.texts.find((item) => hitTestText(item, point));
127
+ if (text) setState({ ...state, selectedTextId: text.id });
128
+ drag = { mode: text ? 'text' : 'image', start: point, imageX: state.imageX, imageY: state.imageY, textX: text?.x ?? 0.5, textY: text?.y ?? 0.8 };
129
+ elements.canvas.setPointerCapture(event.pointerId);
130
+ });
131
+ elements.canvas.addEventListener('pointermove', (event) => {
132
+ if (!drag) return;
133
+ const point = pointerToCanvas(elements.canvas, event);
134
+ const state = getState();
135
+ const delta = { x: point.x - drag.start.x, y: point.y - drag.start.y };
136
+ if (drag.mode === 'image') setState({ ...state, imageX: clamp(drag.imageX + delta.x, -0.5, 0.5), imageY: clamp(drag.imageY + delta.y, -0.5, 0.5) });
137
+ else setState({ ...state, texts: updateTextLayer(state.texts, state.selectedTextId, { x: clamp(drag.textX + delta.x, 0.12, 0.88), y: clamp(drag.textY + delta.y, 0.12, 0.9) }) });
138
+ });
139
+ elements.canvas.addEventListener('pointerup', () => { drag = null; });
140
+ elements.canvas.addEventListener('pointercancel', () => { drag = null; });
141
+ elements.canvas.addEventListener('wheel', (event) => {
142
+ event.preventDefault();
143
+ const nextZoom = clamp(getState().imageZoom - event.deltaY * 0.001, 0.7, 1.8);
144
+ setState({ ...getState(), imageZoom: nextZoom });
145
+ }, { passive: false });
146
+ }
147
+
148
+ function attachStudioInteractions(elements: StudioElements, ui: TokenStampUI, store: StudioStore): void {
149
+ attachFrameEvents(elements, store.getState, store.setState);
150
+ attachRandomEvents(elements, store.getState, store.setState);
151
+ attachColorEvents(elements, store.getState, store.setState);
152
+ attachRangeEvents(elements, store.getState, store.setState);
153
+ attachTextEvents(elements, store.getState, store.setState, ui);
154
+ attachCanvasEvents(elements, store.getState, store.setState);
155
+ const context = { getState: store.getState, setState: store.setState, ui, getMarkers: store.getMarkers, getImage: store.getImage, selectMarker: store.selectMarker, addMarker: store.addMarker, removeMarker: store.removeMarker, setImage: store.setImage };
156
+ attachWorkspaceEvents(elements, context);
157
+ attachFileEvents(elements, context);
158
+ attachBatchEvents(elements, context);
159
+ }
160
+
161
+ export async function mountTokenStampStudio(root: HTMLElement, ui: TokenStampUI): Promise<void> {
162
+ const elements = getElements(root);
163
+ const store = new StudioStore(elements, ui);
164
+ await store.restore();
165
+ store.render();
166
+ attachStudioInteractions(elements, ui, store);
167
+ }
168
+
169
+ function attachRandomEvents(elements: StudioElements, getState: () => TokenStampState, setState: (state: TokenStampState) => void): void {
170
+ getElement<HTMLButtonElement>(elements.root, '[data-random-frame]').addEventListener('click', () => {
171
+ const available = FRAME_PRESETS.filter((frame) => frame.id !== getState().frameId);
172
+ const frame = available[Math.floor(Math.random() * available.length)] ?? FRAME_PRESETS[0]!;
173
+ setState({ ...getState(), frameId: frame.id });
174
+ });
175
+ getElement<HTMLButtonElement>(elements.root, '[data-random-colors]').addEventListener('click', () => {
176
+ const palette = getRandomPalette();
177
+ setState({ ...getState(), ...palette, overlayOpacity: 0.18 });
178
+ });
179
+ }
@@ -0,0 +1,266 @@
1
+ import { getFrameGeometry, getFramePreset } from './logic';
2
+ import type { FramePreset, SavedMarker, TextLayer, TokenStampState } from './types';
3
+
4
+ export function pointerToCanvas(canvas: HTMLCanvasElement, event: PointerEvent): { x: number; y: number } {
5
+ const rect = canvas.getBoundingClientRect();
6
+ return {
7
+ x: Math.min(1, Math.max(0, (event.clientX - rect.left) / rect.width)),
8
+ y: Math.min(1, Math.max(0, (event.clientY - rect.top) / rect.height)),
9
+ };
10
+ }
11
+
12
+ function hexToRgba(hex: string, alpha: number): string {
13
+ const value = hex.replace('#', '');
14
+ const red = Number.parseInt(value.slice(0, 2), 16);
15
+ const green = Number.parseInt(value.slice(2, 4), 16);
16
+ const blue = Number.parseInt(value.slice(4, 6), 16);
17
+ return `rgba(${red}, ${green}, ${blue}, ${alpha})`;
18
+ }
19
+
20
+ function drawGeometry(ctx: CanvasRenderingContext2D, frame: FramePreset, size: number, inset = 0): void {
21
+ const geometry = getFrameGeometry(frame.shape);
22
+ ctx.beginPath();
23
+ if (geometry.kind === 'circle') {
24
+ const radius = size * 0.45 - inset;
25
+ ctx.arc(size / 2, size / 2, radius, 0, Math.PI * 2);
26
+ } else {
27
+ geometry.points?.forEach((point, index) => {
28
+ const x = point.x * size;
29
+ const y = point.y * size;
30
+ if (index === 0) ctx.moveTo(x, y);
31
+ else ctx.lineTo(x, y);
32
+ });
33
+ ctx.closePath();
34
+ }
35
+ }
36
+
37
+ function drawStageGrid(ctx: CanvasRenderingContext2D, size: number): void {
38
+ ctx.fillStyle = '#111720';
39
+ ctx.fillRect(0, 0, size, size);
40
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.035)';
41
+ ctx.lineWidth = 1;
42
+ for (let offset = 0; offset <= size; offset += size / 8) {
43
+ ctx.beginPath();
44
+ ctx.moveTo(offset, 0);
45
+ ctx.lineTo(offset, size);
46
+ ctx.moveTo(0, offset);
47
+ ctx.lineTo(size, offset);
48
+ ctx.stroke();
49
+ }
50
+ }
51
+
52
+ function drawPlaceholder(ctx: CanvasRenderingContext2D, size: number): void {
53
+ ctx.fillStyle = '#273242';
54
+ ctx.fillRect(size * 0.26, size * 0.28, size * 0.48, size * 0.44);
55
+ ctx.strokeStyle = '#d89555';
56
+ ctx.lineWidth = 5;
57
+ ctx.strokeRect(size * 0.31, size * 0.33, size * 0.38, size * 0.34);
58
+ ctx.fillStyle = '#fff4d6';
59
+ ctx.font = '700 30px Georgia, serif';
60
+ ctx.textAlign = 'center';
61
+ ctx.fillText('DROP', size / 2, size * 0.49);
62
+ ctx.font = '500 17px Inter, sans-serif';
63
+ ctx.fillStyle = '#b9c5d6';
64
+ ctx.fillText('your character art', size / 2, size * 0.56);
65
+ }
66
+
67
+ function drawImage(ctx: CanvasRenderingContext2D, image: HTMLImageElement | null, state: TokenStampState, size: number): void {
68
+ if (!image || !image.complete || image.naturalWidth === 0) {
69
+ drawPlaceholder(ctx, size);
70
+ return;
71
+ }
72
+ const sourceRatio = image.naturalWidth / image.naturalHeight;
73
+ const box = size * 0.9 * state.imageZoom;
74
+ const width = sourceRatio >= 1 ? box : box * sourceRatio;
75
+ const height = sourceRatio >= 1 ? box / sourceRatio : box;
76
+ const x = (size - width) / 2 + state.imageX * size;
77
+ const y = (size - height) / 2 + state.imageY * size;
78
+ ctx.drawImage(image, x, y, width, height);
79
+ }
80
+
81
+ function drawTextLayers(ctx: CanvasRenderingContext2D, state: TokenStampState, size: number): void {
82
+ state.texts.forEach((text) => {
83
+ ctx.save();
84
+ ctx.fillStyle = state.textColor;
85
+ ctx.strokeStyle = 'rgba(17, 23, 32, 0.85)';
86
+ ctx.lineWidth = Math.max(2, text.size / 16);
87
+ ctx.font = `700 ${text.size}px Georgia, serif`;
88
+ ctx.textAlign = text.align;
89
+ ctx.textBaseline = 'middle';
90
+ ctx.strokeText(text.text, text.x * size, text.y * size, size * 0.82);
91
+ ctx.fillText(text.text, text.x * size, text.y * size, size * 0.82);
92
+ if (text.id === state.selectedTextId) {
93
+ ctx.setLineDash([8, 6]);
94
+ ctx.strokeStyle = '#fff4d6';
95
+ ctx.lineWidth = 2;
96
+ ctx.strokeRect(text.x * size - size * 0.25, text.y * size - text.size * 0.7, size * 0.5, text.size * 1.4);
97
+ }
98
+ ctx.restore();
99
+ });
100
+ }
101
+
102
+ function drawFrame(ctx: CanvasRenderingContext2D, frame: FramePreset, state: TokenStampState, size: number): void {
103
+ ctx.save();
104
+ if (frame.shape === 'star') ctx.lineJoin = 'round';
105
+ drawGeometry(ctx, frame, size, state.borderWidth * 0.65);
106
+ ctx.strokeStyle = hexToRgba(state.border, state.borderOpacity * 0.38);
107
+ ctx.lineWidth = state.borderWidth + 22;
108
+ ctx.stroke();
109
+ drawGeometry(ctx, frame, size, state.borderWidth * 0.65);
110
+ ctx.strokeStyle = hexToRgba(state.border, state.borderOpacity);
111
+ ctx.lineWidth = state.borderWidth;
112
+ ctx.stroke();
113
+ drawGeometry(ctx, frame, size, state.borderWidth * 0.92);
114
+ ctx.strokeStyle = 'rgba(17, 23, 32, 0.45)';
115
+ ctx.lineWidth = 3;
116
+ ctx.stroke();
117
+ ctx.restore();
118
+ }
119
+
120
+ function drawFrameIcon(canvas: HTMLCanvasElement, frame: FramePreset, state: TokenStampState): void {
121
+ const size = 96;
122
+ canvas.width = size;
123
+ canvas.height = size;
124
+ const ctx = canvas.getContext('2d');
125
+ if (!ctx) return;
126
+ ctx.clearRect(0, 0, size, size);
127
+ ctx.fillStyle = state.background;
128
+ ctx.fillRect(0, 0, size, size);
129
+ drawFrame(ctx, frame, { ...state, borderWidth: 7 }, size);
130
+ }
131
+
132
+ export function drawTokenCanvas(canvas: HTMLCanvasElement, state: TokenStampState, image: HTMLImageElement | null, includeStageGrid = true): void {
133
+ const size = 640;
134
+ canvas.width = size;
135
+ canvas.height = size;
136
+ const ctx = canvas.getContext('2d');
137
+ if (!ctx) return;
138
+ const frame = getFramePreset(state.frameId);
139
+ if (includeStageGrid) drawStageGrid(ctx, size);
140
+ else ctx.clearRect(0, 0, size, size);
141
+ ctx.save();
142
+ drawGeometry(ctx, frame, size);
143
+ ctx.clip();
144
+ ctx.fillStyle = state.background;
145
+ ctx.fillRect(0, 0, size, size);
146
+ drawImage(ctx, image, state, size);
147
+ if (state.overlayOpacity > 0) {
148
+ ctx.fillStyle = hexToRgba(state.overlay, state.overlayOpacity);
149
+ ctx.fillRect(0, 0, size, size);
150
+ }
151
+ ctx.restore();
152
+ drawFrame(ctx, frame, state, size);
153
+ drawTextLayers(ctx, state, size);
154
+ }
155
+
156
+ export function renderFrameGallery(container: HTMLElement, frames: FramePreset[], selectedId: string): void {
157
+ container.replaceChildren();
158
+ frames.forEach((frame) => {
159
+ const button = document.createElement('button');
160
+ button.type = 'button';
161
+ button.className = `frame-choice${frame.id === selectedId ? ' is-selected' : ''}`;
162
+ button.dataset.frameId = frame.id;
163
+ button.setAttribute('aria-pressed', String(frame.id === selectedId));
164
+ button.title = frame.description;
165
+ const swatch = document.createElement('canvas');
166
+ swatch.className = 'frame-swatch';
167
+ swatch.setAttribute('aria-hidden', 'true');
168
+ drawFrameIcon(swatch, frame, { frameId: frame.id, background: '#111720', border: frame.accent, borderWidth: 7, borderOpacity: 1, overlay: frame.accent, overlayOpacity: 0, textColor: '#fff4d6', imageSrc: null, imageName: '', imageX: 0, imageY: 0, imageZoom: 1, scale: 1, texts: [], selectedTextId: null });
169
+ const name = document.createElement('span');
170
+ name.textContent = frame.shortName;
171
+ button.append(swatch, name);
172
+ container.append(button);
173
+ });
174
+ }
175
+
176
+ export function renderTextLayers(container: HTMLElement, texts: TextLayer[], selectedId: string | null, emptyText: string): void {
177
+ container.replaceChildren();
178
+ if (texts.length === 0) {
179
+ const empty = document.createElement('p');
180
+ empty.className = 'text-empty';
181
+ empty.textContent = emptyText;
182
+ container.append(empty);
183
+ return;
184
+ }
185
+ texts.forEach((text) => {
186
+ const button = document.createElement('button');
187
+ button.type = 'button';
188
+ button.className = `text-layer-choice${text.id === selectedId ? ' is-selected' : ''}`;
189
+ button.dataset.textId = text.id;
190
+ button.textContent = text.text || 'Untitled label';
191
+ container.append(button);
192
+ });
193
+ }
194
+
195
+ interface LibraryLabels {
196
+ emptyText: string;
197
+ reuseText: string;
198
+ deleteText: string;
199
+ newText: string;
200
+ activeId: string;
201
+ }
202
+
203
+ function createMarkerPreview(marker: SavedMarker): HTMLCanvasElement {
204
+ const preview = document.createElement('canvas');
205
+ preview.className = 'marker-card-preview';
206
+ const drawPreview = (image: HTMLImageElement | null) => {
207
+ if (image?.complete && image.naturalWidth > 0) drawTokenCanvas(preview, marker.state, image, false);
208
+ else drawFrameIcon(preview, getFramePreset(marker.state.frameId), { ...marker.state, borderWidth: Math.max(5, marker.state.borderWidth / 2) });
209
+ };
210
+ drawPreview(null);
211
+ if (marker.state.imageSrc) {
212
+ const image = new Image();
213
+ image.addEventListener('load', () => drawPreview(image), { once: true });
214
+ image.src = marker.state.imageSrc;
215
+ }
216
+ return preview;
217
+ }
218
+
219
+ function createMarkerCard(marker: SavedMarker, labels: LibraryLabels): HTMLElement {
220
+ const card = document.createElement('article'); card.className = `marker-card${marker.id === labels.activeId ? ' is-active' : ''}`; card.dataset.markerId = marker.id;
221
+ const button = document.createElement('button'); button.type = 'button';
222
+ button.className = 'marker-card-main';
223
+ button.dataset.markerId = marker.id;
224
+ button.setAttribute('aria-label', `${labels.reuseText}: ${marker.name}`);
225
+ if (marker.id === labels.activeId) button.setAttribute('aria-current', 'true');
226
+ const preview = createMarkerPreview(marker);
227
+ const image = document.createElement('img');
228
+ image.alt = marker.name;
229
+ image.loading = 'lazy';
230
+ if (marker.state.imageSrc) image.src = marker.state.imageSrc;
231
+ preview.append(image);
232
+ const details = document.createElement('span');
233
+ details.className = 'marker-card-details';
234
+ const name = document.createElement('strong');
235
+ name.textContent = marker.name;
236
+ const date = document.createElement('small');
237
+ date.textContent = new Date(marker.updatedAt).toLocaleDateString();
238
+ details.append(name, date);
239
+ button.append(preview, details);
240
+ const remove = document.createElement('button');
241
+ remove.type = 'button';
242
+ remove.className = 'marker-card-delete';
243
+ remove.dataset.deleteMarker = marker.id;
244
+ remove.setAttribute('aria-label', `${labels.deleteText}: ${marker.name}`);
245
+ remove.textContent = 'x';
246
+ card.append(button, remove);
247
+ return card;
248
+ }
249
+
250
+ export function renderMarkerLibrary(container: HTMLElement, markers: SavedMarker[], labels: LibraryLabels): void {
251
+ container.replaceChildren();
252
+ const add = document.createElement('button');
253
+ add.type = 'button';
254
+ add.className = 'marker-card marker-card-new';
255
+ add.dataset.newMarker = 'true';
256
+ add.textContent = `+ ${labels.newText}`;
257
+ container.append(add);
258
+ if (markers.length === 0) {
259
+ const empty = document.createElement('p');
260
+ empty.className = 'library-empty';
261
+ empty.textContent = labels.emptyText;
262
+ container.append(empty);
263
+ return;
264
+ }
265
+ markers.forEach((marker) => container.append(createMarkerCard(marker, labels)));
266
+ }