diff --git a/plans/260417-1125-image-importer-enhancements/phase-02-overlay-preview.md b/plans/260417-1125-image-importer-enhancements/phase-02-overlay-preview.md index 8b14ea9..a6096e5 100644 --- a/plans/260417-1125-image-importer-enhancements/phase-02-overlay-preview.md +++ b/plans/260417-1125-image-importer-enhancements/phase-02-overlay-preview.md @@ -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. diff --git a/plans/260417-1125-image-importer-enhancements/plan.md b/plans/260417-1125-image-importer-enhancements/plan.md index 2b87ff3..e00e1f3 100644 --- a/plans/260417-1125-image-importer-enhancements/plan.md +++ b/plans/260417-1125-image-importer-enhancements/plan.md @@ -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 | diff --git a/src/client/App.svelte b/src/client/App.svelte index 160e05e..ff1a99a 100644 --- a/src/client/App.svelte +++ b/src/client/App.svelte @@ -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} /> diff --git a/src/client/components/CanvasRenderer.svelte b/src/client/components/CanvasRenderer.svelte index 5c83b52..bd53d16 100644 --- a/src/client/components/CanvasRenderer.svelte +++ b/src/client/components/CanvasRenderer.svelte @@ -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; diff --git a/src/client/components/ImageImporter.svelte b/src/client/components/ImageImporter.svelte index 03a6c95..8c03426 100644 --- a/src/client/components/ImageImporter.svelte +++ b/src/client/components/ImageImporter.svelte @@ -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 @@ +
+ + +
+