image-to-pixel 3.0.1 → 3.1.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,182 +1,188 @@
1
- // Left panel: Files (the batch), Presets (live thumbnails) and Palettes.
2
-
3
- import { h, render } from './dom.js';
4
- import { menu } from './ui.js';
5
- import { icon } from './icons.js';
6
- import { PRESETS } from '../core/presets.js';
7
- import { PALETTES } from '../core/palette/builtin.js';
8
- import { mergeSettings, defaultSettings } from '../core/settings.js';
9
-
10
- export function createLibrary(app) {
11
- let tab = 'files';
12
- const tabBtn = (id, label, ic) => h('button', { type: 'button', role: 'tab', 'aria-selected': String(id === tab), html: `${icon(ic)}<span>${label}</span>`, onclick: () => setTab(id) });
13
- const tFiles = tabBtn('files', 'Files', 'files');
14
- const tPresets = tabBtn('presets', 'Presets', 'presets');
15
- const tPalettes = tabBtn('palettes', 'Palettes', 'palette');
16
- const filesCount = h('span.count', {});
17
- tFiles.append(filesCount);
18
- const tabs = h('div.itp-seg', { role: 'tablist', 'aria-label': 'Library' }, tFiles, tPresets, tPalettes);
19
-
20
- const search = h('input', { type: 'search', placeholder: 'Search', 'aria-label': 'Search the library' });
21
- const searchBox = h('div.itp-search', {}, h('span', { html: icon('search') }), search);
22
- search.addEventListener('input', () => renderTab());
23
-
24
- const body = h('div.itp-panel-body', {});
25
- const el = h('aside.itp-lib', { 'aria-label': 'Library' }, h('div.itp-panel-head', {}, tabs), searchBox, body);
26
-
27
- function setTab(t) {
28
- tab = t;
29
- for (const [b, id] of [[tFiles, 'files'], [tPresets, 'presets'], [tPalettes, 'palettes']]) b.setAttribute('aria-selected', String(id === t));
30
- search.value = '';
31
- renderTab();
32
- if (t === 'presets' && !thumbs.size) refreshThumbs();
33
- }
34
- const q = () => search.value.trim().toLowerCase();
35
-
36
- // ---- files
37
- function fileMenu(it, anchor) {
38
- const i = app.items.indexOf(it), many = app.items.length > 1;
39
- menu(anchor, [
40
- it.own
41
- ? { label: 'Use shared settings', sub: 'Drop this file\'s own settings', icon: 'link', onClick: () => app.setOwn(it, false) }
42
- : { label: 'Give it its own settings', sub: 'Edits stop affecting other files', icon: 'unlink', disabled: !many, onClick: () => app.setOwn(it, true) },
43
- 'sep',
44
- { label: 'Copy settings', icon: 'copy', onClick: () => app.copySettings(it) },
45
- { label: 'Paste settings', sub: app.clipboard ? 'Gives this file its own settings' : 'Copy settings from a file first', icon: 'clipboard', disabled: !app.clipboard, onClick: () => app.pasteSettings(it) },
46
- { label: 'Apply these settings to all files', icon: 'layers', disabled: !many, onClick: () => app.applyToAll(it) },
47
- 'sep',
48
- { label: 'Duplicate', sub: 'A copy with its own settings, to compare looks', icon: 'copy', onClick: () => app.duplicate(it) },
49
- { label: 'Move up', icon: 'arrowUp', kbd: 'Alt Shift ↑', disabled: i <= 0, onClick: () => app.move(it, -1) },
50
- { label: 'Move down', icon: 'arrowDown', kbd: 'Alt Shift ↓', disabled: i >= app.items.length - 1, onClick: () => app.move(it, 1) },
51
- 'sep',
52
- { label: 'Remove', icon: 'trash', kbd: 'Del', onClick: () => app.removeItem(it) },
53
- ], { align: 'start' });
54
- }
55
-
56
- function renderFiles() {
57
- const items = app.items.filter(i => !q() || i.name.toLowerCase().includes(q()));
58
- const list = h('ul.itp-files', { 'aria-label': 'Files' });
59
- for (const it of items) {
60
- const more = h('button.itp-btn.small.icon', { type: 'button', 'aria-label': `Options for ${it.name}`, 'aria-haspopup': 'menu', title: 'Settings, duplicate, order', html: icon('more'), onclick: e => { e.stopPropagation(); fileMenu(it, more); } });
61
- const sub = it.type === 'video' ? `Video, ${fmtTime(it.duration)}` : `${it.width} × ${it.height}`;
62
- const row = h('li.itp-file-row', { tabindex: '0', 'aria-current': String(app.current === it), 'data-own': it.own ? 'true' : null },
63
- h('img.thumb', { src: it.thumb || '', alt: '' }),
64
- h('div', { style: { minWidth: 0 } },
65
- h('div.title', { text: it.name }),
66
- h('div.sub', {}, sub, it.own ? h('span.itp-own', { title: 'This file has its own settings', html: `${icon('unlink')}<span>Own</span>` }) : null)),
67
- h('div.actions', {}, more,
68
- h('button.itp-btn.small.icon.danger', { type: 'button', 'aria-label': `Remove ${it.name}`, title: 'Remove', html: icon('x'), onclick: e => { e.stopPropagation(); app.removeItem(it); } })));
69
- row.addEventListener('click', () => app.select(it));
70
- row.addEventListener('contextmenu', e => { e.preventDefault(); app.select(it); fileMenu(it, more); });
71
- row.addEventListener('keydown', e => {
72
- if (e.target !== row) return;
73
- if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); app.select(it); }
74
- if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); app.removeItem(it); }
75
- if (e.altKey) return; // Alt+arrows are global shortcuts (next file, move file)
76
- if (e.key === 'ArrowDown') { e.preventDefault(); row.nextElementSibling?.focus(); }
77
- if (e.key === 'ArrowUp') { e.preventDefault(); row.previousElementSibling?.focus(); }
78
- if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) { e.preventDefault(); fileMenu(it, more); }
79
- });
80
- list.append(row);
81
- }
82
- const nOwn = app.items.filter(i => i.own).length;
83
- const summary = !app.items.length ? 'No files yet'
84
- : app.items.length === 1 ? '1 file'
85
- : nOwn ? `${app.items.length} files, ${nOwn} with ${nOwn === 1 ? 'its' : 'their'} own settings`
86
- : `${app.items.length} files, shared settings`;
87
- const head = h('div.itp-group', {}, h('span', { text: summary }),
88
- app.items.length > 1 ? h('button.itp-link', { type: 'button', text: 'Clear', onclick: () => app.clearItems() }) : null);
89
- const add = h('div', { style: { padding: '4px 12px 12px', display: 'flex', gap: '6px' } },
90
- h('button.itp-btn.outline.small', { type: 'button', html: `${icon('plus')}<span>Add files</span>`, onclick: () => app.openFiles() }),
91
- h('button.itp-btn.small', { type: 'button', html: `${icon('clipboard')}<span>Paste</span>`, title: 'Paste an image from the clipboard (Ctrl+V)', onclick: () => app.pasteFromClipboard() }));
92
- render(body, head, list, add, h('div.itp-group', {}, h('span', { text: 'Drop images or videos anywhere in the window. Files share one set of settings unless you give one its own (⋯ menu or right-click).' })));
93
- }
94
-
95
- // ---- presets (thumbnails rendered from the current image)
96
- const thumbs = new Map(); // preset id → canvas
97
- let thumbGen = 0;
98
- function renderPresets() {
99
- const cur = app.store.settings;
100
- const list = PRESETS.filter(p => !q() || (p.name + p.description).toLowerCase().includes(q()));
101
- const grid = h('div.itp-presets', {});
102
- for (const p of list) {
103
- const active = app.activePreset === p.id;
104
- const pic = thumbs.get(p.id) || h('div.ph', {});
105
- const b = h('button.itp-preset', { type: 'button', 'aria-pressed': String(active), title: p.description, onclick: () => app.applyPreset(p) },
106
- pic, h('span.t', { text: p.name }), h('span.d', { text: p.description }));
107
- grid.append(b);
108
- }
109
- render(body, h('div.itp-group', {}, h('span', { text: 'Presets replace the current settings. Undo with Ctrl+Z.' })), grid);
110
- void cur;
111
- }
112
- async function refreshThumbs() {
113
- const gen = ++thumbGen;
114
- const item = app.current;
115
- if (!item || !app.engine) return;
116
- for (const p of PRESETS) {
117
- if (gen !== thumbGen) return;
118
- const s = mergeSettings(defaultSettings(), p.settings);
119
- // small, fast previews: cap the width and skip slow-on-CPU diffusion sizes
120
- s.size = { ...s.size, mode: 'width', width: Math.min(s.size.width, 72), height: 0 };
121
- try {
122
- const r = await app.engine.process(item.source, s);
123
- if (gen !== thumbGen) return;
124
- const c = r.toCanvas(1);
125
- thumbs.set(p.id, c);
126
- if (tab === 'presets') renderPresets();
127
- } catch { /* ignore a failed thumbnail */ }
128
- }
129
- }
130
-
131
- // ---- palettes
132
- function palRow(p, active, removable) {
133
- const strip = h('div.itp-strip', {}, ...p.colors.slice(0, 64).map(c => h('i', { style: { background: c.startsWith('#') ? c : '#' + c } })));
134
- const row = h('li.itp-pal', { tabindex: '0', role: 'button', 'aria-pressed': String(active) },
135
- h('div.row', {}, h('span', { text: p.name }), h('span.n', { text: String(p.colors.length), title: p.author ? `by ${p.author}` : null })), strip);
136
- const pick = () => app.usePalette(p);
137
- row.addEventListener('click', pick);
138
- row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } if (removable && (e.key === 'Delete')) app.deletePalette(p); });
139
- if (removable) {
140
- const del = h('button.itp-btn.small.icon.danger', { type: 'button', 'aria-label': `Delete ${p.name}`, title: 'Delete', html: icon('trash'), onclick: e => { e.stopPropagation(); app.deletePalette(p); } });
141
- row.firstChild.append(del);
142
- }
143
- return row;
144
- }
145
- function renderPalettes() {
146
- const s = app.store.settings.palette;
147
- const match = p => !q() || (p.name + ' ' + (p.author || '')).toLowerCase().includes(q()) || String(p.colors.length) === q();
148
- const mine = app.userPalettes.filter(match);
149
- const built = PALETTES.filter(match);
150
- const actions = h('div', { style: { padding: '0 12px 4px', display: 'flex', gap: '6px', flexWrap: 'wrap' } },
151
- h('button.itp-btn.outline.small', { type: 'button', html: `${icon('upload')}<span>Import file</span>`, onclick: () => app.importPalette() }),
152
- h('button.itp-btn.small', { type: 'button', html: `${icon('palette')}<span>From Lospec</span>`, onclick: () => app.lospecDialog() }));
153
- const yours = mine.length ? [h('div.itp-group', {}, h('span', { text: 'Your palettes' }), h('span.itp-badge', { text: String(mine.length) })),
154
- h('ul.itp-pal-list', {}, ...mine.map(p => palRow(p, s.source === 'custom' && s.name === p.name, true)))] : [];
155
- render(body, actions, ...yours,
156
- h('div.itp-group', {}, h('span', { text: 'Built-in' }), h('span.itp-badge', { text: String(built.length) })),
157
- h('ul.itp-pal-list', {}, ...built.map(p => palRow({ ...p, colors: p.colors.map(c => '#' + c) }, s.source === 'builtin' && s.id === p.id, false))));
158
- }
159
-
160
- function renderTab() {
161
- filesCount.textContent = app.items.length ? String(app.items.length) : '';
162
- if (tab === 'files') renderFiles();
163
- else if (tab === 'presets') renderPresets();
164
- else renderPalettes();
165
- }
166
-
167
- return {
168
- el,
169
- refresh: renderTab,
170
- setTab,
171
- /** Focus a file's row (after it moved). */
172
- focusFile(item) { if (tab === 'files') body.querySelectorAll('.itp-file-row')[app.items.indexOf(item)]?.focus(); },
173
- /** The current image changed: rebuild preset thumbnails. */
174
- sourceChanged() { thumbs.clear(); thumbGen++; if (tab === 'presets') { renderTab(); refreshThumbs(); } },
175
- get tab() { return tab; },
176
- };
177
- }
178
-
179
- export function fmtTime(t = 0) {
180
- const m = Math.floor(t / 60), s = t - m * 60;
181
- return `${m}:${s.toFixed(1).padStart(4, '0')}`;
182
- }
1
+ // Left panel: Files (the batch), Presets (live thumbnails) and Palettes.
2
+
3
+ import { h, render } from './dom.js';
4
+ import { menu } from './ui.js';
5
+ import { icon } from './icons.js';
6
+ import { PRESETS } from '../core/presets.js';
7
+ import { PALETTES } from '../core/palette/builtin.js';
8
+ import { mergeSettings, defaultSettings } from '../core/settings.js';
9
+
10
+ export function createLibrary(app) {
11
+ let tab = 'files';
12
+ const tabBtn = (id, label, ic) => h('button', { type: 'button', role: 'tab', title: label, 'aria-selected': String(id === tab), html: `${icon(ic)}<span>${label}</span>`, onclick: () => setTab(id) });
13
+ const tFiles = tabBtn('files', 'Files', 'files');
14
+ const tPresets = tabBtn('presets', 'Presets', 'presets');
15
+ const tPalettes = tabBtn('palettes', 'Palettes', 'palette');
16
+ const filesCount = h('span.count', {});
17
+ tFiles.append(filesCount);
18
+ const tabs = h('div.itp-seg', { role: 'tablist', 'aria-label': 'Library' }, tFiles, tPresets, tPalettes);
19
+
20
+ const search = h('input', { type: 'search', placeholder: 'Search', 'aria-label': 'Search the library' });
21
+ const searchBox = h('div.itp-search', {}, h('span', { html: icon('search') }), search);
22
+ search.addEventListener('input', () => renderTab());
23
+
24
+ const body = h('div.itp-panel-body', {});
25
+ const el = h('aside.itp-lib', { 'aria-label': 'Library' }, h('div.itp-panel-head', {}, tabs), searchBox, body);
26
+
27
+ function setTab(t) {
28
+ tab = t;
29
+ for (const [b, id] of [[tFiles, 'files'], [tPresets, 'presets'], [tPalettes, 'palettes']]) b.setAttribute('aria-selected', String(id === t));
30
+ search.value = '';
31
+ renderTab();
32
+ if (t === 'presets' && !thumbs.size) refreshThumbs();
33
+ }
34
+ const q = () => search.value.trim().toLowerCase();
35
+
36
+ // ---- files
37
+ function fileMenu(it, anchor) {
38
+ const i = app.items.indexOf(it), many = app.items.length > 1;
39
+ menu(anchor, [
40
+ it.own
41
+ ? { label: 'Use shared settings', sub: 'Drop this file\'s own settings', icon: 'link', onClick: () => app.setOwn(it, false) }
42
+ : { label: 'Give it its own settings', sub: 'Edits stop affecting other files', icon: 'unlink', disabled: !many, onClick: () => app.setOwn(it, true) },
43
+ 'sep',
44
+ { label: 'Copy settings', icon: 'copy', onClick: () => app.copySettings(it) },
45
+ { label: 'Paste settings', sub: app.clipboard ? 'Gives this file its own settings' : 'Copy settings from a file first', icon: 'clipboard', disabled: !app.clipboard, onClick: () => app.pasteSettings(it) },
46
+ { label: 'Apply these settings to all files', icon: 'layers', disabled: !many, onClick: () => app.applyToAll(it) },
47
+ 'sep',
48
+ { label: 'Rename', sub: 'Exports are named after it', icon: 'rename', kbd: 'F2', onClick: () => app.startRename(it) },
49
+ { label: 'Duplicate', sub: 'A copy with its own settings, to compare looks', icon: 'copy', onClick: () => app.duplicate(it) },
50
+ { label: 'Move up', icon: 'arrowUp', kbd: 'Alt Shift ↑', disabled: i <= 0, onClick: () => app.move(it, -1) },
51
+ { label: 'Move down', icon: 'arrowDown', kbd: 'Alt Shift ↓', disabled: i >= app.items.length - 1, onClick: () => app.move(it, 1) },
52
+ 'sep',
53
+ { label: 'Remove', icon: 'trash', kbd: 'Del', onClick: () => app.removeItem(it) },
54
+ ], { align: 'start' });
55
+ }
56
+
57
+ function renderFiles() {
58
+ const items = app.items.filter(i => !q() || i.name.toLowerCase().includes(q()));
59
+ const list = h('ul.itp-files', { 'aria-label': 'Files' });
60
+ for (const it of items) {
61
+ const more = h('button.itp-btn.small.icon', { type: 'button', 'aria-label': `Options for ${it.name}`, 'aria-haspopup': 'menu', title: 'Settings, duplicate, order', html: icon('more'), onclick: e => { e.stopPropagation(); fileMenu(it, more); } });
62
+ const sub = it.type === 'video' ? `Video, ${fmtTime(it.duration)}` : `${it.width} × ${it.height}`;
63
+ const row = h('li.itp-file-row', { tabindex: '0', 'aria-current': String(app.current === it), 'data-own': it.own ? 'true' : null },
64
+ h('img.thumb', { src: it.thumb || '', alt: '' }),
65
+ h('div', { style: { minWidth: 0 } },
66
+ h('div.title', { text: it.name }),
67
+ h('div.sub', {}, sub, it.own ? h('span.itp-own', { title: 'This file has its own settings', html: `${icon('unlink')}<span>Own</span>` }) : null)),
68
+ h('div.actions', {}, more,
69
+ h('button.itp-btn.small.icon.danger', { type: 'button', 'aria-label': `Remove ${it.name}`, title: 'Remove', html: icon('x'), onclick: e => { e.stopPropagation(); app.removeItem(it); } })));
70
+ row.addEventListener('click', () => app.select(it));
71
+ row.addEventListener('dblclick', e => { if (e.target.closest('.title')) app.startRename(it); });
72
+ row.addEventListener('contextmenu', e => { e.preventDefault(); app.select(it); fileMenu(it, more); });
73
+ row.addEventListener('keydown', e => {
74
+ if (e.target !== row) return;
75
+ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); app.select(it); }
76
+ if (e.key === 'Delete' || e.key === 'Backspace') { e.preventDefault(); app.removeItem(it); }
77
+ if (e.altKey) return; // Alt+arrows are global shortcuts (next file, move file)
78
+ if (e.key === 'ArrowDown') { e.preventDefault(); row.nextElementSibling?.focus(); }
79
+ if (e.key === 'ArrowUp') { e.preventDefault(); row.previousElementSibling?.focus(); }
80
+ if (e.key === 'ContextMenu' || (e.key === 'F10' && e.shiftKey)) { e.preventDefault(); fileMenu(it, more); }
81
+ });
82
+ list.append(row);
83
+ }
84
+ const nOwn = app.items.filter(i => i.own).length;
85
+ const summary = !app.items.length ? 'No files yet'
86
+ : app.items.length === 1 ? '1 file'
87
+ : nOwn ? `${app.items.length} files, ${nOwn} with ${nOwn === 1 ? 'its' : 'their'} own settings`
88
+ : `${app.items.length} files, shared settings`;
89
+ const head = h('div.itp-group', {}, h('span', { text: summary }),
90
+ app.items.length > 1 ? h('button.itp-link', { type: 'button', text: 'Clear', onclick: () => app.clearItems() }) : null);
91
+ const add = h('div', { style: { padding: '4px 12px 12px', display: 'flex', gap: '6px' } },
92
+ h('button.itp-btn.outline.small', { type: 'button', html: `${icon('plus')}<span>Add files</span>`, onclick: () => app.openFiles() }),
93
+ h('button.itp-btn.small', { type: 'button', html: `${icon('clipboard')}<span>Paste</span>`, title: 'Paste an image from the clipboard (Ctrl+V)', onclick: () => app.pasteFromClipboard() }));
94
+ const saved = app.savedWorkspace && app.savedWorkspace();
95
+ const savedNote = saved ? h('div.itp-group.itp-saved', {},
96
+ h('span', { text: `Saved in this browser: ${saved.count} file${saved.count === 1 ? '' : 's'}`, title: 'Kept in this browser\'s storage, never uploaded. Clearing the site\'s data also removes it.' }),
97
+ h('button.itp-link', { type: 'button', text: 'Forget', title: 'Remove the saved files from this browser', onclick: () => app.forgetWorkspace() })) : null;
98
+ render(body, head, list, add, savedNote, h('div.itp-group', {}, h('span', { text: 'Drop images or videos anywhere in the window. Files share one set of settings unless you give one its own (⋯ menu or right-click). Save keeps them in this browser for next time.' })));
99
+ }
100
+
101
+ // ---- presets (thumbnails rendered from the current image)
102
+ const thumbs = new Map(); // preset id → canvas
103
+ let thumbGen = 0;
104
+ function renderPresets() {
105
+ const cur = app.store.settings;
106
+ const list = PRESETS.filter(p => !q() || (p.name + p.description).toLowerCase().includes(q()));
107
+ const grid = h('div.itp-presets', {});
108
+ for (const p of list) {
109
+ const active = app.activePreset === p.id;
110
+ const pic = thumbs.get(p.id) || h('div.ph', {});
111
+ const b = h('button.itp-preset', { type: 'button', 'aria-pressed': String(active), title: p.description, onclick: () => app.applyPreset(p) },
112
+ pic, h('span.t', { text: p.name }), h('span.d', { text: p.description }));
113
+ grid.append(b);
114
+ }
115
+ render(body, h('div.itp-group', {}, h('span', { text: 'Presets replace the current settings. Undo with Ctrl+Z.' })), grid);
116
+ void cur;
117
+ }
118
+ async function refreshThumbs() {
119
+ const gen = ++thumbGen;
120
+ const item = app.current;
121
+ if (!item || !app.engine) return;
122
+ for (const p of PRESETS) {
123
+ if (gen !== thumbGen) return;
124
+ const s = mergeSettings(defaultSettings(), p.settings);
125
+ // small, fast previews: cap the width and skip slow-on-CPU diffusion sizes
126
+ s.size = { ...s.size, mode: 'width', width: Math.min(s.size.width, 72), height: 0 };
127
+ try {
128
+ const r = await app.engine.process(item.source, s);
129
+ if (gen !== thumbGen) return;
130
+ const c = r.toCanvas(1);
131
+ thumbs.set(p.id, c);
132
+ if (tab === 'presets') renderPresets();
133
+ } catch { /* ignore a failed thumbnail */ }
134
+ }
135
+ }
136
+
137
+ // ---- palettes
138
+ function palRow(p, active, removable) {
139
+ const strip = h('div.itp-strip', {}, ...p.colors.slice(0, 64).map(c => h('i', { style: { background: c.startsWith('#') ? c : '#' + c } })));
140
+ const row = h('li.itp-pal', { tabindex: '0', role: 'button', 'aria-pressed': String(active) },
141
+ h('div.row', {}, h('span', { text: p.name }), h('span.n', { text: String(p.colors.length), title: p.author ? `by ${p.author}` : null })), strip);
142
+ const pick = () => app.usePalette(p);
143
+ row.addEventListener('click', pick);
144
+ row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); pick(); } if (removable && (e.key === 'Delete')) app.deletePalette(p); });
145
+ if (removable) {
146
+ const del = h('button.itp-btn.small.icon.danger', { type: 'button', 'aria-label': `Delete ${p.name}`, title: 'Delete', html: icon('trash'), onclick: e => { e.stopPropagation(); app.deletePalette(p); } });
147
+ row.firstChild.append(del);
148
+ }
149
+ return row;
150
+ }
151
+ function renderPalettes() {
152
+ const s = app.store.settings.palette;
153
+ const match = p => !q() || (p.name + ' ' + (p.author || '')).toLowerCase().includes(q()) || String(p.colors.length) === q();
154
+ const mine = app.userPalettes.filter(match);
155
+ const built = PALETTES.filter(match);
156
+ const actions = h('div', { style: { padding: '0 12px 4px', display: 'flex', gap: '6px', flexWrap: 'wrap' } },
157
+ h('button.itp-btn.outline.small', { type: 'button', html: `${icon('upload')}<span>Import file</span>`, onclick: () => app.importPalette() }),
158
+ h('button.itp-btn.small', { type: 'button', html: `${icon('palette')}<span>From Lospec</span>`, onclick: () => app.lospecDialog() }));
159
+ const yours = mine.length ? [h('div.itp-group', {}, h('span', { text: 'Your palettes' }), h('span.itp-badge', { text: String(mine.length) })),
160
+ h('ul.itp-pal-list', {}, ...mine.map(p => palRow(p, s.source === 'custom' && s.name === p.name, true)))] : [];
161
+ render(body, actions, ...yours,
162
+ h('div.itp-group', {}, h('span', { text: 'Built-in' }), h('span.itp-badge', { text: String(built.length) })),
163
+ h('ul.itp-pal-list', {}, ...built.map(p => palRow({ ...p, colors: p.colors.map(c => '#' + c) }, s.source === 'builtin' && s.id === p.id, false))));
164
+ }
165
+
166
+ function renderTab() {
167
+ filesCount.textContent = app.items.length ? String(app.items.length) : '';
168
+ if (tab === 'files') renderFiles();
169
+ else if (tab === 'presets') renderPresets();
170
+ else renderPalettes();
171
+ }
172
+
173
+ return {
174
+ el,
175
+ refresh: renderTab,
176
+ setTab,
177
+ /** Focus a file's row (after it moved). */
178
+ focusFile(item) { if (tab === 'files') body.querySelectorAll('.itp-file-row')[app.items.indexOf(item)]?.focus(); },
179
+ /** The current image changed: rebuild preset thumbnails. */
180
+ sourceChanged() { thumbs.clear(); thumbGen++; if (tab === 'presets') { renderTab(); refreshThumbs(); } },
181
+ get tab() { return tab; },
182
+ };
183
+ }
184
+
185
+ export function fmtTime(t = 0) {
186
+ const m = Math.floor(t / 60), s = t - m * 60;
187
+ return `${m}:${s.toFixed(1).padStart(4, '0')}`;
188
+ }