@jjlmoya/utils-tabletop 1.26.0 → 1.28.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 (69) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +4 -0
  3. package/src/entries.ts +4 -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/mus-scoreboard/bibliography.astro +16 -0
  8. package/src/tool/mus-scoreboard/bibliography.ts +12 -0
  9. package/src/tool/mus-scoreboard/component.astro +169 -0
  10. package/src/tool/mus-scoreboard/controller.ts +178 -0
  11. package/src/tool/mus-scoreboard/dom-views.ts +87 -0
  12. package/src/tool/mus-scoreboard/entry.ts +27 -0
  13. package/src/tool/mus-scoreboard/evaluator.ts +27 -0
  14. package/src/tool/mus-scoreboard/i18n/de.ts +187 -0
  15. package/src/tool/mus-scoreboard/i18n/en.ts +187 -0
  16. package/src/tool/mus-scoreboard/i18n/es.ts +187 -0
  17. package/src/tool/mus-scoreboard/i18n/fr.ts +187 -0
  18. package/src/tool/mus-scoreboard/i18n/id.ts +187 -0
  19. package/src/tool/mus-scoreboard/i18n/it.ts +187 -0
  20. package/src/tool/mus-scoreboard/i18n/ja.ts +187 -0
  21. package/src/tool/mus-scoreboard/i18n/ko.ts +187 -0
  22. package/src/tool/mus-scoreboard/i18n/nl.ts +187 -0
  23. package/src/tool/mus-scoreboard/i18n/pl.ts +187 -0
  24. package/src/tool/mus-scoreboard/i18n/pt.ts +187 -0
  25. package/src/tool/mus-scoreboard/i18n/ru.ts +187 -0
  26. package/src/tool/mus-scoreboard/i18n/sv.ts +187 -0
  27. package/src/tool/mus-scoreboard/i18n/tr.ts +187 -0
  28. package/src/tool/mus-scoreboard/i18n/zh.ts +187 -0
  29. package/src/tool/mus-scoreboard/index.ts +11 -0
  30. package/src/tool/mus-scoreboard/logic.test.ts +82 -0
  31. package/src/tool/mus-scoreboard/logic.ts +122 -0
  32. package/src/tool/mus-scoreboard/online-mus-scoreboard.css +740 -0
  33. package/src/tool/mus-scoreboard/seo.astro +16 -0
  34. package/src/tool/mus-scoreboard/storage.ts +29 -0
  35. package/src/tool/mus-scoreboard/ui.ts +63 -0
  36. package/src/tool/token-stamp-studio/bibliography.astro +16 -0
  37. package/src/tool/token-stamp-studio/bibliography.ts +12 -0
  38. package/src/tool/token-stamp-studio/component.astro +91 -0
  39. package/src/tool/token-stamp-studio/controller.ts +179 -0
  40. package/src/tool/token-stamp-studio/dom-views.ts +266 -0
  41. package/src/tool/token-stamp-studio/entry.ts +27 -0
  42. package/src/tool/token-stamp-studio/evaluator.ts +6 -0
  43. package/src/tool/token-stamp-studio/i18n/de.ts +132 -0
  44. package/src/tool/token-stamp-studio/i18n/en.ts +132 -0
  45. package/src/tool/token-stamp-studio/i18n/es.ts +132 -0
  46. package/src/tool/token-stamp-studio/i18n/fr.ts +132 -0
  47. package/src/tool/token-stamp-studio/i18n/id.ts +132 -0
  48. package/src/tool/token-stamp-studio/i18n/it.ts +132 -0
  49. package/src/tool/token-stamp-studio/i18n/ja.ts +132 -0
  50. package/src/tool/token-stamp-studio/i18n/ko.ts +132 -0
  51. package/src/tool/token-stamp-studio/i18n/nl.ts +132 -0
  52. package/src/tool/token-stamp-studio/i18n/pl.ts +132 -0
  53. package/src/tool/token-stamp-studio/i18n/pt.ts +132 -0
  54. package/src/tool/token-stamp-studio/i18n/ru.ts +132 -0
  55. package/src/tool/token-stamp-studio/i18n/sv.ts +132 -0
  56. package/src/tool/token-stamp-studio/i18n/tr.ts +132 -0
  57. package/src/tool/token-stamp-studio/i18n/zh.ts +132 -0
  58. package/src/tool/token-stamp-studio/index.ts +11 -0
  59. package/src/tool/token-stamp-studio/logic.test.ts +36 -0
  60. package/src/tool/token-stamp-studio/logic.ts +127 -0
  61. package/src/tool/token-stamp-studio/seo.astro +16 -0
  62. package/src/tool/token-stamp-studio/storage.ts +69 -0
  63. package/src/tool/token-stamp-studio/store.ts +139 -0
  64. package/src/tool/token-stamp-studio/token-stamp-studio.css +646 -0
  65. package/src/tool/token-stamp-studio/types.ts +65 -0
  66. package/src/tool/token-stamp-studio/ui.ts +50 -0
  67. package/src/tool/token-stamp-studio/workspace-media.ts +36 -0
  68. package/src/tool/token-stamp-studio/workspace.ts +83 -0
  69. package/src/tools.ts +4 -0
@@ -0,0 +1,16 @@
1
+ ---
2
+ import { SEORenderer } from '@jjlmoya/utils-shared';
3
+ import { musScoreboard } 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 = musScoreboard.i18n[locale] || musScoreboard.i18n.en;
12
+ const content = await loader?.();
13
+ if (!content) return null;
14
+ ---
15
+
16
+ {content.seo?.length > 0 && <SEORenderer content={{ locale, sections: content.seo }} />}
@@ -0,0 +1,29 @@
1
+ import type { MusState } from './logic';
2
+
3
+ const STORAGE_KEY = 'jjlmoya-mus-scoreboard-state';
4
+
5
+ export const loadMusState = (): MusState | null => {
6
+ try {
7
+ const raw = window.localStorage.getItem(STORAGE_KEY);
8
+ if (!raw) return null;
9
+ const state = JSON.parse(raw) as MusState;
10
+ if (!state.config.deck) state.config.deck = 'spanish';
11
+ if (state.hand !== 0 && state.hand !== 1) state.hand = 0;
12
+ state.history = state.history.map((entry) => ({ ...entry, hand: entry.hand === 1 ? 1 : 0 }));
13
+ return state;
14
+ } catch {
15
+ return null;
16
+ }
17
+ };
18
+
19
+ export const saveMusState = (state: MusState): void => {
20
+ try {
21
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
22
+ } catch {}
23
+ };
24
+
25
+ export const clearMusState = (): void => {
26
+ try {
27
+ window.localStorage.removeItem(STORAGE_KEY);
28
+ } catch {}
29
+ };
@@ -0,0 +1,63 @@
1
+ export interface MusScoreboardUI {
2
+ [key: string]: string;
3
+ onboarding: string;
4
+ pairOneLabel: string;
5
+ pairTwoLabel: string;
6
+ pairOneDefault: string;
7
+ pairTwoDefault: string;
8
+ targetPointsLabel: string;
9
+ targetPointsHint: string;
10
+ gamesToWinLabel: string;
11
+ gamesToWinHint: string;
12
+ deckLabel: string;
13
+ deckHint: string;
14
+ spanishDeck: string;
15
+ frenchDeck: string;
16
+ frenchDeckNotice: string;
17
+ pointsOption30: string;
18
+ pointsOption40: string;
19
+ pointsOption50: string;
20
+ gamesOption1: string;
21
+ gamesOption2: string;
22
+ gamesOption3: string;
23
+ startMatch: string;
24
+ matchSettings: string;
25
+ liveMatch: string;
26
+ targetLabel: string;
27
+ gameLabel: string;
28
+ gamePlural: string;
29
+ vacaLabel: string;
30
+ scoreLabel: string;
31
+ handLabel: string;
32
+ handSelected: string;
33
+ stonesLabel: string;
34
+ stoneSingular: string;
35
+ stonePlural: string;
36
+ amarracoSingular: string;
37
+ amarracoPlural: string;
38
+ pointsToGo: string;
39
+ addStone: string;
40
+ addAmarraco: string;
41
+ closeGame: string;
42
+ closeGamePrompt: string;
43
+ confirmationTitle: string;
44
+ confirmAction: string;
45
+ cancelAction: string;
46
+ undo: string;
47
+ resetMatch: string;
48
+ resetMatchPrompt: string;
49
+ historyTitle: string;
50
+ noHistory: string;
51
+ matchReady: string;
52
+ closeGameHint: string;
53
+ waitingForScore: string;
54
+ gameWon: string;
55
+ vacaWon: string;
56
+ gameClosed: string;
57
+ pointsAdded: string;
58
+ matchSaved: string;
59
+ lastAction: string;
60
+ settingsApplied: string;
61
+ pairNameRequired: string;
62
+ visualScoreLabel: string;
63
+ }
@@ -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
+ }