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.
- package/LICENSE +21 -21
- package/README.md +69 -69
- package/dist/editor.css.map +1 -1
- package/dist/editor.esm.js +1 -1
- package/dist/editor.esm.js.map +1 -1
- package/dist/fonts/README.md +6 -6
- package/dist/fonts/geist-LICENSE.txt +93 -93
- package/dist/image-to-pixel.bundle.esm.js +13 -13
- package/dist/image-to-pixel.bundle.esm.js.map +2 -2
- package/dist/image-to-pixel.cjs +2 -2
- package/dist/image-to-pixel.cjs.map +2 -2
- package/dist/image-to-pixel.esm.js +2 -2
- package/dist/image-to-pixel.esm.js.map +2 -2
- package/dist/image-to-pixel.js +2 -2
- package/dist/image-to-pixel.js.map +2 -2
- package/dist/image-to-pixel.min.js +11 -11
- package/dist/image-to-pixel.min.js.map +2 -2
- package/dist/video.esm.js +1 -1
- package/dist/video.esm.js.map +1 -1
- package/lib/core/color.js +69 -69
- package/lib/core/cpu/pipeline.js +21 -21
- package/lib/core/cpu/stages.js +227 -227
- package/lib/core/dither/bluenoise-data.js +3 -3
- package/lib/core/dither/kernels.js +27 -27
- package/lib/core/dither/matrices.js +95 -95
- package/lib/core/engine.js +158 -158
- package/lib/core/export/canvas.js +22 -22
- package/lib/core/export/crc32.js +15 -15
- package/lib/core/export/gif.js +153 -153
- package/lib/core/export/png.js +94 -94
- package/lib/core/export/scale.js +103 -103
- package/lib/core/export/sheet.js +95 -95
- package/lib/core/export/zip.js +55 -55
- package/lib/core/gpu/backend.js +159 -159
- package/lib/core/gpu/shaders.js +258 -258
- package/lib/core/palette/builtin.js +36 -36
- package/lib/core/palette/extract.js +69 -69
- package/lib/core/palette/lospec.js +24 -24
- package/lib/core/palette/parse.js +112 -112
- package/lib/core/plan.js +58 -58
- package/lib/core/presets.js +42 -42
- package/lib/core/settings.js +274 -274
- package/lib/core/source.js +89 -89
- package/lib/core/video/frames.js +144 -144
- package/lib/core/video/mux-mp4.js +131 -131
- package/lib/core/video/mux-webm.js +127 -127
- package/lib/editor/controls.js +139 -139
- package/lib/editor/dom.js +65 -65
- package/lib/editor/exporter.js +185 -185
- package/lib/editor/icons.js +61 -61
- package/lib/editor/index.js +793 -793
- package/lib/editor/inspector.js +328 -328
- package/lib/editor/library.js +182 -182
- package/lib/editor/persist.js +18 -18
- package/lib/editor/samples.js +90 -90
- package/lib/editor/stage.js +239 -239
- package/lib/editor/store.js +80 -80
- package/lib/editor/styles/editor.css +385 -385
- package/lib/editor/styles/fonts/README.md +6 -6
- package/lib/editor/styles/fonts/geist-LICENSE.txt +93 -93
- package/lib/editor/styles/tokens.css +89 -89
- package/lib/editor/ui.js +97 -97
- package/lib/index.js +3 -1
- package/lib/video.js +92 -92
- package/package.json +92 -92
- package/types/editor.d.ts +34 -34
- package/types/index.d.ts +196 -196
- package/types/video.d.ts +43 -43
package/lib/editor/stage.js
CHANGED
|
@@ -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
|
+
}
|
package/lib/editor/store.js
CHANGED
|
@@ -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
|
+
}
|