@jjlmoya/utils-tabletop 1.30.0 → 1.32.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jjlmoya/utils-tabletop",
3
- "version": "1.30.0",
3
+ "version": "1.32.0",
4
4
  "type": "module",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -20,7 +20,6 @@ const { ui } = Astro.props as { ui: TokenStampUI };
20
20
  <div class="stage-actions">
21
21
  <label class="button button-primary" for="token-image-input">{ui.chooseImage}</label>
22
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
23
  <button class="button button-quiet" type="button" data-copy>{ui.copy}</button>
25
24
  <button class="button button-primary" type="button" data-download>{ui.download}</button>
26
25
  </div>
@@ -34,21 +34,6 @@ function drawGeometry(ctx: CanvasRenderingContext2D, frame: FramePreset, size: n
34
34
  }
35
35
  }
36
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
37
  function drawPlaceholder(ctx: CanvasRenderingContext2D, size: number): void {
53
38
  ctx.fillStyle = '#273242';
54
39
  ctx.fillRect(size * 0.26, size * 0.28, size * 0.48, size * 0.44);
@@ -89,12 +74,6 @@ function drawTextLayers(ctx: CanvasRenderingContext2D, state: TokenStampState, s
89
74
  ctx.textBaseline = 'middle';
90
75
  ctx.strokeText(text.text, text.x * size, text.y * size, size * 0.82);
91
76
  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
77
  ctx.restore();
99
78
  });
100
79
  }
@@ -129,27 +108,21 @@ function drawFrameIcon(canvas: HTMLCanvasElement, frame: FramePreset, state: Tok
129
108
  drawFrame(ctx, frame, { ...state, borderWidth: 7 }, size);
130
109
  }
131
110
 
132
- export function drawTokenCanvas(canvas: HTMLCanvasElement, state: TokenStampState, image: HTMLImageElement | null, includeStageGrid = true): void {
111
+ export function drawTokenCanvas(canvas: HTMLCanvasElement, state: TokenStampState, image: HTMLImageElement | null): void {
133
112
  const size = 640;
134
- canvas.width = size;
135
- canvas.height = size;
113
+ canvas.width = size; canvas.height = size;
136
114
  const ctx = canvas.getContext('2d');
137
115
  if (!ctx) return;
138
116
  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();
117
+ ctx.clearRect(0, 0, size, size);
118
+ ctx.save(); drawGeometry(ctx, frame, size); ctx.clip();
144
119
  ctx.fillStyle = state.background;
145
120
  ctx.fillRect(0, 0, size, size);
146
121
  drawImage(ctx, image, state, size);
147
122
  if (state.overlayOpacity > 0) {
148
- ctx.fillStyle = hexToRgba(state.overlay, state.overlayOpacity);
149
- ctx.fillRect(0, 0, size, size);
123
+ ctx.fillStyle = hexToRgba(state.overlay, state.overlayOpacity); ctx.fillRect(0, 0, size, size);
150
124
  }
151
- ctx.restore();
152
- drawFrame(ctx, frame, state, size);
125
+ ctx.restore(); drawFrame(ctx, frame, state, size);
153
126
  drawTextLayers(ctx, state, size);
154
127
  }
155
128
 
@@ -204,7 +177,7 @@ function createMarkerPreview(marker: SavedMarker): HTMLCanvasElement {
204
177
  const preview = document.createElement('canvas');
205
178
  preview.className = 'marker-card-preview';
206
179
  const drawPreview = (image: HTMLImageElement | null) => {
207
- if (image?.complete && image.naturalWidth > 0) drawTokenCanvas(preview, marker.state, image, false);
180
+ if (image?.complete && image.naturalWidth > 0) drawTokenCanvas(preview, marker.state, image);
208
181
  else drawFrameIcon(preview, getFramePreset(marker.state.frameId), { ...marker.state, borderWidth: Math.max(5, marker.state.borderWidth / 2) });
209
182
  };
210
183
  drawPreview(null);
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'Porträttönung',
18
18
  stageLabel: 'Live Token Schmiede',
19
19
  chooseImage: 'Porträt wählen',
20
- clearImage: 'Porträt entfernen',
21
20
  positionHint: 'Ziehe das Porträt oder Beschriftungen direkt auf den Token.',
22
21
  markerName: 'Marker Name',
23
22
  markerNamePlaceholder: 'Marker benennen',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'Portrait tint',
18
18
  stageLabel: 'Live token forge',
19
19
  chooseImage: 'Choose portrait',
20
- clearImage: 'Clear portrait',
21
20
  positionHint: 'Drag the portrait or a label directly on the token.',
22
21
  markerName: 'Marker name',
23
22
  markerNamePlaceholder: 'Name this marker',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'Tinte de retrato',
18
18
  stageLabel: 'Forja de fichas en vivo',
19
19
  chooseImage: 'Elegir retrato',
20
- clearImage: 'Quitar retrato',
21
20
  positionHint: 'Arrastra el retrato o las etiquetas directamente sobre la ficha.',
22
21
  markerName: 'Nombre del marcador',
23
22
  markerNamePlaceholder: 'Nombra este marcador',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'Teinte du portrait',
18
18
  stageLabel: 'Forge de jetons en direct',
19
19
  chooseImage: 'Choisir un portrait',
20
- clearImage: 'Effacer le portrait',
21
20
  positionHint: 'Faites glisser le portrait ou les étiquettes directement sur le jeton.',
22
21
  markerName: 'Nom du marqueur',
23
22
  markerNamePlaceholder: 'Nommer ce marqueur',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'Opasitas potret',
18
18
  stageLabel: 'Arena pembuat token',
19
19
  chooseImage: 'Pilih potret',
20
- clearImage: 'Hapus potret',
21
20
  positionHint: 'Geser potret atau label langsung di atas token.',
22
21
  markerName: 'Nama penanda',
23
22
  markerNamePlaceholder: 'Beri nama penanda ini',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'Tinta ritratto',
18
18
  stageLabel: 'Fucina token in tempo reale',
19
19
  chooseImage: 'Scegli ritratto',
20
- clearImage: 'Rimuovi ritratto',
21
20
  positionHint: 'Trascina il ritratto o le etichette direttamente sul token.',
22
21
  markerName: 'Nome segnalino',
23
22
  markerNamePlaceholder: 'Assegna un nome a questo segnalino',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'ポートレート不透明度',
18
18
  stageLabel: 'ライブコマ作成スタジオ',
19
19
  chooseImage: '画像を選択',
20
- clearImage: '画像をクリア',
21
20
  positionHint: 'コマ上で直接画像やラベルをドラッグして移動できます。',
22
21
  markerName: 'マーカー名',
23
22
  markerNamePlaceholder: 'マーカーの名前を入力',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: '초상화 투명도',
18
18
  stageLabel: '실시간 토큰 제작소',
19
19
  chooseImage: '초상화 선택',
20
- clearImage: '초상화 지우기',
21
20
  positionHint: '토큰 위에서 직접 이미지나 라벨을 드래그하여 이동하세요.',
22
21
  markerName: '마커 이름',
23
22
  markerNamePlaceholder: '마커 이름 입력',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'Portrettint',
18
18
  stageLabel: 'Live token smederij',
19
19
  chooseImage: 'Kies portret',
20
- clearImage: 'Portret wissen',
21
20
  positionHint: 'Sleep het portret of de labels rechtstreeks op de token.',
22
21
  markerName: 'Markernaam',
23
22
  markerNamePlaceholder: 'Geef deze marker een naam',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'Odcień portretu',
18
18
  stageLabel: 'Kuźnia żetonów na żywo',
19
19
  chooseImage: 'Wybierz portret',
20
- clearImage: 'Usuń portret',
21
20
  positionHint: 'Przeciągnij portret lub etykiety bezpośrednio na żetonie.',
22
21
  markerName: 'Nazwa znacznika',
23
22
  markerNamePlaceholder: 'Nazwij ten znacznik',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'Tinta do retrato',
18
18
  stageLabel: 'Forja de tokens em tempo real',
19
19
  chooseImage: 'Escolher retrato',
20
- clearImage: 'Remover retrato',
21
20
  positionHint: 'Arraste o retrato ou as etiquetas diretamente sobre o token.',
22
21
  markerName: 'Nome do marcador',
23
22
  markerNamePlaceholder: 'Dê um nome a este marcador',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'Оттенок портрета',
18
18
  stageLabel: 'Кузница жетонов',
19
19
  chooseImage: 'Выбрать портрет',
20
- clearImage: 'Удалить портрет',
21
20
  positionHint: 'Перетаскивайте портрет и подписи прямо на жетоне.',
22
21
  markerName: 'Название маркера',
23
22
  markerNamePlaceholder: 'Назовите этот маркер',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'Porträttoning',
18
18
  stageLabel: 'Live token-smedja',
19
19
  chooseImage: 'Välj porträtt',
20
- clearImage: 'Ta bort porträtt',
21
20
  positionHint: 'Dra porträttet eller etiketter direkt på din token.',
22
21
  markerName: 'Markörnamn',
23
22
  markerNamePlaceholder: 'Namnge denna markör',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: 'Portre tonu',
18
18
  stageLabel: 'Canlı pul atölyesi',
19
19
  chooseImage: 'Portre seçin',
20
- clearImage: 'Portreyi kaldır',
21
20
  positionHint: 'Portreyi veya etiketleri doğrudan pulun üzerine sürükleyin.',
22
21
  markerName: 'İşaretçi adı',
23
22
  markerNamePlaceholder: 'Bu işaretçiyi adlandırın',
@@ -17,7 +17,6 @@ const ui: TokenStampUI = {
17
17
  overlayOpacityLabel: '头像遮罩透明度',
18
18
  stageLabel: '实时棋子锻造台',
19
19
  chooseImage: '选择头像',
20
- clearImage: '清除头像',
21
20
  positionHint: '可以直接在棋子上拖动调整头像或文字标签的位置。',
22
21
  markerName: '标记名称',
23
22
  markerNamePlaceholder: '为此标记命名',
@@ -14,6 +14,9 @@
14
14
  --n-stage-text: #b9c5d6;
15
15
  --n-stage-highlight: #fff4d6;
16
16
  --n-accent-ink: #202a39;
17
+ --n-action: #9f2f24;
18
+ --n-action-hover: #7f241c;
19
+ --n-action-ink: #fff;
17
20
  }
18
21
 
19
22
  .theme-dark {
@@ -32,6 +35,9 @@
32
35
  --n-stage-text: #b9c5d6;
33
36
  --n-stage-highlight: #fff4d6;
34
37
  --n-accent-ink: #202a39;
38
+ --n-action: #9f2f24;
39
+ --n-action-hover: #7f241c;
40
+ --n-action-ink: #fff;
35
41
  }
36
42
 
37
43
  .token-stamp-studio {
@@ -152,7 +158,9 @@
152
158
  background: var(--n-success);
153
159
  }
154
160
  .token-stage {
155
- background: var(--n-stage);
161
+ background-color: var(--n-stage);
162
+ background-image: linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px);
163
+ background-size: 12.5% 12.5%;
156
164
  border: 1px solid var(--n-stage-soft);
157
165
  padding: clamp(0.75rem, 4vw, 2.2rem);
158
166
  position: relative;
@@ -434,11 +442,17 @@
434
442
  text-decoration: none;
435
443
  }
436
444
  .button-primary {
437
- background: var(--n-accent);
438
- border-color: var(--n-accent);
439
- color: var(--n-accent-ink);
445
+ background: var(--n-action);
446
+ border-color: var(--n-action);
447
+ color: var(--n-action-ink);
440
448
  font-weight: 800;
441
449
  }
450
+ .button-primary:hover,
451
+ .button-primary:focus-visible {
452
+ background: var(--n-action-hover);
453
+ border-color: var(--n-action-hover);
454
+ color: var(--n-action-ink);
455
+ }
442
456
  .button-quiet {
443
457
  background: var(--n-card-soft);
444
458
  color: var(--n-text);
@@ -14,7 +14,6 @@ export interface TokenStampUI {
14
14
  overlayOpacityLabel: string;
15
15
  stageLabel: string;
16
16
  chooseImage: string;
17
- clearImage: string;
18
17
  positionHint: string;
19
18
  markerName: string;
20
19
  markerNamePlaceholder: string;
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { zipBlobs } from './workspace-media';
3
+
4
+ describe('zipBlobs', () => {
5
+ it('creates a readable zip with every exported png', async () => {
6
+ const archive = await zipBlobs([
7
+ { name: 'hero.png', blob: new Blob([new Uint8Array([1, 2, 3])]) },
8
+ { name: 'mage.png', blob: new Blob([new Uint8Array([4, 5, 6])]) },
9
+ ]);
10
+ const bytes = new Uint8Array(await archive.arrayBuffer());
11
+ const end = new DataView(bytes.buffer, bytes.byteOffset + bytes.byteLength - 22, 22);
12
+ const content = new TextDecoder().decode(bytes);
13
+
14
+ expect(archive.type).toBe('application/zip');
15
+ expect(bytes.slice(0, 4)).toEqual(new Uint8Array([0x50, 0x4b, 0x03, 0x04]));
16
+ expect(end.getUint16(8, true)).toBe(2);
17
+ expect(content).toContain('hero.png');
18
+ expect(content).toContain('mage.png');
19
+ });
20
+ });
@@ -23,14 +23,108 @@ export function downloadCanvas(canvas: HTMLCanvasElement, name: string): void {
23
23
  link.click();
24
24
  }
25
25
 
26
+ export function canvasToBlob(canvas: HTMLCanvasElement): Promise<Blob> {
27
+ return new Promise((resolve, reject) => {
28
+ canvas.toBlob((blob) => {
29
+ if (blob) resolve(blob);
30
+ else reject(new Error('Canvas could not be exported'));
31
+ }, 'image/png');
32
+ });
33
+ }
34
+
35
+ export function downloadBlob(blob: Blob, name: string): void {
36
+ const link = document.createElement('a');
37
+ const url = URL.createObjectURL(blob);
38
+ link.download = name;
39
+ link.href = url;
40
+ link.click();
41
+ window.setTimeout(() => URL.revokeObjectURL(url), 1000);
42
+ }
43
+
26
44
  export async function copyCanvas(canvas: HTMLCanvasElement): Promise<boolean> {
27
45
  if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') return false;
28
- const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'));
29
- if (!blob) return false;
46
+ const blob = await canvasToBlob(canvas);
30
47
  await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
31
48
  return true;
32
49
  }
33
50
 
51
+ interface ZipEntry {
52
+ name: string;
53
+ data: Uint8Array;
54
+ }
55
+
56
+ const crc32Table = Array.from({ length: 256 }, (_, value) => {
57
+ let crc = value;
58
+ for (let bit = 0; bit < 8; bit += 1) crc = crc & 1 ? 0xedb88320 ^ (crc >>> 1) : crc >>> 1;
59
+ return crc >>> 0;
60
+ });
61
+
62
+ function crc32(data: Uint8Array): number {
63
+ let crc = 0xffffffff;
64
+ data.forEach((value) => { crc = crc32Table[(crc ^ value) & 0xff]! ^ (crc >>> 8); });
65
+ return (crc ^ 0xffffffff) >>> 0;
66
+ }
67
+
68
+ function writeUint16(view: DataView, offset: number, value: number): void {
69
+ view.setUint16(offset, value, true);
70
+ }
71
+
72
+ function writeUint32(view: DataView, offset: number, value: number): void {
73
+ view.setUint32(offset, value, true);
74
+ }
75
+
76
+ function createLocalEntry(entry: ZipEntry, name: Uint8Array, checksum: number): Uint8Array {
77
+ const local = new Uint8Array(30 + name.length + entry.data.length);
78
+ const view = new DataView(local.buffer);
79
+ writeUint32(view, 0, 0x04034b50); writeUint16(view, 4, 20); writeUint16(view, 6, 0x800);
80
+ writeUint16(view, 8, 0); writeUint16(view, 10, 0); writeUint16(view, 12, 0);
81
+ writeUint32(view, 14, checksum); writeUint32(view, 18, entry.data.length); writeUint32(view, 22, entry.data.length);
82
+ writeUint16(view, 26, name.length); local.set(name, 30); local.set(entry.data, 30 + name.length);
83
+ return local;
84
+ }
85
+
86
+ function createCentralEntry(entry: ZipEntry, name: Uint8Array, checksum: number, offset: number): Uint8Array {
87
+ const central = new Uint8Array(46 + name.length);
88
+ const view = new DataView(central.buffer);
89
+ writeUint32(view, 0, 0x02014b50); writeUint16(view, 4, 20); writeUint16(view, 6, 20); writeUint16(view, 8, 0x800);
90
+ writeUint16(view, 10, 0); writeUint16(view, 12, 0); writeUint16(view, 14, 0); writeUint32(view, 16, checksum);
91
+ writeUint32(view, 20, entry.data.length); writeUint32(view, 24, entry.data.length); writeUint16(view, 28, name.length);
92
+ writeUint16(view, 30, 0); writeUint16(view, 32, 0); writeUint16(view, 34, 0); writeUint16(view, 36, 0);
93
+ writeUint32(view, 38, 0); writeUint32(view, 42, offset); central.set(name, 46);
94
+ return central;
95
+ }
96
+
97
+ function createZipEnd(entryCount: number, centralSize: number, offset: number): Uint8Array {
98
+ const end = new Uint8Array(22);
99
+ const view = new DataView(end.buffer);
100
+ writeUint32(view, 0, 0x06054b50); writeUint16(view, 8, entryCount); writeUint16(view, 10, entryCount);
101
+ writeUint32(view, 12, centralSize); writeUint32(view, 16, offset);
102
+ return end;
103
+ }
104
+
105
+ export async function zipBlobs(files: Array<{ name: string; blob: Blob }>): Promise<Blob> {
106
+ const entries: ZipEntry[] = await Promise.all(files.map(async ({ name, blob }) => ({
107
+ name,
108
+ data: new Uint8Array(await blob.arrayBuffer()),
109
+ })));
110
+ const encoder = new TextEncoder();
111
+ const localParts: Uint8Array[] = [];
112
+ const centralParts: Uint8Array[] = [];
113
+ let offset = 0;
114
+
115
+ entries.forEach((entry) => {
116
+ const name = encoder.encode(entry.name);
117
+ const checksum = crc32(entry.data);
118
+ const local = createLocalEntry(entry, name, checksum);
119
+ localParts.push(local);
120
+ centralParts.push(createCentralEntry(entry, name, checksum, offset));
121
+ offset += local.length;
122
+ });
123
+
124
+ const centralSize = centralParts.reduce((total, part) => total + part.length, 0);
125
+ return new Blob([...localParts, ...centralParts, createZipEnd(entries.length, centralSize, offset)], { type: 'application/zip' });
126
+ }
127
+
34
128
  export function cleanName(name: string): string {
35
129
  return name.replace(/\.[^/.]+$/, '').replace(/[^a-z0-9]+/gi, '-').replace(/^-|-$/g, '').toLowerCase() || 'tabletop-token';
36
130
  }
@@ -1,5 +1,5 @@
1
1
  import { drawTokenCanvas } from './dom-views';
2
- import { loadImage, readFile, cleanName, copyCanvas, downloadCanvas } from './workspace-media';
2
+ import { canvasToBlob, cleanName, copyCanvas, downloadBlob, downloadCanvas, loadImage, readFile, zipBlobs } from './workspace-media';
3
3
  import type { StudioElements } from './controller';
4
4
  import type { SavedMarker, TokenStampState } from './types';
5
5
  import type { TokenStampUI } from './ui';
@@ -16,12 +16,6 @@ export interface WorkspaceContext {
16
16
  setImage: (image: HTMLImageElement | null) => void;
17
17
  }
18
18
 
19
- function getElement<T extends Element>(root: HTMLElement, selector: string): T {
20
- const element = root.querySelector<T>(selector);
21
- if (!element) throw new Error(`Token stamp studio element missing: ${selector}`);
22
- return element;
23
- }
24
-
25
19
  function attachLibraryEvents(elements: StudioElements, context: WorkspaceContext): void {
26
20
  elements.library.addEventListener('click', (event) => {
27
21
  const target = event.target as HTMLElement;
@@ -36,10 +30,9 @@ function attachLibraryEvents(elements: StudioElements, context: WorkspaceContext
36
30
  }
37
31
 
38
32
  export function attachWorkspaceEvents(elements: StudioElements, context: WorkspaceContext): void {
39
- getElement<HTMLButtonElement>(elements.root, '[data-clear-image]').addEventListener('click', () => context.setState({ ...context.getState(), imageSrc: null, imageName: '', imageX: 0, imageY: 0 }));
40
33
  const exportCanvas = (): HTMLCanvasElement => {
41
34
  const canvas = document.createElement('canvas');
42
- drawTokenCanvas(canvas, context.getState(), context.getImage(), false);
35
+ drawTokenCanvas(canvas, context.getState(), context.getImage());
43
36
  return canvas;
44
37
  };
45
38
  elements.download.addEventListener('click', () => downloadCanvas(exportCanvas(), cleanName(context.getState().imageName)));
@@ -62,22 +55,53 @@ async function loadDroppedFile(file: File, context: WorkspaceContext): Promise<H
62
55
  return image;
63
56
  }
64
57
 
65
- async function exportBatch(files: File[], state: TokenStampState): Promise<void> {
66
- for (const file of files) {
67
- const source = await readFile(file);
68
- const image = await loadImage(source);
69
- const canvas = document.createElement('canvas');
70
- drawTokenCanvas(canvas, { ...state, imageSrc: source, imageName: file.name }, image, false);
71
- downloadCanvas(canvas, cleanName(file.name));
58
+ async function exportBatch(files: File[], state: TokenStampState, onProgress: (current: number) => void): Promise<{ exported: number; failed: number }> {
59
+ const exports: Array<{ name: string; blob: Blob }> = [];
60
+ const names = new Set<string>();
61
+ let failed = 0;
62
+ for (const [index, file] of files.entries()) {
63
+ try {
64
+ const source = await readFile(file);
65
+ const image = await loadImage(source);
66
+ const canvas = document.createElement('canvas');
67
+ drawTokenCanvas(canvas, { ...state, imageSrc: source, imageName: file.name }, image);
68
+ const baseName = cleanName(file.name);
69
+ let name = `${baseName}.png`;
70
+ let suffix = 2;
71
+ while (names.has(name)) name = `${baseName}-${suffix++}.png`;
72
+ names.add(name);
73
+ exports.push({ name, blob: await canvasToBlob(canvas) });
74
+ } catch {
75
+ failed += 1;
76
+ }
77
+ onProgress(index + 1);
72
78
  }
79
+ if (exports.length > 0) downloadBlob(await zipBlobs(exports), 'tabletop-token-batch.zip');
80
+ return { exported: exports.length, failed };
73
81
  }
74
82
 
75
83
  export function attachBatchEvents(elements: StudioElements, context: WorkspaceContext): void {
76
84
  let files: File[] = [];
85
+ let busy = false;
77
86
  elements.batchInput.addEventListener('change', () => {
78
87
  files = Array.from(elements.batchInput.files ?? []);
88
+ elements.batchInput.value = '';
79
89
  elements.batchStatus.textContent = files.length ? context.ui.batchReady.replace('{count}', String(files.length)) : context.ui.batchHint;
80
90
  elements.batchDownload.disabled = files.length === 0;
81
91
  });
82
- elements.batchDownload.addEventListener('click', () => void exportBatch(files, context.getState()));
92
+ elements.batchDownload.addEventListener('click', () => {
93
+ if (busy || files.length === 0) return;
94
+ busy = true;
95
+ elements.batchDownload.disabled = true;
96
+ elements.batchDownload.setAttribute('aria-busy', 'true');
97
+ void exportBatch(files, context.getState(), (current) => {
98
+ elements.batchStatus.textContent = `${current}/${files.length}`;
99
+ }).then(({ exported, failed }) => {
100
+ elements.batchStatus.textContent = failed ? `${exported}/${files.length}` : String(exported);
101
+ }).finally(() => {
102
+ busy = false;
103
+ elements.batchDownload.disabled = files.length === 0;
104
+ elements.batchDownload.removeAttribute('aria-busy');
105
+ });
106
+ });
83
107
  }