mirror of
https://github.com/tiennm99/rplace.git
synced 2026-10-11 03:13:48 +00:00
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:
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 |
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user