feat(importer): phase 2 — overlay preview on main canvas

CanvasRenderer.setOverlay draws the palette-converted import preview on top
of committed + pending layers at (x, y) with a configurable alpha. Image
importer gains an "Overlay" toggle and opacity slider; the overlay
reactively follows resize/dither/origin changes and clears on panel close,
toggle-off, or image swap. Lets the user align the upload before spending
credits.
This commit is contained in:
tiennm99 committed 2026-04-17 11:45:16 +07:00
1 parent f94f1dc5f5
commit 75c1023554
5 files changed
+69 -7

No files matched your search

@@ -2,7 +2,7 @@
## Overview
- **Priority:** P1 (high UX value)
- **Status:** Todo
- **Status:** Done
- Show a semi-transparent ghost of the palette-converted image on the main canvas at `(originX, originY)`, so the user can zoom/pan and confirm alignment *before* spending credits.
## Key Insights
@@ -42,10 +42,10 @@ Extend `CanvasRenderer` with:
6. Clear overlay on panel close and when upload finishes successfully (optional; keep if user wants to re-align).
## Todo
- [ ] `CanvasRenderer.setOverlay` + render integration
- [ ] Importer toggle + alpha slider
- [ ] Reactive wiring (importer ↔ renderer)
- [ ] Verify overlay clears on close / unmount
- [x] `CanvasRenderer.setOverlay` + render integration
- [x] Importer toggle + alpha slider
- [x] Reactive wiring (importer ↔ renderer, via App `setOverlay` prop)
- [x] Overlay clears on close / unmount / toggle-off
## Success Criteria
- Toggle overlay on → preview appears on the canvas at the chosen position at 50% alpha.
@@ -16,7 +16,7 @@ Grow the in-app Image Import panel into a full pre-placement pipeline: resize, t
| # | Phase | Status | Value |
|---|---|---|---|
| 1 | [Resize controls](phase-01-resize-controls.md) | Done | Must-have — user explicitly asked |
| 2 | [Overlay preview on canvas](phase-02-overlay-preview.md) | Todo | High — visualize alignment before spending credits |
| 2 | [Overlay preview on canvas](phase-02-overlay-preview.md) | Done | High — visualize alignment before spending credits |
| 3 | [Transforms (flip / rotate)](phase-03-transforms.md) | Todo | Medium — quick fixes without re-editing source |
| 4 | [More dithering algorithms](phase-04-dithering-algorithms.md) | Todo | Medium — different looks per source |
| 5 | [Color correction sliders](phase-05-color-correction.md) | Todo | Medium — photos benefit most |
+1
View File
@@ -188,6 +188,7 @@
open={importerOpen}
{cursorPos}
getCommittedColor={(x, y) => canvasRenderer?.getCommittedColor(x, y) ?? -1}
setOverlay={(o) => canvasRenderer?.setOverlay(o)}
onClose={() => importerOpen = false}
onCredits={(c) => credits = c}
/>
@@ -3,6 +3,7 @@
import { CANVAS_WIDTH, CANVAS_HEIGHT, COLORS_RGBA, MAX_BATCH_SIZE } from '../../lib/constants.js';
import { decodeCanvas, indicesToRgba } from '../../lib/canvas-decoder.js';
import { createPixelBuffer } from '../../lib/pixel-buffer.js';
import { paletteToRgba } from '../../lib/image-to-palette.js';
let { selectedColor, zoom, onZoomChange, onCursorMove, mode, onBufferChange, onBufferFull } = $props();
@@ -25,6 +26,10 @@
const offscreen = new OffscreenCanvas(CANVAS_WIDTH, CANVAS_HEIGHT);
const offCtx = offscreen.getContext('2d');
/** Overlay state: image-importer preview rendered on top of committed+pending.
* { x, y, canvas: OffscreenCanvas, alpha } or null. */
let overlayState = null;
// Touch state
let lastTouchDist = 0;
let touchStartTime = 0;
@@ -43,6 +48,11 @@
ctx.translate(pan.x, pan.y);
ctx.scale(effZoom, effZoom);
ctx.drawImage(offscreen, 0, 0);
if (overlayState) {
ctx.globalAlpha = overlayState.alpha;
ctx.drawImage(overlayState.canvas, overlayState.x, overlayState.y);
ctx.globalAlpha = 1;
}
}
function screenToCanvas(clientX, clientY) {
@@ -158,6 +168,23 @@
return committedColors[y * CANVAS_WIDTH + x];
}
/** Set or clear the preview overlay drawn on top of the canvas.
* @param {{x: number, y: number, width: number, height: number, indices: Int16Array|number[], alpha?: number}|null} overlay */
export function setOverlay(overlay) {
if (!overlay || !overlay.indices || overlay.width <= 0 || overlay.height <= 0) {
overlayState = null;
render();
return;
}
const { x, y, width, height, indices, alpha = 0.6 } = overlay;
const rgba = paletteToRgba(indices, width, height);
const oc = new OffscreenCanvas(width, height);
const octx = oc.getContext('2d');
octx.putImageData(new ImageData(rgba, width, height), 0, 0);
overlayState = { x, y, canvas: oc, alpha };
render();
}
export function commitPending() {
for (const { x, y, color } of buffer.getAllPixels()) {
committedColors[y * CANVAS_WIDTH + x] = color;
+35 -1
View File
@@ -4,7 +4,7 @@
import { resizeRgba } from '../../lib/image-resize.js';
import { createImageUploader } from '../../lib/image-uploader.js';
let { open, cursorPos, getCommittedColor, onClose, onCredits } = $props();
let { open, cursorPos, getCommittedColor, setOverlay, onClose, onCredits } = $props();
// Source image (decoded, palette-mapped)
let fileName = $state(null);
@@ -31,6 +31,10 @@
let lockAspect = $state(true);
let resampleMethod = $state('nearest'); // 'nearest' | 'bilinear' | 'box'
// On-canvas overlay preview
let showOverlay = $state(true);
let overlayAlpha = $state(0.6);
// Run state
let status = $state('idle'); // 'idle' | 'running' | 'paused' | 'done' | 'error'
let placed = $state(0);
@@ -131,6 +135,24 @@
$effect(() => { if (open && paletteIndices) renderPreview(); });
// Push overlay state to the canvas renderer whenever its inputs change.
// Clears on close, missing image, or toggle off.
$effect(() => {
if (!setOverlay) return;
if (open && showOverlay && paletteIndices && resizeW > 0 && resizeH > 0) {
setOverlay({
x: originX, y: originY,
width: resizeW, height: resizeH,
indices: paletteIndices,
alpha: overlayAlpha,
});
} else {
setOverlay(null);
}
// On unmount, clear.
return () => setOverlay?.(null);
});
function useCursor() {
originX = cursorPos?.x ?? 0;
originY = cursorPos?.y ?? 0;
@@ -269,6 +291,18 @@
<button onclick={useCursor} title="Set X/Y to current cursor position">Use cursor</button>
</div>
<div class="row">
<label class="checkbox" title="Show the palette-matched image on the main canvas at the target position.">
<input type="checkbox" bind:checked={showOverlay} />
Overlay
</label>
<label class="row" style="flex: 1; gap: 6px;">
<span style="font-size: 0.8rem; color: #aaa;">opacity</span>
<input type="range" min="0.1" max="1" step="0.05" bind:value={overlayAlpha} disabled={!showOverlay} />
<span style="font-size: 0.8rem; color: #aaa; width: 28px;">{Math.round(overlayAlpha * 100)}%</span>
</label>
</div>
<label class="row checkbox">
<input type="checkbox" bind:checked={skipMatching} />
Skip pixels that already match