@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 +1 -1
- package/src/tool/token-stamp-studio/component.astro +0 -1
- package/src/tool/token-stamp-studio/dom-views.ts +7 -34
- package/src/tool/token-stamp-studio/i18n/de.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/en.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/es.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/fr.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/id.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/it.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/ja.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/ko.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/nl.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/pl.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/pt.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/ru.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/sv.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/tr.ts +0 -1
- package/src/tool/token-stamp-studio/i18n/zh.ts +0 -1
- package/src/tool/token-stamp-studio/token-stamp-studio.css +18 -4
- package/src/tool/token-stamp-studio/ui.ts +0 -1
- package/src/tool/token-stamp-studio/workspace-media.test.ts +20 -0
- package/src/tool/token-stamp-studio/workspace-media.ts +96 -2
- package/src/tool/token-stamp-studio/workspace.ts +41 -17
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
140
|
-
|
|
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
|
|
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: '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',
|
|
@@ -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-
|
|
438
|
-
border-color: var(--n-
|
|
439
|
-
color: var(--n-
|
|
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);
|
|
@@ -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
|
|
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 {
|
|
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()
|
|
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<
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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', () =>
|
|
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
|
}
|