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, Upload,
Sparkles, Sparkles,
Save, Save,
Trash2,
Undo2, Undo2,
Redo2, Redo2,
Send, Send,
@ -686,6 +685,7 @@ export function RealtimeCanvasDialog({
const imageLayersRef = useRef<ImageLayer[]>([]); const imageLayersRef = useRef<ImageLayer[]>([]);
const colorInputRef = useRef<HTMLInputElement | null>(null); const colorInputRef = useRef<HTMLInputElement | null>(null);
const layerObjectUrlsRef = useRef<Set<string>>(new Set()); const layerObjectUrlsRef = useRef<Set<string>>(new Set());
const sidebarDraggingLayerIdRef = useRef<string | null>(null);
const drawingHasContentRef = useRef(false); const drawingHasContentRef = useRef(false);
const dragDepthRef = useRef(0); const dragDepthRef = useRef(0);
@ -702,7 +702,7 @@ export function RealtimeCanvasDialog({
const [, setStatusMessage] = useState("等待连接"); const [, setStatusMessage] = useState("等待连接");
const [prompt, setPrompt] = useState(FALLBACK_PROMPT); const [prompt, setPrompt] = useState(FALLBACK_PROMPT);
const [previewUrl, setPreviewUrl] = useState(""); 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 [brushColor, setBrushColor] = useState<string>(DEFAULT_BRUSH_COLOR);
const [canvasBackground, setCanvasBackground] = useState<string>(canvasBackgroundRef.current); const [canvasBackground, setCanvasBackground] = useState<string>(canvasBackgroundRef.current);
const [imageLayers, setImageLayers] = useState<ImageLayer[]>([]); const [imageLayers, setImageLayers] = useState<ImageLayer[]>([]);
@ -850,28 +850,35 @@ export function RealtimeCanvasDialog({
if (!ctx) return; if (!ctx) return;
const renderSeq = layerRenderSeqRef.current + 1; const renderSeq = layerRenderSeqRef.current + 1;
layerRenderSeqRef.current = renderSeq; 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]); }, [drawImageLayersToContext]);
const buildCompositeCanvas = useCallback(async () => { const buildCompositeCanvas = useCallback(async () => {
const drawingCanvas = canvasRef.current; const drawingCanvas = canvasRef.current;
if (!drawingCanvas) return null; 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"); const compositeCanvas = document.createElement("canvas");
compositeCanvas.width = drawingCanvas.width; compositeCanvas.width = drawingCanvas.width;
compositeCanvas.height = drawingCanvas.height; compositeCanvas.height = drawingCanvas.height;
const compositeCtx = compositeCanvas.getContext("2d"); const compositeCtx = compositeCanvas.getContext("2d");
if (!compositeCtx) return null; if (!compositeCtx) return null;
compositeCtx.setTransform(1, 0, 0, 1, 0, 0);
compositeCtx.scale(dpr, dpr);
const didRenderLayers = await drawImageLayersToContext( const didRenderLayers = await drawImageLayersToContext(
compositeCtx, compositeCtx,
compositeCanvas.width, cssWidth,
compositeCanvas.height cssHeight
); );
if (!didRenderLayers) { if (!didRenderLayers) {
return null; return null;
} }
compositeCtx.drawImage(drawingCanvas, 0, 0); compositeCtx.drawImage(drawingCanvas, 0, 0, cssWidth, cssHeight);
return compositeCanvas; return compositeCanvas;
}, [drawImageLayersToContext]); }, [drawImageLayersToContext]);
@ -1374,6 +1381,30 @@ export function RealtimeCanvasDialog({
[adaptiveThrottleMs, markCanvasDirty, pumpFlush] [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( const importFilesAsLayers = useCallback(
async (files: File[]) => { async (files: File[]) => {
if (files.length === 0) return 0; if (files.length === 0) return 0;
@ -1780,7 +1811,7 @@ export function RealtimeCanvasDialog({
if (!point) return; if (!point) return;
pointerDownRef.current = true; pointerDownRef.current = true;
lastPointRef.current = point; lastPointRef.current = point;
if (selectedImageLayer) { if (brushMode === "select" && selectedImageLayer) {
const resizeHandle = getResizeHandleAtPoint(point, selectedImageLayer); const resizeHandle = getResizeHandleAtPoint(point, selectedImageLayer);
if (resizeHandle) { if (resizeHandle) {
resizingLayerIdRef.current = selectedImageLayer.id; resizingLayerIdRef.current = selectedImageLayer.id;
@ -1814,7 +1845,7 @@ export function RealtimeCanvasDialog({
return; return;
} }
} }
const hitLayer = findLayerAtPoint(point); const hitLayer = brushMode === "select" ? findLayerAtPoint(point) : null;
if (hitLayer) { if (hitLayer) {
setSelectedLayerId(hitLayer.id); setSelectedLayerId(hitLayer.id);
draggingLayerIdRef.current = hitLayer.id; draggingLayerIdRef.current = hitLayer.id;
@ -1883,9 +1914,9 @@ export function RealtimeCanvasDialog({
const handlePointerEnter = useCallback(() => { const handlePointerEnter = useCallback(() => {
if (cursorRef.current) { if (cursorRef.current) {
cursorRef.current.style.opacity = cursorRef.current.style.opacity =
!selectedImageLayer && (brushMode === "brush" || brushMode === "eraser") ? "1" : "0"; brushMode === "brush" || brushMode === "eraser" ? "1" : "0";
} }
}, [brushMode, selectedImageLayer]); }, [brushMode]);
const handlePointerMove = useCallback( const handlePointerMove = useCallback(
(event: ReactPointerEvent<HTMLCanvasElement>) => { (event: ReactPointerEvent<HTMLCanvasElement>) => {
@ -1893,18 +1924,18 @@ export function RealtimeCanvasDialog({
// Update cursor // Update cursor
const point = getCanvasPoint(event); const point = getCanvasPoint(event);
if (point && cursorRef.current) { if (point && cursorRef.current) {
const resizeHandle = getResizeHandleAtPoint(point, selectedImageLayer); const resizeHandle =
brushMode === "select" ? getResizeHandleAtPoint(point, selectedImageLayer) : null;
event.currentTarget.style.cursor = resizeHandle event.currentTarget.style.cursor = resizeHandle
? getResizeCursor(resizeHandle) ? getResizeCursor(resizeHandle)
: selectedImageLayer : brushMode === "select" && selectedImageLayer
? "move" ? "move"
: brushMode === "brush" || brushMode === "eraser" : brushMode === "brush" || brushMode === "eraser"
? "none" ? "none"
: brushMode === "select" : brushMode === "select"
? "default" ? "default"
: "crosshair"; : "crosshair";
const showCursor = const showCursor = brushMode === "brush" || brushMode === "eraser";
!selectedImageLayer && (brushMode === "brush" || brushMode === "eraser");
if (showCursor) { if (showCursor) {
cursorRef.current.style.opacity = "1"; cursorRef.current.style.opacity = "1";
cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`; cursorRef.current.style.transform = `translate(${point.x - brushSize / 2}px, ${point.y - brushSize / 2}px)`;
@ -2737,7 +2768,7 @@ export function RealtimeCanvasDialog({
ref={canvasRef} ref={canvasRef}
className={cn( className={cn(
"absolute inset-0 touch-none shadow-sm block", "absolute inset-0 touch-none shadow-sm block",
selectedImageLayer brushMode === "select" && selectedImageLayer
? "cursor-move" ? "cursor-move"
: brushMode === "brush" || brushMode === "eraser" : brushMode === "brush" || brushMode === "eraser"
? "cursor-none" ? "cursor-none"
@ -2756,7 +2787,7 @@ export function RealtimeCanvasDialog({
ref={previewCanvasRef} ref={previewCanvasRef}
className="pointer-events-none absolute inset-0 touch-none" className="pointer-events-none absolute inset-0 touch-none"
/> />
{selectedImageLayer ? ( {selectedImageLayer && brushMode === "select" ? (
<div <div
className="pointer-events-none absolute border border-dashed border-sky-500 shadow-[0_0_0_1px_rgba(14,165,233,0.25)]" className="pointer-events-none absolute border border-dashed border-sky-500 shadow-[0_0_0_1px_rgba(14,165,233,0.25)]"
style={{ style={{
@ -2767,7 +2798,7 @@ export function RealtimeCanvasDialog({
}} }}
/> />
) : null} ) : null}
{selectedImageLayer {selectedImageLayer && brushMode === "select"
? ([ ? ([
{ key: "nw", left: selectedImageLayer.x, top: selectedImageLayer.y }, { key: "nw", left: selectedImageLayer.x, top: selectedImageLayer.y },
{ {
@ -2796,26 +2827,6 @@ export function RealtimeCanvasDialog({
/> />
)) ))
: null} : 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 */} {/* Custom Brush Cursor */}
<div <div
ref={cursorRef} ref={cursorRef}
@ -2845,19 +2856,38 @@ export function RealtimeCanvasDialog({
<button <button
key={layer.id} key={layer.id}
type="button" type="button"
draggable
className={cn( 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 selectedLayerId === layer.id
? "border-black/20 ring-2 ring-black/5" ? "bg-zinc-100 ring-2 ring-black/6"
: "border-black/10" : ""
)} )}
onClick={() => setSelectedLayerId(layer.id)} 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} title={layer.name}
> >
<img <img
src={/^https?:\/\//i.test(layer.src) ? buildImageProxyUrl(layer.src) : layer.src} src={/^https?:\/\//i.test(layer.src) ? buildImageProxyUrl(layer.src) : layer.src}
alt={layer.name} alt={layer.name}
className="h-11 w-11 rounded-[14px] border border-black/10 object-cover" className="h-11 w-11 object-contain"
/> />
</button> </button>
)) ))