refine realtime canvas layer interactions
This commit is contained in:
parent
440f778bf3
commit
215abdfafe
@ -33,12 +33,9 @@ import {
|
||||
Circle as CircleIcon,
|
||||
Slash,
|
||||
Upload,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Sparkles,
|
||||
Save,
|
||||
Trash2,
|
||||
Download,
|
||||
Undo2,
|
||||
Redo2,
|
||||
Send,
|
||||
@ -71,6 +68,7 @@ type RealtimeConfig = {
|
||||
|
||||
type RealtimeStatus = "idle" | "connecting" | "live" | "error";
|
||||
type RealtimeBrushMode = "select" | "brush" | "eraser" | "rect" | "circle" | "line";
|
||||
type LayerResizeHandle = "nw" | "ne" | "sw" | "se";
|
||||
|
||||
type ImageLayer = {
|
||||
id: string;
|
||||
@ -122,6 +120,10 @@ function cloneImageLayers(layers: ImageLayer[]) {
|
||||
return layers.map((layer) => ({ ...layer }));
|
||||
}
|
||||
|
||||
function getResizeCursor(handle: LayerResizeHandle) {
|
||||
return handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize";
|
||||
}
|
||||
|
||||
function readRealtimeProjectsFromStorage() {
|
||||
if (typeof window === "undefined") return [] as SavedRealtimeProject[];
|
||||
try {
|
||||
@ -643,12 +645,22 @@ export function RealtimeCanvasDialog({
|
||||
const connectionRef = useRef<any>(null);
|
||||
const pointerDownRef = useRef(false);
|
||||
const draggingLayerIdRef = useRef<string | null>(null);
|
||||
const resizingLayerIdRef = useRef<string | null>(null);
|
||||
const layerDragOriginRef = useRef<{
|
||||
pointerX: number;
|
||||
pointerY: number;
|
||||
layerX: number;
|
||||
layerY: number;
|
||||
} | null>(null);
|
||||
const layerResizeOriginRef = useRef<{
|
||||
pointerX: number;
|
||||
pointerY: number;
|
||||
layerX: number;
|
||||
layerY: number;
|
||||
layerWidth: number;
|
||||
layerHeight: number;
|
||||
handle: LayerResizeHandle;
|
||||
} | null>(null);
|
||||
const lastPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const shapeStartPointRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const pendingFlushTimerRef = useRef<number | null>(null);
|
||||
@ -719,11 +731,6 @@ export function RealtimeCanvasDialog({
|
||||
() => imageLayers.find((layer) => layer.id === selectedLayerId) || null,
|
||||
[imageLayers, selectedLayerId]
|
||||
);
|
||||
const selectedImageLayerScale = useMemo(() => {
|
||||
if (!selectedImageLayer) return 1;
|
||||
const baseWidth = selectedImageLayer.baseWidth || selectedImageLayer.width || 1;
|
||||
return selectedImageLayer.width / baseWidth;
|
||||
}, [selectedImageLayer]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
@ -914,6 +921,31 @@ export function RealtimeCanvasDialog({
|
||||
return null;
|
||||
}, []);
|
||||
|
||||
const getResizeHandleAtPoint = useCallback(
|
||||
(point: { x: number; y: number }, layer: ImageLayer | null): LayerResizeHandle | null => {
|
||||
if (!layer) return null;
|
||||
const handleSize = 14;
|
||||
const half = handleSize / 2;
|
||||
const handles: Array<{ key: LayerResizeHandle; x: number; y: number }> = [
|
||||
{ key: "nw", x: layer.x, y: layer.y },
|
||||
{ key: "ne", x: layer.x + layer.width, y: layer.y },
|
||||
{ key: "sw", x: layer.x, y: layer.y + layer.height },
|
||||
{ key: "se", x: layer.x + layer.width, y: layer.y + layer.height },
|
||||
];
|
||||
|
||||
for (const handle of handles) {
|
||||
const withinX = point.x >= handle.x - half && point.x <= handle.x + half;
|
||||
const withinY = point.y >= handle.y - half && point.y <= handle.y + half;
|
||||
if (withinX && withinY) {
|
||||
return handle.key;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const updateLayerPosition = useCallback((layerId: string, x: number, y: number) => {
|
||||
const nextLayers = imageLayersRef.current.map((layer) =>
|
||||
layer.id === layerId ? { ...layer, x, y } : layer
|
||||
@ -923,39 +955,83 @@ export function RealtimeCanvasDialog({
|
||||
return nextLayers;
|
||||
}, []);
|
||||
|
||||
const scaleImageLayer = useCallback((layerId: string, nextScale: number) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasWidth = Math.max(1, rect.width);
|
||||
const canvasHeight = Math.max(1, rect.height);
|
||||
const resizeLayerFromHandle = useCallback(
|
||||
(layerId: string, point: { x: number; y: number }, origin: NonNullable<typeof layerResizeOriginRef.current>) => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const nextLayers = imageLayersRef.current.map((layer) => {
|
||||
if (layer.id !== layerId) return layer;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const canvasWidth = Math.max(1, rect.width);
|
||||
const canvasHeight = Math.max(1, rect.height);
|
||||
const aspectRatio = Math.max(0.01, origin.layerWidth / Math.max(1, origin.layerHeight));
|
||||
const minSize = 24;
|
||||
|
||||
const baseWidth = layer.baseWidth || layer.width;
|
||||
const baseHeight = layer.baseHeight || layer.height;
|
||||
const normalizedScale = Math.min(3, Math.max(0.2, nextScale));
|
||||
const targetWidth = Math.min(canvasWidth, Math.max(24, baseWidth * normalizedScale));
|
||||
const targetHeight = Math.min(canvasHeight, Math.max(24, baseHeight * normalizedScale));
|
||||
const centerX = layer.x + layer.width / 2;
|
||||
const centerY = layer.y + layer.height / 2;
|
||||
const maxX = Math.max(0, canvasWidth - targetWidth);
|
||||
const maxY = Math.max(0, canvasHeight - targetHeight);
|
||||
let anchorX = origin.layerX;
|
||||
let anchorY = origin.layerY;
|
||||
let maxWidth = canvasWidth;
|
||||
let maxHeight = canvasHeight;
|
||||
|
||||
return {
|
||||
...layer,
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
x: Math.min(maxX, Math.max(0, centerX - targetWidth / 2)),
|
||||
y: Math.min(maxY, Math.max(0, centerY - targetHeight / 2)),
|
||||
};
|
||||
});
|
||||
if (origin.handle === "nw") {
|
||||
anchorX = origin.layerX + origin.layerWidth;
|
||||
anchorY = origin.layerY + origin.layerHeight;
|
||||
maxWidth = anchorX;
|
||||
maxHeight = anchorY;
|
||||
} else if (origin.handle === "ne") {
|
||||
anchorX = origin.layerX;
|
||||
anchorY = origin.layerY + origin.layerHeight;
|
||||
maxWidth = canvasWidth - anchorX;
|
||||
maxHeight = anchorY;
|
||||
} else if (origin.handle === "sw") {
|
||||
anchorX = origin.layerX + origin.layerWidth;
|
||||
anchorY = origin.layerY;
|
||||
maxWidth = anchorX;
|
||||
maxHeight = canvasHeight - anchorY;
|
||||
} else {
|
||||
anchorX = origin.layerX;
|
||||
anchorY = origin.layerY;
|
||||
maxWidth = canvasWidth - anchorX;
|
||||
maxHeight = canvasHeight - anchorY;
|
||||
}
|
||||
|
||||
imageLayersRef.current = nextLayers;
|
||||
setImageLayers(nextLayers);
|
||||
return nextLayers;
|
||||
}, []);
|
||||
let targetWidth = Math.max(minSize, Math.abs(point.x - anchorX));
|
||||
let targetHeight = targetWidth / aspectRatio;
|
||||
if (targetHeight < minSize) {
|
||||
targetHeight = minSize;
|
||||
targetWidth = targetHeight * aspectRatio;
|
||||
}
|
||||
|
||||
if (targetHeight > Math.abs(point.y - anchorY)) {
|
||||
targetHeight = Math.max(minSize, Math.abs(point.y - anchorY));
|
||||
targetWidth = targetHeight * aspectRatio;
|
||||
}
|
||||
|
||||
const fitRatio = Math.min(1, maxWidth / targetWidth, maxHeight / targetHeight);
|
||||
targetWidth = Math.max(minSize, targetWidth * fitRatio);
|
||||
targetHeight = Math.max(minSize, targetHeight * fitRatio);
|
||||
|
||||
const nextX =
|
||||
origin.handle === "ne" || origin.handle === "se" ? anchorX : anchorX - targetWidth;
|
||||
const nextY =
|
||||
origin.handle === "sw" || origin.handle === "se" ? anchorY : anchorY - targetHeight;
|
||||
|
||||
const nextLayers = imageLayersRef.current.map((layer) =>
|
||||
layer.id === layerId
|
||||
? {
|
||||
...layer,
|
||||
x: Math.max(0, nextX),
|
||||
y: Math.max(0, nextY),
|
||||
width: Math.min(canvasWidth, targetWidth),
|
||||
height: Math.min(canvasHeight, targetHeight),
|
||||
}
|
||||
: layer
|
||||
);
|
||||
|
||||
imageLayersRef.current = nextLayers;
|
||||
setImageLayers(nextLayers);
|
||||
return nextLayers;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const exportDrawingLayerDataUrl = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
@ -1705,6 +1781,21 @@ export function RealtimeCanvasDialog({
|
||||
pointerDownRef.current = true;
|
||||
lastPointRef.current = point;
|
||||
if (selectedImageLayer) {
|
||||
const resizeHandle = getResizeHandleAtPoint(point, selectedImageLayer);
|
||||
if (resizeHandle) {
|
||||
resizingLayerIdRef.current = selectedImageLayer.id;
|
||||
layerResizeOriginRef.current = {
|
||||
pointerX: point.x,
|
||||
pointerY: point.y,
|
||||
layerX: selectedImageLayer.x,
|
||||
layerY: selectedImageLayer.y,
|
||||
layerWidth: selectedImageLayer.width,
|
||||
layerHeight: selectedImageLayer.height,
|
||||
handle: resizeHandle,
|
||||
};
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
return;
|
||||
}
|
||||
const hitSelectedLayer =
|
||||
point.x >= selectedImageLayer.x &&
|
||||
point.x <= selectedImageLayer.x + selectedImageLayer.width &&
|
||||
@ -1761,6 +1852,7 @@ export function RealtimeCanvasDialog({
|
||||
drawStroke,
|
||||
findLayerAtPoint,
|
||||
getCanvasPoint,
|
||||
getResizeHandleAtPoint,
|
||||
markCanvasDirtyStroke,
|
||||
selectedImageLayer,
|
||||
scheduleFlush,
|
||||
@ -1772,12 +1864,14 @@ export function RealtimeCanvasDialog({
|
||||
if (cursorRef.current) {
|
||||
cursorRef.current.style.opacity = '0';
|
||||
}
|
||||
if (draggingLayerIdRef.current) {
|
||||
if (draggingLayerIdRef.current || resizingLayerIdRef.current) {
|
||||
return;
|
||||
}
|
||||
pointerDownRef.current = false;
|
||||
draggingLayerIdRef.current = null;
|
||||
resizingLayerIdRef.current = null;
|
||||
layerDragOriginRef.current = null;
|
||||
layerResizeOriginRef.current = null;
|
||||
lastPointRef.current = null;
|
||||
shapeStartPointRef.current = null;
|
||||
clearPreviewCanvas();
|
||||
@ -1799,20 +1893,36 @@ export function RealtimeCanvasDialog({
|
||||
// Update cursor
|
||||
const point = getCanvasPoint(event);
|
||||
if (point && cursorRef.current) {
|
||||
const showCursor =
|
||||
!selectedImageLayer && (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)`;
|
||||
cursorRef.current.style.width = `${brushSize}px`;
|
||||
cursorRef.current.style.height = `${brushSize}px`;
|
||||
} else {
|
||||
cursorRef.current.style.opacity = "0";
|
||||
}
|
||||
const resizeHandle = getResizeHandleAtPoint(point, selectedImageLayer);
|
||||
event.currentTarget.style.cursor = resizeHandle
|
||||
? getResizeCursor(resizeHandle)
|
||||
: selectedImageLayer
|
||||
? "move"
|
||||
: brushMode === "brush" || brushMode === "eraser"
|
||||
? "none"
|
||||
: brushMode === "select"
|
||||
? "default"
|
||||
: "crosshair";
|
||||
const showCursor =
|
||||
!selectedImageLayer && (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)`;
|
||||
cursorRef.current.style.width = `${brushSize}px`;
|
||||
cursorRef.current.style.height = `${brushSize}px`;
|
||||
} else {
|
||||
cursorRef.current.style.opacity = "0";
|
||||
}
|
||||
}
|
||||
|
||||
if (!pointerDownRef.current) return;
|
||||
if (!point || !lastPointRef.current) return;
|
||||
if (resizingLayerIdRef.current && layerResizeOriginRef.current) {
|
||||
resizeLayerFromHandle(resizingLayerIdRef.current, point, layerResizeOriginRef.current);
|
||||
markCanvasDirty(null);
|
||||
scheduleFlush(false, false, "preview");
|
||||
return;
|
||||
}
|
||||
if (draggingLayerIdRef.current && layerDragOriginRef.current) {
|
||||
const activeLayer = imageLayersRef.current.find(
|
||||
(layer) => layer.id === draggingLayerIdRef.current
|
||||
@ -1860,8 +1970,10 @@ export function RealtimeCanvasDialog({
|
||||
scheduleFlush,
|
||||
brushMode,
|
||||
brushSize,
|
||||
getResizeHandleAtPoint,
|
||||
markCanvasDirty,
|
||||
markCanvasDirtyStroke,
|
||||
resizeLayerFromHandle,
|
||||
selectedImageLayer,
|
||||
updateLayerPosition,
|
||||
]
|
||||
@ -1871,9 +1983,24 @@ export function RealtimeCanvasDialog({
|
||||
(event: ReactPointerEvent<HTMLCanvasElement>) => {
|
||||
event.preventDefault();
|
||||
pointerDownRef.current = false;
|
||||
const wasResizingLayer = resizingLayerIdRef.current;
|
||||
const wasDraggingLayer = draggingLayerIdRef.current;
|
||||
const endPoint = getCanvasPoint(event);
|
||||
const shapeStart = shapeStartPointRef.current;
|
||||
if (wasResizingLayer) {
|
||||
resizingLayerIdRef.current = null;
|
||||
layerResizeOriginRef.current = null;
|
||||
lastPointRef.current = null;
|
||||
shapeStartPointRef.current = null;
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
saveHistory(imageLayersRef.current);
|
||||
scheduleFlush(true, false, "refine");
|
||||
return;
|
||||
}
|
||||
if (wasDraggingLayer) {
|
||||
draggingLayerIdRef.current = null;
|
||||
layerDragOriginRef.current = null;
|
||||
@ -1904,6 +2031,7 @@ export function RealtimeCanvasDialog({
|
||||
lastPointRef.current = null;
|
||||
shapeStartPointRef.current = null;
|
||||
layerDragOriginRef.current = null;
|
||||
layerResizeOriginRef.current = null;
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
@ -1928,10 +2056,31 @@ export function RealtimeCanvasDialog({
|
||||
clearPreviewCanvas();
|
||||
}, [brushMode, clearPreviewCanvas]);
|
||||
|
||||
const deleteSelectedImageLayer = useCallback(() => {
|
||||
if (selectedLayerId === DRAW_LAYER_ID) return;
|
||||
|
||||
const nextLayers = imageLayersRef.current.filter((layer) => layer.id !== selectedLayerId);
|
||||
if (nextLayers.length === imageLayersRef.current.length) return;
|
||||
|
||||
imageLayersRef.current = nextLayers;
|
||||
setImageLayers(nextLayers);
|
||||
setSelectedLayerId(DRAW_LAYER_ID);
|
||||
saveHistory(nextLayers);
|
||||
markCanvasDirty(null);
|
||||
scheduleFlush(true, false, "refine");
|
||||
}, [markCanvasDirty, saveHistory, scheduleFlush, selectedLayerId]);
|
||||
|
||||
// Shortcuts
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
const activeElement = document.activeElement as HTMLElement | null;
|
||||
const isTypingTarget =
|
||||
activeElement instanceof HTMLInputElement ||
|
||||
activeElement instanceof HTMLTextAreaElement ||
|
||||
activeElement instanceof HTMLSelectElement ||
|
||||
activeElement?.isContentEditable;
|
||||
|
||||
// Undo/Redo
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 'z') {
|
||||
e.preventDefault();
|
||||
@ -1948,10 +2097,18 @@ export function RealtimeCanvasDialog({
|
||||
if (e.key === ']') {
|
||||
setBrushSize(prev => Math.min(50, prev + 2));
|
||||
}
|
||||
if (
|
||||
!isTypingTarget &&
|
||||
(e.key === "Delete" || e.key === "Backspace") &&
|
||||
selectedLayerId !== DRAW_LAYER_ID
|
||||
) {
|
||||
e.preventDefault();
|
||||
deleteSelectedImageLayer();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [open, undo, redo]);
|
||||
}, [deleteSelectedImageLayer, open, redo, selectedLayerId, undo]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@ -2110,45 +2267,6 @@ export function RealtimeCanvasDialog({
|
||||
scheduleFlush(true, true, "refine");
|
||||
}, [clearPreviewCanvas, resetCanvas, scheduleFlush]);
|
||||
|
||||
const moveImageLayer = useCallback(
|
||||
(layerId: string, direction: "up" | "down") => {
|
||||
const currentLayers = imageLayersRef.current;
|
||||
const index = currentLayers.findIndex((layer) => layer.id === layerId);
|
||||
if (index === -1) return;
|
||||
|
||||
const targetIndex = direction === "up" ? index + 1 : index - 1;
|
||||
if (targetIndex < 0 || targetIndex >= currentLayers.length) return;
|
||||
|
||||
const nextLayers = cloneImageLayers(currentLayers);
|
||||
const [layer] = nextLayers.splice(index, 1);
|
||||
nextLayers.splice(targetIndex, 0, layer);
|
||||
|
||||
imageLayersRef.current = nextLayers;
|
||||
setImageLayers(nextLayers);
|
||||
setSelectedLayerId(layerId);
|
||||
saveHistory(nextLayers);
|
||||
markCanvasDirty(null);
|
||||
scheduleFlush(true, false, "refine");
|
||||
},
|
||||
[markCanvasDirty, saveHistory, scheduleFlush]
|
||||
);
|
||||
|
||||
const removeImageLayer = useCallback(
|
||||
(layerId: string) => {
|
||||
const currentLayers = imageLayersRef.current;
|
||||
const nextLayers = currentLayers.filter((layer) => layer.id !== layerId);
|
||||
if (nextLayers.length === currentLayers.length) return;
|
||||
|
||||
imageLayersRef.current = nextLayers;
|
||||
setImageLayers(nextLayers);
|
||||
setSelectedLayerId((current) => (current === layerId ? DRAW_LAYER_ID : current));
|
||||
saveHistory(nextLayers);
|
||||
markCanvasDirty(null);
|
||||
scheduleFlush(true, false, "refine");
|
||||
},
|
||||
[markCanvasDirty, saveHistory, scheduleFlush]
|
||||
);
|
||||
|
||||
const handleSketchUpload = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(event.target.files || []);
|
||||
@ -2214,26 +2332,6 @@ export function RealtimeCanvasDialog({
|
||||
[importFilesAsLayers]
|
||||
);
|
||||
|
||||
const handleDownloadCanvas = useCallback(async () => {
|
||||
const compositeCanvas = await buildCompositeCanvas();
|
||||
if (!compositeCanvas) return;
|
||||
try {
|
||||
const exportedImage = await canvasToJpegDataUri(
|
||||
compositeCanvas,
|
||||
2048,
|
||||
0.92,
|
||||
canvasBackgroundRef.current
|
||||
);
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = exportedImage.dataUri;
|
||||
anchor.download = `realtime-canvas-${Date.now()}.jpg`;
|
||||
anchor.click();
|
||||
toast.success("已保存当前画布");
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : "保存失败");
|
||||
}
|
||||
}, [buildCompositeCanvas]);
|
||||
|
||||
const handleApplyToCanvas = useCallback(() => {
|
||||
if (!previewUrl) {
|
||||
toast.error("请先生成预览结果");
|
||||
@ -2247,6 +2345,19 @@ export function RealtimeCanvasDialog({
|
||||
onOpenChange(false);
|
||||
}, [onApplyResult, onOpenChange, previewUrl, prompt]);
|
||||
|
||||
const handleDownloadPreview = useCallback(() => {
|
||||
if (!previewUrl) {
|
||||
toast.error("请先生成预览结果");
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = /^https?:\/\//i.test(previewUrl) ? buildImageProxyUrl(previewUrl) : previewUrl;
|
||||
anchor.download = `realtime-preview-${Date.now()}.png`;
|
||||
anchor.rel = "noopener";
|
||||
anchor.click();
|
||||
}, [previewUrl]);
|
||||
|
||||
const startBlankProject = useCallback(() => {
|
||||
setPrompt(FALLBACK_PROMPT);
|
||||
setPreviewUrl("");
|
||||
@ -2487,15 +2598,6 @@ export function RealtimeCanvasDialog({
|
||||
</div>
|
||||
|
||||
<div className="flex h-10 shrink-0 items-center gap-0.5 rounded-full border border-black/10 bg-white px-1">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100"
|
||||
onClick={() => uploadInputRef.current?.click()}
|
||||
title="上传图片"
|
||||
>
|
||||
<Upload className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
@ -2516,24 +2618,15 @@ export function RealtimeCanvasDialog({
|
||||
>
|
||||
<Redo2 className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 rounded-full text-zinc-600 hover:bg-zinc-100"
|
||||
onClick={handleDownloadCanvas}
|
||||
title="保存图片"
|
||||
>
|
||||
<Download className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{standalone ? (
|
||||
<div className="flex h-10 shrink-0 items-center rounded-full border border-black/10 bg-white px-1">
|
||||
<div className="flex h-10 shrink-0 items-center rounded-full border border-black bg-black px-1 shadow-sm">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-8 w-8 rounded-full"
|
||||
className="h-8 w-8 rounded-full text-white hover:bg-white/10 hover:text-white"
|
||||
onClick={() => onOpenChange(false)}
|
||||
title="关闭页面"
|
||||
>
|
||||
@ -2674,6 +2767,55 @@ export function RealtimeCanvasDialog({
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
{selectedImageLayer
|
||||
? ([
|
||||
{ key: "nw", left: selectedImageLayer.x, top: selectedImageLayer.y },
|
||||
{
|
||||
key: "ne",
|
||||
left: selectedImageLayer.x + selectedImageLayer.width,
|
||||
top: selectedImageLayer.y,
|
||||
},
|
||||
{
|
||||
key: "sw",
|
||||
left: selectedImageLayer.x,
|
||||
top: selectedImageLayer.y + selectedImageLayer.height,
|
||||
},
|
||||
{
|
||||
key: "se",
|
||||
left: selectedImageLayer.x + selectedImageLayer.width,
|
||||
top: selectedImageLayer.y + selectedImageLayer.height,
|
||||
},
|
||||
] as const).map((handle) => (
|
||||
<div
|
||||
key={handle.key}
|
||||
className="pointer-events-none absolute z-20 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full border border-white bg-black shadow-[0_2px_8px_rgba(0,0,0,0.2)]"
|
||||
style={{
|
||||
left: handle.left,
|
||||
top: handle.top,
|
||||
}}
|
||||
/>
|
||||
))
|
||||
: 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}
|
||||
@ -2687,14 +2829,11 @@ export function RealtimeCanvasDialog({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute left-3 top-28 bottom-4 z-10 flex w-[4.4rem] max-w-[18vw] flex-col rounded-[18px] border border-black/10 bg-white/96 p-1.5 shadow-xl">
|
||||
<div className="flex items-center justify-between border-b border-black/10 pb-2">
|
||||
<div className="absolute left-4 top-28 bottom-4 z-10 flex w-16 max-w-[18vw] flex-col rounded-[24px] border border-black/10 bg-white/96 p-2 shadow-[0_12px_36px_rgba(15,23,42,0.12)]">
|
||||
<div className="flex items-center justify-center border-b border-black/10 pb-2">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-50 text-zinc-900">
|
||||
<Layers3 className="h-4 w-4" />
|
||||
</div>
|
||||
<span className="rounded-full bg-zinc-100 px-1.5 py-0.5 text-[10px] text-zinc-500">
|
||||
{imageLayers.length} 张
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex-1 space-y-1.5 overflow-y-auto pr-0.5">
|
||||
{imageLayers.length === 0 ? (
|
||||
@ -2702,126 +2841,28 @@ export function RealtimeCanvasDialog({
|
||||
暂无图层
|
||||
</div>
|
||||
) : (
|
||||
[...imageLayers].reverse().map((layer, displayIndex) => {
|
||||
const actualIndex = imageLayers.length - 1 - displayIndex;
|
||||
const isTopMost = actualIndex === imageLayers.length - 1;
|
||||
const isBottomMost = actualIndex === 0;
|
||||
return (
|
||||
<div
|
||||
key={layer.id}
|
||||
className={cn(
|
||||
"rounded-2xl border bg-white px-1.5 py-1.5 shadow-sm transition",
|
||||
selectedLayerId === layer.id
|
||||
? "border-black/20 ring-2 ring-black/5"
|
||||
: "border-black/10"
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="w-full"
|
||||
onClick={() => setSelectedLayerId(layer.id)}
|
||||
title={layer.name}
|
||||
>
|
||||
<img
|
||||
src={/^https?:\/\//i.test(layer.src) ? buildImageProxyUrl(layer.src) : layer.src}
|
||||
alt={layer.name}
|
||||
className="h-11 w-11 rounded-xl border border-black/10 object-cover"
|
||||
/>
|
||||
</button>
|
||||
<div className="mt-1 flex items-center justify-center gap-0.5">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 rounded-md p-0 text-zinc-500 hover:bg-zinc-100"
|
||||
onClick={() => setSelectedLayerId(layer.id)}
|
||||
title={isTopMost ? "当前最上层" : isBottomMost ? "当前底层" : "中间图层"}
|
||||
>
|
||||
<div className="h-1.5 w-1.5 rounded-full bg-zinc-400" />
|
||||
</Button>
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 rounded-md p-0 text-zinc-500 hover:bg-zinc-100"
|
||||
onClick={() => moveImageLayer(layer.id, "up")}
|
||||
disabled={isTopMost}
|
||||
title="上移图层"
|
||||
>
|
||||
<ChevronUp className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 rounded-md p-0 text-zinc-500 hover:bg-zinc-100"
|
||||
onClick={() => moveImageLayer(layer.id, "down")}
|
||||
disabled={isBottomMost}
|
||||
title="下移图层"
|
||||
>
|
||||
<ChevronDown className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-5 w-5 rounded-md p-0 text-zinc-500 hover:bg-rose-50 hover:text-rose-600"
|
||||
onClick={() => removeImageLayer(layer.id)}
|
||||
title="删除图层"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
[...imageLayers].reverse().map((layer) => (
|
||||
<button
|
||||
key={layer.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"flex w-full items-center justify-center rounded-[18px] border bg-white p-1.5 shadow-sm transition hover:border-black/20",
|
||||
selectedLayerId === layer.id
|
||||
? "border-black/20 ring-2 ring-black/5"
|
||||
: "border-black/10"
|
||||
)}
|
||||
onClick={() => setSelectedLayerId(layer.id)}
|
||||
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"
|
||||
/>
|
||||
</button>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{selectedImageLayer ? (
|
||||
<div className="mt-2 rounded-2xl border border-black/10 bg-zinc-50 p-1.5">
|
||||
<div className="text-center text-[10px] font-medium text-zinc-700">
|
||||
{Math.round(selectedImageLayerScale * 100)}%
|
||||
</div>
|
||||
<div className="mt-1 flex items-center justify-center gap-1">
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 rounded-full"
|
||||
onClick={() => {
|
||||
const nextLayers = scaleImageLayer(
|
||||
selectedImageLayer.id,
|
||||
selectedImageLayerScale - 0.1
|
||||
);
|
||||
if (nextLayers) {
|
||||
saveHistory(nextLayers);
|
||||
markCanvasDirty(null);
|
||||
scheduleFlush(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-sm leading-none">-</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
size="icon"
|
||||
variant="ghost"
|
||||
className="h-6 w-6 rounded-full"
|
||||
onClick={() => {
|
||||
const nextLayers = scaleImageLayer(
|
||||
selectedImageLayer.id,
|
||||
selectedImageLayerScale + 0.1
|
||||
);
|
||||
if (nextLayers) {
|
||||
saveHistory(nextLayers);
|
||||
markCanvasDirty(null);
|
||||
scheduleFlush(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="text-sm leading-none">+</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
@ -2854,6 +2895,18 @@ export function RealtimeCanvasDialog({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{previewUrl ? (
|
||||
<div className="absolute bottom-5 right-5">
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
className="h-10 rounded-full bg-white px-4 text-sm font-medium text-zinc-900 shadow-[0_10px_30px_rgba(15,23,42,0.12)] hover:bg-zinc-100"
|
||||
onClick={handleDownloadPreview}
|
||||
>
|
||||
下载图片
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user