image-to-pixel 3.0.0 → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +69 -69
  3. package/dist/editor.css.map +1 -1
  4. package/dist/editor.esm.js +1 -1
  5. package/dist/editor.esm.js.map +1 -1
  6. package/dist/fonts/README.md +6 -6
  7. package/dist/fonts/geist-LICENSE.txt +93 -93
  8. package/dist/image-to-pixel.bundle.esm.js +13 -13
  9. package/dist/image-to-pixel.bundle.esm.js.map +2 -2
  10. package/dist/image-to-pixel.cjs +2 -2
  11. package/dist/image-to-pixel.cjs.map +2 -2
  12. package/dist/image-to-pixel.esm.js +2 -2
  13. package/dist/image-to-pixel.esm.js.map +2 -2
  14. package/dist/image-to-pixel.js +2 -2
  15. package/dist/image-to-pixel.js.map +2 -2
  16. package/dist/image-to-pixel.min.js +11 -11
  17. package/dist/image-to-pixel.min.js.map +2 -2
  18. package/dist/video.esm.js +1 -1
  19. package/dist/video.esm.js.map +1 -1
  20. package/lib/core/color.js +69 -69
  21. package/lib/core/cpu/pipeline.js +21 -21
  22. package/lib/core/cpu/stages.js +227 -227
  23. package/lib/core/dither/bluenoise-data.js +3 -3
  24. package/lib/core/dither/kernels.js +27 -27
  25. package/lib/core/dither/matrices.js +95 -95
  26. package/lib/core/engine.js +158 -158
  27. package/lib/core/export/canvas.js +22 -22
  28. package/lib/core/export/crc32.js +15 -15
  29. package/lib/core/export/gif.js +153 -153
  30. package/lib/core/export/png.js +94 -94
  31. package/lib/core/export/scale.js +103 -103
  32. package/lib/core/export/sheet.js +95 -95
  33. package/lib/core/export/zip.js +55 -55
  34. package/lib/core/gpu/backend.js +159 -159
  35. package/lib/core/gpu/shaders.js +258 -258
  36. package/lib/core/palette/builtin.js +36 -36
  37. package/lib/core/palette/extract.js +69 -69
  38. package/lib/core/palette/lospec.js +24 -24
  39. package/lib/core/palette/parse.js +112 -112
  40. package/lib/core/plan.js +58 -58
  41. package/lib/core/presets.js +42 -42
  42. package/lib/core/settings.js +274 -274
  43. package/lib/core/source.js +89 -89
  44. package/lib/core/video/frames.js +144 -144
  45. package/lib/core/video/mux-mp4.js +131 -131
  46. package/lib/core/video/mux-webm.js +127 -127
  47. package/lib/editor/controls.js +139 -139
  48. package/lib/editor/dom.js +65 -65
  49. package/lib/editor/exporter.js +185 -185
  50. package/lib/editor/icons.js +61 -61
  51. package/lib/editor/index.js +793 -793
  52. package/lib/editor/inspector.js +328 -328
  53. package/lib/editor/library.js +182 -182
  54. package/lib/editor/persist.js +18 -18
  55. package/lib/editor/samples.js +90 -90
  56. package/lib/editor/stage.js +239 -239
  57. package/lib/editor/store.js +80 -80
  58. package/lib/editor/styles/editor.css +385 -385
  59. package/lib/editor/styles/fonts/README.md +6 -6
  60. package/lib/editor/styles/fonts/geist-LICENSE.txt +93 -93
  61. package/lib/editor/styles/tokens.css +89 -89
  62. package/lib/editor/ui.js +97 -97
  63. package/lib/index.js +3 -1
  64. package/lib/video.js +92 -92
  65. package/package.json +92 -92
  66. package/types/editor.d.ts +34 -34
  67. package/types/index.d.ts +196 -196
  68. package/types/video.d.ts +43 -43
@@ -1,239 +1,239 @@
1
- // The preview canvas: crisp integer zoom, pan, before/after split, pixel grid, eyedropper.
2
-
3
- import { h } from './dom.js';
4
- import { icon } from './icons.js';
5
-
6
- export const ZOOM_STEPS = [0.25, 0.5, 1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64];
7
-
8
- export function createStage({ onPick, onViewChange } = {}) {
9
- const screen = h('canvas.screen', { role: 'img', 'aria-label': 'Preview' });
10
- const splitLeft = h('span.lbl.l', { text: 'Original' });
11
- const splitRight = h('span.lbl.r', { text: 'Pixel' });
12
- const handle = h('button', { type: 'button', 'aria-label': 'Drag to compare original and result', html: icon('compare') });
13
- const split = h('div.itp-split', { hidden: true }, splitLeft, splitRight, handle);
14
- const el = h('div.itp-view', { tabindex: '0', 'aria-label': 'Canvas. Scroll to zoom, drag to pan.' }, screen, split);
15
- const ctx = screen.getContext('2d');
16
-
17
- const st = {
18
- result: null, // canvas, output size
19
- original: null, // drawable, any size
20
- crop: null, // part of the original shown, as fractions { x, y, w, h }
21
- w: 0, h: 0,
22
- x: 0, y: 0, z: 1, // top-left in css px, css px per output pixel
23
- fitted: true,
24
- compare: false, splitAt: 0.5,
25
- grid: false, picking: false,
26
- };
27
- let dpr = 1, vw = 0, vh = 0, pattern = null, patternKey = '';
28
-
29
- function checker() {
30
- const css = getComputedStyle(el);
31
- const a = css.getPropertyValue('--checker-a').trim() || '#1a1b1f', b = css.getPropertyValue('--checker-b').trim() || '#222';
32
- const key = a + b;
33
- if (key === patternKey) return pattern;
34
- const c = document.createElement('canvas'); c.width = c.height = 16;
35
- const g = c.getContext('2d');
36
- g.fillStyle = a; g.fillRect(0, 0, 16, 16); g.fillStyle = b; g.fillRect(0, 0, 8, 8); g.fillRect(8, 8, 8, 8);
37
- patternKey = key; pattern = ctx.createPattern(c, 'repeat');
38
- return pattern;
39
- }
40
-
41
- function resize() {
42
- const r = el.getBoundingClientRect();
43
- const d = Math.min(3, window.devicePixelRatio || 1);
44
- // ignore resize notifications where nothing actually changed size
45
- if (Math.abs(r.width - vw) < 0.5 && Math.abs(r.height - vh) < 0.5 && d === dpr && screen.width > 1) { draw(); return; }
46
- dpr = d; vw = r.width; vh = r.height;
47
- screen.width = Math.max(1, Math.round(vw * dpr)); screen.height = Math.max(1, Math.round(vh * dpr));
48
- if (st.fitted) fit(false); else draw();
49
- }
50
-
51
- function fitZoom() {
52
- if (!st.w) return 1;
53
- const pad = vw < 600 ? 16 : 48;
54
- // exact, not rounded: a fitted image always fills the same box, whatever its pixel size
55
- return Math.max(0.05, Math.min((vw - pad) / st.w, (vh - pad) / st.h));
56
- }
57
-
58
- function fit(notify = true) {
59
- st.z = fitZoom();
60
- st.x = (vw - st.w * st.z) / 2; st.y = (vh - st.h * st.z) / 2;
61
- st.fitted = true;
62
- draw(); if (notify && onViewChange) onViewChange(view());
63
- }
64
-
65
- function zoomTo(z, cx = vw / 2, cy = vh / 2) {
66
- z = Math.max(0.05, Math.min(128, z));
67
- const px = (cx - st.x) / st.z, py = (cy - st.y) / st.z;
68
- st.z = z; st.x = cx - px * z; st.y = cy - py * z;
69
- st.fitted = false;
70
- draw(); if (onViewChange) onViewChange(view());
71
- }
72
-
73
- function zoomStep(dir, cx, cy) {
74
- const cur = st.z;
75
- let next;
76
- if (dir > 0) next = ZOOM_STEPS.find(s => s > cur + 1e-6) || cur * 1.5;
77
- else next = [...ZOOM_STEPS].reverse().find(s => s < cur - 1e-6) || cur / 1.5;
78
- zoomTo(next, cx, cy);
79
- }
80
-
81
- function draw() {
82
- ctx.setTransform(1, 0, 0, 1, 0, 0);
83
- ctx.clearRect(0, 0, screen.width, screen.height);
84
- if (!st.result) { split.hidden = true; return; }
85
- ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
86
- // snap the image rect to device pixels so every output pixel is the same size
87
- const x = Math.round(st.x * dpr) / dpr, y = Math.round(st.y * dpr) / dpr;
88
- const w = Math.round(st.w * st.z * dpr) / dpr, hh = Math.round(st.h * st.z * dpr) / dpr;
89
- ctx.fillStyle = checker();
90
- ctx.fillRect(x, y, w, hh);
91
- ctx.imageSmoothingEnabled = false;
92
- ctx.drawImage(st.result, x, y, w, hh);
93
-
94
- if (st.compare && st.original) {
95
- const sx = x + w * st.splitAt;
96
- ctx.save();
97
- ctx.beginPath(); ctx.rect(x, y, Math.max(0, sx - x), hh); ctx.clip();
98
- ctx.fillStyle = checker(); ctx.fillRect(x, y, w, hh);
99
- ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';
100
- try {
101
- const ow = st.original.naturalWidth || st.original.videoWidth || st.original.width;
102
- const oh = st.original.naturalHeight || st.original.videoHeight || st.original.height;
103
- const c = st.crop || { x: 0, y: 0, w: 1, h: 1 };
104
- ctx.drawImage(st.original, c.x * ow, c.y * oh, c.w * ow, c.h * oh, x, y, w, hh);
105
- } catch { /* video not ready */ }
106
- ctx.restore();
107
- split.hidden = false;
108
- split.style.left = `${sx}px`;
109
- split.style.top = `${Math.max(0, y)}px`; split.style.bottom = `${Math.max(0, vh - (y + hh))}px`;
110
- } else split.hidden = true;
111
-
112
- if (st.grid && st.z >= 6) {
113
- ctx.save();
114
- ctx.beginPath(); ctx.rect(x, y, w, hh); ctx.clip();
115
- ctx.strokeStyle = 'rgba(127,127,127,0.35)';
116
- ctx.lineWidth = 1 / dpr;
117
- ctx.beginPath();
118
- const x0 = Math.max(0, Math.floor(-st.x / st.z)), x1 = Math.min(st.w, Math.ceil((vw - st.x) / st.z));
119
- const y0 = Math.max(0, Math.floor(-st.y / st.z)), y1 = Math.min(st.h, Math.ceil((vh - st.y) / st.z));
120
- for (let i = x0; i <= x1; i++) { const px = Math.round((x + i * st.z) * dpr) / dpr + 0.5 / dpr; ctx.moveTo(px, Math.max(y, 0)); ctx.lineTo(px, Math.min(y + hh, vh)); }
121
- for (let j = y0; j <= y1; j++) { const py = Math.round((y + j * st.z) * dpr) / dpr + 0.5 / dpr; ctx.moveTo(Math.max(x, 0), py); ctx.lineTo(Math.min(x + w, vw), py); }
122
- ctx.stroke();
123
- ctx.restore();
124
- }
125
- }
126
-
127
- // ---- input
128
- let drag = null;
129
- const pointers = new Map();
130
- el.addEventListener('pointerdown', e => {
131
- if (e.target === handle || handle.contains(e.target)) return;
132
- // overlays inside the view (empty state, floating tools) get normal clicks: only the canvas pans
133
- if (e.target !== el && e.target !== screen) return;
134
- el.focus({ preventScroll: true });
135
- pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
136
- el.setPointerCapture(e.pointerId);
137
- if (st.picking && e.button === 0 && pointers.size === 1) {
138
- const r = el.getBoundingClientRect();
139
- const u = (e.clientX - r.left - st.x) / (st.w * st.z), v = (e.clientY - r.top - st.y) / (st.h * st.z);
140
- if (u >= 0 && v >= 0 && u < 1 && v < 1 && onPick) onPick(u, v);
141
- return;
142
- }
143
- drag = { x: e.clientX, y: e.clientY, ox: st.x, oy: st.y };
144
- el.classList.add('panning');
145
- });
146
- el.addEventListener('pointermove', e => {
147
- if (!pointers.has(e.pointerId)) return;
148
- const prev = pointers.get(e.pointerId);
149
- pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
150
- if (pointers.size === 2) {
151
- // pinch
152
- const [a, b] = [...pointers.values()];
153
- const other = [...pointers.entries()].find(([id]) => id !== e.pointerId)[1];
154
- const d0 = Math.hypot(prev.x - other.x, prev.y - other.y), d1 = Math.hypot(a.x - b.x, a.y - b.y);
155
- if (d0 > 0) {
156
- const r = el.getBoundingClientRect();
157
- zoomTo(st.z * d1 / d0, (a.x + b.x) / 2 - r.left, (a.y + b.y) / 2 - r.top);
158
- }
159
- drag = null;
160
- return;
161
- }
162
- if (!drag) return;
163
- st.x = drag.ox + e.clientX - drag.x; st.y = drag.oy + e.clientY - drag.y;
164
- st.fitted = false;
165
- draw();
166
- });
167
- const end = e => { pointers.delete(e.pointerId); if (!pointers.size) { drag = null; el.classList.remove('panning'); if (onViewChange) onViewChange(view()); } };
168
- el.addEventListener('pointerup', end);
169
- el.addEventListener('pointercancel', end);
170
- el.addEventListener('wheel', e => {
171
- e.preventDefault();
172
- const r = el.getBoundingClientRect();
173
- const cx = e.clientX - r.left, cy = e.clientY - r.top;
174
- const trackpadPan = !e.ctrlKey && e.deltaMode === 0 && Math.abs(e.deltaX) > 0;
175
- if (trackpadPan) { st.x -= e.deltaX; st.y -= e.deltaY; st.fitted = false; draw(); return; }
176
- if (e.ctrlKey && e.deltaMode === 0) zoomTo(st.z * Math.exp(-e.deltaY * 0.01), cx, cy); // pinch on trackpads
177
- else zoomStep(e.deltaY < 0 ? 1 : -1, cx, cy);
178
- }, { passive: false });
179
- el.addEventListener('dblclick', () => fit());
180
-
181
- // split handle
182
- handle.addEventListener('pointerdown', e => {
183
- e.preventDefault(); handle.setPointerCapture(e.pointerId);
184
- const move = ev => {
185
- const r = el.getBoundingClientRect();
186
- st.splitAt = Math.min(1, Math.max(0, (ev.clientX - r.left - st.x) / (st.w * st.z)));
187
- draw();
188
- };
189
- const up = () => { handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', up); };
190
- handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', up);
191
- });
192
- handle.addEventListener('keydown', e => {
193
- if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
194
- e.preventDefault(); e.stopPropagation();
195
- st.splitAt = Math.min(1, Math.max(0, st.splitAt + (e.key === 'ArrowLeft' ? -0.05 : 0.05))); draw();
196
- }
197
- });
198
-
199
- new ResizeObserver(resize).observe(el);
200
- const view = () => ({ zoom: st.z, fitted: st.fitted, x: st.x, y: st.y });
201
-
202
- return {
203
- el,
204
- /** Show a result canvas (and the original for compare). */
205
- show(result, original, crop) {
206
- const sizeChanged = !st.result || result.width !== st.w || result.height !== st.h;
207
- const oldRect = { w: st.w * st.z, cx: st.x + st.w * st.z / 2, cy: st.y + st.h * st.z / 2 };
208
- st.result = result; st.original = original || st.original; st.crop = crop || null;
209
- if (sizeChanged) {
210
- st.w = result.width; st.h = result.height;
211
- if (st.fitted || !oldRect.w) {
212
- // not zoomed or panned by hand: fit exactly, so the image fills the same box as before
213
- fit();
214
- } else {
215
- // zoomed or panned: keep the same centre and on-screen width
216
- const z = oldRect.w / st.w;
217
- st.z = z; st.x = oldRect.cx - st.w * z / 2; st.y = oldRect.cy - st.h * z / 2;
218
- draw();
219
- if (onViewChange) onViewChange(view());
220
- }
221
- } else draw();
222
- },
223
- clear() { st.result = null; st.original = null; st.w = st.h = 0; st.fitted = true; draw(); },
224
- fit, zoomTo, zoomStep,
225
- zoomIn() { zoomStep(1); }, zoomOut() { zoomStep(-1); },
226
- get zoom() { return st.z; },
227
- get fitted() { return st.fitted; },
228
- /** Where the image sits on screen (css px), for tests and integrations. */
229
- get rect() { return { x: st.x, y: st.y, width: st.w * st.z, height: st.h * st.z, zoom: st.z }; },
230
- setCompare(on) { st.compare = on; draw(); },
231
- get compare() { return st.compare; },
232
- setGrid(on) { st.grid = on; draw(); },
233
- get grid() { return st.grid; },
234
- setPicking(on) { st.picking = on; el.classList.toggle('picking', on); },
235
- get picking() { return st.picking; },
236
- redraw: draw,
237
- resize,
238
- };
239
- }
1
+ // The preview canvas: crisp integer zoom, pan, before/after split, pixel grid, eyedropper.
2
+
3
+ import { h } from './dom.js';
4
+ import { icon } from './icons.js';
5
+
6
+ export const ZOOM_STEPS = [0.25, 0.5, 1, 2, 3, 4, 6, 8, 12, 16, 24, 32, 48, 64];
7
+
8
+ export function createStage({ onPick, onViewChange } = {}) {
9
+ const screen = h('canvas.screen', { role: 'img', 'aria-label': 'Preview' });
10
+ const splitLeft = h('span.lbl.l', { text: 'Original' });
11
+ const splitRight = h('span.lbl.r', { text: 'Pixel' });
12
+ const handle = h('button', { type: 'button', 'aria-label': 'Drag to compare original and result', html: icon('compare') });
13
+ const split = h('div.itp-split', { hidden: true }, splitLeft, splitRight, handle);
14
+ const el = h('div.itp-view', { tabindex: '0', 'aria-label': 'Canvas. Scroll to zoom, drag to pan.' }, screen, split);
15
+ const ctx = screen.getContext('2d');
16
+
17
+ const st = {
18
+ result: null, // canvas, output size
19
+ original: null, // drawable, any size
20
+ crop: null, // part of the original shown, as fractions { x, y, w, h }
21
+ w: 0, h: 0,
22
+ x: 0, y: 0, z: 1, // top-left in css px, css px per output pixel
23
+ fitted: true,
24
+ compare: false, splitAt: 0.5,
25
+ grid: false, picking: false,
26
+ };
27
+ let dpr = 1, vw = 0, vh = 0, pattern = null, patternKey = '';
28
+
29
+ function checker() {
30
+ const css = getComputedStyle(el);
31
+ const a = css.getPropertyValue('--checker-a').trim() || '#1a1b1f', b = css.getPropertyValue('--checker-b').trim() || '#222';
32
+ const key = a + b;
33
+ if (key === patternKey) return pattern;
34
+ const c = document.createElement('canvas'); c.width = c.height = 16;
35
+ const g = c.getContext('2d');
36
+ g.fillStyle = a; g.fillRect(0, 0, 16, 16); g.fillStyle = b; g.fillRect(0, 0, 8, 8); g.fillRect(8, 8, 8, 8);
37
+ patternKey = key; pattern = ctx.createPattern(c, 'repeat');
38
+ return pattern;
39
+ }
40
+
41
+ function resize() {
42
+ const r = el.getBoundingClientRect();
43
+ const d = Math.min(3, window.devicePixelRatio || 1);
44
+ // ignore resize notifications where nothing actually changed size
45
+ if (Math.abs(r.width - vw) < 0.5 && Math.abs(r.height - vh) < 0.5 && d === dpr && screen.width > 1) { draw(); return; }
46
+ dpr = d; vw = r.width; vh = r.height;
47
+ screen.width = Math.max(1, Math.round(vw * dpr)); screen.height = Math.max(1, Math.round(vh * dpr));
48
+ if (st.fitted) fit(false); else draw();
49
+ }
50
+
51
+ function fitZoom() {
52
+ if (!st.w) return 1;
53
+ const pad = vw < 600 ? 16 : 48;
54
+ // exact, not rounded: a fitted image always fills the same box, whatever its pixel size
55
+ return Math.max(0.05, Math.min((vw - pad) / st.w, (vh - pad) / st.h));
56
+ }
57
+
58
+ function fit(notify = true) {
59
+ st.z = fitZoom();
60
+ st.x = (vw - st.w * st.z) / 2; st.y = (vh - st.h * st.z) / 2;
61
+ st.fitted = true;
62
+ draw(); if (notify && onViewChange) onViewChange(view());
63
+ }
64
+
65
+ function zoomTo(z, cx = vw / 2, cy = vh / 2) {
66
+ z = Math.max(0.05, Math.min(128, z));
67
+ const px = (cx - st.x) / st.z, py = (cy - st.y) / st.z;
68
+ st.z = z; st.x = cx - px * z; st.y = cy - py * z;
69
+ st.fitted = false;
70
+ draw(); if (onViewChange) onViewChange(view());
71
+ }
72
+
73
+ function zoomStep(dir, cx, cy) {
74
+ const cur = st.z;
75
+ let next;
76
+ if (dir > 0) next = ZOOM_STEPS.find(s => s > cur + 1e-6) || cur * 1.5;
77
+ else next = [...ZOOM_STEPS].reverse().find(s => s < cur - 1e-6) || cur / 1.5;
78
+ zoomTo(next, cx, cy);
79
+ }
80
+
81
+ function draw() {
82
+ ctx.setTransform(1, 0, 0, 1, 0, 0);
83
+ ctx.clearRect(0, 0, screen.width, screen.height);
84
+ if (!st.result) { split.hidden = true; return; }
85
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
86
+ // snap the image rect to device pixels so every output pixel is the same size
87
+ const x = Math.round(st.x * dpr) / dpr, y = Math.round(st.y * dpr) / dpr;
88
+ const w = Math.round(st.w * st.z * dpr) / dpr, hh = Math.round(st.h * st.z * dpr) / dpr;
89
+ ctx.fillStyle = checker();
90
+ ctx.fillRect(x, y, w, hh);
91
+ ctx.imageSmoothingEnabled = false;
92
+ ctx.drawImage(st.result, x, y, w, hh);
93
+
94
+ if (st.compare && st.original) {
95
+ const sx = x + w * st.splitAt;
96
+ ctx.save();
97
+ ctx.beginPath(); ctx.rect(x, y, Math.max(0, sx - x), hh); ctx.clip();
98
+ ctx.fillStyle = checker(); ctx.fillRect(x, y, w, hh);
99
+ ctx.imageSmoothingEnabled = true; ctx.imageSmoothingQuality = 'high';
100
+ try {
101
+ const ow = st.original.naturalWidth || st.original.videoWidth || st.original.width;
102
+ const oh = st.original.naturalHeight || st.original.videoHeight || st.original.height;
103
+ const c = st.crop || { x: 0, y: 0, w: 1, h: 1 };
104
+ ctx.drawImage(st.original, c.x * ow, c.y * oh, c.w * ow, c.h * oh, x, y, w, hh);
105
+ } catch { /* video not ready */ }
106
+ ctx.restore();
107
+ split.hidden = false;
108
+ split.style.left = `${sx}px`;
109
+ split.style.top = `${Math.max(0, y)}px`; split.style.bottom = `${Math.max(0, vh - (y + hh))}px`;
110
+ } else split.hidden = true;
111
+
112
+ if (st.grid && st.z >= 6) {
113
+ ctx.save();
114
+ ctx.beginPath(); ctx.rect(x, y, w, hh); ctx.clip();
115
+ ctx.strokeStyle = 'rgba(127,127,127,0.35)';
116
+ ctx.lineWidth = 1 / dpr;
117
+ ctx.beginPath();
118
+ const x0 = Math.max(0, Math.floor(-st.x / st.z)), x1 = Math.min(st.w, Math.ceil((vw - st.x) / st.z));
119
+ const y0 = Math.max(0, Math.floor(-st.y / st.z)), y1 = Math.min(st.h, Math.ceil((vh - st.y) / st.z));
120
+ for (let i = x0; i <= x1; i++) { const px = Math.round((x + i * st.z) * dpr) / dpr + 0.5 / dpr; ctx.moveTo(px, Math.max(y, 0)); ctx.lineTo(px, Math.min(y + hh, vh)); }
121
+ for (let j = y0; j <= y1; j++) { const py = Math.round((y + j * st.z) * dpr) / dpr + 0.5 / dpr; ctx.moveTo(Math.max(x, 0), py); ctx.lineTo(Math.min(x + w, vw), py); }
122
+ ctx.stroke();
123
+ ctx.restore();
124
+ }
125
+ }
126
+
127
+ // ---- input
128
+ let drag = null;
129
+ const pointers = new Map();
130
+ el.addEventListener('pointerdown', e => {
131
+ if (e.target === handle || handle.contains(e.target)) return;
132
+ // overlays inside the view (empty state, floating tools) get normal clicks: only the canvas pans
133
+ if (e.target !== el && e.target !== screen) return;
134
+ el.focus({ preventScroll: true });
135
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
136
+ el.setPointerCapture(e.pointerId);
137
+ if (st.picking && e.button === 0 && pointers.size === 1) {
138
+ const r = el.getBoundingClientRect();
139
+ const u = (e.clientX - r.left - st.x) / (st.w * st.z), v = (e.clientY - r.top - st.y) / (st.h * st.z);
140
+ if (u >= 0 && v >= 0 && u < 1 && v < 1 && onPick) onPick(u, v);
141
+ return;
142
+ }
143
+ drag = { x: e.clientX, y: e.clientY, ox: st.x, oy: st.y };
144
+ el.classList.add('panning');
145
+ });
146
+ el.addEventListener('pointermove', e => {
147
+ if (!pointers.has(e.pointerId)) return;
148
+ const prev = pointers.get(e.pointerId);
149
+ pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
150
+ if (pointers.size === 2) {
151
+ // pinch
152
+ const [a, b] = [...pointers.values()];
153
+ const other = [...pointers.entries()].find(([id]) => id !== e.pointerId)[1];
154
+ const d0 = Math.hypot(prev.x - other.x, prev.y - other.y), d1 = Math.hypot(a.x - b.x, a.y - b.y);
155
+ if (d0 > 0) {
156
+ const r = el.getBoundingClientRect();
157
+ zoomTo(st.z * d1 / d0, (a.x + b.x) / 2 - r.left, (a.y + b.y) / 2 - r.top);
158
+ }
159
+ drag = null;
160
+ return;
161
+ }
162
+ if (!drag) return;
163
+ st.x = drag.ox + e.clientX - drag.x; st.y = drag.oy + e.clientY - drag.y;
164
+ st.fitted = false;
165
+ draw();
166
+ });
167
+ const end = e => { pointers.delete(e.pointerId); if (!pointers.size) { drag = null; el.classList.remove('panning'); if (onViewChange) onViewChange(view()); } };
168
+ el.addEventListener('pointerup', end);
169
+ el.addEventListener('pointercancel', end);
170
+ el.addEventListener('wheel', e => {
171
+ e.preventDefault();
172
+ const r = el.getBoundingClientRect();
173
+ const cx = e.clientX - r.left, cy = e.clientY - r.top;
174
+ const trackpadPan = !e.ctrlKey && e.deltaMode === 0 && Math.abs(e.deltaX) > 0;
175
+ if (trackpadPan) { st.x -= e.deltaX; st.y -= e.deltaY; st.fitted = false; draw(); return; }
176
+ if (e.ctrlKey && e.deltaMode === 0) zoomTo(st.z * Math.exp(-e.deltaY * 0.01), cx, cy); // pinch on trackpads
177
+ else zoomStep(e.deltaY < 0 ? 1 : -1, cx, cy);
178
+ }, { passive: false });
179
+ el.addEventListener('dblclick', () => fit());
180
+
181
+ // split handle
182
+ handle.addEventListener('pointerdown', e => {
183
+ e.preventDefault(); handle.setPointerCapture(e.pointerId);
184
+ const move = ev => {
185
+ const r = el.getBoundingClientRect();
186
+ st.splitAt = Math.min(1, Math.max(0, (ev.clientX - r.left - st.x) / (st.w * st.z)));
187
+ draw();
188
+ };
189
+ const up = () => { handle.removeEventListener('pointermove', move); handle.removeEventListener('pointerup', up); };
190
+ handle.addEventListener('pointermove', move); handle.addEventListener('pointerup', up);
191
+ });
192
+ handle.addEventListener('keydown', e => {
193
+ if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
194
+ e.preventDefault(); e.stopPropagation();
195
+ st.splitAt = Math.min(1, Math.max(0, st.splitAt + (e.key === 'ArrowLeft' ? -0.05 : 0.05))); draw();
196
+ }
197
+ });
198
+
199
+ new ResizeObserver(resize).observe(el);
200
+ const view = () => ({ zoom: st.z, fitted: st.fitted, x: st.x, y: st.y });
201
+
202
+ return {
203
+ el,
204
+ /** Show a result canvas (and the original for compare). */
205
+ show(result, original, crop) {
206
+ const sizeChanged = !st.result || result.width !== st.w || result.height !== st.h;
207
+ const oldRect = { w: st.w * st.z, cx: st.x + st.w * st.z / 2, cy: st.y + st.h * st.z / 2 };
208
+ st.result = result; st.original = original || st.original; st.crop = crop || null;
209
+ if (sizeChanged) {
210
+ st.w = result.width; st.h = result.height;
211
+ if (st.fitted || !oldRect.w) {
212
+ // not zoomed or panned by hand: fit exactly, so the image fills the same box as before
213
+ fit();
214
+ } else {
215
+ // zoomed or panned: keep the same centre and on-screen width
216
+ const z = oldRect.w / st.w;
217
+ st.z = z; st.x = oldRect.cx - st.w * z / 2; st.y = oldRect.cy - st.h * z / 2;
218
+ draw();
219
+ if (onViewChange) onViewChange(view());
220
+ }
221
+ } else draw();
222
+ },
223
+ clear() { st.result = null; st.original = null; st.w = st.h = 0; st.fitted = true; draw(); },
224
+ fit, zoomTo, zoomStep,
225
+ zoomIn() { zoomStep(1); }, zoomOut() { zoomStep(-1); },
226
+ get zoom() { return st.z; },
227
+ get fitted() { return st.fitted; },
228
+ /** Where the image sits on screen (css px), for tests and integrations. */
229
+ get rect() { return { x: st.x, y: st.y, width: st.w * st.z, height: st.h * st.z, zoom: st.z }; },
230
+ setCompare(on) { st.compare = on; draw(); },
231
+ get compare() { return st.compare; },
232
+ setGrid(on) { st.grid = on; draw(); },
233
+ get grid() { return st.grid; },
234
+ setPicking(on) { st.picking = on; el.classList.toggle('picking', on); },
235
+ get picking() { return st.picking; },
236
+ redraw: draw,
237
+ resize,
238
+ };
239
+ }
@@ -1,80 +1,80 @@
1
- // Settings store with undo/redo. Rapid edits to the same control (a slider drag) are
2
- // coalesced into one history step.
3
- //
4
- // The store edits one "target" at a time: the shared settings, or one file's own settings.
5
- // Each target keeps its own undo history, so switching files never mixes their histories.
6
-
7
- import { mergeSettings, normalizeSettings } from '../core/settings.js';
8
-
9
- export function createStore(initialSettings) {
10
- let settings = normalizeSettings(initialSettings);
11
- let target = 'shared';
12
- let past = [], future = [];
13
- const histories = new Map(); // target → { past, future }
14
- const subs = new Set();
15
- let lastKey = null, lastTime = 0;
16
- const LIMIT = 200;
17
-
18
- const emit = (why) => { for (const fn of subs) fn(settings, why); };
19
- const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
20
-
21
- return {
22
- get settings() { return settings; },
23
- /** 'shared' or a file id. */
24
- get target() { return target; },
25
- subscribe(fn) { subs.add(fn); return () => subs.delete(fn); },
26
-
27
- /**
28
- * Patch settings ({ dither: { strength: 0.5 } }).
29
- * @param {object} patch
30
- * @param {{ key?: string, history?: boolean }} [opts] key: coalesce consecutive edits with the same key
31
- */
32
- update(patch, opts = {}) {
33
- const next = mergeSettings(settings, patch);
34
- if (same(next, settings)) return;
35
- const now = Date.now();
36
- const coalesce = opts.key && opts.key === lastKey && now - lastTime < 1200;
37
- if (opts.history !== false && !coalesce) {
38
- past.push(settings); if (past.length > LIMIT) past.shift();
39
- future.length = 0;
40
- }
41
- lastKey = opts.key || null; lastTime = now;
42
- settings = next;
43
- emit('update');
44
- },
45
-
46
- /** Replace everything (preset, loaded JSON, share link). One undo step. */
47
- replace(next, why = 'replace') {
48
- next = normalizeSettings(next);
49
- if (same(next, settings)) return;
50
- past.push(settings); future.length = 0; lastKey = null;
51
- settings = next;
52
- emit(why);
53
- },
54
-
55
- /**
56
- * Switch what is being edited. The current target's history is kept for when it comes back.
57
- * @param {string} key 'shared' or a file id
58
- * @param {object} value the settings to edit
59
- */
60
- setTarget(key, value) {
61
- histories.set(target, { past, future });
62
- target = key;
63
- const h = histories.get(key) || { past: [], future: [] };
64
- past = h.past; future = h.future; lastKey = null;
65
- settings = normalizeSettings(value);
66
- emit('target');
67
- },
68
-
69
- /** Forget a target's history (its file was removed or relinked). */
70
- dropTarget(key) { if (key !== target) histories.delete(key); },
71
-
72
- /** End the current coalescing group (call on slider release). */
73
- commit() { lastKey = null; },
74
-
75
- undo() { if (!past.length) return false; future.push(settings); settings = past.pop(); lastKey = null; emit('undo'); return true; },
76
- redo() { if (!future.length) return false; past.push(settings); settings = future.pop(); lastKey = null; emit('redo'); return true; },
77
- get canUndo() { return past.length > 0; },
78
- get canRedo() { return future.length > 0; },
79
- };
80
- }
1
+ // Settings store with undo/redo. Rapid edits to the same control (a slider drag) are
2
+ // coalesced into one history step.
3
+ //
4
+ // The store edits one "target" at a time: the shared settings, or one file's own settings.
5
+ // Each target keeps its own undo history, so switching files never mixes their histories.
6
+
7
+ import { mergeSettings, normalizeSettings } from '../core/settings.js';
8
+
9
+ export function createStore(initialSettings) {
10
+ let settings = normalizeSettings(initialSettings);
11
+ let target = 'shared';
12
+ let past = [], future = [];
13
+ const histories = new Map(); // target → { past, future }
14
+ const subs = new Set();
15
+ let lastKey = null, lastTime = 0;
16
+ const LIMIT = 200;
17
+
18
+ const emit = (why) => { for (const fn of subs) fn(settings, why); };
19
+ const same = (a, b) => JSON.stringify(a) === JSON.stringify(b);
20
+
21
+ return {
22
+ get settings() { return settings; },
23
+ /** 'shared' or a file id. */
24
+ get target() { return target; },
25
+ subscribe(fn) { subs.add(fn); return () => subs.delete(fn); },
26
+
27
+ /**
28
+ * Patch settings ({ dither: { strength: 0.5 } }).
29
+ * @param {object} patch
30
+ * @param {{ key?: string, history?: boolean }} [opts] key: coalesce consecutive edits with the same key
31
+ */
32
+ update(patch, opts = {}) {
33
+ const next = mergeSettings(settings, patch);
34
+ if (same(next, settings)) return;
35
+ const now = Date.now();
36
+ const coalesce = opts.key && opts.key === lastKey && now - lastTime < 1200;
37
+ if (opts.history !== false && !coalesce) {
38
+ past.push(settings); if (past.length > LIMIT) past.shift();
39
+ future.length = 0;
40
+ }
41
+ lastKey = opts.key || null; lastTime = now;
42
+ settings = next;
43
+ emit('update');
44
+ },
45
+
46
+ /** Replace everything (preset, loaded JSON, share link). One undo step. */
47
+ replace(next, why = 'replace') {
48
+ next = normalizeSettings(next);
49
+ if (same(next, settings)) return;
50
+ past.push(settings); future.length = 0; lastKey = null;
51
+ settings = next;
52
+ emit(why);
53
+ },
54
+
55
+ /**
56
+ * Switch what is being edited. The current target's history is kept for when it comes back.
57
+ * @param {string} key 'shared' or a file id
58
+ * @param {object} value the settings to edit
59
+ */
60
+ setTarget(key, value) {
61
+ histories.set(target, { past, future });
62
+ target = key;
63
+ const h = histories.get(key) || { past: [], future: [] };
64
+ past = h.past; future = h.future; lastKey = null;
65
+ settings = normalizeSettings(value);
66
+ emit('target');
67
+ },
68
+
69
+ /** Forget a target's history (its file was removed or relinked). */
70
+ dropTarget(key) { if (key !== target) histories.delete(key); },
71
+
72
+ /** End the current coalescing group (call on slider release). */
73
+ commit() { lastKey = null; },
74
+
75
+ undo() { if (!past.length) return false; future.push(settings); settings = past.pop(); lastKey = null; emit('undo'); return true; },
76
+ redo() { if (!future.length) return false; past.push(settings); settings = future.pop(); lastKey = null; emit('redo'); return true; },
77
+ get canUndo() { return past.length > 0; },
78
+ get canRedo() { return future.length > 0; },
79
+ };
80
+ }