Files
label-designer/src/components/LabelDesigner.tsx
2026-06-23 15:26:51 +08:00

2135 lines
72 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { LabelTemplate, TemplateElement } from '../types';
import { mmToPx, MM_TO_PX, shouldRenderElement, centerElementOnLabel } from '../utils';
import { getSelectionBounds } from '../elementAlign';
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
import {
createDefaultTableElement,
getTableColWidths,
getTableRowHeights,
iterTableCells,
toggleTableCellSelection,
scaleTableTracks,
getCellAnchor,
getCellRectPx,
type TableCellCoord,
} from '../tableUtils';
import { useIsNarrowScreen } from '../hooks/useIsMobile';
import { CanvasLabelImage } from './CanvasLabelImage';
import { ElementFloatPreview, getFloatPreviewPaddingMm, renderElementFloatPreview } from './ElementFloatPreview';
import { renderLabelToDataUrl } from '../labelRenderer';
import { useAppDialog } from './AppDialog';
import { MobileMainToolButtons, type MobileToolButtonItem } from './MobileMainToolButtons';
import {
ZoomIn,
ZoomOut,
Type,
Barcode,
QrCode,
Image as ImageIcon,
Move,
Trash2,
Table2,
} from 'lucide-react';
interface LabelDesignerProps {
template: LabelTemplate;
onChange: (updated: LabelTemplate) => void;
selectedElementIds: string[];
onSelectElements: (ids: string[]) => void;
selectedTableCells?: TableCellCoord[];
onSelectTableCells?: (cells: TableCellCoord[] | ((prev: TableCellCoord[]) => TableCellCoord[])) => void;
variant?: 'desktop' | 'mobile';
/** 微调等场景下隐藏选区框与缩放手柄 */
suppressSelectionChrome?: boolean;
elementClipboard?: Pick<
ElementClipboardActions,
'copySelectedElements' | 'pasteClipboardElements' | 'canCopy' | 'canPaste' | 'clipboardCount'
>;
}
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table';
type ResizeCorner = 'nw' | 'ne' | 'sw' | 'se';
const MIN_ELEM_W = 2;
const MIN_ELEM_H = 2;
function applyCornerResize(
startX: number,
startY: number,
startW: number,
startH: number,
deltaXMm: number,
deltaYMm: number,
corner: ResizeCorner
) {
let x = startX;
let y = startY;
let w = startW;
let h = startH;
if (corner.includes('e')) w = startW + deltaXMm;
if (corner.includes('w')) {
w = startW - deltaXMm;
x = startX + deltaXMm;
}
if (corner.includes('s')) h = startH + deltaYMm;
if (corner.includes('n')) {
h = startH - deltaYMm;
y = startY + deltaYMm;
}
if (w < MIN_ELEM_W) {
if (corner.includes('w')) x += w - MIN_ELEM_W;
w = MIN_ELEM_W;
}
if (h < MIN_ELEM_H) {
if (corner.includes('n')) y += h - MIN_ELEM_H;
h = MIN_ELEM_H;
}
return {
x: Math.round(x * 10) / 10,
y: Math.round(y * 10) / 10,
w: Math.round(w * 10) / 10,
h: Math.round(h * 10) / 10,
};
}
const MARQUEE_MIN_PX = 4;
const DRAG_START_THRESHOLD_PX = 4;
/** 松手前极短时间内的微小位移视为抖动,提交上一稳定位置 */
const DRAG_RELEASE_JITTER_PX = 4;
const DRAG_RELEASE_JITTER_MS = 48;
const MODERATE_ZOOM_MAX = 2.0;
function blurActiveField() {
const active = document.activeElement;
if (
active instanceof HTMLInputElement ||
active instanceof HTMLTextAreaElement ||
active instanceof HTMLSelectElement
) {
active.blur();
}
}
/** 在容器内等比约束画布显示尺寸,避免宽/高单独触顶导致变形 */
function constrainCanvasDisplaySize(
widthMm: number,
heightMm: number,
zoom: number,
maxW: number,
maxH: number
) {
let displayW = mmToPx(widthMm, zoom);
let displayH = mmToPx(heightMm, zoom);
if (displayW > maxW) {
displayW = maxW;
const cappedZoom = displayW / (widthMm * MM_TO_PX);
displayH = mmToPx(heightMm, cappedZoom);
}
if (displayH > maxH) {
displayH = maxH;
const cappedZoom = displayH / (heightMm * MM_TO_PX);
displayW = mmToPx(widthMm, cappedZoom);
}
const displayZoom = displayW / (widthMm * MM_TO_PX);
const canvasRenderScale = Math.max(
1,
Math.round(MM_TO_PX * displayZoom * 100) / 100
);
return { displayW, displayH, displayZoom, canvasRenderScale };
}
const ZOOM_MIN = 0.5;
const ZOOM_MAX = 5;
function getMaxZoomForContainer(
widthMm: number,
heightMm: number,
maxW: number,
maxH: number
) {
if (maxW <= 0 || maxH <= 0) return ZOOM_MAX;
const byW = maxW / (widthMm * MM_TO_PX);
const byH = maxH / (heightMm * MM_TO_PX);
return Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, byW, byH));
}
function clampZoomValue(value: number, maxZoom: number) {
return Math.min(maxZoom, Math.max(ZOOM_MIN, Math.round(value * 100) / 100));
}
function rectFullyContains(
outer: { left: number; top: number; right: number; bottom: number },
inner: { left: number; top: number; right: number; bottom: number }
) {
return (
outer.left <= inner.left &&
outer.top <= inner.top &&
outer.right >= inner.right &&
outer.bottom >= inner.bottom
);
}
function applyQrcodeSquareResize(
startX: number,
startY: number,
startW: number,
startH: number,
corner: ResizeCorner,
deltaXMm: number,
deltaYMm: number
) {
const rect = applyCornerResize(
startX,
startY,
startW,
startH,
deltaXMm,
deltaYMm,
corner
);
const size = Math.max(rect.w, rect.h, MIN_ELEM_W);
let x = startX;
let y = startY;
if (corner.includes('w')) x = startX + startW - size;
if (corner.includes('n')) y = startY + startH - size;
return {
x: Math.round(x * 10) / 10,
y: Math.round(y * 10) / 10,
w: Math.round(size * 10) / 10,
h: Math.round(size * 10) / 10,
};
}
export const LabelDesigner: React.FC<LabelDesignerProps> = ({
template,
onChange,
selectedElementIds,
onSelectElements,
selectedTableCells = [],
onSelectTableCells,
variant = 'desktop',
suppressSelectionChrome = false,
elementClipboard,
}) => {
const { showConfirm } = useAppDialog();
const selectedElementId = selectedElementIds[0] || null;
const isMobileLayout = variant === 'mobile';
const isNarrow = useIsNarrowScreen() || isMobileLayout;
const [zoom, setZoom] = useState<number>(MODERATE_ZOOM_MAX);
const [canvasAreaSize, setCanvasAreaSize] = useState({ w: 0, h: 0 });
const [activeTool, setActiveTool] = useState<ActiveTool>('move');
const canvasAreaRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null);
const imageInputRef = useRef<HTMLInputElement | null>(null);
const previewDefaults = useMemo(
() => template.variableDefaults ?? null,
[template.variableDefaults]
);
const printColorMode = template.paper?.printColorMode ?? 'cmyk';
const showReferencePreview = useMemo(
() =>
!!template.previewReferenceBackground &&
template.enablePreviewReferenceBackground !== false,
[template.previewReferenceBackground, template.enablePreviewReferenceBackground]
);
const localElementsRef = useRef<TemplateElement[] | null>(null);
/** 交互开始时的元素快照:拖动底图分层、缩放节流重绘 */
const [interactionSnapshot, setInteractionSnapshot] = useState<TemplateElement[] | null>(
null
);
/** 缩放时仅重绘当前元素的浮层内容 */
const [interactionFloatElements, setInteractionFloatElements] = useState<
TemplateElement[] | null
>(null);
const dragRafRef = useRef<number | null>(null);
const pendingDragElementsRef = useRef<TemplateElement[] | null>(null);
const pendingDragDeltaRef = useRef({ x: 0, y: 0 });
type DragCommitSnapshot = {
elements: TemplateElement[];
delta: { x: number; y: number };
time: number;
};
const dragCommitHistoryRef = useRef<DragCommitSnapshot | null>(null);
const dragCommitHistoryPrevRef = useRef<DragCommitSnapshot | null>(null);
const dragFloatLayerRef = useRef<HTMLDivElement | null>(null);
const canvasWrapperRef = useRef<HTMLDivElement | null>(null);
const interactionPortalRef = useRef<HTMLDivElement | null>(null);
const dragFloatImgRefs = useRef<Map<string, HTMLImageElement>>(new Map());
const dragFloatUrlsRef = useRef<Record<string, string>>({});
const dragFloatElementsRef = useRef<TemplateElement[]>([]);
const dragVisualActivatedRef = useRef(false);
const dragTransformLoopRef = useRef<number | null>(null);
const [dragFloatElements, setDragFloatElements] = useState<TemplateElement[]>([]);
const [dragInteractionVisualActive, setDragInteractionVisualActive] = useState(false);
const [interactionCanvasReady, setInteractionCanvasReady] = useState(false);
const interactionFloatPaintRef = useRef(0);
const interactionFloatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [dragState, setDragState] = useState<{
elementId: string;
type: 'drag' | 'resize';
resizeCorner?: ResizeCorner;
startX: number;
startY: number;
startElemX: number;
startElemY: number;
startElemW: number;
startElemH: number;
startTableRowHeights?: number[];
startTableColWidths?: number[];
groupStartPositions?: Record<string, { x: number; y: number }>;
} | null>(null);
const [marqueeState, setMarqueeState] = useState<{
startX: number;
startY: number;
currentX: number;
currentY: number;
additive: boolean;
} | null>(null);
const ignoreCanvasClickRef = useRef(false);
const dragMovedRef = useRef(false);
const tableCellPointerRef = useRef<{
row: number;
col: number;
elementId: string;
startX: number;
startY: number;
pointerId: number;
dragStarted: boolean;
} | null>(null);
const multiTouchActiveRef = useRef(false);
const touchPointerMapRef = useRef<Map<number, { x: number; y: number }>>(new Map());
const marqueeRef = useRef(marqueeState);
marqueeRef.current = marqueeState;
const selectedIdsRef = useRef(selectedElementIds);
selectedIdsRef.current = selectedElementIds;
const templateElementsRef = useRef(template.elements);
templateElementsRef.current = template.elements;
const templateRef = useRef(template);
templateRef.current = template;
const addImageElement = useCallback(
(src: string = '') => {
const current = templateRef.current;
const id = `image_${Date.now()}`;
const n = current.elements.length + 1;
const newElement: TemplateElement = {
id,
type: 'image',
name: `图片 ${n}`,
...centerElementOnLabel(current.width, current.height, 30, 30),
width: 30,
height: 30,
content: src,
fontSize: 12,
textAlign: 'center',
fontWeight: 'normal',
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
imageFit: 'contain',
};
onChange({
...current,
elements: [...current.elements, newElement],
});
onSelectElements([id]);
setActiveTool('move');
},
[onChange, onSelectElements]
);
const addElement = useCallback(
(type: 'text' | 'barcode' | 'qrcode' | 'table') => {
const current = templateRef.current;
let newElement: TemplateElement;
const id = `${type}_${Date.now()}`;
const n = current.elements.length + 1;
if (type === 'table') {
newElement = createDefaultTableElement(id, n);
} else if (type === 'text') {
newElement = {
id,
type: 'text',
name: `文本 ${n}`,
x: 5,
y: 5,
width: 30,
height: 8,
content: '文本内容',
fontSize: 12,
textAlign: 'left',
verticalAlign: 'middle',
wordWrap: true,
fontWeight: 'normal',
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
};
} else if (type === 'barcode') {
newElement = {
id,
type: 'barcode',
name: `条形码 ${n}`,
x: 5,
y: 15,
width: 50,
height: 12,
content: '123456789',
fontSize: 10,
textAlign: 'center',
fontWeight: 'normal',
barcodeFormat: 'CODE128',
showText: true,
fontFamily: 'mono',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
};
} else {
newElement = {
id,
type: 'qrcode',
name: `二维码 ${n}`,
x: 40,
y: 10,
width: 20,
height: 20,
content: 'https://example.com',
fontSize: 10,
textAlign: 'center',
fontWeight: 'normal',
barcodeFormat: 'CODE128',
showText: false,
fontFamily: 'sans',
backgroundColor: '#ffffff',
backgroundOpacity: 0,
};
}
const centered = centerElementOnLabel(
current.width,
current.height,
newElement.width,
newElement.height
);
newElement = { ...newElement, ...centered };
onChange({
...current,
elements: [...current.elements, newElement],
});
onSelectElements([id]);
setActiveTool('move');
},
[onChange, onSelectElements]
);
const deleteSelectedElements = useCallback(async () => {
const current = templateRef.current;
const ids =
selectedElementIds.length > 0
? selectedElementIds
: selectedElementId
? [selectedElementId]
: [];
const deletable = ids.filter((id) => {
const el = current.elements.find((item) => item.id === id);
return el && !el.locked;
});
if (deletable.length === 0) return;
const message =
deletable.length === 1
? `确定删除「${current.elements.find((item) => item.id === deletable[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
: `确定删除选中的 ${deletable.length} 个元素吗?此操作无法撤销。`;
const confirmed = await showConfirm(message, {
title: '删除元素',
confirmLabel: '删除',
variant: 'error',
});
if (!confirmed) return;
const idSet = new Set(deletable);
const remaining = current.elements.filter((el) => !idSet.has(el.id));
onChange({ ...templateRef.current, elements: remaining });
onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
}, [selectedElementIds, selectedElementId, onChange, onSelectElements, showConfirm]);
const handleToolClick = (tool: ActiveTool) => {
if (tool === 'move') {
setActiveTool('move');
} else {
addElement(tool);
}
};
const handleImageToolClick = () => {
imageInputRef.current?.click();
};
const handleImageFile = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !file.type.startsWith('image/')) {
addImageElement('');
e.target.value = '';
return;
}
const reader = new FileReader();
reader.onload = () => addImageElement(reader.result as string);
reader.onerror = () => addImageElement('');
reader.readAsDataURL(file);
e.target.value = '';
};
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
const activeTag = document.activeElement?.tagName.toLowerCase();
if (activeTag === 'input' || activeTag === 'textarea' || activeTag === 'select') return;
if (e.key === 'v' || e.key === 'V') setActiveTool('move');
if (e.key === 't' || e.key === 'T') addElement('text');
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
e.preventDefault();
setActiveTool('move');
onSelectElements(templateRef.current.elements.filter((el) => !el.locked).map((el) => el.id));
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
e.preventDefault();
elementClipboard?.copySelectedElements();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
e.preventDefault();
elementClipboard?.pasteClipboardElements();
setActiveTool('move');
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
void deleteSelectedElements();
return;
}
const moveIds =
selectedElementIds.length > 0 ? selectedElementIds : selectedElementId ? [selectedElementId] : [];
if (moveIds.length === 0) return;
const increment = e.shiftKey ? 0.5 : 0.1;
let deltaX = 0;
let deltaY = 0;
if (e.key === 'ArrowUp') deltaY = -increment;
else if (e.key === 'ArrowDown') deltaY = increment;
else if (e.key === 'ArrowLeft') deltaX = -increment;
else if (e.key === 'ArrowRight') deltaX = increment;
else return;
const idSet = new Set(moveIds);
e.preventDefault();
const current = templateRef.current;
onChange({
...current,
elements: current.elements.map((el) => {
if (!idSet.has(el.id) || el.locked) return el;
return {
...el,
x: Math.round((el.x + deltaX) * 10) / 10,
y: Math.round((el.y + deltaY) * 10) / 10,
};
}),
});
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [
selectedElementId,
selectedElementIds,
template,
onChange,
addElement,
onSelectElements,
deleteSelectedElements,
elementClipboard,
]);
const clearInteractionCanvasState = useCallback(() => {
if (interactionFloatTimerRef.current !== null) {
clearTimeout(interactionFloatTimerRef.current);
interactionFloatTimerRef.current = null;
}
interactionFloatPaintRef.current = 0;
if (dragFloatLayerRef.current) {
dragFloatLayerRef.current.style.transform = '';
}
if (dragTransformLoopRef.current !== null) {
cancelAnimationFrame(dragTransformLoopRef.current);
dragTransformLoopRef.current = null;
}
setInteractionSnapshot(null);
setInteractionFloatElements(null);
setDragFloatElements([]);
dragFloatElementsRef.current = [];
dragVisualActivatedRef.current = false;
setDragInteractionVisualActive(false);
setInteractionCanvasReady(false);
dragFloatImgRefs.current.clear();
dragFloatUrlsRef.current = {};
pendingDragDeltaRef.current = { x: 0, y: 0 };
dragCommitHistoryRef.current = null;
dragCommitHistoryPrevRef.current = null;
}, []);
const cancelActiveInteraction = useCallback(() => {
if (dragRafRef.current !== null) {
cancelAnimationFrame(dragRafRef.current);
dragRafRef.current = null;
}
pendingDragElementsRef.current = null;
localElementsRef.current = null;
clearInteractionCanvasState();
setDragState(null);
dragMovedRef.current = false;
setMarqueeState(null);
}, [clearInteractionCanvasState]);
const shouldIgnoreTouchPointer = (e: { pointerType: string; isPrimary: boolean }) => {
if (multiTouchActiveRef.current) return true;
if (e.pointerType === 'touch' && !e.isPrimary) return true;
return false;
};
useEffect(() => {
if (!isMobileLayout && !isNarrow) return;
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== 'touch') return;
const target = e.target as Node | null;
if (!canvasAreaRef.current?.contains(target)) return;
touchPointerMapRef.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (touchPointerMapRef.current.size > 1) {
multiTouchActiveRef.current = true;
cancelActiveInteraction();
}
};
const onPointerUp = (e: PointerEvent) => {
if (e.pointerType !== 'touch') return;
touchPointerMapRef.current.delete(e.pointerId);
if (touchPointerMapRef.current.size <= 1) {
multiTouchActiveRef.current = false;
}
};
window.addEventListener('pointerdown', onPointerDown, true);
window.addEventListener('pointerup', onPointerUp, true);
window.addEventListener('pointercancel', onPointerUp, true);
return () => {
window.removeEventListener('pointerdown', onPointerDown, true);
window.removeEventListener('pointerup', onPointerUp, true);
window.removeEventListener('pointercancel', onPointerUp, true);
touchPointerMapRef.current.clear();
multiTouchActiveRef.current = false;
};
}, [isMobileLayout, isNarrow, cancelActiveInteraction]);
const applyModerateZoom = useCallback(() => {
const area = canvasAreaRef.current;
if (!area) return;
const pad = isNarrow ? 32 : 80;
const availW = area.clientWidth - pad;
const availH = area.clientHeight - pad;
if (availW <= 0 || availH <= 0) return;
const docW = mmToPx(template.width, 1);
const docH = mmToPx(template.height, 1);
const fitZoom = Math.min(availW / docW, availH / docH, MODERATE_ZOOM_MAX);
setZoom(Math.max(0.5, Math.round(fitZoom * 100) / 100));
}, [template.width, template.height, isNarrow]);
useEffect(() => {
applyModerateZoom();
}, [template.id, applyModerateZoom]);
useEffect(() => {
const area = canvasAreaRef.current;
if (!area) return;
const updateSize = () => {
setCanvasAreaSize({ w: area.clientWidth, h: area.clientHeight });
};
updateSize();
const observer = new ResizeObserver(updateSize);
observer.observe(area);
return () => observer.disconnect();
}, []);
const canvasPad = 32;
const canvasLayout = useMemo(() => {
const maxW = canvasAreaSize.w - canvasPad;
const maxH = canvasAreaSize.h - canvasPad;
const maxZoom = getMaxZoomForContainer(template.width, template.height, maxW, maxH);
if (maxW <= 0 || maxH <= 0) {
const effectiveZoom = clampZoomValue(zoom, maxZoom);
const displayW = mmToPx(template.width, effectiveZoom);
const displayH = mmToPx(template.height, effectiveZoom);
return {
displayW,
displayH,
displayZoom: effectiveZoom,
canvasRenderScale: Math.max(1, Math.round(MM_TO_PX * effectiveZoom * 100) / 100),
maxZoom,
};
}
const constrained = constrainCanvasDisplaySize(
template.width,
template.height,
zoom,
maxW,
maxH
);
return { ...constrained, maxZoom };
}, [template.width, template.height, zoom, canvasAreaSize.w, canvasAreaSize.h]);
const {
displayW: canvasDisplayW,
displayH: canvasDisplayH,
displayZoom,
canvasRenderScale,
maxZoom,
} = canvasLayout;
const displayZoomRef = useRef(displayZoom);
displayZoomRef.current = displayZoom;
const maxZoomRef = useRef(maxZoom);
maxZoomRef.current = maxZoom;
const prefetchDragFloatImages = useCallback(
(elements: TemplateElement[]) => {
if (elements.length === 0) return;
void Promise.all(
elements.map(async (el) => {
try {
const url = await renderElementFloatPreview(
el,
canvasRenderScale,
previewDefaults
);
dragFloatUrlsRef.current[el.id] = url;
const img = dragFloatImgRefs.current.get(el.id);
if (img) {
img.src = url;
if (img.complete) {
img.style.opacity = '1';
const placeholder = img.previousElementSibling as HTMLElement | null;
if (placeholder) placeholder.style.display = 'none';
}
}
} catch (err) {
console.error('Drag float preview error:', err);
}
})
);
},
[canvasRenderScale, previewDefaults]
);
useEffect(() => {
setZoom((z) => (z > maxZoom ? clampZoomValue(z, maxZoom) : z));
}, [maxZoom]);
useEffect(() => {
const area = canvasAreaRef.current;
if (!area) return;
const handleWheel = (e: WheelEvent) => {
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
const delta = e.deltaY > 0 ? -0.25 : 0.25;
setZoom((z) => clampZoomValue(z + delta, maxZoomRef.current));
}
};
area.addEventListener('wheel', handleWheel, { passive: false });
return () => area.removeEventListener('wheel', handleWheel);
}, []);
const marqueeActive = marqueeState !== null;
useEffect(() => {
if (!marqueeActive) return;
const handlePointerMove = (e: PointerEvent) => {
if (multiTouchActiveRef.current) return;
const canvas = canvasRef.current;
if (!canvas) return;
const rect = canvas.getBoundingClientRect();
const currentX = e.clientX - rect.left;
const currentY = e.clientY - rect.top;
setMarqueeState((prev) => (prev ? { ...prev, currentX, currentY } : null));
};
const handlePointerUp = () => {
if (multiTouchActiveRef.current) {
setMarqueeState(null);
return;
}
const state = marqueeRef.current;
setMarqueeState(null);
if (!state) return;
const left = Math.min(state.startX, state.currentX);
const top = Math.min(state.startY, state.currentY);
const right = Math.max(state.startX, state.currentX);
const bottom = Math.max(state.startY, state.currentY);
const isTiny =
right - left < MARQUEE_MIN_PX && bottom - top < MARQUEE_MIN_PX;
ignoreCanvasClickRef.current = true;
if (isTiny) {
if (!state.additive) {
requestAnimationFrame(() => onSelectElements([]));
}
return;
}
const marqueeRect = { left, top, right, bottom };
const elements = localElementsRef.current || templateElementsRef.current;
const hitIds = elements
.filter((el) => {
if (el.locked) return false;
const elRect = {
left: mmToPx(el.x, displayZoomRef.current),
top: mmToPx(el.y, displayZoomRef.current),
right: mmToPx(el.x + el.width, displayZoomRef.current),
bottom: mmToPx(el.y + el.height, displayZoomRef.current),
};
return rectFullyContains(marqueeRect, elRect);
})
.map((el) => el.id);
if (state.additive) {
const merged = new Set([...selectedIdsRef.current, ...hitIds]);
onSelectElements([...merged]);
} else {
onSelectElements(hitIds);
}
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
return () => {
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
}, [marqueeActive, displayZoom, onSelectElements]);
useEffect(() => {
if (!dragState) return;
dragMovedRef.current = false;
const syncInteractionPortalLayout = () => {
const wrapper = canvasWrapperRef.current;
const portal = interactionPortalRef.current;
if (wrapper && portal) {
const rect = wrapper.getBoundingClientRect();
portal.style.left = `${rect.left}px`;
portal.style.top = `${rect.top}px`;
portal.style.width = `${rect.width}px`;
portal.style.height = `${rect.height}px`;
}
};
const runDragTransformLoop = () => {
const tick = () => {
syncInteractionPortalLayout();
const layer = dragFloatLayerRef.current;
if (layer) {
const { x, y } = pendingDragDeltaRef.current;
layer.style.transform = `translate3d(${x}px, ${y}px, 0)`;
}
dragTransformLoopRef.current = requestAnimationFrame(tick);
};
dragTransformLoopRef.current = requestAnimationFrame(tick);
};
if (dragState.type === 'drag') {
runDragTransformLoop();
}
const handlePointerMove = (e: PointerEvent) => {
if (multiTouchActiveRef.current) return;
const deltaX = e.clientX - dragState.startX;
const deltaY = e.clientY - dragState.startY;
if (dragState.type === 'drag') {
pendingDragDeltaRef.current = { x: deltaX, y: deltaY };
}
const pastDragThreshold =
Math.abs(deltaX) > DRAG_START_THRESHOLD_PX || Math.abs(deltaY) > DRAG_START_THRESHOLD_PX;
if (dragState.type === 'drag' && !pastDragThreshold) {
return;
}
if (dragState.type === 'drag' && pastDragThreshold) {
dragMovedRef.current = true;
if (!dragVisualActivatedRef.current) {
dragVisualActivatedRef.current = true;
setDragInteractionVisualActive(true);
prefetchDragFloatImages(dragFloatElementsRef.current);
}
}
const MM_PER_PX = 1 / (3.78 * displayZoomRef.current);
const deltaXMm = deltaX * MM_PER_PX;
const deltaYMm = deltaY * MM_PER_PX;
if (dragState.type === 'drag') {
const groupStarts = dragState.groupStartPositions;
if (!groupStarts || Object.keys(groupStarts).length === 0) return;
const updated = template.elements.map((el) => {
const start = groupStarts[el.id];
if (!start || el.locked) return el;
return {
...el,
x: Math.round((start.x + deltaXMm) * 10) / 10,
y: Math.round((start.y + deltaYMm) * 10) / 10,
};
});
pendingDragElementsRef.current = updated;
dragCommitHistoryPrevRef.current = dragCommitHistoryRef.current;
dragCommitHistoryRef.current = {
elements: updated,
delta: { x: deltaX, y: deltaY },
time: performance.now(),
};
} else if (dragState.type === 'resize') {
const elem = template.elements.find((el) => el.id === dragState.elementId);
if (!elem || elem.locked) return;
dragMovedRef.current = true;
const corner = dragState.resizeCorner ?? 'se';
const rect =
elem.type === 'qrcode'
? applyQrcodeSquareResize(
dragState.startElemX,
dragState.startElemY,
dragState.startElemW,
dragState.startElemH,
corner,
deltaXMm,
deltaYMm
)
: applyCornerResize(
dragState.startElemX,
dragState.startElemY,
dragState.startElemW,
dragState.startElemH,
deltaXMm,
deltaYMm,
corner
);
const updated = template.elements.map((el) => {
if (el.id !== dragState.elementId) return el;
if (
el.type === 'table' &&
dragState.startTableRowHeights &&
dragState.startTableColWidths
) {
return scaleTableTracks(
{ ...el, x: rect.x, y: rect.y },
rect.w,
rect.h,
dragState.startElemW,
dragState.startElemH,
dragState.startTableRowHeights,
dragState.startTableColWidths
);
}
return { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h };
});
pendingDragElementsRef.current = updated;
const paintInteractionFloat = () => {
interactionFloatPaintRef.current = performance.now();
interactionFloatTimerRef.current = null;
const pending = pendingDragElementsRef.current?.find(
(el) => el.id === dragState.elementId
);
if (pending) setInteractionFloatElements([pending]);
};
if (dragRafRef.current === null) {
dragRafRef.current = requestAnimationFrame(() => {
dragRafRef.current = null;
const next = pendingDragElementsRef.current;
if (next) {
localElementsRef.current = next;
const now = performance.now();
const elapsed = now - interactionFloatPaintRef.current;
if (elapsed >= 66 || interactionFloatPaintRef.current === 0) {
paintInteractionFloat();
} else if (interactionFloatTimerRef.current === null) {
interactionFloatTimerRef.current = setTimeout(
paintInteractionFloat,
66 - elapsed
);
}
}
});
}
}
};
const resolveDragCommitElements = (): TemplateElement[] | null => {
if (dragState.type !== 'drag') {
return pendingDragElementsRef.current ?? localElementsRef.current;
}
const cur = dragCommitHistoryRef.current;
const prev = dragCommitHistoryPrevRef.current;
if (cur && prev) {
const micro = Math.hypot(cur.delta.x - prev.delta.x, cur.delta.y - prev.delta.y);
const dt = cur.time - prev.time;
if (micro > 0 && micro <= DRAG_RELEASE_JITTER_PX && dt <= DRAG_RELEASE_JITTER_MS) {
return prev.elements;
}
return cur.elements;
}
return cur?.elements ?? pendingDragElementsRef.current ?? localElementsRef.current;
};
const handlePointerUp = () => {
if (multiTouchActiveRef.current) {
cancelActiveInteraction();
return;
}
if (dragRafRef.current !== null) {
cancelAnimationFrame(dragRafRef.current);
dragRafRef.current = null;
}
const commitElements = resolveDragCommitElements();
if (commitElements) {
localElementsRef.current = commitElements;
}
pendingDragElementsRef.current = null;
if (localElementsRef.current) {
const shouldCommit =
dragState.type === 'resize' || dragMovedRef.current;
if (shouldCommit) {
onChange({ ...templateRef.current, elements: localElementsRef.current });
}
}
localElementsRef.current = null;
clearInteractionCanvasState();
setDragState(null);
};
window.addEventListener('pointermove', handlePointerMove);
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
return () => {
if (dragTransformLoopRef.current !== null) {
cancelAnimationFrame(dragTransformLoopRef.current);
dragTransformLoopRef.current = null;
}
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
}, [dragState, template, displayZoom, onChange, cancelActiveInteraction, clearInteractionCanvasState, prefetchDragFloatImages]);
const buildGroupStartPositions = (element: TemplateElement) => {
const moveIds =
selectedElementIds.includes(element.id) && selectedElementIds.length > 1
? selectedElementIds
: [element.id];
const groupStartPositions: Record<string, { x: number; y: number }> = {};
for (const id of moveIds) {
const el = template.elements.find((item) => item.id === id);
if (el && !el.locked) {
groupStartPositions[id] = { x: el.x, y: el.y };
}
}
return groupStartPositions;
};
const startMarquee = (e: React.PointerEvent) => {
if (activeTool !== 'move' || e.button !== 0) return;
if (shouldIgnoreTouchPointer(e)) return;
const canvas = canvasRef.current;
if (!canvas) return;
blurActiveField();
const rect = canvas.getBoundingClientRect();
const startX = e.clientX - rect.left;
const startY = e.clientY - rect.top;
e.preventDefault();
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
setMarqueeState({
startX,
startY,
currentX: startX,
currentY: startY,
additive: e.shiftKey || e.ctrlKey || e.metaKey,
});
};
const handleWorkspacePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
if (activeTool !== 'move' || e.button !== 0) return;
if (shouldIgnoreTouchPointer(e)) return;
const target = e.target as HTMLElement;
if (target.closest('[data-label-element]') || target.closest('.ps-handle')) return;
startMarquee(e);
};
const handleElementPointerDown = (
e: React.PointerEvent,
element: TemplateElement,
actionType: 'drag' | 'resize'
) => {
if (e.button !== 0) return;
if (shouldIgnoreTouchPointer(e)) return;
e.stopPropagation();
if (e.shiftKey || e.ctrlKey || e.metaKey) {
return;
}
handleDragStart(e, element, actionType);
};
const handleElementClick = (e: React.MouseEvent, element: TemplateElement) => {
e.stopPropagation();
if (dragMovedRef.current) {
dragMovedRef.current = false;
return;
}
const isMulti = e.shiftKey || e.ctrlKey || e.metaKey;
if (isMulti) {
if (selectedElementIds.includes(element.id)) {
onSelectElements(selectedElementIds.filter((x) => x !== element.id));
} else {
onSelectElements([...selectedElementIds, element.id]);
}
} else {
onSelectElements([element.id]);
}
};
const handleDragStart = (
e: React.PointerEvent,
element: TemplateElement,
actionType: 'drag' | 'resize'
) => {
if (shouldIgnoreTouchPointer(e)) return;
e.preventDefault();
if (!selectedElementIds.includes(element.id)) {
onSelectElements([element.id]);
}
if (element.locked) return;
e.currentTarget.setPointerCapture(e.pointerId);
const groupStartPositions = buildGroupStartPositions(element);
const moveIds = new Set(Object.keys(groupStartPositions));
const floating = template.elements.filter(
(el) =>
moveIds.has(el.id) &&
shouldRenderElement(el, null, 0, previewDefaults, 'design')
);
setInteractionSnapshot(template.elements);
setInteractionFloatElements(null);
interactionFloatPaintRef.current = 0;
dragFloatUrlsRef.current = {};
dragFloatImgRefs.current.clear();
dragCommitHistoryRef.current = null;
dragCommitHistoryPrevRef.current = null;
dragVisualActivatedRef.current = false;
setDragInteractionVisualActive(false);
setInteractionCanvasReady(false);
dragFloatElementsRef.current = floating;
setDragFloatElements(floating);
setDragState({
elementId: element.id,
type: actionType,
startX: e.clientX,
startY: e.clientY,
startElemX: element.x,
startElemY: element.y,
startElemW: element.width,
startElemH: element.height,
groupStartPositions,
});
};
const handleTableCellPointerDown = (
e: React.PointerEvent,
element: TemplateElement,
row: number,
col: number
) => {
if (e.button !== 0 || shouldIgnoreTouchPointer(e) || element.locked) return;
e.stopPropagation();
e.currentTarget.setPointerCapture(e.pointerId);
tableCellPointerRef.current = {
row,
col,
elementId: element.id,
startX: e.clientX,
startY: e.clientY,
pointerId: e.pointerId,
dragStarted: false,
};
};
const handleTableCellPointerMove = (
e: React.PointerEvent,
element: TemplateElement
) => {
const pending = tableCellPointerRef.current;
if (!pending || pending.pointerId !== e.pointerId || pending.elementId !== element.id) {
return;
}
if (pending.dragStarted || element.locked) return;
const dx = e.clientX - pending.startX;
const dy = e.clientY - pending.startY;
if (
Math.abs(dx) > DRAG_START_THRESHOLD_PX ||
Math.abs(dy) > DRAG_START_THRESHOLD_PX
) {
pending.dragStarted = true;
dragMovedRef.current = true;
tableCellPointerRef.current = null;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
handleDragStart(e, element, 'drag');
}
};
const handleTableCellPointerUp = (e: React.PointerEvent) => {
const pending = tableCellPointerRef.current;
if (!pending || pending.pointerId !== e.pointerId) return;
if (!pending.dragStarted && onSelectTableCells) {
onSelectTableCells((prev) =>
toggleTableCellSelection(prev, { row: pending.row, col: pending.col })
);
}
tableCellPointerRef.current = null;
try {
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
} catch {
/* ignore */
}
};
const handleResizeStart = (
e: React.PointerEvent,
element: TemplateElement,
corner: ResizeCorner
) => {
if (shouldIgnoreTouchPointer(e)) return;
e.stopPropagation();
e.preventDefault();
onSelectElements([element.id]);
if (element.locked) return;
e.currentTarget.setPointerCapture(e.pointerId);
setInteractionSnapshot(template.elements);
setInteractionFloatElements([element]);
interactionFloatPaintRef.current = 0;
setInteractionCanvasReady(false);
setDragState({
elementId: element.id,
type: 'resize',
resizeCorner: corner,
startX: e.clientX,
startY: e.clientY,
startElemX: element.x,
startElemY: element.y,
startElemW: element.width,
startElemH: element.height,
startTableRowHeights:
element.type === 'table' ? getTableRowHeights(element) : undefined,
startTableColWidths:
element.type === 'table' ? getTableColWidths(element) : undefined,
});
};
const fitToView = () => {
const area = canvasAreaRef.current;
if (!area) return;
const pad = isMobileLayout ? 32 : 80;
const availW = area.clientWidth - pad;
const availH = area.clientHeight - pad;
const docW = mmToPx(template.width, 1);
const docH = mmToPx(template.height, 1);
const fitZoom = Math.min(availW / docW, availH / docH, 5);
setZoom(Math.max(0.5, Math.round(fitZoom * 100) / 100));
};
useEffect(() => {
if (!isMobileLayout) return;
const timer = window.setTimeout(() => fitToView(), 0);
return () => window.clearTimeout(timer);
}, [isMobileLayout, template.width, template.height]);
const selectedElemObj = template.elements.find((el) => el.id === selectedElementId);
const filterVisibleElements = useCallback(
(elements: TemplateElement[]) =>
elements.filter((el) =>
shouldRenderElement(el, null, 0, previewDefaults, 'design')
),
[previewDefaults]
);
const dragMovingIdSet = useMemo(() => {
if (dragState?.type !== 'drag' || !dragState.groupStartPositions) return null;
return new Set(Object.keys(dragState.groupStartPositions));
}, [dragState]);
const liftSelectedAboveDim = activeTool === 'move' && selectedElementIds.length > 0;
const staticCanvasElements = useMemo(() => {
let elements: TemplateElement[];
if (
dragState?.type === 'drag' &&
interactionSnapshot &&
dragMovingIdSet &&
dragInteractionVisualActive &&
interactionCanvasReady
) {
elements = filterVisibleElements(
interactionSnapshot.filter((el) => !dragMovingIdSet.has(el.id))
);
} else if (dragState?.type === 'resize' && interactionSnapshot && interactionCanvasReady) {
elements = filterVisibleElements(
interactionSnapshot.filter((el) => el.id !== dragState.elementId)
);
} else {
elements = filterVisibleElements(template.elements);
}
if (liftSelectedAboveDim) {
const selectedSet = new Set(selectedElementIds);
elements = elements.filter((el) => !selectedSet.has(el.id));
}
return elements;
}, [
dragState?.type,
dragState?.elementId,
interactionSnapshot,
dragMovingIdSet,
dragInteractionVisualActive,
interactionCanvasReady,
template.elements,
filterVisibleElements,
liftSelectedAboveDim,
selectedElementIds,
]);
const selectedLiftElements = useMemo(() => {
if (!liftSelectedAboveDim) return null;
let elements = filterVisibleElements(
template.elements.filter((el) => selectedElementIds.includes(el.id))
);
if (dragState?.type === 'drag' && dragInteractionVisualActive && dragMovingIdSet) {
elements = elements.filter((el) => !dragMovingIdSet.has(el.id));
}
if (dragState?.type === 'resize' && interactionCanvasReady && dragState.elementId) {
elements = elements.filter((el) => el.id !== dragState.elementId);
}
return elements.length > 0 ? elements : null;
}, [
liftSelectedAboveDim,
selectedElementIds,
template.elements,
filterVisibleElements,
dragState?.type,
dragState?.elementId,
dragInteractionVisualActive,
dragMovingIdSet,
interactionCanvasReady,
]);
const resizeFloatElements = useMemo(() => {
if (dragState?.type !== 'resize' || !interactionFloatElements?.length) return null;
return filterVisibleElements(interactionFloatElements);
}, [dragState?.type, interactionFloatElements, filterVisibleElements]);
const allowInteractionOverflow = dragState !== null;
const syncInteractionPortalLayout = useCallback(() => {
const wrapper = canvasWrapperRef.current;
const portal = interactionPortalRef.current;
if (!wrapper || !portal) return;
const rect = wrapper.getBoundingClientRect();
portal.style.left = `${rect.left}px`;
portal.style.top = `${rect.top}px`;
portal.style.width = `${rect.width}px`;
portal.style.height = `${rect.height}px`;
}, []);
useEffect(() => {
if (!allowInteractionOverflow) return;
syncInteractionPortalLayout();
window.addEventListener('scroll', syncInteractionPortalLayout, true);
window.addEventListener('resize', syncInteractionPortalLayout);
return () => {
window.removeEventListener('scroll', syncInteractionPortalLayout, true);
window.removeEventListener('resize', syncInteractionPortalLayout);
};
}, [allowInteractionOverflow, syncInteractionPortalLayout]);
useEffect(() => {
const shouldPrepareCanvas =
(dragState?.type === 'drag' && dragInteractionVisualActive) ||
dragState?.type === 'resize';
if (!shouldPrepareCanvas) {
setInteractionCanvasReady(false);
return;
}
setInteractionCanvasReady(false);
let cancelled = false;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!cancelled) setInteractionCanvasReady(true);
});
});
return () => {
cancelled = true;
};
}, [dragState, dragInteractionVisualActive]);
const hideSelectionChrome =
suppressSelectionChrome || (dragState?.type === 'drag' && dragInteractionVisualActive);
const showUnselectedDimLayer = activeTool === 'move';
const showUnselectedDim = showUnselectedDimLayer && selectedElementIds.length > 0;
const multiSelectionBounds = useMemo(() => {
if (selectedElementIds.length < 2 || hideSelectionChrome) return null;
const selected = template.elements.filter(
(el) => selectedElementIds.includes(el.id) && !el.locked
);
if (selected.length < 2) return null;
return getSelectionBounds(selected);
}, [selectedElementIds, template.elements, hideSelectionChrome]);
const dragMultiSelectionBounds = useMemo(() => {
if (
selectedElementIds.length < 2 ||
dragState?.type !== 'drag' ||
!dragInteractionVisualActive ||
dragFloatElements.length < 2
) {
return null;
}
return getSelectionBounds(dragFloatElements);
}, [
selectedElementIds.length,
dragState?.type,
dragInteractionVisualActive,
dragFloatElements,
]);
const resizingPreviewElement =
dragState?.type === 'resize'
? interactionFloatElements?.find((el) => el.id === dragState.elementId)
: null;
const overlayElements = filterVisibleElements(template.elements);
const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动', shortcut: 'V' },
{ id: 'text', icon: <Type className="w-[18px] h-[18px]" />, label: '文本', shortcut: 'T' },
{ id: 'table', icon: <Table2 className="w-[18px] h-[18px]" />, label: '表格' },
{ id: 'barcode', icon: <Barcode className="w-[18px] h-[18px]" />, label: '条形码' },
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
];
const mobileToolItems = useMemo<MobileToolButtonItem[]>(
() => [
...tools.map((tool) => ({
id: tool.id,
icon: tool.icon,
label: tool.label,
shortcut: tool.shortcut,
active: activeTool === tool.id,
onClick: () => handleToolClick(tool.id),
})),
{
id: 'image',
icon: <ImageIcon className="w-[18px] h-[18px]" />,
label: '图片',
active: false,
onClick: handleImageToolClick,
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置
[activeTool, handleToolClick, handleImageToolClick]
);
const mainToolButtons = (
<>
{tools.map((tool) => (
<button
key={tool.id}
className={`ps-tool-btn ${activeTool === tool.id ? 'active' : ''} ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`}
onClick={() => handleToolClick(tool.id)}
title={`${tool.label}${tool.shortcut ? ` (${tool.shortcut})` : ''}`}
>
{tool.icon}
</button>
))}
<button
className={`ps-tool-btn ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`}
onClick={handleImageToolClick}
title="图片工具"
>
<ImageIcon className="w-[18px] h-[18px]" />
</button>
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFile}
/>
</>
);
const deleteToolButton =
selectedElementIds.length > 0 ? (
<button
className={`ps-tool-btn hover:!bg-red-900/40 hover:!text-red-400 ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`}
onClick={() => void deleteSelectedElements()}
title="删除选中元素"
>
<Trash2 className="w-[18px] h-[18px]" />
</button>
) : null;
const zoomControls = (
<div className="flex items-center gap-1.5 shrink-0">
<div className="ps-zoom-control">
<button
className="ps-zoom-btn"
onClick={() => setZoom((z) => clampZoomValue(z - 0.25, maxZoom))}
title="缩小"
>
<ZoomOut className="w-3 h-3" />
</button>
<span className="px-1 text-[10px] font-mono text-[#ccc] min-w-[32px] text-center select-none">
{Math.round(displayZoom * 100)}%
</span>
<button
className="ps-zoom-btn"
onClick={() => setZoom((z) => clampZoomValue(z + 0.25, maxZoom))}
disabled={displayZoom >= maxZoom - 0.001}
title="放大"
>
<ZoomIn className="w-3 h-3" />
</button>
</div>
<button
type="button"
onClick={fitToView}
className="h-[24px] text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"
title="适合窗口"
>
</button>
</div>
);
const showInteractionFloatPortal =
typeof document !== 'undefined' &&
((dragState?.type === 'drag' &&
dragInteractionVisualActive &&
dragFloatElements.length > 0) ||
(resizeFloatElements && resizeFloatElements.length > 0));
const interactionFloatPortal = showInteractionFloatPortal
? createPortal(
<div
ref={interactionPortalRef}
className="pointer-events-none"
style={{
position: 'fixed',
left: 0,
top: 0,
width: 0,
height: 0,
overflow: 'visible',
zIndex: 10000,
}}
>
{dragState?.type === 'drag' && dragFloatElements.length > 0 && (
<div
ref={dragFloatLayerRef}
className="absolute inset-0 pointer-events-none"
style={{
overflow: 'visible',
willChange: 'transform',
}}
>
{dragFloatElements.map((el) => {
const padMm = getFloatPreviewPaddingMm(el);
const padPx = mmToPx(padMm, displayZoom);
const boxW = mmToPx(el.width, displayZoom);
const boxH = mmToPx(el.height, displayZoom);
return (
<div
key={el.id}
className="absolute pointer-events-none"
style={{
left: `${mmToPx(el.x, displayZoom)}px`,
top: `${mmToPx(el.y, displayZoom)}px`,
width: `${boxW}px`,
height: `${boxH}px`,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
transformOrigin: 'center center',
}}
>
<div
data-drag-float-placeholder={el.id}
className="absolute inset-0 bg-white shadow-[0_1px_4px_rgba(0,0,0,0.12)]"
/>
<img
ref={(node) => {
if (node) {
dragFloatImgRefs.current.set(el.id, node);
const url = dragFloatUrlsRef.current[el.id];
if (url) {
node.src = url;
if (node.complete) {
node.style.opacity = '1';
const placeholder = node.previousElementSibling as HTMLElement | null;
if (placeholder) placeholder.style.display = 'none';
}
}
} else {
dragFloatImgRefs.current.delete(el.id);
}
}}
alt=""
className="absolute pointer-events-none select-none opacity-0"
style={{
left: `${-padPx}px`,
top: `${-padPx}px`,
width: `${boxW + padPx * 2}px`,
height: `${boxH + padPx * 2}px`,
}}
onLoad={(e) => {
const img = e.currentTarget;
img.style.opacity = '1';
const placeholder = img.previousElementSibling as HTMLElement | null;
if (placeholder) placeholder.style.display = 'none';
}}
/>
{selectedElementIds.length === 1 && (
<div
className="absolute inset-0 ps-resize-outline pointer-events-none z-[3]"
aria-hidden
/>
)}
</div>
);
})}
{dragMultiSelectionBounds && (
<div
className="absolute ps-resize-outline pointer-events-none z-[5]"
style={{
left: `${mmToPx(dragMultiSelectionBounds.minX, displayZoom)}px`,
top: `${mmToPx(dragMultiSelectionBounds.minY, displayZoom)}px`,
width: `${mmToPx(
dragMultiSelectionBounds.maxX - dragMultiSelectionBounds.minX,
displayZoom
)}px`,
height: `${mmToPx(
dragMultiSelectionBounds.maxY - dragMultiSelectionBounds.minY,
displayZoom
)}px`,
}}
aria-hidden
/>
)}
</div>
)}
{resizeFloatElements && resizeFloatElements.length > 0 && (
<div
className="absolute inset-0 pointer-events-none"
style={{ overflow: 'visible' }}
>
{resizeFloatElements.map((el) => {
const left = mmToPx(el.x, displayZoom);
const top = mmToPx(el.y, displayZoom);
const width = mmToPx(el.width, displayZoom);
const height = mmToPx(el.height, displayZoom);
return (
<div
key={el.id}
className="absolute pointer-events-none"
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
transformOrigin: 'center center',
}}
>
<ElementFloatPreview
element={el}
renderScale={canvasRenderScale}
displayZoom={displayZoom}
variableDefaults={previewDefaults}
/>
<div className="absolute inset-0 ps-resize-outline z-[2]" aria-hidden />
</div>
);
})}
</div>
)}
</div>,
document.body
)
: null;
return (
<>
<div
className={
isMobileLayout
? `mobile-design-canvas-shell flex flex-col flex-1 min-w-0 min-h-0${
allowInteractionOverflow ? ' mobile-design-canvas-shell-interaction' : ''
}`
: 'flex flex-1 min-w-0 min-h-0'
}
data-interaction-active={allowInteractionOverflow ? '' : undefined}
>
{!isMobileLayout && (
<div className="ps-toolbar flex flex-col">
{mainToolButtons}
<div className="flex-1" />
{deleteToolButton}
</div>
)}
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<div
ref={canvasAreaRef}
className={`flex-1 ps-workspace-bg ps-design-canvas-touch flex items-center justify-center min-h-0 min-w-0 p-4 ${
allowInteractionOverflow ? 'overflow-visible' : 'overflow-auto scrollbar-hide overscroll-contain'
}`}
onPointerDown={handleWorkspacePointerDown}
onClick={(e) => {
if (ignoreCanvasClickRef.current) {
ignoreCanvasClickRef.current = false;
return;
}
if (e.target === e.currentTarget) {
requestAnimationFrame(() => onSelectElements([]));
}
}}
>
<div
ref={canvasWrapperRef}
className="relative shrink-0 grow-0"
style={{
width: `${canvasDisplayW}px`,
height: `${canvasDisplayH}px`,
overflow: allowInteractionOverflow ? 'visible' : 'hidden',
}}
>
<div
ref={canvasRef}
className={`absolute inset-0 shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none overflow-hidden ${
showReferencePreview ? '' : 'bg-white'
}`}
>
{showReferencePreview && (
<img
src={template.previewReferenceBackground}
alt=""
className="absolute inset-0 w-full h-full pointer-events-none select-none z-0"
style={{
objectFit: template.previewReferenceBackgroundFit ?? 'cover',
opacity: Math.min(
1,
Math.max(0, (template.previewReferenceBackgroundOpacity ?? 100) / 100)
),
}}
/>
)}
{/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */}
<div className="absolute inset-0 overflow-hidden pointer-events-none z-[1]">
<CanvasLabelImage
widthMm={template.width}
heightMm={template.height}
elements={staticCanvasElements}
rowData={null}
rowIndex={0}
showBorder={false}
transparentBackground={showReferencePreview}
variableDefaults={previewDefaults}
printColorMode={printColorMode}
renderScale={canvasRenderScale}
/>
</div>
{showUnselectedDimLayer && (
<div
className={`ps-unselected-dim-layer${
showUnselectedDim ? ' ps-unselected-dim-layer--visible' : ''
}`}
aria-hidden={!showUnselectedDim}
/>
)}
{selectedLiftElements && (
<div className="ps-selected-lift-layer">
<CanvasLabelImage
widthMm={template.width}
heightMm={template.height}
elements={selectedLiftElements}
rowData={null}
rowIndex={0}
showBorder={false}
transparentBackground
variableDefaults={previewDefaults}
printColorMode={printColorMode}
renderScale={canvasRenderScale}
/>
</div>
)}
{activeTool === 'move' && (
<div
className="absolute inset-0 z-[1] cursor-crosshair touch-none"
aria-hidden
onPointerDown={startMarquee}
/>
)}
{marqueeState && (
<div
className="ps-marquee-select"
style={{
left: `${Math.min(marqueeState.startX, marqueeState.currentX)}px`,
top: `${Math.min(marqueeState.startY, marqueeState.currentY)}px`,
width: `${Math.abs(marqueeState.currentX - marqueeState.startX)}px`,
height: `${Math.abs(marqueeState.currentY - marqueeState.startY)}px`,
}}
/>
)}
{overlayElements.map((element, index) => {
const isLocked = !!element.locked;
const isSelected = !isLocked && selectedElementIds.includes(element.id);
const isPrimary = selectedElementIds.indexOf(element.id) === 0;
const isBeingDragged =
dragInteractionVisualActive &&
dragState?.type === 'drag' &&
!!dragMovingIdSet?.has(element.id);
const isBeingResized =
dragState?.type === 'resize' && dragState.elementId === element.id;
if (isBeingDragged) {
return null;
}
if (isBeingResized && interactionCanvasReady && resizingPreviewElement) {
return null;
}
const overlayElement =
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
const showResizeHandles =
isSelected &&
!hideSelectionChrome &&
!isLocked &&
selectedElementIds.length === 1 &&
dragState?.type !== 'resize';
const elemX = mmToPx(overlayElement.x, displayZoom);
const elemY = mmToPx(overlayElement.y, displayZoom);
const elemW = mmToPx(overlayElement.width, displayZoom);
const elemH = mmToPx(overlayElement.height, displayZoom);
return (
<div
key={element.id}
data-label-element
className={`absolute group touch-none ${
isLocked
? 'pointer-events-none'
: isSelected
? 'cursor-move'
: 'cursor-move hover:outline hover:outline-1 hover:outline-[#31a8ff]/40'
}`}
style={{
left: `${elemX}px`,
top: `${elemY}px`,
width: `${elemW}px`,
height: `${elemH}px`,
zIndex: isBeingResized
? index + 2000
: isSelected
? index + 1000
: index + 10,
transform: overlayElement.rotation
? `rotate(${overlayElement.rotation}deg)`
: undefined,
transformOrigin: 'center center',
pointerEvents: isBeingResized ? 'none' : undefined,
}}
onPointerDown={
isLocked || isBeingResized
? undefined
: (e) => handleElementPointerDown(e, element, 'drag')
}
onClick={isLocked ? undefined : (e) => handleElementClick(e, element)}
>
{selectedElementIds.length > 1 && isSelected && (
<div
className={`absolute -top-5 right-0 text-[9px] font-bold px-1 py-0.5 z-50 text-white min-w-[14px] text-center ${
isPrimary ? 'bg-[#31a8ff]' : 'bg-emerald-600'
}`}
>
{selectedElementIds.indexOf(element.id) + 1}
</div>
)}
{isBeingResized && (
<div className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]" aria-hidden />
)}
{isSelected &&
!hideSelectionChrome &&
!isLocked &&
selectedElementIds.length === 1 &&
!isBeingResized && (
<div
className="absolute inset-0 ps-resize-outline pointer-events-none z-[60]"
aria-hidden
/>
)}
{showResizeHandles && (
<>
<div
className="ps-handle top-0 left-0 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize"
onPointerDown={(e) => handleResizeStart(e, element, 'nw')}
title="拖拽调整尺寸"
/>
<div
className="ps-handle top-0 right-0 translate-x-1/2 -translate-y-1/2 cursor-ne-resize"
onPointerDown={(e) => handleResizeStart(e, element, 'ne')}
title="拖拽调整尺寸"
/>
<div
className="ps-handle bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-sw-resize"
onPointerDown={(e) => handleResizeStart(e, element, 'sw')}
title="拖拽调整尺寸"
/>
<div
className="ps-handle bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-se-resize"
onPointerDown={(e) => handleResizeStart(e, element, 'se')}
title="拖拽调整尺寸"
/>
</>
)}
{!isLocked && (
<div className="absolute top-[-16px] left-0 bg-[#3c3c3c] text-[#ccc] text-[9px] px-1 py-px opacity-0 group-hover:opacity-100 transition-opacity whitespace-nowrap pointer-events-none z-50">
{element.name}
</div>
)}
{isSelected &&
!hideSelectionChrome &&
!isLocked &&
selectedElementIds.length === 1 &&
overlayElement.type === 'table' &&
onSelectTableCells && (
<div className="absolute inset-0 z-10 pointer-events-none">
{iterTableCells(overlayElement).map(({ row, col }) => {
const { x: cellX, y: cellY, width: cellW, height: cellH } =
getCellRectPx(overlayElement, row, col, canvasRenderScale);
const isCellSelected = selectedTableCells.some((c) => {
const anchor = getCellAnchor(overlayElement, c.row, c.col);
return anchor.row === row && anchor.col === col;
});
return (
<div
key={`cell-${row}-${col}`}
className={`absolute pointer-events-auto cursor-grab active:cursor-grabbing touch-none ${
isCellSelected
? 'bg-[#31a8ff]/25'
: 'hover:bg-[#31a8ff]/10'
}`}
style={{
left: `${cellX}px`,
top: `${cellY}px`,
width: `${cellW}px`,
height: `${cellH}px`,
boxShadow: isCellSelected
? 'inset 0 0 0 1px #31a8ff'
: undefined,
}}
onPointerDown={(e) =>
handleTableCellPointerDown(e, overlayElement, row, col)
}
onPointerMove={(e) =>
handleTableCellPointerMove(e, overlayElement)
}
onPointerUp={(e) => handleTableCellPointerUp(e)}
onPointerCancel={(e) => handleTableCellPointerUp(e)}
title={`单元格 ${row + 1},${col + 1}(拖动移动表格)`}
/>
);
})}
</div>
)}
</div>
);
})}
{multiSelectionBounds && (
<div
className="absolute ps-resize-outline pointer-events-none z-[1005]"
style={{
left: `${mmToPx(multiSelectionBounds.minX, displayZoom)}px`,
top: `${mmToPx(multiSelectionBounds.minY, displayZoom)}px`,
width: `${mmToPx(
multiSelectionBounds.maxX - multiSelectionBounds.minX,
displayZoom
)}px`,
height: `${mmToPx(
multiSelectionBounds.maxY - multiSelectionBounds.minY,
displayZoom
)}px`,
}}
aria-hidden
/>
)}
</div>
</div>
</div>
{isMobileLayout ? (
<div className="mobile-design-toolstrip">
<MobileMainToolButtons items={mobileToolItems} />
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFile}
/>
{zoomControls}
</div>
) : (
<div className={`ps-status-bar ${isNarrow ? 'ps-status-bar-compact' : ''}`}>
<div className="flex items-center gap-2 min-w-0 truncate">
{isNarrow ? (
<span className="truncate">
{template.width}×{template.height}
{selectedElemObj ? ` · ${selectedElemObj.name}` : ''}
{selectedElementIds.length > 1 ? ` · ${selectedElementIds.length}` : ''}
</span>
) : (
<>
<span>
: {template.width} × {template.height} mm
</span>
{selectedElemObj && selectedElementIds.length === 1 && (
<span className="text-[#31a8ff]">
| {selectedElemObj.name} X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
{selectedElemObj.width} H:{selectedElemObj.height} mm
</span>
)}
{multiSelectionBounds && (
<span className="text-emerald-400">
| {selectedElementIds.length} X:{multiSelectionBounds.minX} Y:
{multiSelectionBounds.minY} mm
</span>
)}
{activeTool === 'move' && (
<span className="text-[#888] hidden sm:inline">
| · Shift/Ctrl+ · Ctrl+A · Ctrl+C/V
</span>
)}
</>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{!isNarrow && (
<span className="hidden sm:inline text-[10px]">
使 · · Ctrl+
</span>
)}
{zoomControls}
<button
type="button"
onClick={applyModerateZoom}
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"
title="适中缩放"
>
</button>
</div>
</div>
)}
</div>
</div>
{interactionFloatPortal}
</>
);
};