1651 lines
55 KiB
TypeScript
1651 lines
55 KiB
TypeScript
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 } from '../utils';
|
||
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
||
import { CanvasLabelImage } from './CanvasLabelImage';
|
||
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
|
||
import { renderLabelToDataUrl } from '../labelRenderer';
|
||
import { useAppDialog } from './AppDialog';
|
||
import {
|
||
ZoomIn,
|
||
ZoomOut,
|
||
Type,
|
||
Barcode,
|
||
QrCode,
|
||
Image as ImageIcon,
|
||
Move,
|
||
Trash2,
|
||
} from 'lucide-react';
|
||
|
||
interface LabelDesignerProps {
|
||
template: LabelTemplate;
|
||
onChange: (updated: LabelTemplate) => void;
|
||
selectedElementIds: string[];
|
||
onSelectElements: (ids: string[]) => void;
|
||
variant?: 'desktop' | 'mobile';
|
||
/** 微调等场景下隐藏选区框与缩放手柄 */
|
||
suppressSelectionChrome?: boolean;
|
||
}
|
||
|
||
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
|
||
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,
|
||
variant = 'desktop',
|
||
suppressSelectionChrome = false,
|
||
}) => {
|
||
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 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 dragTransformLoopRef = useRef<number | null>(null);
|
||
const [dragFloatElements, setDragFloatElements] = useState<TemplateElement[]>([]);
|
||
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;
|
||
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 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 addImageElement = useCallback(
|
||
(src: string = '') => {
|
||
const id = `image_${Date.now()}`;
|
||
const n = template.elements.length + 1;
|
||
const newElement: TemplateElement = {
|
||
id,
|
||
type: 'image',
|
||
name: `图片 ${n}`,
|
||
x: 5,
|
||
y: 5,
|
||
width: 30,
|
||
height: 30,
|
||
content: src,
|
||
fontSize: 12,
|
||
textAlign: 'center',
|
||
fontWeight: 'normal',
|
||
barcodeFormat: 'CODE128',
|
||
showText: false,
|
||
fontFamily: 'sans',
|
||
backgroundColor: 'transparent',
|
||
imageFit: 'contain',
|
||
};
|
||
onChange({
|
||
...template,
|
||
elements: [...template.elements, newElement],
|
||
});
|
||
onSelectElements([id]);
|
||
setActiveTool('move');
|
||
},
|
||
[template, onChange, onSelectElements]
|
||
);
|
||
|
||
const addElement = useCallback(
|
||
(type: 'text' | 'barcode' | 'qrcode') => {
|
||
let newElement: TemplateElement;
|
||
const id = `${type}_${Date.now()}`;
|
||
const n = template.elements.length + 1;
|
||
|
||
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: 'transparent',
|
||
};
|
||
} 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: 'transparent',
|
||
};
|
||
} 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: 'transparent',
|
||
};
|
||
}
|
||
|
||
onChange({
|
||
...template,
|
||
elements: [...template.elements, newElement],
|
||
});
|
||
onSelectElements([id]);
|
||
setActiveTool('move');
|
||
},
|
||
[template, onChange, onSelectElements]
|
||
);
|
||
|
||
const deleteSelectedElements = useCallback(async () => {
|
||
const ids =
|
||
selectedElementIds.length > 0
|
||
? selectedElementIds
|
||
: selectedElementId
|
||
? [selectedElementId]
|
||
: [];
|
||
const deletable = ids.filter((id) => {
|
||
const el = template.elements.find((item) => item.id === id);
|
||
return el && !el.locked;
|
||
});
|
||
if (deletable.length === 0) return;
|
||
|
||
const message =
|
||
deletable.length === 1
|
||
? `确定删除「${template.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 = template.elements.filter((el) => !idSet.has(el.id));
|
||
onChange({ ...template, elements: remaining });
|
||
onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
|
||
}, [selectedElementIds, selectedElementId, template, 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(template.elements.filter((el) => !el.locked).map((el) => el.id));
|
||
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 ? 2 : 0.5;
|
||
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();
|
||
onChange({
|
||
...template,
|
||
elements: template.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,
|
||
]);
|
||
|
||
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([]);
|
||
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;
|
||
|
||
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;
|
||
}
|
||
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) =>
|
||
el.id === dragState.elementId
|
||
? { ...el, x: rect.x, y: rect.y, width: rect.w, height: rect.h }
|
||
: el
|
||
);
|
||
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({ ...template, 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]);
|
||
|
||
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;
|
||
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,
|
||
});
|
||
|
||
if (actionType === 'drag' && floating.length > 0) {
|
||
void Promise.all(
|
||
floating.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);
|
||
}
|
||
})
|
||
);
|
||
}
|
||
};
|
||
|
||
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;
|
||
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,
|
||
});
|
||
};
|
||
|
||
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 staticCanvasElements = useMemo(() => {
|
||
if (dragState?.type === 'drag' && interactionSnapshot && dragMovingIdSet) {
|
||
return filterVisibleElements(
|
||
interactionSnapshot.filter((el) => !dragMovingIdSet.has(el.id))
|
||
);
|
||
}
|
||
if (dragState?.type === 'resize' && interactionSnapshot) {
|
||
return filterVisibleElements(
|
||
interactionSnapshot.filter((el) => el.id !== dragState.elementId)
|
||
);
|
||
}
|
||
return filterVisibleElements(template.elements);
|
||
}, [
|
||
dragState?.type,
|
||
dragState?.elementId,
|
||
interactionSnapshot,
|
||
dragMovingIdSet,
|
||
template.elements,
|
||
filterVisibleElements,
|
||
]);
|
||
|
||
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]);
|
||
|
||
const hideSelectionChrome = suppressSelectionChrome || dragState?.type === 'drag';
|
||
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: 'barcode', icon: <Barcode className="w-[18px] h-[18px]" />, label: '条形码' },
|
||
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
|
||
];
|
||
|
||
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.5 text-[10px] font-mono text-[#ccc] min-w-[42px] 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' && 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) => (
|
||
<div
|
||
key={el.id}
|
||
className="absolute pointer-events-none"
|
||
style={{
|
||
left: `${mmToPx(el.x, displayZoom)}px`,
|
||
top: `${mmToPx(el.y, displayZoom)}px`,
|
||
width: `${mmToPx(el.width, displayZoom)}px`,
|
||
height: `${mmToPx(el.height, displayZoom)}px`,
|
||
}}
|
||
>
|
||
<div
|
||
data-drag-float-placeholder={el.id}
|
||
className="absolute inset-0 bg-white border border-[#31a8ff]/40 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 inset-0 w-full h-full pointer-events-none select-none opacity-0"
|
||
onLoad={(e) => {
|
||
const img = e.currentTarget;
|
||
img.style.opacity = '1';
|
||
const placeholder = img.previousElementSibling as HTMLElement | null;
|
||
if (placeholder) placeholder.style.display = 'none';
|
||
}}
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
{resizeFloatElements && resizeFloatElements.length > 0 && (
|
||
<div
|
||
className="absolute inset-0 pointer-events-none"
|
||
style={{ overflow: 'visible' }}
|
||
>
|
||
{resizeFloatElements.map((el) => (
|
||
<ElementFloatPreview
|
||
key={el.id}
|
||
element={el}
|
||
renderScale={canvasRenderScale}
|
||
displayZoom={displayZoom}
|
||
variableDefaults={previewDefaults}
|
||
/>
|
||
))}
|
||
</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'
|
||
}`}
|
||
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 bg-white shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none overflow-hidden"
|
||
>
|
||
{/* <div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]">
|
||
{template.width} mm
|
||
</div>
|
||
<div
|
||
className="absolute -left-8 top-0 text-[10px] font-mono text-[#aaa]"
|
||
style={{ writingMode: 'vertical-rl', transform: 'rotate(180deg)' }}
|
||
>
|
||
{template.height} mm
|
||
</div> */}
|
||
|
||
{/* 整页统一渲染:半透明背景按图层层级与下方元素真实合成 */}
|
||
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
|
||
<CanvasLabelImage
|
||
widthMm={template.width}
|
||
heightMm={template.height}
|
||
elements={staticCanvasElements}
|
||
rowData={null}
|
||
rowIndex={0}
|
||
showBorder={false}
|
||
variableDefaults={previewDefaults}
|
||
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 =
|
||
dragState?.type === 'drag' && !!dragMovingIdSet?.has(element.id);
|
||
const isBeingResized =
|
||
dragState?.type === 'resize' && dragState.elementId === element.id;
|
||
if (isBeingDragged) {
|
||
return null;
|
||
}
|
||
|
||
const overlayElement =
|
||
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
|
||
|
||
const showSelectionRing = isSelected && !hideSelectionChrome;
|
||
const showResizeHandles =
|
||
showSelectionRing &&
|
||
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'
|
||
: showSelectionRing
|
||
? 'ps-selection-ring cursor-move'
|
||
: 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>
|
||
)}
|
||
|
||
{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>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
|
||
{isMobileLayout ? (
|
||
<div className="mobile-design-toolstrip">
|
||
<div className="mobile-design-toolstrip-tools">
|
||
{mainToolButtons}
|
||
{deleteToolButton}
|
||
</div>
|
||
{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 && (
|
||
<span className="text-[#31a8ff]">
|
||
| {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
|
||
{selectedElemObj.width} H:{selectedElemObj.height} mm
|
||
</span>
|
||
)}
|
||
{selectedElementIds.length > 1 && (
|
||
<span className="text-emerald-400">| 已选 {selectedElementIds.length} 个</span>
|
||
)}
|
||
{activeTool === 'move' && (
|
||
<span className="text-[#888] hidden sm:inline">
|
||
| 框选 · Shift/Ctrl+点选 · Ctrl+A 全选
|
||
</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}
|
||
</>
|
||
);
|
||
};
|