polish realtime canvas editing interactions
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-03-08 02:42:24 +08:00
parent 215abdfafe
commit 5ca33daa75

View File

@ -35,7 +35,6 @@ import {
Upload,
Sparkles,
Save,
Trash2,
Undo2,
Redo2,
Send,
@ -686,6 +685,7 @@ export function RealtimeCanvasDialog({
const imageLayersRef = useRef<ImageLayer[]>([]);
const colorInputRef = useRef<HTMLInputElement | null>(null);
const layerObjectUrlsRef = useRef<Set<string>>(new Set());
const sidebarDraggingLayerIdRef = useRef<string | null>(null);
const drawingHasContentRef = useRef(false);
const dragDepthRef = useRef(0);
@ -702,7 +702,7 @@ export function RealtimeCanvasDialog({
const [, setStatusMessage] = useState("等待连接");
const [prompt, setPrompt] = useState(FALLBACK_PROMPT);
const [previewUrl, setPreviewUrl] = useState("");
const [brushMode, setBrushMode] = useState<RealtimeBrushMode>("brush");
const [brushMode, setBrushMode] = useState<RealtimeBrushMode>("select");
const [brushColor, setBrushColor] = useState<string>(DEFAULT_BRUSH_COLOR);
const [canvasBackground, setCanvasBackground] = useState<string>(canvasBackgroundRef.current);
const [imageLayers, setImageLayers] = useState<ImageLayer[]>([]);
@ -850,28 +850,35 @@ export function RealtimeCanvasDialog({
if (!ctx) return;
const renderSeq = layerRenderSeqRef.current + 1;
layerRenderSeqRef.current = renderSeq;
await drawImageLayersToContext(ctx, layerCanvas.width, layerCanvas.height, { renderSeq });
const cssWidth = Math.max(1, parseFloat(layerCanvas.style.width) || layerCanvas.width);
const cssHeight = Math.max(1, parseFloat(layerCanvas.style.height) || layerCanvas.height);
await drawImageLayersToContext(ctx, cssWidth, cssHeight, { renderSeq });
}, [drawImageLayersToContext]);
const buildCompositeCanvas = useCallback(async () => {
const drawingCanvas = canvasRef.current;
if (!drawingCanvas) return null;
const cssWidth = Math.max(1, parseFloat(drawingCanvas.style.width) || drawingCanvas.width);
const cssHeight = Math.max(1, parseFloat(drawingCanvas.style.height) || drawingCanvas.height);
const dpr = Math.max(1, drawingCanvas.width / cssWidth);
const compositeCanvas = document.createElement("canvas");
compositeCanvas.width = drawingCanvas.width;
compositeCanvas.height = drawingCanvas.height;
const compositeCtx = compositeCanvas.getContext("2d");
if (!compositeCtx) return null;
compositeCtx.setTransform(1, 0, 0, 1, 0, 0);
compositeCtx.scale(dpr, dpr);
const didRenderLayers = await drawImageLayersToContext(
compositeCtx,
compositeCanvas.width,
compositeCanvas.height
cssWidth,
cssHeight
);
if (!didRenderLayers) {
return null;
}
compositeCtx.drawImage(drawingCanvas, 0, 0);
compositeCtx.drawImage(drawingCanvas, 0, 0, cssWidth, cssHeight);
return compositeCanvas;
}, [drawImageLayersToContext]);
@ -1374,6 +1381,30 @@ export function RealtimeCanvasDialog({
[adaptiveThrottleMs, markCanvasDirty, pumpFlush]
);
const reorderLayersFromSidebar = useCallback(
(draggedLayerId: string, targetLayerId: string) => {
if (!draggedLayerId || !targetLayerId || draggedLayerId === targetLayerId) return;
const displayLayers = [...imageLayersRef.current].reverse();
const sourceIndex = displayLayers.findIndex((layer) => layer.id === draggedLayerId);
const targetIndex = displayLayers.findIndex((layer) => layer.id === targetLayerId);
if (sourceIndex === -1 || targetIndex === -1 || sourceIndex === targetIndex) return;
const nextDisplayLayers = [...displayLayers];
const [movedLayer] = nextDisplayLayers.splice(sourceIndex, 1);
nextDisplayLayers.splice(targetIndex, 0, movedLayer);
const nextLayers = nextDisplayLayers.reverse();
imageLayersRef.current = nextLayers;
setImageLayers(nextLayers);
setSelectedLayerId(draggedLayerId);
saveHistory(nextLayers);
markCanvasDirty(null);
scheduleFlush(true, false, "refine");
},
[markCanvasDirty, saveHistory, scheduleFlush]
);
const importFilesAsLayers = useCallback(
async (files: File[]) => {
if (files.length === 0) return 0;
@ -1780,7 +1811,7 @@ export function RealtimeCanvasDialog({
if (!point) return;
pointerDownRef.current = true;
lastPointRef.current = point;
if (selectedImageLayer) {
if (brushMode === "select" && selectedImageLayer) {
const resizeHandle = getResizeHandleAtPoint(point, selectedImageLayer);
if (resizeHandle) {
resizingLayerIdRef.current = selectedImageLayer.id;
@ -1814,7 +1845,7 @@ export function RealtimeCanvasDialog({
return;
}
}
const hitLayer = findLayerAtPoint(point);
const hitLayer = brushMode === "select" ? findLayerAtPoint(point) : null;
if (hitLayer) {
setSelectedLayerId(hitLayer.id);
draggingLayerIdRef.current = hitLayer.id;
@ -1883,9 +1914,9 @@ export function RealtimeCanvasDialog({
const handlePointerEnter = useCallback(() => {
if (cursorRef.current) {
cursorRef.current.style.opacity =
!selectedImageLayer && (brushMode === "brush" || brushMode === "eraser") ? "1" : "0";
brushMode === "brush" || brushMode === "eraser" ? "1" : "0";
}
}, [brushMode, selectedImageLayer]);
}, [brushMode]);
const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => {
@ -1893,18 +1924,18 @@ export function RealtimeCanvasDialog({
// Update cursor
const point = getCanvasPoint(event);
if (point && cursorRef.current) {
const resizeHandle = getResizeHandleAtPoint(point, selectedImageLayer);
const resizeHandle =
brushMode === "select" ? getResizeHandleAtPoint(point, selectedImageLayer) : null;
event.currentTarget.style.cursor = resizeHandle
? getResizeCursor(resizeHandle)
: selectedImageLayer
: brushMode === "select" && selectedImageLayer
? "move"
: brushMode === "brush" || brushMode === "eraser"
? "none"
: brushMode === "select"
? "default"
: "crosshair";
const showCursor =
!selectedImageLayer && (brushMode === "brush" || brushMode === "eraser");
const showCursor = brushMode === "brush" || brushMode === "eraser";
if (showCursor) {
cursorRef.current.style.opacity = "1";
cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`;
@ -2737,7 +2768,7 @@ export function RealtimeCanvasDialog({
ref={canvasRef}
className={cn(
"absolute inset-0 touch-none shadow-sm block",
selectedImageLayer
brushMode === "select" && selectedImageLayer
? "cursor-move"
: brushMode === "brush" || brushMode === "eraser"
? "cursor-none"
@ -2756,7 +2787,7 @@ export function RealtimeCanvasDialog({
ref={previewCanvasRef}
className="pointer-events-none absolute inset-0 touch-none"
/>
{selectedImageLayer ? (
{selectedImageLayer && brushMode === "select" ? (
<div
className="pointer-events-none absolute border border-dashed border-sky-500 shadow-[0_0_0_1px_rgba(14,165,233,0.25)]"
style={{
@ -2767,7 +2798,7 @@ export function RealtimeCanvasDialog({
}}
/>
) : null}
{selectedImageLayer
{selectedImageLayer && brushMode === "select"
? ([
{ key: "nw", left: selectedImageLayer.x, top: selectedImageLayer.y },
{
@ -2796,26 +2827,6 @@ export function RealtimeCanvasDialog({
/>
))
: null}
{selectedImageLayer ? (
<div
className="absolute z-20"
style={{
left: Math.max(8, selectedImageLayer.x + selectedImageLayer.width - 84),
top: Math.max(8, selectedImageLayer.y - 42),
}}
>
<Button
type="button"
size="sm"
className="h-8 rounded-full bg-black px-3 text-xs font-medium text-white shadow-lg hover:bg-black/90"
onClick={deleteSelectedImageLayer}
title="删除图层"
>
<Trash2 className="mr-1.5 h-3.5 w-3.5" />
删除
</Button>
</div>
) : null}
{/* Custom Brush Cursor */}
<div
ref={cursorRef}
@ -2845,19 +2856,38 @@ export function RealtimeCanvasDialog({
<button
key={layer.id}
type="button"
draggable
className={cn(
"flex w-full items-center justify-center rounded-[18px] border bg-white p-1.5 shadow-sm transition hover:border-black/20",
"flex w-full items-center justify-center rounded-[16px] p-1 transition hover:bg-zinc-50",
selectedLayerId === layer.id
? "border-black/20 ring-2 ring-black/5"
: "border-black/10"
? "bg-zinc-100 ring-2 ring-black/6"
: ""
)}
onClick={() => setSelectedLayerId(layer.id)}
onDragStart={() => {
sidebarDraggingLayerIdRef.current = layer.id;
}}
onDragEnd={() => {
sidebarDraggingLayerIdRef.current = null;
}}
onDragOver={(event) => {
event.preventDefault();
event.dataTransfer.dropEffect = "move";
}}
onDrop={(event) => {
event.preventDefault();
const draggingId = sidebarDraggingLayerIdRef.current;
if (draggingId) {
reorderLayersFromSidebar(draggingId, layer.id);
}
sidebarDraggingLayerIdRef.current = null;
}}
title={layer.name}
>
<img
src={/^https?:\/\//i.test(layer.src) ? buildImageProxyUrl(layer.src) : layer.src}
alt={layer.name}
className="h-11 w-11 rounded-[14px] border border-black/10 object-cover"
className="h-11 w-11 object-contain"
/>
</button>
))