image-to-pixel 3.1.0 → 3.2.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.
@@ -1,1002 +1,1051 @@
1
- // The editor UI. mountEditor(element) builds the whole app inside `element`.
2
- // The core engine (lib/core) has no idea this file exists.
3
-
4
- import { createPixelator } from '../core/engine.js';
5
- import { defaultSettings, decodeSettings, encodeSettings, settingsToJSON, settingsFromJSON, mergeSettings } from '../core/settings.js';
6
- import { parsePalette, toGPL, toHexFile, toPaletteJSON } from '../core/palette/parse.js';
7
- import { fetchLospec } from '../core/palette/lospec.js';
8
- import { detectGrid } from '../core/export/scale.js';
9
- import { toHex } from '../core/color.js';
10
- import { estimateFps, videoReady, seekTo, sniffVideoCodec, hasAudioTrack } from '../core/video/frames.js';
11
- import { downloadBlob } from '../core/export/canvas.js';
12
- import { PRESETS } from '../core/presets.js';
13
- import { h, render, pickFiles } from './dom.js';
14
- import { icon } from './icons.js';
15
- import { createStore } from './store.js';
16
- import { createStage } from './stage.js';
17
- import { createInspector } from './inspector.js';
18
- import { createLibrary, fmtTime } from './library.js';
19
- import { createExporter } from './exporter.js';
20
- import { toast, menu, closeMenu, dialog } from './ui.js';
21
- import { SAMPLES, loadSample, sampleURL } from './samples.js';
22
- import * as persist from './persist.js';
23
- import { saveWorkspace, loadWorkspace, forgetWorkspace, isQuotaError, fmtBytes } from './workspace.js';
24
-
25
- const IMAGE_TYPES = /^image\//;
26
- const VIDEO_TYPES = /^video\//;
27
- const PALETTE_EXT = /\.(gpl|hex|pal|txt|json)$/i;
28
-
29
- /**
30
- * Mount the editor.
31
- * @param {HTMLElement} root
32
- * @param {{ settings?: object, persist?: boolean, samples?: boolean, homeHref?: string, docsHref?: string }} [options]
33
- */
34
- export async function mountEditor(root, options = {}) {
35
- const usePersist = options.persist !== false;
36
- const narrow = () => matchMedia('(max-width: 900px)').matches;
37
-
38
- // ---- initial settings: share link > saved > defaults
39
- let initial = options.settings || (usePersist && persist.load('settings', null)) || defaultSettings();
40
- if (/[#&]s=/.test(location.hash)) {
41
- try { initial = decodeSettings(location.hash); } catch { toast('That share link could not be read; using your last settings', { kind: 'error' }); }
42
- }
43
- const store = createStore(initial);
44
-
45
- const app = {
46
- store,
47
- engine: null,
48
- items: [],
49
- current: null,
50
- result: null,
51
- exporting: false,
52
- activePreset: null,
53
- userPalettes: usePersist ? persist.load('palettes', []) : [],
54
- /** Settings shared by every file that doesn't have its own. */
55
- shared: store.settings,
56
- /** Settings copied with "Copy settings", ready to paste onto another file. */
57
- clipboard: null,
58
- videoSupport: { mp4: false, webm: false },
59
- toast: (m, o) => toast(m, o),
60
- };
61
-
62
- // ================================================================ layout
63
- const stage = createStage({ onPick: pickAt, onViewChange: () => updateZoomLabel() });
64
- const inspector = createInspector(app);
65
- const library = createLibrary(app);
66
- const exporter = createExporter(app);
67
-
68
- const btn = (ic, label, onclick, extra = {}) => h('button.itp-btn.icon', { type: 'button', 'aria-label': label, title: label, html: icon(ic), onclick, ...extra });
69
-
70
- const fileName = h('span.name', { text: 'No image' });
71
- // click the name to rename (F2); the extension stays as it was
72
- fileName.addEventListener('click', () => { if (app.current) startRename(app.current); });
73
- const fileMeta = h('span.meta.num', {});
74
- const undoBtn = btn('undo', 'Undo (Ctrl+Z)', () => store.undo());
75
- const redoBtn = btn('redo', 'Redo (Ctrl+Shift+Z)', () => store.redo());
76
- const compareBtn = btn('compare', 'Compare with original (C)', () => toggleCompare(), { 'aria-pressed': 'false' });
77
- const gridBtn = btn('grid', 'Pixel grid (G)', () => toggleGrid(), { 'aria-pressed': 'false' });
78
- const zoomBtn = h('button.itp-btn.itp-zoom.num', { type: 'button', 'aria-label': 'Zoom', 'aria-haspopup': 'menu', html: `<span>Fit</span>${icon('chevronDown')}`, onclick: e => zoomMenu(e.currentTarget) });
79
- const themeBtn = btn('moon', 'Toggle theme (T)', () => toggleTheme());
80
- const libBtn = btn('sidebarLeft', 'Toggle library (\\)', () => togglePanel('lib'));
81
- const inspBtn = btn('sidebarRight', 'Toggle inspector (/)', () => togglePanel('insp'));
82
- const helpBtn = btn('keyboard', 'Keyboard shortcuts (?)', () => showHelp());
83
- const exportBtn = h('button.itp-btn.primary', { type: 'button', 'aria-haspopup': 'menu', html: `${icon('download')}<span>Export</span>`, onclick: e => exportMenu(e.currentTarget) });
84
- const saveBtn = h('button.itp-btn.outline', { type: 'button', title: 'Save these files and their settings in this browser', html: `${icon('save')}<span class="hide-sm">Save</span>`, onclick: () => saveNow() });
85
- saveBtn.hidden = !usePersist;
86
- const brand = h('a.itp-brand', { href: options.homeHref || '#', title: 'Image-to-Pixel' }, h('span.itp-mark', { 'aria-hidden': 'true' }, ...Array.from({ length: 9 }, () => h('i'))), h('span.hide-sm', { text: 'Image-to-Pixel' }));
87
- if (!options.homeHref) brand.addEventListener('click', e => e.preventDefault());
88
-
89
- const top = h('header.itp-top', {},
90
- libBtn, brand, h('span.sep.hide-sm'),
91
- h('div.itp-file', {}, fileName, fileMeta),
92
- h('div.spacer'),
93
- h('span.hide-sm.contents', {}, undoBtn, redoBtn, h('span.sep')),
94
- compareBtn, gridBtn, zoomBtn,
95
- h('span.sep.hide-sm'), h('span.hide-sm.contents', {}, themeBtn, helpBtn), inspBtn,
96
- h('span', { style: { width: '6px' } }), saveBtn, exportBtn);
97
-
98
- // stage extras
99
- const sampleButtons = options.samples === false ? null : h('div.samples', {}, ...SAMPLES.map(s => h('button', { type: 'button', title: `Try "${s.name}"`, 'aria-label': `Open sample: ${s.name}`, onclick: () => openSample(s) }, h('img', { src: sampleURL(s), alt: '' }))));
100
- const empty = h('div.itp-empty', {}, h('div.itp-drop', {},
101
- h('h2', { text: 'Drop an image or a video' }),
102
- h('p', { text: 'PNG, JPG, WebP, GIF, SVG, MP4 or WebM. Add several files to process them as a batch. You can also paste with Ctrl+V.' }),
103
- h('div.actions', {},
104
- h('button.itp-btn.primary', { type: 'button', html: `${icon('upload')}<span>Choose files</span>`, onclick: () => openFiles() }),
105
- h('button.itp-btn.outline', { type: 'button', html: `${icon('clipboard')}<span>Paste</span>`, onclick: () => pasteFromClipboard() })),
106
- sampleButtons ? h('p', { style: { fontSize: 'var(--fs-xs)', color: 'var(--faint)' }, text: 'Or start from a sample' }) : null,
107
- sampleButtons));
108
- const status = h('div.itp-float.bl', { 'aria-live': 'polite' });
109
- const busy = h('div.itp-busy.itp-float', { hidden: true, style: { padding: '6px 10px', alignItems: 'center', gap: '8px', fontSize: 'var(--fs-xs)', color: 'var(--muted)', display: 'flex' } }, h('span.itp-spinner'), h('span', { text: 'Rendering' }));
110
- const viewTools = h('div.itp-float.br', {},
111
- btn('zoomOut', 'Zoom out (-)', () => stage.zoomOut()),
112
- btn('fit', 'Fit to view (F)', () => stage.fit()),
113
- btn('zoomIn', 'Zoom in (+)', () => stage.zoomIn()));
114
- stage.el.append(empty, status, busy, viewTools);
115
-
116
- // transport
117
- const playBtn = btn('play', 'Play (Space)', () => togglePlay());
118
- const timeLabel = h('span.itp-time', {});
119
- const scrub = h('input.itp-scrub', { type: 'range', min: 0, max: 1000, value: 0, 'aria-label': 'Video position' });
120
- const fpsLabel = h('span.muted.num', { style: { fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap' } });
121
- // sound: muted by default; the choice (and volume) is remembered and applies to every video
122
- const sound = { muted: persist.load('muted', true) !== false, volume: Number(persist.load('volume', 1)) };
123
- if (!(sound.volume >= 0 && sound.volume <= 1)) sound.volume = 1;
124
- // YouTube-style: the speaker button opens a vertical volume slider; all the way down is mute.
125
- const muteBtn = btn('volumeOff', 'Volume (M mutes)', () => toggleVolPop(), { 'aria-haspopup': 'true', 'aria-expanded': 'false' });
126
- const volFill = h('div.fill');
127
- const volThumb = h('div.thumb');
128
- const volSlider = h('div.itp-vslider', { role: 'slider', tabindex: '0', 'aria-label': 'Volume', 'aria-orientation': 'vertical', 'aria-valuemin': '0', 'aria-valuemax': '100' }, h('div.track', {}, volFill, volThumb));
129
- const volValue = h('span.num', {});
130
- const volPop = h('div.itp-volpop', { hidden: true }, volValue, volSlider);
131
- const soundBox = h('div.itp-sound', { hidden: true }, volPop, muteBtn);
132
- const setLevel = v => setSound(v <= 0.005 ? { muted: true } : { muted: false, volume: Math.min(1, v) });
133
- const levelAt = e => { const r = volSlider.querySelector('.track').getBoundingClientRect(); return Math.max(0, Math.min(1, (r.bottom - e.clientY) / r.height)); };
134
- volSlider.addEventListener('pointerdown', e => {
135
- e.preventDefault(); volSlider.focus(); volSlider.setPointerCapture(e.pointerId);
136
- const before = sound.muted ? 0 : sound.volume;
137
- setLevel(levelAt(e));
138
- const move = ev => setLevel(levelAt(ev));
139
- const up = () => {
140
- volSlider.removeEventListener('pointermove', move); volSlider.removeEventListener('pointerup', up); volSlider.removeEventListener('pointercancel', up);
141
- // dragged down to mute: M brings back the volume it had before the drag
142
- if (sound.muted && before > 0) setSound({ muted: true, volume: before });
143
- };
144
- volSlider.addEventListener('pointermove', move); volSlider.addEventListener('pointerup', up); volSlider.addEventListener('pointercancel', up);
145
- });
146
- volSlider.addEventListener('keydown', e => {
147
- const cur = sound.muted ? 0 : sound.volume;
148
- const next = { ArrowUp: cur + 0.05, ArrowRight: cur + 0.05, ArrowDown: cur - 0.05, ArrowLeft: cur - 0.05, PageUp: cur + 0.2, PageDown: cur - 0.2, Home: 0, End: 1 }[e.key];
149
- if (next === undefined) { if (e.key === 'Escape') { e.preventDefault(); toggleVolPop(false); muteBtn.focus(); } return; }
150
- e.preventDefault(); e.stopPropagation(); setLevel(next);
151
- });
152
- volSlider.addEventListener('wheel', e => { e.preventDefault(); setLevel((sound.muted ? 0 : sound.volume) - Math.sign(e.deltaY) * 0.05); }, { passive: false });
153
- function toggleVolPop(open = volPop.hidden) {
154
- volPop.hidden = !open;
155
- muteBtn.setAttribute('aria-expanded', String(open));
156
- if (open) {
157
- const close = e => { if (!soundBox.contains(e.target)) { toggleVolPop(false); document.removeEventListener('pointerdown', close, true); } };
158
- document.addEventListener('pointerdown', close, true);
159
- }
160
- }
161
- const transport = h('div.itp-transport', { role: 'group', 'aria-label': 'Video' },
162
- btn('stepBack', 'Previous frame (,)', () => stepFrame(-1)), playBtn, btn('stepForward', 'Next frame (.)', () => stepFrame(1)),
163
- timeLabel, scrub, soundBox, fpsLabel);
164
- const stageWrap = h('main.itp-stage', { 'data-video': 'false' }, stage.el, transport);
165
-
166
- // on narrow screens the library is a drawer: a tap on the canvas closes it
167
- stageWrap.addEventListener('pointerdown', () => { if (narrow() && shell.dataset.lib === 'open') togglePanel('lib'); }, true);
168
- const shell = h('div.itp.itp-app', { 'data-lib': narrow() ? 'closed' : persist.load('lib', 'open'), 'data-insp': persist.load('insp', 'open') }, top, library.el, stageWrap, inspector.el);
169
- render(root, shell);
170
- applyTheme(persist.load('theme', null));
171
-
172
- // ================================================================ engine
173
- status.textContent = 'Starting…';
174
- try {
175
- app.engine = await createPixelator();
176
- } catch (e) {
177
- toast(`Could not start: ${e.message}`, { kind: 'error' });
178
- throw e;
179
- }
180
- inspector.el.querySelector('#itp-backend').textContent = app.engine.backend === 'webgl' ? 'GPU · ShaderMate' : 'CPU';
181
- inspector.el.querySelector('#itp-backend').title = app.engine.backend === 'webgl' ? `WebGL 2: ${app.engine.renderer}` : (app.engine.fallbackReason || 'CPU renderer');
182
- import('../video.js').then(m => m.videoSupport()).then(v => { app.videoSupport = v; }).catch(() => { });
183
-
184
- // ================================================================ rendering
185
- let dirty = false, running = false, lastSource = null;
186
- const outCanvas = document.createElement('canvas');
187
-
188
- function schedule() {
189
- dirty = true;
190
- if (!running) requestAnimationFrame(run);
191
- }
192
- app.schedule = schedule;
193
-
194
- async function run() {
195
- if (running || !dirty) return;
196
- if (app.exporting) return;
197
- dirty = false;
198
- const item = app.current;
199
- if (!item) { app.result = null; stage.clear(); updateStatus(); return; }
200
- running = true;
201
- const slow = setTimeout(() => { busy.hidden = false; }, 250);
202
- try {
203
- if (item.type === 'video') app.engine.invalidate();
204
- const res = await app.engine.process(item.source, store.settings);
205
- if (item !== app.current) return;
206
- app.result = res;
207
- res.toCanvas(1, { canvas: outCanvas });
208
- stage.show(outCanvas, item.source, res.crop);
209
- inspector.setStats(res);
210
- inspector.sync(store.settings, res);
211
- if (lastSource !== item) { lastSource = item; }
212
- updateStatus();
213
- } catch (e) {
214
- console.error(e);
215
- toast(`Rendering failed: ${e.message}`, { kind: 'error' });
216
- } finally {
217
- clearTimeout(slow); busy.hidden = true;
218
- running = false;
219
- if (dirty) requestAnimationFrame(run);
220
- }
221
- }
222
-
223
- function updateStatus() {
224
- const r = app.result, it = app.current;
225
- fileName.textContent = it ? it.name : 'No image';
226
- fileMeta.textContent = it ? (it.type === 'video' ? `${it.width} × ${it.height}, ${fmtTime(it.duration)}` : `${it.width} × ${it.height}`) : '';
227
- empty.hidden = !!it;
228
- viewTools.hidden = !it;
229
- if (!it || !r) { status.hidden = true; return; }
230
- status.hidden = false;
231
- render(status, h('span', {}, h('b', { text: `${r.width} × ${r.height}` }), ' px'), h('span', { text: `${Math.round(stage.zoom * 100) / 100}× zoom` }));
232
- undoBtn.disabled = !store.canUndo; redoBtn.disabled = !store.canRedo;
233
- }
234
- function updateZoomLabel() {
235
- zoomBtn.querySelector('span').textContent = !app.current || stage.fitted ? 'Fit' : stage.zoom >= 1 ? `${Math.round(stage.zoom * 10) / 10}×` : `${Math.round(stage.zoom * 100)}%`;
236
- if (app.result) updateStatus();
237
- }
238
-
239
- store.subscribe((s, why) => {
240
- // write the edit back to whatever is being edited: this file's own settings, or the shared ones
241
- const cur = app.current;
242
- if (cur && cur.own && store.target === cur.id) cur.own = s;
243
- else if (store.target === 'shared') app.shared = s;
244
- inspector.sync(s, app.result);
245
- undoBtn.disabled = !store.canUndo; redoBtn.disabled = !store.canRedo;
246
- if (why !== 'preset') app.activePreset = null;
247
- if (usePersist) saveSoon();
248
- if (library.tab === 'palettes') library.refresh();
249
- schedule();
250
- });
251
- let saveT = null;
252
- const saveSoon = () => { clearTimeout(saveT); saveT = setTimeout(() => persist.save('settings', app.shared), 300); };
253
-
254
- // ================================================================ files
255
- async function makeThumb(drawable, w, hgt) {
256
- const c = document.createElement('canvas');
257
- const k = 72 / Math.max(w, hgt);
258
- c.width = Math.max(1, Math.round(w * k)); c.height = Math.max(1, Math.round(hgt * k));
259
- try { c.getContext('2d').drawImage(drawable, 0, 0, c.width, c.height); } catch { /* not ready */ }
260
- return c.toDataURL();
261
- }
262
-
263
- async function addImage(name, drawable, file = null) {
264
- const w = drawable.naturalWidth || drawable.width, hh = drawable.naturalHeight || drawable.height;
265
- const item = { id: crypto.randomUUID?.() || String(Math.random()), name, type: 'image', source: drawable, width: w, height: hh, file };
266
- item.thumb = await makeThumb(drawable, w, hh);
267
- app.items.push(item);
268
- return item;
269
- }
270
-
271
- async function addVideo(name, file) {
272
- // No `loop` attribute: looping by hand on 'ended' is more reliable (some MOV/MP4 files stalled at the
273
- // end and lost their sound after Chrome's built-in loop).
274
- const video = h('video', { muted: true, playsInline: true, preload: 'auto', crossOrigin: 'anonymous' });
275
- video.muted = true;
276
- video.src = URL.createObjectURL(file);
277
- try {
278
- await videoReady(video);
279
- } catch (e) {
280
- URL.revokeObjectURL(video.src);
281
- if (e.code === 3 || e.code === 4) {
282
- const codec = await sniffVideoCodec(file).catch(() => '');
283
- const alt = codec === 'H.264' ? 'WebM (VP9)' : codec === 'VP9' ? 'H.264 MP4' : 'H.264 MP4 or WebM (VP9)';
284
- const err = new Error(codec
285
- ? `it's ${codec}, which this browser can't play. Re-save it as ${alt}, or open the editor in a browser that plays ${codec}.`
286
- : `this browser can't play this video's format. Re-save it as ${alt}.`);
287
- err.code = e.code;
288
- throw err;
289
- }
290
- throw e;
291
- }
292
- const item = { id: crypto.randomUUID?.() || String(Math.random()), name, type: 'video', source: video, width: video.videoWidth, height: video.videoHeight, duration: video.duration, fps: 30, file };
293
- await seekTo(video, Math.min(0.05, video.duration / 2)).catch(() => { });
294
- item.thumb = await makeThumb(video, item.width, item.height);
295
- app.items.push(item);
296
- // measuring plays the video muted for a moment; Play waits for it so the two don't fight
297
- item.fpsReady = estimateFps(video, 30).catch(() => 30).then(f => { item.fps = f; item.fpsReady = null; if (app.current === item) updateTransport(); inspector.syncAnim(); });
298
- // null = can't tell from the container: the sound controls stay, just in case
299
- hasAudioTrack(file).then(a => { item.hasAudio = a; if (app.current === item) updateTransport(); });
300
- video.addEventListener('timeupdate', () => { if (app.current === item) updateTransport(); });
301
- video.addEventListener('play', () => { if (app.current === item) { updateTransport(); pumpVideo(item); } });
302
- video.addEventListener('pause', () => { if (app.current === item) { updateTransport(); schedule(); } });
303
- video.addEventListener('seeked', () => { if (app.current === item && !app.exporting) schedule(); });
304
- video.addEventListener('ended', () => {
305
- if (app.current !== item || app.exporting || item.fpsReady) return;
306
- video.currentTime = 0;
307
- video.play().catch(() => { });
308
- });
309
- return item;
310
- }
311
-
312
- async function addFiles(files) {
313
- const media = [], palettes = [], settingsFiles = [];
314
- for (const f of files) {
315
- if (PALETTE_EXT.test(f.name) && !/^image|^video/.test(f.type)) {
316
- if (/\.json$/i.test(f.name)) settingsFiles.push(f); else palettes.push(f);
317
- } else if (IMAGE_TYPES.test(f.type) || VIDEO_TYPES.test(f.type) || /\.(png|jpe?g|gif|webp|bmp|svg|avif|mp4|webm|mov)$/i.test(f.name)) media.push(f);
318
- }
319
- for (const f of palettes) await importPaletteFile(f);
320
- for (const f of settingsFiles) {
321
- const text = await f.text();
322
- try {
323
- const obj = JSON.parse(text);
324
- if (obj.colors || Array.isArray(obj)) await importPaletteFile(f, text);
325
- else { store.replace(settingsFromJSON(obj)); toast(`Loaded settings from ${f.name}`, { kind: 'ok' }); }
326
- } catch (e) { toast(`${f.name}: ${e.message}`, { kind: 'error' }); }
327
- }
328
- let first = null;
329
- const t = media.length > 2 ? toast(`Loading ${media.length} files`, { progress: true }) : null;
330
- for (let i = 0; i < media.length; i++) {
331
- const f = media[i];
332
- try {
333
- let item;
334
- if (VIDEO_TYPES.test(f.type) || /\.(mp4|webm|mov)$/i.test(f.name)) item = await addVideo(f.name, f);
335
- else {
336
- let drawable;
337
- try { drawable = await createImageBitmap(f); } catch {
338
- drawable = new Image(); drawable.src = URL.createObjectURL(f); await drawable.decode();
339
- }
340
- item = await addImage(f.name, drawable, f);
341
- }
342
- first ||= item;
343
- } catch (e) { toast(`${f.name} could not be opened: ${e.message}`, { kind: 'error' }); }
344
- t?.progress((i + 1) / media.length);
345
- }
346
- t?.close();
347
- if (first) select(first);
348
- library.refresh();
349
- if (media.length > 1) toast(`Added ${media.length} files. They share one set of settings.`, { kind: 'ok' });
350
- }
351
- app.addFiles = addFiles;
352
-
353
- async function openFiles() {
354
- const files = await pickFiles({ accept: 'image/*,video/*,.gpl,.hex,.pal,.txt,.json', multiple: true });
355
- if (files.length) addFiles(files);
356
- }
357
- app.openFiles = openFiles;
358
-
359
- async function openSample(s) {
360
- const img = await loadSample(s);
361
- const item = await addImage(`${s.name}.svg`, img, new File([s.svg], `${s.name}.svg`, { type: 'image/svg+xml' }));
362
- select(item);
363
- library.refresh();
364
- }
365
-
366
- async function pasteFromClipboard() {
367
- try {
368
- const items = await navigator.clipboard.read();
369
- const files = [];
370
- for (const it of items) for (const type of it.types) if (IMAGE_TYPES.test(type)) {
371
- const b = await it.getType(type);
372
- files.push(new File([b], `pasted-${new Date().toISOString().slice(11, 19).replace(/:/g, '')}.${type.split('/')[1]}`, { type }));
373
- }
374
- if (files.length) addFiles(files); else toast('The clipboard has no image', { kind: 'error' });
375
- } catch {
376
- toast('Press Ctrl+V (⌘V) to paste. The browser blocked direct clipboard access.');
377
- }
378
- }
379
- app.pasteFromClipboard = pasteFromClipboard;
380
-
381
- function select(item) {
382
- if (app.current && app.current.type === 'video' && app.current !== item) app.current.source.pause();
383
- app.current = item;
384
- editTarget(item);
385
- stageWrap.dataset.video = String(item && item.type === 'video');
386
- if (item && item.type === 'video') updateTransport();
387
- stage.clear();
388
- app.result = null;
389
- if (stage.picking) togglePicker();
390
- library.refresh();
391
- library.sourceChanged();
392
- schedule();
393
- updateStatus();
394
- }
395
- app.select = select;
396
-
397
- function removeItem(item) {
398
- const i = app.items.indexOf(item);
399
- if (i < 0) return;
400
- app.items.splice(i, 1);
401
- store.dropTarget(item.id);
402
- if (item.type === 'video') { item.source.pause(); URL.revokeObjectURL(item.source.src); }
403
- if (item.source.close && !app.items.some(i => i.source === item.source)) item.source.close(); // duplicates share a bitmap
404
- if (app.current === item) select(app.items[Math.min(i, app.items.length - 1)] || null);
405
- library.refresh();
406
- inspector.sync(store.settings, app.result);
407
- }
408
- app.removeItem = removeItem;
409
- app.clearItems = () => { for (const it of app.items.slice()) removeItem(it); };
410
-
411
- // ================================================================ per-file settings
412
- /** The settings a file renders with. */
413
- const settingsFor = item => (item === app.current ? store.settings : (item && item.own) || app.shared);
414
- app.settingsFor = settingsFor;
415
- const clone = v => JSON.parse(JSON.stringify(v));
416
-
417
- /** Point the store at the right settings for this file (its own, or the shared ones). */
418
- function editTarget(item, force = false) {
419
- const key = item && item.own ? item.id : 'shared';
420
- if (store.target === key && !force) return;
421
- store.setTarget(key, key === 'shared' ? app.shared : item.own);
422
- }
423
-
424
- /** Give a file its own settings (a copy of what it uses now), or link it back to the shared ones. */
425
- function setOwn(item, own, { quiet = false } = {}) {
426
- if (!item || !!item.own === own) return;
427
- if (own) {
428
- item.own = clone(settingsFor(item));
429
- if (item === app.current) editTarget(item);
430
- if (!quiet) toast(`${item.name} now has its own settings. Changes no longer affect other files.`);
431
- } else {
432
- const was = item.own;
433
- item.own = null;
434
- store.dropTarget(item.id);
435
- if (item === app.current) editTarget(item);
436
- if (!quiet) toast(`${item.name} uses the shared settings again`, { action: { label: 'Undo', onClick: () => { item.own = was; if (item === app.current) editTarget(item); afterScopeChange(); } } });
437
- }
438
- afterScopeChange();
439
- }
440
- function afterScopeChange() {
441
- library.refresh();
442
- inspector.sync(store.settings, app.result);
443
- if (app.result) inspector.setStats(app.result);
444
- schedule();
445
- }
446
- app.setOwn = setOwn;
447
-
448
- app.copySettings = item => {
449
- app.clipboard = clone(settingsFor(item));
450
- toast(`Copied the settings of ${item.name}`, { timeout: 1800 });
451
- library.refresh();
452
- };
453
- app.pasteSettings = item => {
454
- if (!app.clipboard) return;
455
- if (!item.own) setOwn(item, true, { quiet: true });
456
- if (item === app.current) store.replace(app.clipboard, 'paste');
457
- else item.own = clone(app.clipboard);
458
- toast(`Pasted settings onto ${item.name}`, { kind: 'ok', timeout: 1800 });
459
- afterScopeChange();
460
- };
461
- /** Make this file's settings the shared ones, and link every file to them. */
462
- app.applyToAll = item => {
463
- const snapshot = { shared: app.shared, own: new Map(app.items.map(i => [i, i.own])) };
464
- app.shared = clone(settingsFor(item));
465
- for (const it of app.items) { if (it.own) store.dropTarget(it.id); it.own = null; }
466
- store.setTarget('shared', app.shared);
467
- if (usePersist) saveSoon();
468
- toast(`Every file now uses the settings of ${item.name}`, {
469
- action: { label: 'Undo', onClick: () => {
470
- app.shared = snapshot.shared;
471
- for (const [it, own] of snapshot.own) it.own = own;
472
- editTarget(app.current, true);
473
- afterScopeChange();
474
- } },
475
- });
476
- afterScopeChange();
477
- };
478
- /** A second copy of a file with its own settings, for comparing two looks. */
479
- app.duplicate = async item => {
480
- let copy;
481
- if (item.type === 'video') copy = await addVideo(nameCopy(item.name), item.file);
482
- else copy = await addImage(nameCopy(item.name), item.source, item.file);
483
- // place it right after the original
484
- app.items.splice(app.items.indexOf(copy), 1);
485
- app.items.splice(app.items.indexOf(item) + 1, 0, copy);
486
- copy.own = clone(settingsFor(item));
487
- select(copy);
488
- toast(`Duplicated ${item.name}. The copy has its own settings.`, { timeout: 2400 });
489
- };
490
- const nameCopy = name => { const m = /^(.*?)(\.[^.]*)?$/.exec(name); return `${m[1]} copy${m[2] || ''}`; };
491
- app.move = (item, dir) => {
492
- const i = app.items.indexOf(item), j = i + dir;
493
- if (i < 0 || j < 0 || j >= app.items.length) return;
494
- [app.items[i], app.items[j]] = [app.items[j], app.items[i]];
495
- library.refresh();
496
- if (document.activeElement === document.body || document.activeElement?.closest?.('.itp-files')) library.focusFile(item);
497
- };
498
-
499
- // ================================================================ rename
500
- const splitExt = name => { const m = /^(.*?)(\.[^.\s]{1,5})?$/.exec(name); return [m[1], m[2] || '']; };
501
- /** Rename a file. The extension is kept: exports are named after it ("<name>@4x.png"). */
502
- app.rename = (item, next) => {
503
- const [, ext] = splitExt(item.name);
504
- let base = String(next || '').trim().replace(/[\\/:*?"<>|]+/g, '-');
505
- if (ext && base.toLowerCase().endsWith(ext.toLowerCase())) base = base.slice(0, -ext.length);
506
- if (!base || base + ext === item.name) return;
507
- item.name = base + ext;
508
- library.refresh();
509
- updateStatus();
510
- };
511
- /** Edit the name in place in the header, with the name (not the extension) selected. */
512
- function startRename(item) {
513
- if (!item || fileName.hidden) return;
514
- if (app.current !== item) select(item);
515
- const [base] = splitExt(item.name);
516
- const input = h('input.itp-name-input', { type: 'text', value: item.name, 'aria-label': 'File name', spellcheck: false });
517
- input.style.width = `${Math.min(40, Math.max(12, item.name.length + 2))}ch`;
518
- fileName.hidden = true;
519
- fileName.after(input);
520
- input.focus();
521
- input.setSelectionRange(0, base.length);
522
- let done = false;
523
- const finish = commit => {
524
- if (done) return;
525
- done = true;
526
- if (commit) app.rename(item, input.value);
527
- input.remove();
528
- fileName.hidden = false;
529
- };
530
- input.addEventListener('keydown', e => {
531
- e.stopPropagation();
532
- if (e.key === 'Enter') { e.preventDefault(); finish(true); }
533
- if (e.key === 'Escape') { e.preventDefault(); finish(false); }
534
- });
535
- input.addEventListener('blur', () => finish(true));
536
- }
537
- app.startRename = startRename;
538
-
539
- // ================================================================ saved workspace
540
- // Files (their bytes), names, per-file settings and the shared settings, in this browser's IndexedDB.
541
- let savedInfo = null; // { count, bytes, savedAt } of what is saved, or null
542
- app.savedWorkspace = () => savedInfo;
543
-
544
- /** A Blob for a file, for saving: the original bytes, or a PNG of what was added without a file. */
545
- async function bytesOf(item) {
546
- if (item.file) return item.file;
547
- const c = document.createElement('canvas');
548
- c.width = item.width; c.height = item.height;
549
- c.getContext('2d').drawImage(item.source, 0, 0);
550
- return new Promise(res => c.toBlob(b => res(b), 'image/png'));
551
- }
552
-
553
- async function saveNow() {
554
- if (!usePersist) return;
555
- if (!app.items.length) {
556
- if (savedInfo) { await forgetSaved(); return; }
557
- toast('Add files first, then Save keeps them here for next time.');
558
- return;
559
- }
560
- saveBtn.disabled = true;
561
- try {
562
- const items = [];
563
- for (const it of app.items) items.push({ name: it.name, type: it.type, file: await bytesOf(it), own: it.own ? clone(it.own) : null });
564
- const bytes = await saveWorkspace({ shared: clone(app.shared), current: Math.max(0, app.items.indexOf(app.current)), items });
565
- const first = !savedInfo;
566
- savedInfo = { count: items.length, bytes, savedAt: Date.now() };
567
- library.refresh();
568
- toast(first
569
- ? `Saved ${items.length} file${items.length === 1 ? '' : 's'} (${fmtBytes(bytes)}) in this browser only. They reopen next time; remove them with Forget in the Files list.`
570
- : `Saved ${items.length} file${items.length === 1 ? '' : 's'} (${fmtBytes(bytes)})`, { kind: 'ok', timeout: first ? 7000 : 2200 });
571
- } catch (e) {
572
- console.error(e);
573
- toast(isQuotaError(e) ? "Not enough browser storage to save these files. Remove some (videos are large) and try again." : `Couldn't save: ${e.message}`, { kind: 'error' });
574
- } finally {
575
- saveBtn.disabled = false;
576
- }
577
- }
578
- app.saveWorkspace = saveNow;
579
-
580
- async function forgetSaved() {
581
- try { await forgetWorkspace(); } catch (e) { toast(`Couldn't remove the saved files: ${e.message}`, { kind: 'error' }); return; }
582
- savedInfo = null;
583
- library.refresh();
584
- toast('Removed the saved files from this browser. Files open now stay open.');
585
- }
586
- app.forgetWorkspace = forgetSaved;
587
-
588
- /** Reopen the saved workspace (on start, when nothing else was opened). */
589
- async function restoreSaved() {
590
- const ws = await loadWorkspace();
591
- if (!ws) return;
592
- savedInfo = { count: ws.items.length, bytes: ws.items.reduce((a, it) => a + (it.file ? it.file.size : 0), 0), savedAt: ws.savedAt };
593
- if (app.items.length || !ws.items.length) { library.refresh(); return; }
594
- if (ws.shared && !/[#&]s=/.test(location.hash)) store.setTarget('shared', ws.shared); // a share link's settings win
595
- const t = ws.items.length > 2 ? toast(`Opening ${ws.items.length} saved files`, { progress: true }) : null;
596
- const added = [];
597
- for (let i = 0; i < ws.items.length; i++) {
598
- const s = ws.items[i];
599
- try {
600
- const file = s.file instanceof File ? s.file : new File([s.file], s.name, { type: s.file.type });
601
- let item;
602
- if (s.type === 'video') item = await addVideo(s.name, file);
603
- else {
604
- let drawable;
605
- try { drawable = await createImageBitmap(file); } catch { drawable = new Image(); drawable.src = URL.createObjectURL(file); await drawable.decode(); }
606
- item = await addImage(s.name, drawable, file);
607
- }
608
- item.own = s.own || null;
609
- added.push(item);
610
- } catch (e) { console.warn('[workspace] skipped', s.name, e); }
611
- t?.progress((i + 1) / ws.items.length);
612
- }
613
- t?.close();
614
- const cur = added[Math.min(ws.current || 0, added.length - 1)];
615
- if (cur) select(cur);
616
- library.refresh();
617
- if (added.length) toast(`Reopened your saved files (${added.length})`, { timeout: 2200 });
618
- }
619
-
620
- // ================================================================ video
621
- function updateTransport() {
622
- const it = app.current;
623
- if (!it || it.type !== 'video') return;
624
- const v = it.source;
625
- playBtn.innerHTML = icon(v.paused ? 'play' : 'pause');
626
- playBtn.setAttribute('aria-label', v.paused ? 'Play (Space)' : 'Pause (Space)');
627
- const frame = Math.floor(v.currentTime * it.fps + 1e-6);
628
- timeLabel.innerHTML = `${fmtTime(v.currentTime)} <span>/ ${fmtTime(v.duration)}</span>`;
629
- timeLabel.title = `Frame ${frame}`;
630
- scrub.value = String(Math.round(v.currentTime / (v.duration || 1) * 1000));
631
- scrub.style.setProperty('--p', `${scrub.value / 10}%`);
632
- fpsLabel.textContent = `${it.fps} fps`;
633
- soundBox.hidden = it.hasAudio === false;
634
- applySound(v);
635
- }
636
- /** Mute/volume on the current video and in the controls. */
637
- function applySound(v) {
638
- if (v && !app.exporting && !app.current?.fpsReady) {
639
- if (v.muted !== sound.muted) v.muted = sound.muted;
640
- if (v.volume !== sound.volume) v.volume = sound.volume;
641
- }
642
- const level = sound.muted ? 0 : sound.volume;
643
- const pct = Math.round(level * 100);
644
- muteBtn.innerHTML = icon(level ? 'volume' : 'volumeOff');
645
- const label = level ? `Volume ${pct}% (M mutes)` : 'Muted (M unmutes)';
646
- muteBtn.setAttribute('aria-label', label); muteBtn.title = label;
647
- volSlider.setAttribute('aria-valuenow', String(pct));
648
- volSlider.setAttribute('aria-valuetext', level ? `${pct}%` : 'Muted');
649
- volSlider.style.setProperty('--v', `${pct}%`);
650
- volValue.textContent = level ? String(pct) : 'Off';
651
- }
652
- function setSound(patch) {
653
- Object.assign(sound, patch);
654
- // unmuting at zero volume brings the volume back up
655
- if (patch.muted === false && !sound.volume) sound.volume = 1;
656
- persist.save('muted', sound.muted); persist.save('volume', sound.volume);
657
- applySound(app.current && app.current.type === 'video' ? app.current.source : null);
658
- }
659
- function pumpVideo(item) {
660
- const v = item.source;
661
- const tick = () => {
662
- if (v.paused || app.current !== item) return;
663
- schedule();
664
- if ('requestVideoFrameCallback' in v) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick);
665
- };
666
- tick();
667
- }
668
- async function togglePlay() {
669
- const it = app.current;
670
- if (!it || it.type !== 'video') return;
671
- if (!it.source.paused) { it.source.pause(); return; }
672
- if (it.fpsReady) await it.fpsReady; // still measuring the frame rate (plays muted for a moment)
673
- if (app.current !== it) return;
674
- applySound(it.source);
675
- if (it.source.ended) it.source.currentTime = 0;
676
- it.source.play().catch(e => toast(e.message, { kind: 'error' }));
677
- }
678
- function stepFrame(dir) {
679
- const it = app.current;
680
- if (!it || it.type !== 'video') return;
681
- const v = it.source; v.pause();
682
- const frame = Math.floor(v.currentTime * it.fps + 1e-6) + dir;
683
- seekTo(v, (Math.max(0, frame) + 0.5) / it.fps).then(() => { updateTransport(); schedule(); });
684
- }
685
- scrub.addEventListener('input', () => {
686
- const it = app.current; if (!it || it.type !== 'video') return;
687
- it.source.currentTime = scrub.value / 1000 * it.source.duration;
688
- });
689
-
690
- // ================================================================ view
691
- function toggleCompare(force) {
692
- const on = force ?? !stage.compare;
693
- stage.setCompare(on); compareBtn.setAttribute('aria-pressed', String(on));
694
- }
695
- function toggleGrid(force) {
696
- const on = force ?? !stage.grid;
697
- stage.setGrid(on); gridBtn.setAttribute('aria-pressed', String(on));
698
- if (on && stage.zoom < 6 && app.result) toast('The grid shows from 6× zoom');
699
- }
700
- function zoomMenu(anchor) {
701
- menu(anchor, [
702
- { label: 'Fit to view', kbd: 'F', icon: 'fit', onClick: () => stage.fit() },
703
- 'sep',
704
- ...[1, 2, 4, 8, 16, 32].map(z => ({ label: `${z}×`, sub: z === 1 ? 'One screen pixel per pixel' : null, onClick: () => stage.zoomTo(z) })),
705
- 'sep',
706
- { label: 'Zoom in', kbd: '+', icon: 'zoomIn', onClick: () => stage.zoomIn() },
707
- { label: 'Zoom out', kbd: '-', icon: 'zoomOut', onClick: () => stage.zoomOut() },
708
- ]);
709
- }
710
- function togglePanel(which) {
711
- const key = which === 'lib' ? 'lib' : 'insp';
712
- const open = shell.dataset[key] !== 'open';
713
- shell.dataset[key] = open ? 'open' : 'closed';
714
- if (!narrow() || which === 'insp') persist.save(key, shell.dataset[key]);
715
- requestAnimationFrame(() => stage.resize());
716
- }
717
- function applyTheme(t) {
718
- if (t) document.documentElement.dataset.theme = t; else delete document.documentElement.dataset.theme;
719
- const dark = t ? t === 'dark' : !matchMedia('(prefers-color-scheme: light)').matches;
720
- themeBtn.innerHTML = icon(dark ? 'sun' : 'moon');
721
- themeBtn.setAttribute('aria-label', dark ? 'Switch to light theme (T)' : 'Switch to dark theme (T)');
722
- stage.redraw();
723
- }
724
- function toggleTheme() {
725
- const cur = document.documentElement.dataset.theme || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
726
- const next = cur === 'dark' ? 'light' : 'dark';
727
- persist.save('theme', next);
728
- applyTheme(next);
729
- }
730
-
731
- // ================================================================ palettes
732
- function pixelsOf(item, maxSize = 4096) {
733
- const w = item.width, hh = item.height, k = Math.min(1, maxSize / Math.max(w, hh));
734
- const c = document.createElement('canvas'); c.width = Math.round(w * k); c.height = Math.round(hh * k);
735
- const ctx = c.getContext('2d', { willReadFrequently: true });
736
- ctx.drawImage(item.source, 0, 0, c.width, c.height);
737
- return ctx.getImageData(0, 0, c.width, c.height);
738
- }
739
- function pickAt(u, v) {
740
- const it = app.current; if (!it) return;
741
- const img = pixelsOf(it, 1024);
742
- const c = app.result ? app.result.crop : { x: 0, y: 0, w: 1, h: 1 };
743
- const x = Math.min(img.width - 1, Math.floor((c.x + u * c.w) * img.width)), y = Math.min(img.height - 1, Math.floor((c.y + v * c.h) * img.height));
744
- const o = (y * img.width + x) * 4;
745
- const hex = toHex([img.data[o], img.data[o + 1], img.data[o + 2]]);
746
- const s = store.settings;
747
- const colors = s.palette.source === 'custom' ? s.palette.colors : [];
748
- if (colors.includes(hex)) return toast(`${hex} is already in the palette`);
749
- store.update({ palette: { source: 'custom', name: s.palette.source === 'custom' ? s.palette.name : 'Picked', colors: [...colors, hex] } });
750
- toast(`Added ${hex}`, { kind: 'ok', timeout: 1500 });
751
- }
752
- let pickerBtn = null;
753
- function togglePicker(button) {
754
- if (button) pickerBtn = button;
755
- const on = !stage.picking;
756
- stage.setPicking(on);
757
- pickerBtn?.setAttribute('aria-pressed', String(on));
758
- if (on) {
759
- toggleCompare(true);
760
- toast('Click the image to add colours. Press Esc when done.');
761
- }
762
- }
763
- app.togglePicker = togglePicker;
764
-
765
- function usePalette(p) {
766
- if (p.id && !p.custom) store.update({ palette: { source: 'builtin', id: p.id } });
767
- else store.update({ palette: { source: 'custom', name: p.name, colors: p.colors } });
768
- }
769
- app.usePalette = usePalette;
770
-
771
- function rememberPalette(name, colors) {
772
- const list = app.userPalettes.filter(p => p.name !== name);
773
- list.unshift({ name, colors, custom: true });
774
- app.userPalettes = list.slice(0, 100);
775
- if (usePersist) persist.save('palettes', app.userPalettes);
776
- if (library.tab === 'palettes') library.refresh();
777
- }
778
- async function importPaletteFile(file, text) {
779
- try {
780
- const p = parsePalette(text ?? await file.text(), file.name);
781
- rememberPalette(p.name || 'Imported', p.colors);
782
- store.update({ palette: { source: 'custom', name: p.name || 'Imported', colors: p.colors } });
783
- toast(`Imported "${p.name}" (${p.colors.length} colours)`, { kind: 'ok' });
784
- } catch (e) { toast(`${file.name}: ${e.message}`, { kind: 'error' }); }
785
- }
786
- app.importPalette = async () => {
787
- const [f] = await pickFiles({ accept: '.gpl,.hex,.pal,.txt,.json' });
788
- if (f) importPaletteFile(f);
789
- };
790
- app.savePalette = () => {
791
- const s = store.settings;
792
- const colors = s.palette.source === 'extract' ? (app.result?.palette || []) : s.palette.colors;
793
- if (!colors.length) return toast('There is no palette to save', { kind: 'error' });
794
- const input = h('input.itp-input', { value: s.palette.source === 'custom' ? s.palette.name : `${s.palette.name} copy`, 'aria-label': 'Palette name' });
795
- const fmt = h('select.itp-select', { 'aria-label': 'Also download as' }, h('option', { value: '', text: "Don't download" }), h('option', { value: 'gpl', text: '.gpl (GIMP, Aseprite)' }), h('option', { value: 'hex', text: '.hex (Lospec)' }), h('option', { value: 'json', text: '.json' }));
796
- dialog('Save palette', [h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Name' })), input), h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Also download as' })), fmt),
797
- h('div.itp-strip', { style: { height: '18px' } }, ...colors.map(c => h('i', { style: { background: c } })))], {
798
- actions: [{ label: 'Cancel' }, { label: 'Save', primary: true, onClick: () => {
799
- const name = input.value.trim() || 'Palette';
800
- rememberPalette(name, colors);
801
- store.update({ palette: { source: 'custom', name, colors } });
802
- const slug = name.replace(/[^\w-]+/g, '-').toLowerCase();
803
- if (fmt.value === 'gpl') downloadBlob(toGPL(name, colors), `${slug}.gpl`, 'text/plain');
804
- if (fmt.value === 'hex') downloadBlob(toHexFile(colors), `${slug}.hex`, 'text/plain');
805
- if (fmt.value === 'json') downloadBlob(toPaletteJSON(name, colors), `${slug}.json`, 'application/json');
806
- toast(`Saved "${name}" to Your palettes`, { kind: 'ok' });
807
- } }],
808
- });
809
- input.select();
810
- };
811
- app.deletePalette = p => {
812
- app.userPalettes = app.userPalettes.filter(x => x !== p);
813
- if (usePersist) persist.save('palettes', app.userPalettes);
814
- library.refresh();
815
- toast(`Deleted "${p.name}"`, { action: { label: 'Undo', onClick: () => rememberPalette(p.name, p.colors) } });
816
- };
817
- app.lospecDialog = () => {
818
- const input = h('input.itp-input', { placeholder: 'e.g. rgbg-36 or a lospec.com palette URL', 'aria-label': 'Lospec slug or URL' });
819
- const note = h('div.help', { text: 'Looks the palette up on lospec.com. Needs an internet connection.' });
820
- const d = dialog('Palette from Lospec', [h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Slug or URL' })), input, note)], {
821
- actions: [{ label: 'Cancel' }, { label: 'Fetch', primary: true, onClick: async () => {
822
- note.textContent = 'Fetching…'; note.style.color = '';
823
- try {
824
- const p = await fetchLospec(input.value);
825
- rememberPalette(p.name, p.colors);
826
- store.update({ palette: { source: 'custom', name: p.name, colors: p.colors } });
827
- toast(`Loaded "${p.name}" from Lospec`, { kind: 'ok' });
828
- return true;
829
- } catch (e) { note.textContent = e.message; note.style.color = 'var(--danger)'; return false; }
830
- } }],
831
- });
832
- input.addEventListener('keydown', e => { if (e.key === 'Enter') d.querySelector('footer .primary').click(); });
833
- input.focus();
834
- };
835
-
836
- app.detectGrid = () => {
837
- const it = app.current; if (!it || it.type !== 'image') return toast('Open an image first', { kind: 'error' });
838
- const b = detectGrid(pixelsOf(it));
839
- if (b <= 1) return toast('No pixel grid found. This image doesn\'t look upscaled.');
840
- store.update({ size: { mode: 'block', block: b, resample: 'nearest' } });
841
- toast(`Found ${b} px blocks: switched to pixel size ${b} with nearest sampling`, { kind: 'ok' });
842
- };
843
-
844
- // ================================================================ settings
845
- app.settingsJSON = () => settingsToJSON(store.settings);
846
- app.copyLink = async () => {
847
- const url = `${location.origin}${location.pathname}#s=${encodeSettings(store.settings)}`;
848
- try { await navigator.clipboard.writeText(url); toast('Link copied. It holds the settings, not the image.', { kind: 'ok' }); }
849
- catch { dialog('Share link', [h('textarea.itp-input', { rows: 3, readOnly: true, value: url })]); }
850
- history.replaceState(null, '', `#s=${encodeSettings(store.settings)}`);
851
- };
852
- app.saveSettingsFile = () => downloadBlob(settingsToJSON(store.settings), 'image-to-pixel-settings.json', 'application/json');
853
- app.loadSettingsFile = async () => {
854
- const [f] = await pickFiles({ accept: '.json,application/json' });
855
- if (!f) return;
856
- try { store.replace(settingsFromJSON(await f.text())); toast(`Loaded ${f.name}`, { kind: 'ok' }); } catch (e) { toast(`${f.name}: ${e.message}`, { kind: 'error' }); }
857
- };
858
- app.resetSettings = () => { store.replace(defaultSettings()); toast('Settings reset', { action: { label: 'Undo', onClick: () => store.undo() } }); };
859
- app.applyPreset = p => {
860
- store.replace(mergeSettings(defaultSettings(), { ...p.settings, export: store.settings.export }), 'preset');
861
- app.activePreset = p.id;
862
- library.refresh();
863
- };
864
-
865
- Object.assign(app, exporter);
866
-
867
- function exportMenu(anchor) {
868
- const it = app.current;
869
- const video = it && it.type === 'video';
870
- const images = app.items.filter(i => i.type === 'image').length;
871
- const s = store.settings;
872
- menu(anchor, [
873
- { group: 'Image' },
874
- { label: 'PNG', sub: app.result ? `${app.result.width * s.export.scale} × ${app.result.height * s.export.scale}, ${s.export.scale}× scale` : 'Open an image first', icon: 'image', kbd: 'Ctrl S', disabled: !app.result, onClick: () => app.exportPNG() },
875
- { label: 'Copy to clipboard', icon: 'copy', disabled: !app.result, onClick: () => app.copyImage() },
876
- { label: app.items.length > 1 ? `All ${app.items.length} files as ZIP` : 'All files as ZIP', sub: app.items.length > 1 ? null : 'Add two or more files', icon: 'zip', disabled: app.items.length < 2, onClick: () => app.exportZip() },
877
- { group: 'Animation' },
878
- { label: video ? 'GIF from this video' : 'GIF from all images', icon: 'gif', disabled: !video && images < 2, onClick: () => app.exportAnimation({ ...animDefaults(), format: 'gif' }) },
879
- { label: 'More formats…', sub: 'WebM, MP4, PNG sequence, sprite sheet', icon: 'film', onClick: () => { inspector.setTab('export'); if (shell.dataset.insp !== 'open') togglePanel('insp'); } },
880
- { group: 'Other' },
881
- { label: 'Tileset', sub: '16 × 16 tiles, duplicates removed', icon: 'tiles', disabled: !app.result, onClick: () => app.exportTileset({ tile: 16, flips: false }) },
882
- { label: 'Copy settings link', icon: 'link', onClick: () => app.copyLink() },
883
- { label: 'Settings as JSON', icon: 'braces', onClick: () => app.saveSettingsFile() },
884
- ]);
885
- }
886
- const animDefaults = () => {
887
- const it = app.current;
888
- const video = it && it.type === 'video';
889
- return { source: video ? 'video' : 'files', fps: video ? Math.min(30, Math.round(it.fps || 12)) : 8, start: 0, end: video ? it.duration : 0 };
890
- };
891
-
892
- // ================================================================ help
893
- function showHelp() {
894
- const keys = [
895
- ['Open files', 'Ctrl O'], ['Paste image', 'Ctrl V'], ['Export PNG', 'Ctrl S'], ['Undo', 'Ctrl Z'], ['Redo', 'Ctrl Shift Z'],
896
- ['Compare', 'C'], ['Pixel grid', 'G'], ['Fit', 'F'], ['Zoom in / out', '+ -'], ['Pixel width ±1', '[ ]'], ['Pixel width ±8', 'Shift [ ]'],
897
- ['Next / previous dither', 'D Shift D'], ['Play / pause video', 'Space'], ['Mute / unmute video', 'M'], ['Rename file', 'F2'], ['Step one frame', ', .'], ['Library', '\\'], ['Inspector', '/'],
898
- ['Theme', 'T'], ['Next / previous file', 'Alt ↓ ↑'], ['Move file down / up', 'Alt Shift ↓ ↑'], ['This list', '?'],
899
- ];
900
- dialog('Keyboard shortcuts', [h('div.itp-keys', {}, ...keys.map(([a, k]) => h('div', {}, h('span', { text: a }), h('span', {}, ...k.split(' ').filter(Boolean).map(x => h('kbd', { text: x }))))))]);
901
- }
902
-
903
- // ================================================================ input
904
- const typing = e => e.target.closest('input, select, textarea, [contenteditable="true"]') && !(e.target.type === 'range' || e.target.type === 'checkbox');
905
- const DITHER_ORDER = ['none', 'bayer4', 'bayer8', 'bluenoise', 'cluster4', 'floyd-steinberg', 'atkinson', 'sierra-lite', 'jarvis'];
906
- window.addEventListener('keydown', e => {
907
- if (!shell.isConnected) return;
908
- if (document.querySelector('dialog[open]')) return;
909
- const mod = e.ctrlKey || e.metaKey;
910
- const k = e.key;
911
- if (mod && k.toLowerCase() === 'z') { if (typing(e)) return; e.preventDefault(); e.shiftKey ? store.redo() : store.undo(); return; }
912
- if (mod && k.toLowerCase() === 'y') { if (typing(e)) return; e.preventDefault(); store.redo(); return; }
913
- if (mod && k.toLowerCase() === 's') { e.preventDefault(); app.exportPNG(); return; }
914
- if (mod && k.toLowerCase() === 'o') { e.preventDefault(); openFiles(); return; }
915
- if (mod || e.altKey && !['ArrowUp', 'ArrowDown'].includes(k)) return;
916
- if (typing(e)) { if (k === 'Escape') e.target.blur(); return; }
917
- const s = store.settings;
918
- switch (k) {
919
- case 'c': case 'C': toggleCompare(); break;
920
- case 'g': case 'G': toggleGrid(); break;
921
- case 'f': case 'F': stage.fit(); break;
922
- case '+': case '=': stage.zoomIn(); break;
923
- case '-': case '_': stage.zoomOut(); break;
924
- case 't': case 'T': toggleTheme(); break;
925
- case '\\': togglePanel('lib'); break;
926
- case '/': togglePanel('insp'); break;
927
- case '?': showHelp(); break;
928
- case '[': case '{': store.update({ size: { mode: 'width', width: Math.max(8, s.size.width - (e.shiftKey ? 8 : 1)) } }, { key: 'kw' }); break;
929
- case ']': case '}': store.update({ size: { mode: 'width', width: s.size.width + (e.shiftKey ? 8 : 1) } }, { key: 'kw' }); break;
930
- case 'd': case 'D': {
931
- const i = DITHER_ORDER.indexOf(s.dither.method);
932
- const next = DITHER_ORDER[(i + (e.shiftKey ? -1 : 1) + DITHER_ORDER.length) % DITHER_ORDER.length];
933
- store.update({ dither: { method: next } });
934
- toast(next === 'none' ? 'No dither' : next.replace(/-/g, ' '), { timeout: 900 });
935
- break;
936
- }
937
- case ' ': if (app.current?.type === 'video') { e.preventDefault(); togglePlay(); } break;
938
- case 'F2': if (app.current) { e.preventDefault(); startRename(app.current); } break;
939
- case 'm': case 'M': if (app.current?.type === 'video' && !soundBox.hidden) { e.preventDefault(); setSound({ muted: !sound.muted }); } break;
940
- case ',': stepFrame(-1); break;
941
- case '.': stepFrame(1); break;
942
- case 'Escape': if (stage.picking) togglePicker(); else closeMenu(); break;
943
- case 'ArrowDown': case 'ArrowUp':
944
- if (e.altKey && e.shiftKey && app.current) { e.preventDefault(); app.move(app.current, k === 'ArrowDown' ? 1 : -1); break; }
945
- if (e.altKey && app.items.length > 1) {
946
- e.preventDefault();
947
- const i = app.items.indexOf(app.current);
948
- select(app.items[(i + (k === 'ArrowDown' ? 1 : -1) + app.items.length) % app.items.length]);
949
- }
950
- break;
951
- default: return;
952
- }
953
- });
954
-
955
- // drag & drop, paste
956
- let dragDepth = 0;
957
- shell.addEventListener('dragenter', e => { if ([...e.dataTransfer.types].includes('Files')) { dragDepth++; shell.classList.add('itp-dragging'); } });
958
- shell.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; shell.classList.remove('itp-dragging'); } });
959
- shell.addEventListener('dragover', e => { if ([...e.dataTransfer.types].includes('Files')) e.preventDefault(); });
960
- shell.addEventListener('drop', e => {
961
- e.preventDefault(); dragDepth = 0; shell.classList.remove('itp-dragging');
962
- if (e.dataTransfer.files.length) addFiles([...e.dataTransfer.files]);
963
- });
964
- window.addEventListener('paste', e => {
965
- if (!shell.isConnected || typing(e)) return;
966
- const files = [...(e.clipboardData?.files || [])];
967
- if (files.length) { e.preventDefault(); addFiles(files); return; }
968
- const text = e.clipboardData?.getData('text') || '';
969
- if (/#?[0-9a-f]{6}/i.test(text) && /\n|,|\s/.test(text.trim())) {
970
- try { const p = parsePalette(text); store.update({ palette: { source: 'custom', name: 'Pasted', colors: p.colors } }); toast(`Pasted a ${p.colors.length}-colour palette`, { kind: 'ok' }); } catch { /* not a palette */ }
971
- }
972
- });
973
- window.addEventListener('hashchange', () => {
974
- if (/[#&]s=/.test(location.hash)) { try { store.replace(decodeSettings(location.hash)); } catch { /* ignore */ } }
975
- });
976
-
977
- // first paint
978
- inspector.sync(store.settings, null);
979
- library.refresh();
980
- updateStatus();
981
- undoBtn.disabled = redoBtn.disabled = true;
982
- if (usePersist && options.restore !== false) restoreSaved().catch(e => console.warn('[workspace]', e));
983
-
984
- const api = {
985
- app, store,
986
- /** Add File objects (images, videos, palettes, settings JSON). */
987
- addFiles,
988
- /** Add an image-like source directly. */
989
- async addImage(source, name = 'image') { const it = await addImage(name, source); select(it); library.refresh(); return it; },
990
- openSample: id => openSample(SAMPLES.find(s => s.id === id) || SAMPLES[0]),
991
- applyPreset: id => { const p = PRESETS.find(x => x.id === id); if (p) app.applyPreset(p); },
992
- setTheme: t => { persist.save('theme', t); applyTheme(t); },
993
- get result() { return app.result; },
994
- /** On-screen rectangle of the image in the canvas. */
995
- get view() { return stage.rect; },
996
- /** Resolves after pending renders finish. */
997
- async idle() { for (let i = 0; i < 200 && (dirty || running); i++) await new Promise(r => setTimeout(r, 25)); },
998
- };
999
- return api;
1000
- }
1001
-
1002
- export { SAMPLES };
1
+ // The editor UI. mountEditor(element) builds the whole app inside `element`.
2
+ // The core engine (lib/core) has no idea this file exists.
3
+
4
+ import { createPixelator } from '../core/engine.js';
5
+ import { defaultSettings, decodeSettings, encodeSettings, settingsToJSON, settingsFromJSON, mergeSettings } from '../core/settings.js';
6
+ import { parsePalette, toGPL, toHexFile, toPaletteJSON } from '../core/palette/parse.js';
7
+ import { fetchLospec } from '../core/palette/lospec.js';
8
+ import { detectGrid } from '../core/export/scale.js';
9
+ import { toHex } from '../core/color.js';
10
+ import { estimateFps, videoReady, seekTo, sniffVideoCodec, hasAudioTrack } from '../core/video/frames.js';
11
+ import { downloadBlob } from '../core/export/canvas.js';
12
+ import { PRESETS } from '../core/presets.js';
13
+ import { h, render, pickFiles } from './dom.js';
14
+ import { icon } from './icons.js';
15
+ import { createStore } from './store.js';
16
+ import { createStage } from './stage.js';
17
+ import { createInspector } from './inspector.js';
18
+ import { createLibrary, fmtTime } from './library.js';
19
+ import { createExporter } from './exporter.js';
20
+ import { createVideoStoryboard } from './storyboard.js';
21
+ import { createTimelinePreview } from './timeline-preview.js';
22
+ import { toast, menu, closeMenu, dialog } from './ui.js';
23
+ import { SAMPLES, loadSample, sampleURL } from './samples.js';
24
+ import * as persist from './persist.js';
25
+ import { saveWorkspace, loadWorkspace, forgetWorkspace, isQuotaError, fmtBytes } from './workspace.js';
26
+
27
+ const IMAGE_TYPES = /^image\//;
28
+ const VIDEO_TYPES = /^video\//;
29
+ const PALETTE_EXT = /\.(gpl|hex|pal|txt|json)$/i;
30
+
31
+ /**
32
+ * Mount the editor.
33
+ * @param {HTMLElement} root
34
+ * @param {{ settings?: object, persist?: boolean, samples?: boolean, homeHref?: string, docsHref?: string }} [options]
35
+ */
36
+ export async function mountEditor(root, options = {}) {
37
+ const usePersist = options.persist !== false;
38
+ const narrow = () => matchMedia('(max-width: 900px)').matches;
39
+
40
+ // ---- initial settings: share link > saved > defaults
41
+ let initial = options.settings || (usePersist && persist.load('settings', null)) || defaultSettings();
42
+ if (/[#&]s=/.test(location.hash)) {
43
+ try { initial = decodeSettings(location.hash); } catch { toast('That share link could not be read; using your last settings', { kind: 'error' }); }
44
+ }
45
+ const store = createStore(initial);
46
+
47
+ const app = {
48
+ store,
49
+ engine: null,
50
+ items: [],
51
+ current: null,
52
+ result: null,
53
+ exporting: false,
54
+ activePreset: null,
55
+ userPalettes: usePersist ? persist.load('palettes', []) : [],
56
+ /** Settings shared by every file that doesn't have its own. */
57
+ shared: store.settings,
58
+ /** Settings copied with "Copy settings", ready to paste onto another file. */
59
+ clipboard: null,
60
+ videoSupport: { mp4: false, webm: false },
61
+ toast: (m, o) => toast(m, o),
62
+ };
63
+
64
+ // ================================================================ layout
65
+ const stage = createStage({ onPick: pickAt, onViewChange: () => updateZoomLabel() });
66
+ const inspector = createInspector(app);
67
+ const library = createLibrary(app);
68
+ const exporter = createExporter(app);
69
+
70
+ const btn = (ic, label, onclick, extra = {}) => h('button.itp-btn.icon', { type: 'button', 'aria-label': label, title: label, html: icon(ic), onclick, ...extra });
71
+
72
+ const fileName = h('span.name', { text: 'No image' });
73
+ // click the name to rename (F2); the extension stays as it was
74
+ fileName.addEventListener('click', () => { if (app.current) startRename(app.current); });
75
+ const fileMeta = h('span.meta.num', {});
76
+ const undoBtn = btn('undo', 'Undo (Ctrl+Z)', () => store.undo());
77
+ const redoBtn = btn('redo', 'Redo (Ctrl+Shift+Z)', () => store.redo());
78
+ const compareBtn = btn('compare', 'Compare with original (C)', () => toggleCompare(), { 'aria-pressed': 'false' });
79
+ const gridBtn = btn('grid', 'Pixel grid (G)', () => toggleGrid(), { 'aria-pressed': 'false' });
80
+ const zoomBtn = h('button.itp-btn.itp-zoom.num', { type: 'button', 'aria-label': 'Zoom', 'aria-haspopup': 'menu', html: `<span>Fit</span>${icon('chevronDown')}`, onclick: e => zoomMenu(e.currentTarget) });
81
+ const themeBtn = btn('moon', 'Toggle theme (T)', () => toggleTheme());
82
+ const libBtn = btn('sidebarLeft', 'Toggle library (\\)', () => togglePanel('lib'));
83
+ const inspBtn = btn('sidebarRight', 'Toggle inspector (/)', () => togglePanel('insp'));
84
+ const helpBtn = btn('keyboard', 'Keyboard shortcuts (?)', () => showHelp());
85
+ const exportBtn = h('button.itp-btn.primary', { type: 'button', 'aria-haspopup': 'menu', html: `${icon('download')}<span>Export</span>`, onclick: e => exportMenu(e.currentTarget) });
86
+ const saveBtn = h('button.itp-btn.outline', { type: 'button', title: 'Save these files and their settings in this browser', html: `${icon('save')}<span class="hide-sm">Save</span>`, onclick: () => saveNow() });
87
+ saveBtn.hidden = !usePersist;
88
+ const brand = h('a.itp-brand', { href: options.homeHref || '#', title: 'Image-to-Pixel' }, h('span.itp-mark', { 'aria-hidden': 'true' }, ...Array.from({ length: 9 }, () => h('i'))), h('span.hide-sm', { text: 'Image-to-Pixel' }));
89
+ if (!options.homeHref) brand.addEventListener('click', e => e.preventDefault());
90
+
91
+ const top = h('header.itp-top', {},
92
+ libBtn, brand, h('span.sep.hide-sm'),
93
+ h('div.itp-file', {}, fileName, fileMeta),
94
+ h('div.spacer'),
95
+ h('span.hide-sm.contents', {}, undoBtn, redoBtn, h('span.sep')),
96
+ compareBtn, gridBtn, zoomBtn,
97
+ h('span.sep.hide-sm'), h('span.hide-sm.contents', {}, themeBtn, helpBtn), inspBtn,
98
+ h('span', { style: { width: '6px' } }), saveBtn, exportBtn);
99
+
100
+ // stage extras
101
+ const sampleButtons = options.samples === false ? null : h('div.samples', {}, ...SAMPLES.map(s => h('button', { type: 'button', title: `Try "${s.name}"`, 'aria-label': `Open sample: ${s.name}`, onclick: () => openSample(s) }, h('img', { src: sampleURL(s), alt: '' }))));
102
+ const empty = h('div.itp-empty', {}, h('div.itp-drop', {},
103
+ h('h2', { text: 'Drop an image or a video' }),
104
+ h('p', { text: 'PNG, JPG, WebP, GIF, SVG, MP4 or WebM. Add several files to process them as a batch. You can also paste with Ctrl+V.' }),
105
+ h('div.actions', {},
106
+ h('button.itp-btn.primary', { type: 'button', html: `${icon('upload')}<span>Choose files</span>`, onclick: () => openFiles() }),
107
+ h('button.itp-btn.outline', { type: 'button', html: `${icon('clipboard')}<span>Paste</span>`, onclick: () => pasteFromClipboard() })),
108
+ sampleButtons ? h('p', { style: { fontSize: 'var(--fs-xs)', color: 'var(--faint)' }, text: 'Or start from a sample' }) : null,
109
+ sampleButtons));
110
+ const status = h('div.itp-float.bl', { 'aria-live': 'polite' });
111
+ const busy = h('div.itp-busy.itp-float', { hidden: true, style: { padding: '6px 10px', alignItems: 'center', gap: '8px', fontSize: 'var(--fs-xs)', color: 'var(--muted)', display: 'flex' } }, h('span.itp-spinner'), h('span', { text: 'Rendering' }));
112
+ const viewTools = h('div.itp-float.br', {},
113
+ btn('zoomOut', 'Zoom out (-)', () => stage.zoomOut()),
114
+ btn('fit', 'Fit to view (F)', () => stage.fit()),
115
+ btn('zoomIn', 'Zoom in (+)', () => stage.zoomIn()));
116
+ stage.el.append(empty, status, busy, viewTools);
117
+
118
+ // transport
119
+ const playBtn = btn('play', 'Play (Space)', () => togglePlay());
120
+ const timeLabel = h('span.itp-time', {});
121
+ const scrub = h('input.itp-scrub', { type: 'range', min: 0, max: 1000, value: 0, 'aria-label': 'Video position' });
122
+ const fpsLabel = h('span.muted.num', { style: { fontSize: 'var(--fs-xs)', whiteSpace: 'nowrap' } });
123
+ // sound: muted by default; the choice (and volume) is remembered and applies to every video
124
+ const sound = { muted: persist.load('muted', true) !== false, volume: Number(persist.load('volume', 1)) };
125
+ if (!(sound.volume >= 0 && sound.volume <= 1)) sound.volume = 1;
126
+ // YouTube-style: the speaker button opens a vertical volume slider; all the way down is mute.
127
+ const muteBtn = btn('volumeOff', 'Volume (M mutes)', () => toggleVolPop(), { 'aria-haspopup': 'true', 'aria-expanded': 'false' });
128
+ const volFill = h('div.fill');
129
+ const volThumb = h('div.thumb');
130
+ const volSlider = h('div.itp-vslider', { role: 'slider', tabindex: '0', 'aria-label': 'Volume', 'aria-orientation': 'vertical', 'aria-valuemin': '0', 'aria-valuemax': '100' }, h('div.track', {}, volFill, volThumb));
131
+ const volValue = h('span.num', {});
132
+ const volPop = h('div.itp-volpop', { hidden: true }, volValue, volSlider);
133
+ const soundBox = h('div.itp-sound', { hidden: true }, volPop, muteBtn);
134
+ const setLevel = v => setSound(v <= 0.005 ? { muted: true } : { muted: false, volume: Math.min(1, v) });
135
+ const levelAt = e => { const r = volSlider.querySelector('.track').getBoundingClientRect(); return Math.max(0, Math.min(1, (r.bottom - e.clientY) / r.height)); };
136
+ volSlider.addEventListener('pointerdown', e => {
137
+ e.preventDefault(); volSlider.focus(); volSlider.setPointerCapture(e.pointerId);
138
+ const before = sound.muted ? 0 : sound.volume;
139
+ setLevel(levelAt(e));
140
+ const move = ev => setLevel(levelAt(ev));
141
+ const up = () => {
142
+ volSlider.removeEventListener('pointermove', move); volSlider.removeEventListener('pointerup', up); volSlider.removeEventListener('pointercancel', up);
143
+ // dragged down to mute: M brings back the volume it had before the drag
144
+ if (sound.muted && before > 0) setSound({ muted: true, volume: before });
145
+ };
146
+ volSlider.addEventListener('pointermove', move); volSlider.addEventListener('pointerup', up); volSlider.addEventListener('pointercancel', up);
147
+ });
148
+ volSlider.addEventListener('keydown', e => {
149
+ const cur = sound.muted ? 0 : sound.volume;
150
+ const next = { ArrowUp: cur + 0.05, ArrowRight: cur + 0.05, ArrowDown: cur - 0.05, ArrowLeft: cur - 0.05, PageUp: cur + 0.2, PageDown: cur - 0.2, Home: 0, End: 1 }[e.key];
151
+ if (next === undefined) { if (e.key === 'Escape') { e.preventDefault(); toggleVolPop(false); muteBtn.focus(); } return; }
152
+ e.preventDefault(); e.stopPropagation(); setLevel(next);
153
+ });
154
+ volSlider.addEventListener('wheel', e => { e.preventDefault(); setLevel((sound.muted ? 0 : sound.volume) - Math.sign(e.deltaY) * 0.05); }, { passive: false });
155
+ function toggleVolPop(open = volPop.hidden) {
156
+ volPop.hidden = !open;
157
+ muteBtn.setAttribute('aria-expanded', String(open));
158
+ if (open) {
159
+ const close = e => { if (!soundBox.contains(e.target)) { toggleVolPop(false); document.removeEventListener('pointerdown', close, true); } };
160
+ document.addEventListener('pointerdown', close, true);
161
+ }
162
+ }
163
+ const transport = h('div.itp-transport', { role: 'group', 'aria-label': 'Video' },
164
+ btn('stepBack', 'Previous frame (,)', () => stepFrame(-1)), playBtn, btn('stepForward', 'Next frame (.)', () => stepFrame(1)),
165
+ timeLabel, scrub, soundBox, fpsLabel);
166
+ // The frame-selection canvas replaces the preview while it is on, so its tiles feed the stats.
167
+ let statsResult = null;
168
+ function updateStats(res) {
169
+ if (res === statsResult) return false;
170
+ statsResult = res;
171
+ inspector.setStats(res);
172
+ return true;
173
+ }
174
+ const timelinePreview = createTimelinePreview(app);
175
+ const storyboard = createVideoStoryboard(app, {
176
+ onModeChange: active => {
177
+ timelinePreview.hide();
178
+ stage.el.hidden = active;
179
+ transport.hidden = active;
180
+ compareBtn.disabled = gridBtn.disabled = active;
181
+ if (!active) { stage.resize(); schedule(); }
182
+ updateZoomLabel();
183
+ },
184
+ onZoomChange: updateZoomLabel,
185
+ onRangeChange: range => inspector.setAnimationRange(range),
186
+ onSelectionChange: state => {
187
+ inspector.setFrameSelectionState(state);
188
+ const res = state.active ? state.result : app.result;
189
+ if (updateStats(res) && res && state.active) inspector.sync(store.settings, res);
190
+ },
191
+ animationOptions: () => inspector.animationOptions(),
192
+ });
193
+ app.setFrameSelection = enabled => storyboard.setMode(enabled);
194
+ app.setFrameRange = (start, end) => storyboard.setRange(start, end);
195
+ app.setAnimationFps = fps => storyboard.setPlaybackFps(fps);
196
+ app.frameSelection = () => storyboard.exportSelection();
197
+ /** The result the stats and size readouts describe: the main preview, or the selection's frame. */
198
+ const previewResult = () => (storyboard.active ? storyboard.currentResult() : app.result);
199
+ const stageWrap = h('main.itp-stage', { 'data-video': 'false' }, stage.el, storyboard.el, transport, storyboard.transport, timelinePreview.el);
200
+ timelinePreview.bind(scrub);
201
+ timelinePreview.bind(storyboard.range, { getFrame: storyboard.frameCanvas });
202
+
203
+ // on narrow screens the library is a drawer: a tap on the canvas closes it
204
+ stageWrap.addEventListener('pointerdown', () => { if (narrow() && shell.dataset.lib === 'open') togglePanel('lib'); }, true);
205
+ const shell = h('div.itp.itp-app', { 'data-lib': narrow() ? 'closed' : persist.load('lib', 'open'), 'data-insp': persist.load('insp', 'open') }, top, library.el, stageWrap, inspector.el);
206
+ render(root, shell);
207
+ applyTheme(persist.load('theme', null));
208
+
209
+ // ================================================================ engine
210
+ status.textContent = 'Starting…';
211
+ try {
212
+ app.engine = await createPixelator();
213
+ } catch (e) {
214
+ toast(`Could not start: ${e.message}`, { kind: 'error' });
215
+ throw e;
216
+ }
217
+ inspector.el.querySelector('#itp-backend').textContent = app.engine.backend === 'webgl' ? 'GPU · ShaderMate' : 'CPU';
218
+ inspector.el.querySelector('#itp-backend').title = app.engine.backend === 'webgl' ? `WebGL 2: ${app.engine.renderer}` : (app.engine.fallbackReason || 'CPU renderer');
219
+ import('../video.js').then(m => m.videoSupport()).then(v => { app.videoSupport = v; }).catch(() => { });
220
+
221
+ // ================================================================ rendering
222
+ let dirty = false, running = false, lastSource = null;
223
+ const outCanvas = document.createElement('canvas');
224
+
225
+ function schedule() {
226
+ dirty = true;
227
+ if (!running) requestAnimationFrame(run);
228
+ }
229
+ app.schedule = schedule;
230
+
231
+ async function run() {
232
+ if (running || !dirty) return;
233
+ if (app.exporting || storyboard.active) return;
234
+ dirty = false;
235
+ const item = app.current;
236
+ if (!item) { app.result = null; stage.clear(); updateStatus(); return; }
237
+ running = true;
238
+ const slow = setTimeout(() => { busy.hidden = false; }, 250);
239
+ try {
240
+ if (item.type === 'video') app.engine.invalidate();
241
+ const res = await app.engine.process(item.source, store.settings);
242
+ if (item !== app.current || storyboard.active) return;
243
+ app.result = res;
244
+ res.toCanvas(1, { canvas: outCanvas });
245
+ stage.show(outCanvas, item.source, res.crop);
246
+ updateStats(res);
247
+ inspector.sync(store.settings, res);
248
+ if (lastSource !== item) { lastSource = item; }
249
+ updateStatus();
250
+ } catch (e) {
251
+ console.error(e);
252
+ toast(`Rendering failed: ${e.message}`, { kind: 'error' });
253
+ } finally {
254
+ clearTimeout(slow); busy.hidden = true;
255
+ running = false;
256
+ if (dirty) requestAnimationFrame(run);
257
+ }
258
+ }
259
+
260
+ function updateStatus() {
261
+ const r = app.result, it = app.current;
262
+ fileName.textContent = it ? it.name : 'No image';
263
+ fileMeta.textContent = it ? (it.type === 'video' ? `${it.width} × ${it.height}, ${fmtTime(it.duration)}` : `${it.width} × ${it.height}`) : '';
264
+ empty.hidden = !!it;
265
+ viewTools.hidden = !it;
266
+ if (!it || !r) { status.hidden = true; return; }
267
+ status.hidden = false;
268
+ render(status, h('span', {}, h('b', { text: `${r.width} × ${r.height}` }), ' px'), h('span', { text: `${Math.round(stage.zoom * 100) / 100}× zoom` }));
269
+ undoBtn.disabled = !store.canUndo; redoBtn.disabled = !store.canRedo;
270
+ }
271
+ function updateZoomLabel() {
272
+ const view = storyboard.active ? storyboard : stage;
273
+ zoomBtn.querySelector('span').textContent = !app.current || view.fitted ? 'Fit' : view.zoom >= 1 ? `${Math.round(view.zoom * 10) / 10}×` : `${Math.round(view.zoom * 100)}%`;
274
+ if (app.result) updateStatus();
275
+ }
276
+
277
+ store.subscribe((s, why) => {
278
+ // write the edit back to whatever is being edited: this file's own settings, or the shared ones
279
+ const cur = app.current;
280
+ if (cur && cur.own && store.target === cur.id) cur.own = s;
281
+ else if (store.target === 'shared') app.shared = s;
282
+ inspector.sync(s, previewResult());
283
+ undoBtn.disabled = !store.canUndo; redoBtn.disabled = !store.canRedo;
284
+ if (why !== 'preset') app.activePreset = null;
285
+ if (usePersist) saveSoon();
286
+ if (library.tab === 'palettes') library.refresh();
287
+ timelinePreview.reset();
288
+ storyboard.refresh();
289
+ schedule();
290
+ });
291
+ let saveT = null;
292
+ const saveSoon = () => { clearTimeout(saveT); saveT = setTimeout(() => persist.save('settings', app.shared), 300); };
293
+
294
+ // ================================================================ files
295
+ async function makeThumb(drawable, w, hgt) {
296
+ const c = document.createElement('canvas');
297
+ const k = 72 / Math.max(w, hgt);
298
+ c.width = Math.max(1, Math.round(w * k)); c.height = Math.max(1, Math.round(hgt * k));
299
+ try { c.getContext('2d').drawImage(drawable, 0, 0, c.width, c.height); } catch { /* not ready */ }
300
+ return c.toDataURL();
301
+ }
302
+
303
+ async function addImage(name, drawable, file = null) {
304
+ const w = drawable.naturalWidth || drawable.width, hh = drawable.naturalHeight || drawable.height;
305
+ const item = { id: crypto.randomUUID?.() || String(Math.random()), name, type: 'image', source: drawable, width: w, height: hh, file };
306
+ item.thumb = await makeThumb(drawable, w, hh);
307
+ app.items.push(item);
308
+ return item;
309
+ }
310
+
311
+ async function addVideo(name, file) {
312
+ // No `loop` attribute: looping by hand on 'ended' is more reliable (some MOV/MP4 files stalled at the
313
+ // end and lost their sound after Chrome's built-in loop).
314
+ const video = h('video', { muted: true, playsInline: true, preload: 'auto', crossOrigin: 'anonymous' });
315
+ video.muted = true;
316
+ video.src = URL.createObjectURL(file);
317
+ try {
318
+ await videoReady(video);
319
+ } catch (e) {
320
+ URL.revokeObjectURL(video.src);
321
+ if (e.code === 3 || e.code === 4) {
322
+ const codec = await sniffVideoCodec(file).catch(() => '');
323
+ const alt = codec === 'H.264' ? 'WebM (VP9)' : codec === 'VP9' ? 'H.264 MP4' : 'H.264 MP4 or WebM (VP9)';
324
+ const err = new Error(codec
325
+ ? `it's ${codec}, which this browser can't play. Re-save it as ${alt}, or open the editor in a browser that plays ${codec}.`
326
+ : `this browser can't play this video's format. Re-save it as ${alt}.`);
327
+ err.code = e.code;
328
+ throw err;
329
+ }
330
+ throw e;
331
+ }
332
+ const item = { id: crypto.randomUUID?.() || String(Math.random()), name, type: 'video', source: video, width: video.videoWidth, height: video.videoHeight, duration: video.duration, fps: 30, file };
333
+ await seekTo(video, Math.min(0.05, video.duration / 2)).catch(() => { });
334
+ item.thumb = await makeThumb(video, item.width, item.height);
335
+ app.items.push(item);
336
+ // measuring plays the video muted for a moment; Play waits for it so the two don't fight
337
+ item.fpsReady = estimateFps(video, 30).catch(() => 30).then(f => { item.fps = f; item.fpsReady = null; if (app.current === item) updateTransport(); inspector.syncAnim(); });
338
+ // null = can't tell from the container: the sound controls stay, just in case
339
+ hasAudioTrack(file).then(a => { item.hasAudio = a; if (app.current === item) updateTransport(); });
340
+ video.addEventListener('timeupdate', () => { if (app.current === item) updateTransport(); });
341
+ video.addEventListener('play', () => { if (app.current === item) { updateTransport(); pumpVideo(item); } });
342
+ video.addEventListener('pause', () => { if (app.current === item) { updateTransport(); schedule(); } });
343
+ video.addEventListener('seeked', () => { if (app.current === item && !app.exporting) schedule(); });
344
+ video.addEventListener('ended', () => {
345
+ if (app.current !== item || app.exporting || item.fpsReady || storyboard.active) return;
346
+ video.currentTime = 0;
347
+ video.play().catch(() => { });
348
+ });
349
+ return item;
350
+ }
351
+
352
+ async function addFiles(files) {
353
+ const media = [], palettes = [], settingsFiles = [];
354
+ for (const f of files) {
355
+ if (PALETTE_EXT.test(f.name) && !/^image|^video/.test(f.type)) {
356
+ if (/\.json$/i.test(f.name)) settingsFiles.push(f); else palettes.push(f);
357
+ } else if (IMAGE_TYPES.test(f.type) || VIDEO_TYPES.test(f.type) || /\.(png|jpe?g|gif|webp|bmp|svg|avif|mp4|webm|mov)$/i.test(f.name)) media.push(f);
358
+ }
359
+ for (const f of palettes) await importPaletteFile(f);
360
+ for (const f of settingsFiles) {
361
+ const text = await f.text();
362
+ try {
363
+ const obj = JSON.parse(text);
364
+ if (obj.colors || Array.isArray(obj)) await importPaletteFile(f, text);
365
+ else { store.replace(settingsFromJSON(obj)); toast(`Loaded settings from ${f.name}`, { kind: 'ok' }); }
366
+ } catch (e) { toast(`${f.name}: ${e.message}`, { kind: 'error' }); }
367
+ }
368
+ let first = null;
369
+ const t = media.length > 2 ? toast(`Loading ${media.length} files`, { progress: true }) : null;
370
+ for (let i = 0; i < media.length; i++) {
371
+ const f = media[i];
372
+ try {
373
+ let item;
374
+ if (VIDEO_TYPES.test(f.type) || /\.(mp4|webm|mov)$/i.test(f.name)) item = await addVideo(f.name, f);
375
+ else {
376
+ let drawable;
377
+ try { drawable = await createImageBitmap(f); } catch {
378
+ drawable = new Image(); drawable.src = URL.createObjectURL(f); await drawable.decode();
379
+ }
380
+ item = await addImage(f.name, drawable, f);
381
+ }
382
+ first ||= item;
383
+ } catch (e) { toast(`${f.name} could not be opened: ${e.message}`, { kind: 'error' }); }
384
+ t?.progress((i + 1) / media.length);
385
+ }
386
+ t?.close();
387
+ if (first) select(first);
388
+ library.refresh();
389
+ if (media.length > 1) toast(`Added ${media.length} files. They share one set of settings.`, { kind: 'ok' });
390
+ }
391
+ app.addFiles = addFiles;
392
+
393
+ async function openFiles() {
394
+ const files = await pickFiles({ accept: 'image/*,video/*,.gpl,.hex,.pal,.txt,.json', multiple: true });
395
+ if (files.length) addFiles(files);
396
+ }
397
+ app.openFiles = openFiles;
398
+
399
+ async function openSample(s) {
400
+ const img = await loadSample(s);
401
+ const item = await addImage(`${s.name}.svg`, img, new File([s.svg], `${s.name}.svg`, { type: 'image/svg+xml' }));
402
+ select(item);
403
+ library.refresh();
404
+ }
405
+
406
+ async function pasteFromClipboard() {
407
+ try {
408
+ const items = await navigator.clipboard.read();
409
+ const files = [];
410
+ for (const it of items) for (const type of it.types) if (IMAGE_TYPES.test(type)) {
411
+ const b = await it.getType(type);
412
+ files.push(new File([b], `pasted-${new Date().toISOString().slice(11, 19).replace(/:/g, '')}.${type.split('/')[1]}`, { type }));
413
+ }
414
+ if (files.length) addFiles(files); else toast('The clipboard has no image', { kind: 'error' });
415
+ } catch {
416
+ toast('Press Ctrl+V (⌘V) to paste. The browser blocked direct clipboard access.');
417
+ }
418
+ }
419
+ app.pasteFromClipboard = pasteFromClipboard;
420
+
421
+ function select(item) {
422
+ if (app.current && app.current.type === 'video' && app.current !== item) app.current.source.pause();
423
+ app.current = item;
424
+ timelinePreview.reset();
425
+ storyboard.setItem(item);
426
+ editTarget(item);
427
+ stageWrap.dataset.video = String(item && item.type === 'video');
428
+ if (item && item.type === 'video') updateTransport();
429
+ stage.clear();
430
+ app.result = null;
431
+ if (stage.picking) togglePicker();
432
+ library.refresh();
433
+ library.sourceChanged();
434
+ schedule();
435
+ updateStatus();
436
+ }
437
+ app.select = select;
438
+
439
+ function removeItem(item) {
440
+ const i = app.items.indexOf(item);
441
+ if (i < 0) return;
442
+ app.items.splice(i, 1);
443
+ store.dropTarget(item.id);
444
+ if (item.type === 'video') { item.source.pause(); URL.revokeObjectURL(item.source.src); }
445
+ if (item.source.close && !app.items.some(i => i.source === item.source)) item.source.close(); // duplicates share a bitmap
446
+ if (app.current === item) select(app.items[Math.min(i, app.items.length - 1)] || null);
447
+ library.refresh();
448
+ inspector.sync(store.settings, previewResult());
449
+ }
450
+ app.removeItem = removeItem;
451
+ app.clearItems = () => { for (const it of app.items.slice()) removeItem(it); };
452
+
453
+ // ================================================================ per-file settings
454
+ /** The settings a file renders with. */
455
+ const settingsFor = item => (item === app.current ? store.settings : (item && item.own) || app.shared);
456
+ app.settingsFor = settingsFor;
457
+ const clone = v => JSON.parse(JSON.stringify(v));
458
+
459
+ /** Point the store at the right settings for this file (its own, or the shared ones). */
460
+ function editTarget(item, force = false) {
461
+ const key = item && item.own ? item.id : 'shared';
462
+ if (store.target === key && !force) return;
463
+ store.setTarget(key, key === 'shared' ? app.shared : item.own);
464
+ }
465
+
466
+ /** Give a file its own settings (a copy of what it uses now), or link it back to the shared ones. */
467
+ function setOwn(item, own, { quiet = false } = {}) {
468
+ if (!item || !!item.own === own) return;
469
+ if (own) {
470
+ item.own = clone(settingsFor(item));
471
+ if (item === app.current) editTarget(item);
472
+ if (!quiet) toast(`${item.name} now has its own settings. Changes no longer affect other files.`);
473
+ } else {
474
+ const was = item.own;
475
+ item.own = null;
476
+ store.dropTarget(item.id);
477
+ if (item === app.current) editTarget(item);
478
+ if (!quiet) toast(`${item.name} uses the shared settings again`, { action: { label: 'Undo', onClick: () => { item.own = was; if (item === app.current) editTarget(item); afterScopeChange(); } } });
479
+ }
480
+ afterScopeChange();
481
+ }
482
+ function afterScopeChange() {
483
+ library.refresh();
484
+ const res = previewResult();
485
+ inspector.sync(store.settings, res);
486
+ if (res) updateStats(res);
487
+ schedule();
488
+ }
489
+ app.setOwn = setOwn;
490
+
491
+ app.copySettings = item => {
492
+ app.clipboard = clone(settingsFor(item));
493
+ toast(`Copied the settings of ${item.name}`, { timeout: 1800 });
494
+ library.refresh();
495
+ };
496
+ app.pasteSettings = item => {
497
+ if (!app.clipboard) return;
498
+ if (!item.own) setOwn(item, true, { quiet: true });
499
+ if (item === app.current) store.replace(app.clipboard, 'paste');
500
+ else item.own = clone(app.clipboard);
501
+ toast(`Pasted settings onto ${item.name}`, { kind: 'ok', timeout: 1800 });
502
+ afterScopeChange();
503
+ };
504
+ /** Make this file's settings the shared ones, and link every file to them. */
505
+ app.applyToAll = item => {
506
+ const snapshot = { shared: app.shared, own: new Map(app.items.map(i => [i, i.own])) };
507
+ app.shared = clone(settingsFor(item));
508
+ for (const it of app.items) { if (it.own) store.dropTarget(it.id); it.own = null; }
509
+ store.setTarget('shared', app.shared);
510
+ if (usePersist) saveSoon();
511
+ toast(`Every file now uses the settings of ${item.name}`, {
512
+ action: { label: 'Undo', onClick: () => {
513
+ app.shared = snapshot.shared;
514
+ for (const [it, own] of snapshot.own) it.own = own;
515
+ editTarget(app.current, true);
516
+ afterScopeChange();
517
+ } },
518
+ });
519
+ afterScopeChange();
520
+ };
521
+ /** A second copy of a file with its own settings, for comparing two looks. */
522
+ app.duplicate = async item => {
523
+ let copy;
524
+ if (item.type === 'video') copy = await addVideo(nameCopy(item.name), item.file);
525
+ else copy = await addImage(nameCopy(item.name), item.source, item.file);
526
+ // place it right after the original
527
+ app.items.splice(app.items.indexOf(copy), 1);
528
+ app.items.splice(app.items.indexOf(item) + 1, 0, copy);
529
+ copy.own = clone(settingsFor(item));
530
+ select(copy);
531
+ toast(`Duplicated ${item.name}. The copy has its own settings.`, { timeout: 2400 });
532
+ };
533
+ const nameCopy = name => { const m = /^(.*?)(\.[^.]*)?$/.exec(name); return `${m[1]} copy${m[2] || ''}`; };
534
+ app.move = (item, dir) => {
535
+ const i = app.items.indexOf(item), j = i + dir;
536
+ if (i < 0 || j < 0 || j >= app.items.length) return;
537
+ [app.items[i], app.items[j]] = [app.items[j], app.items[i]];
538
+ library.refresh();
539
+ if (document.activeElement === document.body || document.activeElement?.closest?.('.itp-files')) library.focusFile(item);
540
+ };
541
+
542
+ // ================================================================ rename
543
+ const splitExt = name => { const m = /^(.*?)(\.[^.\s]{1,5})?$/.exec(name); return [m[1], m[2] || '']; };
544
+ /** Rename a file. The extension is kept: exports are named after it ("<name>@4x.png"). */
545
+ app.rename = (item, next) => {
546
+ const [, ext] = splitExt(item.name);
547
+ let base = String(next || '').trim().replace(/[\\/:*?"<>|]+/g, '-');
548
+ if (ext && base.toLowerCase().endsWith(ext.toLowerCase())) base = base.slice(0, -ext.length);
549
+ if (!base || base + ext === item.name) return;
550
+ item.name = base + ext;
551
+ library.refresh();
552
+ updateStatus();
553
+ };
554
+ /** Edit the name in place in the header, with the name (not the extension) selected. */
555
+ function startRename(item) {
556
+ if (!item || fileName.hidden) return;
557
+ if (app.current !== item) select(item);
558
+ const [base] = splitExt(item.name);
559
+ const input = h('input.itp-name-input', { type: 'text', value: item.name, 'aria-label': 'File name', spellcheck: false });
560
+ input.style.width = `${Math.min(40, Math.max(12, item.name.length + 2))}ch`;
561
+ fileName.hidden = true;
562
+ fileName.after(input);
563
+ input.focus();
564
+ input.setSelectionRange(0, base.length);
565
+ let done = false;
566
+ const finish = commit => {
567
+ if (done) return;
568
+ done = true;
569
+ if (commit) app.rename(item, input.value);
570
+ input.remove();
571
+ fileName.hidden = false;
572
+ };
573
+ input.addEventListener('keydown', e => {
574
+ e.stopPropagation();
575
+ if (e.key === 'Enter') { e.preventDefault(); finish(true); }
576
+ if (e.key === 'Escape') { e.preventDefault(); finish(false); }
577
+ });
578
+ input.addEventListener('blur', () => finish(true));
579
+ }
580
+ app.startRename = startRename;
581
+
582
+ // ================================================================ saved workspace
583
+ // Files (their bytes), names, per-file settings and the shared settings, in this browser's IndexedDB.
584
+ let savedInfo = null; // { count, bytes, savedAt } of what is saved, or null
585
+ app.savedWorkspace = () => savedInfo;
586
+
587
+ /** A Blob for a file, for saving: the original bytes, or a PNG of what was added without a file. */
588
+ async function bytesOf(item) {
589
+ if (item.file) return item.file;
590
+ const c = document.createElement('canvas');
591
+ c.width = item.width; c.height = item.height;
592
+ c.getContext('2d').drawImage(item.source, 0, 0);
593
+ return new Promise(res => c.toBlob(b => res(b), 'image/png'));
594
+ }
595
+
596
+ async function saveNow() {
597
+ if (!usePersist) return;
598
+ if (!app.items.length) {
599
+ if (savedInfo) { await forgetSaved(); return; }
600
+ toast('Add files first, then Save keeps them here for next time.');
601
+ return;
602
+ }
603
+ saveBtn.disabled = true;
604
+ try {
605
+ const items = [];
606
+ for (const it of app.items) items.push({ name: it.name, type: it.type, file: await bytesOf(it), own: it.own ? clone(it.own) : null });
607
+ const bytes = await saveWorkspace({ shared: clone(app.shared), current: Math.max(0, app.items.indexOf(app.current)), items });
608
+ const first = !savedInfo;
609
+ savedInfo = { count: items.length, bytes, savedAt: Date.now() };
610
+ library.refresh();
611
+ toast(first
612
+ ? `Saved ${items.length} file${items.length === 1 ? '' : 's'} (${fmtBytes(bytes)}) in this browser only. They reopen next time; remove them with Forget in the Files list.`
613
+ : `Saved ${items.length} file${items.length === 1 ? '' : 's'} (${fmtBytes(bytes)})`, { kind: 'ok', timeout: first ? 7000 : 2200 });
614
+ } catch (e) {
615
+ console.error(e);
616
+ toast(isQuotaError(e) ? "Not enough browser storage to save these files. Remove some (videos are large) and try again." : `Couldn't save: ${e.message}`, { kind: 'error' });
617
+ } finally {
618
+ saveBtn.disabled = false;
619
+ }
620
+ }
621
+ app.saveWorkspace = saveNow;
622
+
623
+ async function forgetSaved() {
624
+ try { await forgetWorkspace(); } catch (e) { toast(`Couldn't remove the saved files: ${e.message}`, { kind: 'error' }); return; }
625
+ savedInfo = null;
626
+ library.refresh();
627
+ toast('Removed the saved files from this browser. Files open now stay open.');
628
+ }
629
+ app.forgetWorkspace = forgetSaved;
630
+
631
+ /** Reopen the saved workspace (on start, when nothing else was opened). */
632
+ async function restoreSaved() {
633
+ const ws = await loadWorkspace();
634
+ if (!ws) return;
635
+ savedInfo = { count: ws.items.length, bytes: ws.items.reduce((a, it) => a + (it.file ? it.file.size : 0), 0), savedAt: ws.savedAt };
636
+ if (app.items.length || !ws.items.length) { library.refresh(); return; }
637
+ if (ws.shared && !/[#&]s=/.test(location.hash)) store.setTarget('shared', ws.shared); // a share link's settings win
638
+ const t = ws.items.length > 2 ? toast(`Opening ${ws.items.length} saved files`, { progress: true }) : null;
639
+ const added = [];
640
+ for (let i = 0; i < ws.items.length; i++) {
641
+ const s = ws.items[i];
642
+ try {
643
+ const file = s.file instanceof File ? s.file : new File([s.file], s.name, { type: s.file.type });
644
+ let item;
645
+ if (s.type === 'video') item = await addVideo(s.name, file);
646
+ else {
647
+ let drawable;
648
+ try { drawable = await createImageBitmap(file); } catch { drawable = new Image(); drawable.src = URL.createObjectURL(file); await drawable.decode(); }
649
+ item = await addImage(s.name, drawable, file);
650
+ }
651
+ item.own = s.own || null;
652
+ added.push(item);
653
+ } catch (e) { console.warn('[workspace] skipped', s.name, e); }
654
+ t?.progress((i + 1) / ws.items.length);
655
+ }
656
+ t?.close();
657
+ const cur = added[Math.min(ws.current || 0, added.length - 1)];
658
+ if (cur) select(cur);
659
+ library.refresh();
660
+ if (added.length) toast(`Reopened your saved files (${added.length})`, { timeout: 2200 });
661
+ }
662
+
663
+ // ================================================================ video
664
+ function updateTransport() {
665
+ const it = app.current;
666
+ if (!it || it.type !== 'video') return;
667
+ const v = it.source;
668
+ playBtn.innerHTML = icon(v.paused ? 'play' : 'pause');
669
+ playBtn.setAttribute('aria-label', v.paused ? 'Play (Space)' : 'Pause (Space)');
670
+ const frame = Math.floor(v.currentTime * it.fps + 1e-6);
671
+ timeLabel.innerHTML = `${fmtTime(v.currentTime)} <span>/ ${fmtTime(v.duration)}</span>`;
672
+ timeLabel.title = `Frame ${frame}`;
673
+ scrub.value = String(Math.round(v.currentTime / (v.duration || 1) * 1000));
674
+ scrub.style.setProperty('--p', `${scrub.value / 10}%`);
675
+ fpsLabel.textContent = `${it.fps} fps`;
676
+ soundBox.hidden = it.hasAudio === false;
677
+ applySound(v);
678
+ }
679
+ /** Mute/volume on the current video and in the controls. */
680
+ function applySound(v) {
681
+ if (v && !app.exporting && !app.current?.fpsReady) {
682
+ if (v.muted !== sound.muted) v.muted = sound.muted;
683
+ if (v.volume !== sound.volume) v.volume = sound.volume;
684
+ }
685
+ const level = sound.muted ? 0 : sound.volume;
686
+ const pct = Math.round(level * 100);
687
+ muteBtn.innerHTML = icon(level ? 'volume' : 'volumeOff');
688
+ const label = level ? `Volume ${pct}% (M mutes)` : 'Muted (M unmutes)';
689
+ muteBtn.setAttribute('aria-label', label); muteBtn.title = label;
690
+ volSlider.setAttribute('aria-valuenow', String(pct));
691
+ volSlider.setAttribute('aria-valuetext', level ? `${pct}%` : 'Muted');
692
+ volSlider.style.setProperty('--v', `${pct}%`);
693
+ volValue.textContent = level ? String(pct) : 'Off';
694
+ }
695
+ function setSound(patch) {
696
+ Object.assign(sound, patch);
697
+ // unmuting at zero volume brings the volume back up
698
+ if (patch.muted === false && !sound.volume) sound.volume = 1;
699
+ persist.save('muted', sound.muted); persist.save('volume', sound.volume);
700
+ applySound(app.current && app.current.type === 'video' ? app.current.source : null);
701
+ }
702
+ function pumpVideo(item) {
703
+ const v = item.source;
704
+ const tick = () => {
705
+ if (v.paused || app.current !== item) return;
706
+ schedule();
707
+ if ('requestVideoFrameCallback' in v) v.requestVideoFrameCallback(tick); else requestAnimationFrame(tick);
708
+ };
709
+ tick();
710
+ }
711
+ async function togglePlay() {
712
+ const it = app.current;
713
+ if (!it || it.type !== 'video') return;
714
+ if (storyboard.active) { storyboard.toggleAnimation(); return; }
715
+ if (!it.source.paused) { it.source.pause(); return; }
716
+ if (it.fpsReady) await it.fpsReady; // still measuring the frame rate (plays muted for a moment)
717
+ if (app.current !== it || storyboard.active) return;
718
+ applySound(it.source);
719
+ if (it.source.ended) it.source.currentTime = 0;
720
+ it.source.play().catch(e => toast(e.message, { kind: 'error' }));
721
+ }
722
+ function stepFrame(dir) {
723
+ const it = app.current;
724
+ if (!it || it.type !== 'video' || storyboard.active) return;
725
+ const v = it.source; v.pause();
726
+ const frame = Math.floor(v.currentTime * it.fps + 1e-6) + dir;
727
+ seekTo(v, (Math.max(0, frame) + 0.5) / it.fps).then(() => { updateTransport(); schedule(); });
728
+ }
729
+ scrub.addEventListener('input', () => {
730
+ const it = app.current; if (!it || it.type !== 'video') return;
731
+ it.source.currentTime = scrub.value / 1000 * it.source.duration;
732
+ });
733
+
734
+ // ================================================================ view
735
+ function toggleCompare(force) {
736
+ if (storyboard.active) return;
737
+ const on = force ?? !stage.compare;
738
+ stage.setCompare(on); compareBtn.setAttribute('aria-pressed', String(on));
739
+ }
740
+ function toggleGrid(force) {
741
+ if (storyboard.active) return;
742
+ const on = force ?? !stage.grid;
743
+ stage.setGrid(on); gridBtn.setAttribute('aria-pressed', String(on));
744
+ if (on && stage.zoom < 6 && app.result) toast('The grid shows from 6× zoom');
745
+ }
746
+ function zoomMenu(anchor) {
747
+ const view = storyboard.active ? storyboard : stage;
748
+ menu(anchor, [
749
+ { label: storyboard.active ? 'Fit canvas to view' : 'Fit to view', kbd: 'F', icon: 'fit', onClick: () => view.fit() },
750
+ 'sep',
751
+ ...[1, 2, 4, 8, 16, 32].map(z => ({ label: `${z}×`, sub: z === 1 && !storyboard.active ? 'One screen pixel per pixel' : null, onClick: () => view.zoomTo(z) })),
752
+ 'sep',
753
+ { label: 'Zoom in', kbd: '+', icon: 'zoomIn', onClick: () => view.zoomStep(1) },
754
+ { label: 'Zoom out', kbd: '-', icon: 'zoomOut', onClick: () => view.zoomStep(-1) },
755
+ ]);
756
+ }
757
+ function togglePanel(which) {
758
+ const key = which === 'lib' ? 'lib' : 'insp';
759
+ const open = shell.dataset[key] !== 'open';
760
+ shell.dataset[key] = open ? 'open' : 'closed';
761
+ if (!narrow() || which === 'insp') persist.save(key, shell.dataset[key]);
762
+ requestAnimationFrame(() => stage.resize());
763
+ }
764
+ function applyTheme(t) {
765
+ if (t) document.documentElement.dataset.theme = t; else delete document.documentElement.dataset.theme;
766
+ const dark = t ? t === 'dark' : !matchMedia('(prefers-color-scheme: light)').matches;
767
+ themeBtn.innerHTML = icon(dark ? 'sun' : 'moon');
768
+ themeBtn.setAttribute('aria-label', dark ? 'Switch to light theme (T)' : 'Switch to dark theme (T)');
769
+ stage.redraw();
770
+ }
771
+ function toggleTheme() {
772
+ const cur = document.documentElement.dataset.theme || (matchMedia('(prefers-color-scheme: light)').matches ? 'light' : 'dark');
773
+ const next = cur === 'dark' ? 'light' : 'dark';
774
+ persist.save('theme', next);
775
+ applyTheme(next);
776
+ }
777
+
778
+ // ================================================================ palettes
779
+ function pixelsOf(item, maxSize = 4096) {
780
+ const w = item.width, hh = item.height, k = Math.min(1, maxSize / Math.max(w, hh));
781
+ const c = document.createElement('canvas'); c.width = Math.round(w * k); c.height = Math.round(hh * k);
782
+ const ctx = c.getContext('2d', { willReadFrequently: true });
783
+ ctx.drawImage(item.source, 0, 0, c.width, c.height);
784
+ return ctx.getImageData(0, 0, c.width, c.height);
785
+ }
786
+ function pickAt(u, v) {
787
+ const it = app.current; if (!it) return;
788
+ const img = pixelsOf(it, 1024);
789
+ const c = app.result ? app.result.crop : { x: 0, y: 0, w: 1, h: 1 };
790
+ const x = Math.min(img.width - 1, Math.floor((c.x + u * c.w) * img.width)), y = Math.min(img.height - 1, Math.floor((c.y + v * c.h) * img.height));
791
+ const o = (y * img.width + x) * 4;
792
+ const hex = toHex([img.data[o], img.data[o + 1], img.data[o + 2]]);
793
+ const s = store.settings;
794
+ const colors = s.palette.source === 'custom' ? s.palette.colors : [];
795
+ if (colors.includes(hex)) return toast(`${hex} is already in the palette`);
796
+ store.update({ palette: { source: 'custom', name: s.palette.source === 'custom' ? s.palette.name : 'Picked', colors: [...colors, hex] } });
797
+ toast(`Added ${hex}`, { kind: 'ok', timeout: 1500 });
798
+ }
799
+ let pickerBtn = null;
800
+ function togglePicker(button) {
801
+ if (button) pickerBtn = button;
802
+ const on = !stage.picking;
803
+ stage.setPicking(on);
804
+ pickerBtn?.setAttribute('aria-pressed', String(on));
805
+ if (on) {
806
+ toggleCompare(true);
807
+ toast('Click the image to add colours. Press Esc when done.');
808
+ }
809
+ }
810
+ app.togglePicker = togglePicker;
811
+
812
+ function usePalette(p) {
813
+ if (p.id && !p.custom) store.update({ palette: { source: 'builtin', id: p.id } });
814
+ else store.update({ palette: { source: 'custom', name: p.name, colors: p.colors } });
815
+ }
816
+ app.usePalette = usePalette;
817
+
818
+ function rememberPalette(name, colors) {
819
+ const list = app.userPalettes.filter(p => p.name !== name);
820
+ list.unshift({ name, colors, custom: true });
821
+ app.userPalettes = list.slice(0, 100);
822
+ if (usePersist) persist.save('palettes', app.userPalettes);
823
+ if (library.tab === 'palettes') library.refresh();
824
+ }
825
+ async function importPaletteFile(file, text) {
826
+ try {
827
+ const p = parsePalette(text ?? await file.text(), file.name);
828
+ rememberPalette(p.name || 'Imported', p.colors);
829
+ store.update({ palette: { source: 'custom', name: p.name || 'Imported', colors: p.colors } });
830
+ toast(`Imported "${p.name}" (${p.colors.length} colours)`, { kind: 'ok' });
831
+ } catch (e) { toast(`${file.name}: ${e.message}`, { kind: 'error' }); }
832
+ }
833
+ app.importPalette = async () => {
834
+ const [f] = await pickFiles({ accept: '.gpl,.hex,.pal,.txt,.json' });
835
+ if (f) importPaletteFile(f);
836
+ };
837
+ app.savePalette = () => {
838
+ const s = store.settings;
839
+ const colors = s.palette.source === 'extract' ? (app.result?.palette || []) : s.palette.colors;
840
+ if (!colors.length) return toast('There is no palette to save', { kind: 'error' });
841
+ const input = h('input.itp-input', { value: s.palette.source === 'custom' ? s.palette.name : `${s.palette.name} copy`, 'aria-label': 'Palette name' });
842
+ const fmt = h('select.itp-select', { 'aria-label': 'Also download as' }, h('option', { value: '', text: "Don't download" }), h('option', { value: 'gpl', text: '.gpl (GIMP, Aseprite)' }), h('option', { value: 'hex', text: '.hex (Lospec)' }), h('option', { value: 'json', text: '.json' }));
843
+ dialog('Save palette', [h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Name' })), input), h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Also download as' })), fmt),
844
+ h('div.itp-strip', { style: { height: '18px' } }, ...colors.map(c => h('i', { style: { background: c } })))], {
845
+ actions: [{ label: 'Cancel' }, { label: 'Save', primary: true, onClick: () => {
846
+ const name = input.value.trim() || 'Palette';
847
+ rememberPalette(name, colors);
848
+ store.update({ palette: { source: 'custom', name, colors } });
849
+ const slug = name.replace(/[^\w-]+/g, '-').toLowerCase();
850
+ if (fmt.value === 'gpl') downloadBlob(toGPL(name, colors), `${slug}.gpl`, 'text/plain');
851
+ if (fmt.value === 'hex') downloadBlob(toHexFile(colors), `${slug}.hex`, 'text/plain');
852
+ if (fmt.value === 'json') downloadBlob(toPaletteJSON(name, colors), `${slug}.json`, 'application/json');
853
+ toast(`Saved "${name}" to Your palettes`, { kind: 'ok' });
854
+ } }],
855
+ });
856
+ input.select();
857
+ };
858
+ app.deletePalette = p => {
859
+ app.userPalettes = app.userPalettes.filter(x => x !== p);
860
+ if (usePersist) persist.save('palettes', app.userPalettes);
861
+ library.refresh();
862
+ toast(`Deleted "${p.name}"`, { action: { label: 'Undo', onClick: () => rememberPalette(p.name, p.colors) } });
863
+ };
864
+ app.lospecDialog = () => {
865
+ const input = h('input.itp-input', { placeholder: 'e.g. rgbg-36 or a lospec.com palette URL', 'aria-label': 'Lospec slug or URL' });
866
+ const note = h('div.help', { text: 'Looks the palette up on lospec.com. Needs an internet connection.' });
867
+ const d = dialog('Palette from Lospec', [h('div.itp-field', {}, h('div.lab', {}, h('span', { text: 'Slug or URL' })), input, note)], {
868
+ actions: [{ label: 'Cancel' }, { label: 'Fetch', primary: true, onClick: async () => {
869
+ note.textContent = 'Fetching…'; note.style.color = '';
870
+ try {
871
+ const p = await fetchLospec(input.value);
872
+ rememberPalette(p.name, p.colors);
873
+ store.update({ palette: { source: 'custom', name: p.name, colors: p.colors } });
874
+ toast(`Loaded "${p.name}" from Lospec`, { kind: 'ok' });
875
+ return true;
876
+ } catch (e) { note.textContent = e.message; note.style.color = 'var(--danger)'; return false; }
877
+ } }],
878
+ });
879
+ input.addEventListener('keydown', e => { if (e.key === 'Enter') d.querySelector('footer .primary').click(); });
880
+ input.focus();
881
+ };
882
+
883
+ app.detectGrid = () => {
884
+ const it = app.current; if (!it || it.type !== 'image') return toast('Open an image first', { kind: 'error' });
885
+ const b = detectGrid(pixelsOf(it));
886
+ if (b <= 1) return toast('No pixel grid found. This image doesn\'t look upscaled.');
887
+ store.update({ size: { mode: 'block', block: b, resample: 'nearest' } });
888
+ toast(`Found ${b} px blocks: switched to pixel size ${b} with nearest sampling`, { kind: 'ok' });
889
+ };
890
+
891
+ // ================================================================ settings
892
+ app.settingsJSON = () => settingsToJSON(store.settings);
893
+ app.copyLink = async () => {
894
+ const url = `${location.origin}${location.pathname}#s=${encodeSettings(store.settings)}`;
895
+ try { await navigator.clipboard.writeText(url); toast('Link copied. It holds the settings, not the image.', { kind: 'ok' }); }
896
+ catch { dialog('Share link', [h('textarea.itp-input', { rows: 3, readOnly: true, value: url })]); }
897
+ history.replaceState(null, '', `#s=${encodeSettings(store.settings)}`);
898
+ };
899
+ app.saveSettingsFile = () => downloadBlob(settingsToJSON(store.settings), 'image-to-pixel-settings.json', 'application/json');
900
+ app.loadSettingsFile = async () => {
901
+ const [f] = await pickFiles({ accept: '.json,application/json' });
902
+ if (!f) return;
903
+ try { store.replace(settingsFromJSON(await f.text())); toast(`Loaded ${f.name}`, { kind: 'ok' }); } catch (e) { toast(`${f.name}: ${e.message}`, { kind: 'error' }); }
904
+ };
905
+ app.resetSettings = () => { store.replace(defaultSettings()); toast('Settings reset', { action: { label: 'Undo', onClick: () => store.undo() } }); };
906
+ app.applyPreset = p => {
907
+ store.replace(mergeSettings(defaultSettings(), { ...p.settings, export: store.settings.export }), 'preset');
908
+ app.activePreset = p.id;
909
+ library.refresh();
910
+ };
911
+
912
+ Object.assign(app, exporter);
913
+
914
+ function exportMenu(anchor) {
915
+ const it = app.current;
916
+ const video = it && it.type === 'video';
917
+ const images = app.items.filter(i => i.type === 'image').length;
918
+ const s = store.settings;
919
+ const selecting = storyboard.active;
920
+ const offNote = selecting ? 'Turn off frame selection first' : null;
921
+ menu(anchor, [
922
+ { group: 'Image' },
923
+ { label: 'PNG', sub: offNote || (app.result ? `${app.result.width * s.export.scale} × ${app.result.height * s.export.scale}, ${s.export.scale}× scale` : 'Open an image first'), icon: 'image', kbd: 'Ctrl S', disabled: !app.result || selecting, onClick: () => app.exportPNG() },
924
+ { label: 'Copy to clipboard', sub: offNote, icon: 'copy', disabled: !app.result || selecting, onClick: () => app.copyImage() },
925
+ { label: app.items.length > 1 ? `All ${app.items.length} files as ZIP` : 'All files as ZIP', sub: app.items.length > 1 ? null : 'Add two or more files', icon: 'zip', disabled: app.items.length < 2, onClick: () => app.exportZip() },
926
+ { group: 'Animation' },
927
+ { label: video ? 'GIF from this video' : 'GIF from all images', icon: 'gif', disabled: !video && images < 2, onClick: () => app.exportAnimation({ ...animDefaults(), format: 'gif' }) },
928
+ { label: 'More formats…', sub: 'WebM, MP4, PNG sequence, sprite sheet', icon: 'film', onClick: () => { inspector.setTab('export'); if (shell.dataset.insp !== 'open') togglePanel('insp'); } },
929
+ { group: 'Other' },
930
+ { label: 'Tileset', sub: offNote || '16 × 16 tiles, duplicates removed', icon: 'tiles', disabled: !app.result || selecting, onClick: () => app.exportTileset({ tile: 16, flips: false }) },
931
+ { label: 'Copy settings link', icon: 'link', onClick: () => app.copyLink() },
932
+ { label: 'Settings as JSON', icon: 'braces', onClick: () => app.saveSettingsFile() },
933
+ ]);
934
+ }
935
+ const animDefaults = () => {
936
+ const it = app.current;
937
+ const video = it && it.type === 'video';
938
+ return { source: video ? 'video' : 'files', fps: video ? Math.min(30, Math.round(it.fps || 12)) : 8, start: 0, end: video ? it.duration : 0 };
939
+ };
940
+
941
+ // ================================================================ help
942
+ function showHelp() {
943
+ const keys = [
944
+ ['Open files', 'Ctrl O'], ['Paste image', 'Ctrl V'], ['Export PNG', 'Ctrl S'], ['Undo', 'Ctrl Z'], ['Redo', 'Ctrl Shift Z'],
945
+ ['Compare', 'C'], ['Pixel grid', 'G'], ['Fit', 'F'], ['Zoom in / out', '+ -'], ['Pixel width ±1', '[ ]'], ['Pixel width ±8', 'Shift [ ]'],
946
+ ['Next / previous dither', 'D Shift D'], ['Play / pause video', 'Space'], ['Mute / unmute video', 'M'], ['Rename file', 'F2'], ['Step one frame', ', .'], ['Library', '\\'], ['Inspector', '/'],
947
+ ['Theme', 'T'], ['Next / previous file', 'Alt ↓ ↑'], ['Move file down / up', 'Alt Shift ↓ ↑'], ['This list', '?'],
948
+ ];
949
+ dialog('Keyboard shortcuts', [h('div.itp-keys', {}, ...keys.map(([a, k]) => h('div', {}, h('span', { text: a }), h('span', {}, ...k.split(' ').filter(Boolean).map(x => h('kbd', { text: x }))))))]);
950
+ }
951
+
952
+ // ================================================================ input
953
+ const typing = e => e.target.closest('input, select, textarea, [contenteditable="true"]') && !(e.target.type === 'range' || e.target.type === 'checkbox');
954
+ const DITHER_ORDER = ['none', 'bayer4', 'bayer8', 'bluenoise', 'cluster4', 'floyd-steinberg', 'atkinson', 'sierra-lite', 'jarvis'];
955
+ window.addEventListener('keydown', e => {
956
+ if (!shell.isConnected) return;
957
+ if (document.querySelector('dialog[open]')) return;
958
+ const mod = e.ctrlKey || e.metaKey;
959
+ const k = e.key;
960
+ if (mod && k.toLowerCase() === 'z') { if (typing(e)) return; e.preventDefault(); e.shiftKey ? store.redo() : store.undo(); return; }
961
+ if (mod && k.toLowerCase() === 'y') { if (typing(e)) return; e.preventDefault(); store.redo(); return; }
962
+ if (mod && k.toLowerCase() === 's') { e.preventDefault(); app.exportPNG(); return; }
963
+ if (mod && k.toLowerCase() === 'o') { e.preventDefault(); openFiles(); return; }
964
+ if (mod || e.altKey && !['ArrowUp', 'ArrowDown'].includes(k)) return;
965
+ if (typing(e)) { if (k === 'Escape') e.target.blur(); return; }
966
+ const s = store.settings;
967
+ switch (k) {
968
+ case 'c': case 'C': toggleCompare(); break;
969
+ case 'g': case 'G': toggleGrid(); break;
970
+ case 'f': case 'F': if (storyboard.active) storyboard.fit(); else stage.fit(); break;
971
+ case '+': case '=': if (storyboard.active) storyboard.zoomStep(1); else stage.zoomIn(); break;
972
+ case '-': case '_': if (storyboard.active) storyboard.zoomStep(-1); else stage.zoomOut(); break;
973
+ case 't': case 'T': toggleTheme(); break;
974
+ case '\\': togglePanel('lib'); break;
975
+ case '/': togglePanel('insp'); break;
976
+ case '?': showHelp(); break;
977
+ case '[': case '{': store.update({ size: { mode: 'width', width: Math.max(8, s.size.width - (e.shiftKey ? 8 : 1)) } }, { key: 'kw' }); break;
978
+ case ']': case '}': store.update({ size: { mode: 'width', width: s.size.width + (e.shiftKey ? 8 : 1) } }, { key: 'kw' }); break;
979
+ case 'd': case 'D': {
980
+ const i = DITHER_ORDER.indexOf(s.dither.method);
981
+ const next = DITHER_ORDER[(i + (e.shiftKey ? -1 : 1) + DITHER_ORDER.length) % DITHER_ORDER.length];
982
+ store.update({ dither: { method: next } });
983
+ toast(next === 'none' ? 'No dither' : next.replace(/-/g, ' '), { timeout: 900 });
984
+ break;
985
+ }
986
+ case ' ': if (e.target.closest('button')) break; if (app.current?.type === 'video') { e.preventDefault(); togglePlay(); } break;
987
+ case 'F2': if (app.current) { e.preventDefault(); startRename(app.current); } break;
988
+ case 'm': case 'M': if (app.current?.type === 'video' && !soundBox.hidden) { e.preventDefault(); setSound({ muted: !sound.muted }); } break;
989
+ case ',': stepFrame(-1); break;
990
+ case '.': stepFrame(1); break;
991
+ case 'Escape': if (stage.picking) togglePicker(); else closeMenu(); break;
992
+ case 'ArrowDown': case 'ArrowUp':
993
+ if (e.altKey && e.shiftKey && app.current) { e.preventDefault(); app.move(app.current, k === 'ArrowDown' ? 1 : -1); break; }
994
+ if (e.altKey && app.items.length > 1) {
995
+ e.preventDefault();
996
+ const i = app.items.indexOf(app.current);
997
+ select(app.items[(i + (k === 'ArrowDown' ? 1 : -1) + app.items.length) % app.items.length]);
998
+ }
999
+ break;
1000
+ default: return;
1001
+ }
1002
+ });
1003
+
1004
+ // drag & drop, paste
1005
+ let dragDepth = 0;
1006
+ shell.addEventListener('dragenter', e => { if ([...e.dataTransfer.types].includes('Files')) { dragDepth++; shell.classList.add('itp-dragging'); } });
1007
+ shell.addEventListener('dragleave', () => { if (--dragDepth <= 0) { dragDepth = 0; shell.classList.remove('itp-dragging'); } });
1008
+ shell.addEventListener('dragover', e => { if ([...e.dataTransfer.types].includes('Files')) e.preventDefault(); });
1009
+ shell.addEventListener('drop', e => {
1010
+ e.preventDefault(); dragDepth = 0; shell.classList.remove('itp-dragging');
1011
+ if (e.dataTransfer.files.length) addFiles([...e.dataTransfer.files]);
1012
+ });
1013
+ window.addEventListener('paste', e => {
1014
+ if (!shell.isConnected || typing(e)) return;
1015
+ const files = [...(e.clipboardData?.files || [])];
1016
+ if (files.length) { e.preventDefault(); addFiles(files); return; }
1017
+ const text = e.clipboardData?.getData('text') || '';
1018
+ if (/#?[0-9a-f]{6}/i.test(text) && /\n|,|\s/.test(text.trim())) {
1019
+ try { const p = parsePalette(text); store.update({ palette: { source: 'custom', name: 'Pasted', colors: p.colors } }); toast(`Pasted a ${p.colors.length}-colour palette`, { kind: 'ok' }); } catch { /* not a palette */ }
1020
+ }
1021
+ });
1022
+ window.addEventListener('hashchange', () => {
1023
+ if (/[#&]s=/.test(location.hash)) { try { store.replace(decodeSettings(location.hash)); } catch { /* ignore */ } }
1024
+ });
1025
+
1026
+ // first paint
1027
+ inspector.sync(store.settings, null);
1028
+ library.refresh();
1029
+ updateStatus();
1030
+ undoBtn.disabled = redoBtn.disabled = true;
1031
+ if (usePersist && options.restore !== false) restoreSaved().catch(e => console.warn('[workspace]', e));
1032
+
1033
+ const api = {
1034
+ app, store,
1035
+ /** Add File objects (images, videos, palettes, settings JSON). */
1036
+ addFiles,
1037
+ /** Add an image-like source directly. */
1038
+ async addImage(source, name = 'image') { const it = await addImage(name, source); select(it); library.refresh(); return it; },
1039
+ openSample: id => openSample(SAMPLES.find(s => s.id === id) || SAMPLES[0]),
1040
+ applyPreset: id => { const p = PRESETS.find(x => x.id === id); if (p) app.applyPreset(p); },
1041
+ setTheme: t => { persist.save('theme', t); applyTheme(t); },
1042
+ get result() { return app.result; },
1043
+ /** On-screen rectangle of the image in the canvas. */
1044
+ get view() { return stage.rect; },
1045
+ /** Resolves after pending renders finish. */
1046
+ async idle() { for (let i = 0; i < 200 && (dirty || running); i++) await new Promise(r => setTimeout(r, 25)); },
1047
+ };
1048
+ return api;
1049
+ }
1050
+
1051
+ export { SAMPLES };