Skip to content

Commit 0654039

Browse files
committed
ehtool: stabilize minimap and viewport interactions in editor
1 parent b8eef08 commit 0654039

1 file changed

Lines changed: 170 additions & 25 deletions

File tree

client/src/views/ehtool/ProofreadingEditor.js

Lines changed: 170 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ const ProofreadingEditor = forwardRef(
6868
const [tool, setTool] = useState("paint"); // 'paint', 'erase', or 'hand'
6969
const [paintBrushSize, setPaintBrushSize] = useState(10);
7070
const [eraseBrushSize, setEraseBrushSize] = useState(10);
71-
const [showMask, setShowMask] = useState(false);
71+
const [showMask, setShowMask] = useState(true);
7272
const [zoom, setZoom] = useState(1.0);
7373
const [isDrawing, setIsDrawing] = useState(false);
7474
const [cursorPos, setCursorPos] = useState({ x: 0, y: 0 }); // Image coordinates
@@ -93,9 +93,15 @@ const ProofreadingEditor = forwardRef(
9393
width: 0,
9494
height: 0,
9595
});
96+
const lastCanvasSizeRef = useRef({ width: 0, height: 0 });
9697
const lastDrawRef = useRef(null);
9798
const minimapRafRef = useRef(null);
9899
const axisViewStateRef = useRef({});
100+
const mousePosRafRef = useRef(null);
101+
const pendingMousePosRef = useRef(null);
102+
const minimapLayoutRef = useRef(null);
103+
const minimapSourceRef = useRef(null);
104+
const minimapOverlayRef = useRef(null);
99105

100106
// Expose handleSave to parent
101107
useImperativeHandle(ref, () => ({
@@ -138,6 +144,17 @@ const ProofreadingEditor = forwardRef(
138144
overlayActiveAlpha,
139145
]);
140146

147+
useEffect(() => {
148+
return () => {
149+
if (minimapRafRef.current) {
150+
cancelAnimationFrame(minimapRafRef.current);
151+
}
152+
if (mousePosRafRef.current) {
153+
cancelAnimationFrame(mousePosRafRef.current);
154+
}
155+
};
156+
}, []);
157+
141158
const resolveImageSource = (source) => {
142159
if (!source) return "";
143160
if (source.startsWith("data:image") || source.startsWith("blob:")) {
@@ -146,6 +163,30 @@ const ProofreadingEditor = forwardRef(
146163
return `data:image/png;base64,${source}`;
147164
};
148165

166+
const getMinimapLayout = (
167+
targetWidth,
168+
targetHeight,
169+
sourceWidth,
170+
sourceHeight,
171+
) => {
172+
if (!targetWidth || !targetHeight || !sourceWidth || !sourceHeight) {
173+
return null;
174+
}
175+
const scale = Math.min(
176+
targetWidth / sourceWidth,
177+
targetHeight / sourceHeight,
178+
);
179+
const drawWidth = sourceWidth * scale;
180+
const drawHeight = sourceHeight * scale;
181+
return {
182+
scale,
183+
drawWidth,
184+
drawHeight,
185+
drawX: (targetWidth - drawWidth) / 2,
186+
drawY: (targetHeight - drawHeight) / 2,
187+
};
188+
};
189+
149190
const loadRenderableImage = async (source) => {
150191
const normalized = resolveImageSource(source);
151192
if (!normalized) throw new Error("Image source is missing");
@@ -185,6 +226,26 @@ const ProofreadingEditor = forwardRef(
185226
);
186227
if (baseImage?.close) baseImage.close();
187228

229+
const previousCanvas = lastCanvasSizeRef.current;
230+
if (previousCanvas.width > 0 && previousCanvas.height > 0) {
231+
const scaleX = canvas.width / previousCanvas.width;
232+
const scaleY = canvas.height / previousCanvas.height;
233+
if (
234+
Number.isFinite(scaleX) &&
235+
Number.isFinite(scaleY) &&
236+
scaleX > 0 &&
237+
scaleY > 0
238+
) {
239+
setOffset((prev) => ({
240+
x: prev.x * scaleX,
241+
y: prev.y * scaleY,
242+
}));
243+
}
244+
}
245+
lastCanvasSizeRef.current = {
246+
width: canvas.width,
247+
height: canvas.height,
248+
};
188249
setCanvasDimensions({ width: canvas.width, height: canvas.height });
189250

190251
if (maskBase64) {
@@ -227,14 +288,14 @@ const ProofreadingEditor = forwardRef(
227288
ctx.imageSmoothingEnabled = false;
228289
ctx.putImageData(imageDataRef.current, 0, 0);
229290

230-
if (overlayAllRef.current && overlayAllAlpha > 0.001) {
291+
if (showMask && overlayAllRef.current && overlayAllAlpha > 0.001) {
231292
ctx.save();
232293
ctx.globalAlpha = overlayAllAlpha;
233294
ctx.drawImage(overlayAllRef.current, 0, 0, canvas.width, canvas.height);
234295
ctx.restore();
235296
}
236297

237-
if (overlayActiveAlpha > 0.001) {
298+
if (showMask && overlayActiveAlpha > 0.001) {
238299
// Use editable mask as the only active-layer source to avoid opacity
239300
// compounding when repainting already-active pixels.
240301
if (maskDataRef.current) {
@@ -301,9 +362,24 @@ const ProofreadingEditor = forwardRef(
301362
const rect = canvas.getBoundingClientRect();
302363
const mx = e.clientX - rect.left;
303364
const my = e.clientY - rect.top;
304-
305-
const ix = (mx / rect.width) * canvasDimensions.width;
306-
const iy = (my / rect.height) * canvasDimensions.height;
365+
const layout = minimapLayoutRef.current;
366+
if (!layout) return;
367+
const clampedX = Math.max(
368+
layout.drawX,
369+
Math.min(mx, layout.drawX + layout.drawWidth),
370+
);
371+
const clampedY = Math.max(
372+
layout.drawY,
373+
Math.min(my, layout.drawY + layout.drawHeight),
374+
);
375+
const relX =
376+
layout.drawWidth > 0 ? (clampedX - layout.drawX) / layout.drawWidth : 0;
377+
const relY =
378+
layout.drawHeight > 0
379+
? (clampedY - layout.drawY) / layout.drawHeight
380+
: 0;
381+
const ix = relX * canvasDimensions.width;
382+
const iy = relY * canvasDimensions.height;
307383

308384
setOffset({
309385
x: -(ix - canvasDimensions.width / 2) * zoom,
@@ -340,22 +416,82 @@ const ProofreadingEditor = forwardRef(
340416
ctx.fillStyle = "#000";
341417
ctx.fillRect(0, 0, width, height);
342418

343-
// Mirror the currently rendered viewport source so minimap stays faithful.
344-
const mainCanvas = canvasRef.current;
345-
if (mainCanvas) {
346-
ctx.drawImage(mainCanvas, 0, 0, width, height);
347-
} else {
348-
const tempCanvas = document.createElement("canvas");
349-
tempCanvas.width = canvasDimensions.width;
350-
tempCanvas.height = canvasDimensions.height;
351-
const tempCtx = tempCanvas.getContext("2d");
352-
tempCtx.putImageData(imageDataRef.current, 0, 0);
353-
ctx.drawImage(tempCanvas, 0, 0, width, height);
419+
if (!minimapSourceRef.current) {
420+
minimapSourceRef.current = document.createElement("canvas");
421+
}
422+
if (!minimapOverlayRef.current) {
423+
minimapOverlayRef.current = document.createElement("canvas");
424+
}
425+
const sourceCanvas = minimapSourceRef.current;
426+
sourceCanvas.width = canvasDimensions.width;
427+
sourceCanvas.height = canvasDimensions.height;
428+
const sourceCtx = sourceCanvas.getContext("2d");
429+
sourceCtx.clearRect(0, 0, sourceCanvas.width, sourceCanvas.height);
430+
sourceCtx.putImageData(imageDataRef.current, 0, 0);
431+
432+
if (showMask && overlayAllRef.current && overlayAllAlpha > 0.001) {
433+
sourceCtx.save();
434+
sourceCtx.globalAlpha = overlayAllAlpha;
435+
sourceCtx.drawImage(
436+
overlayAllRef.current,
437+
0,
438+
0,
439+
sourceCanvas.width,
440+
sourceCanvas.height,
441+
);
442+
sourceCtx.restore();
443+
}
444+
445+
if (showMask && overlayActiveAlpha > 0.001) {
446+
if (maskDataRef.current) {
447+
const overlayCanvas = minimapOverlayRef.current;
448+
overlayCanvas.width = sourceCanvas.width;
449+
overlayCanvas.height = sourceCanvas.height;
450+
const overlayCtx = overlayCanvas.getContext("2d");
451+
overlayCtx.clearRect(0, 0, overlayCanvas.width, overlayCanvas.height);
452+
overlayCtx.putImageData(
453+
createMaskOverlay(
454+
maskDataRef.current,
455+
activeColor,
456+
overlayActiveAlpha,
457+
),
458+
0,
459+
0,
460+
);
461+
sourceCtx.drawImage(overlayCanvas, 0, 0);
462+
} else if (overlayActiveRef.current) {
463+
sourceCtx.save();
464+
sourceCtx.globalAlpha = overlayActiveAlpha;
465+
sourceCtx.drawImage(
466+
overlayActiveRef.current,
467+
0,
468+
0,
469+
sourceCanvas.width,
470+
sourceCanvas.height,
471+
);
472+
sourceCtx.restore();
473+
}
354474
}
355475

476+
const layout = getMinimapLayout(
477+
width,
478+
height,
479+
canvasDimensions.width,
480+
canvasDimensions.height,
481+
);
482+
if (!layout) return;
483+
minimapLayoutRef.current = layout;
484+
ctx.drawImage(
485+
sourceCanvas,
486+
layout.drawX,
487+
layout.drawY,
488+
layout.drawWidth,
489+
layout.drawHeight,
490+
);
491+
356492
// Draw viewport rectangle
357-
const scaleX = width / canvasDimensions.width;
358-
const scaleY = height / canvasDimensions.height;
493+
const scaleX = layout.drawWidth / canvasDimensions.width;
494+
const scaleY = layout.drawHeight / canvasDimensions.height;
359495

360496
const halfW = container.clientWidth / 2;
361497
const halfH = container.clientHeight / 2;
@@ -387,15 +523,15 @@ const ProofreadingEditor = forwardRef(
387523
ctx.strokeStyle = "#ff4d4f";
388524
ctx.lineWidth = 2;
389525
ctx.strokeRect(
390-
clampedLeft * scaleX,
391-
clampedTop * scaleY,
526+
layout.drawX + clampedLeft * scaleX,
527+
layout.drawY + clampedTop * scaleY,
392528
rectW * scaleX,
393529
rectH * scaleY,
394530
);
395531
ctx.fillStyle = "rgba(255, 77, 79, 0.2)";
396532
ctx.fillRect(
397-
clampedLeft * scaleX,
398-
clampedTop * scaleY,
533+
layout.drawX + clampedLeft * scaleX,
534+
layout.drawY + clampedTop * scaleY,
399535
rectW * scaleX,
400536
rectH * scaleY,
401537
);
@@ -614,8 +750,16 @@ const ProofreadingEditor = forwardRef(
614750
const container = containerRef.current;
615751
if (container) {
616752
const rect = container.getBoundingClientRect();
617-
// Use client coordinates directly for more speed-resilient tracking
618-
setMousePos({ x: e.clientX - rect.left, y: e.clientY - rect.top });
753+
pendingMousePosRef.current = {
754+
x: e.clientX - rect.left,
755+
y: e.clientY - rect.top,
756+
};
757+
if (!mousePosRafRef.current) {
758+
mousePosRafRef.current = requestAnimationFrame(() => {
759+
mousePosRafRef.current = null;
760+
setMousePos(pendingMousePosRef.current);
761+
});
762+
}
619763
}
620764

621765
const coords = getCanvasCoordinates(e);
@@ -638,6 +782,7 @@ const ProofreadingEditor = forwardRef(
638782
setIsDrawing(false);
639783
setIsPanning(false);
640784
lastDrawRef.current = null;
785+
setMousePos(null);
641786
drawMinimap();
642787
};
643788

0 commit comments

Comments
 (0)