polish realtime canvas editing interactions
This commit is contained in:
parent
215abdfafe
commit
5ca33daa75
@ -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>
|
||||||
))
|
))
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user