This commit is contained in:
iqudoo
2026-06-13 03:09:01 +08:00
parent 4e31850dde
commit 979a565468
3 changed files with 1279 additions and 1100 deletions

View File

@@ -3,6 +3,7 @@ import { LabelTemplate, TemplateElement } from '../types';
import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils'; import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils';
import { useIsNarrowScreen } from '../hooks/useIsMobile'; import { useIsNarrowScreen } from '../hooks/useIsMobile';
import { CanvasLabelImage } from './CanvasLabelImage'; import { CanvasLabelImage } from './CanvasLabelImage';
import { useAppDialog } from './AppDialog';
import { import {
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
@@ -85,6 +86,56 @@ function blurActiveField() {
} }
} }
/** 在容器内等比约束画布显示尺寸,避免宽/高单独触顶导致变形 */
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( function rectFullyContains(
outer: { left: number; top: number; right: number; bottom: number }, outer: { left: number; top: number; right: number; bottom: number },
inner: { left: number; top: number; right: number; bottom: number } inner: { left: number; top: number; right: number; bottom: number }
@@ -137,10 +188,12 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onSelectElements, onSelectElements,
variant = 'desktop', variant = 'desktop',
}) => { }) => {
const { showConfirm } = useAppDialog();
const selectedElementId = selectedElementIds[0] || null; const selectedElementId = selectedElementIds[0] || null;
const isMobileLayout = variant === 'mobile'; const isMobileLayout = variant === 'mobile';
const isNarrow = useIsNarrowScreen() || isMobileLayout; const isNarrow = useIsNarrowScreen() || isMobileLayout;
const [zoom, setZoom] = useState<number>(MODERATE_ZOOM_MAX); const [zoom, setZoom] = useState<number>(MODERATE_ZOOM_MAX);
const [canvasAreaSize, setCanvasAreaSize] = useState({ w: 0, h: 0 });
const [activeTool, setActiveTool] = useState<ActiveTool>('move'); const [activeTool, setActiveTool] = useState<ActiveTool>('move');
const canvasAreaRef = useRef<HTMLDivElement | null>(null); const canvasAreaRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLDivElement | null>(null); const canvasRef = useRef<HTMLDivElement | null>(null);
@@ -296,20 +349,36 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
[template, onChange, onSelectElements] [template, onChange, onSelectElements]
); );
const deleteSelectedElements = useCallback(() => { const deleteSelectedElements = useCallback(async () => {
const idSet = new Set( const ids =
selectedElementIds.length > 0 selectedElementIds.length > 0
? selectedElementIds ? selectedElementIds
: selectedElementId : selectedElementId
? [selectedElementId] ? [selectedElementId]
: [] : [];
); const deletable = ids.filter((id) => {
if (idSet.size === 0) return; const el = template.elements.find((item) => item.id === id);
return el && !el.locked;
});
if (deletable.length === 0) return;
const remaining = template.elements.filter((el) => !idSet.has(el.id) || el.locked); 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 }); onChange({ ...template, elements: remaining });
onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id))); onSelectElements(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
}, [selectedElementIds, selectedElementId, template, onChange, onSelectElements]); }, [selectedElementIds, selectedElementId, template, onChange, onSelectElements, showConfirm]);
const handleToolClick = (tool: ActiveTool) => { const handleToolClick = (tool: ActiveTool) => {
if (tool === 'move') { if (tool === 'move') {
@@ -352,7 +421,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
} }
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault(); e.preventDefault();
deleteSelectedElements(); void deleteSelectedElements();
return; return;
} }
@@ -470,6 +539,61 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
applyModerateZoom(); applyModerateZoom();
}, [template.id, 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(() => { useEffect(() => {
const area = canvasAreaRef.current; const area = canvasAreaRef.current;
if (!area) return; if (!area) return;
@@ -478,7 +602,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (e.ctrlKey || e.metaKey) { if (e.ctrlKey || e.metaKey) {
e.preventDefault(); e.preventDefault();
const delta = e.deltaY > 0 ? -0.25 : 0.25; const delta = e.deltaY > 0 ? -0.25 : 0.25;
setZoom((z) => Math.min(5, Math.max(0.5, Math.round((z + delta) * 100) / 100))); setZoom((z) => clampZoomValue(z + delta, maxZoomRef.current));
} }
}; };
@@ -533,10 +657,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
.filter((el) => { .filter((el) => {
if (el.locked) return false; if (el.locked) return false;
const elRect = { const elRect = {
left: mmToPx(el.x, zoom), left: mmToPx(el.x, displayZoomRef.current),
top: mmToPx(el.y, zoom), top: mmToPx(el.y, displayZoomRef.current),
right: mmToPx(el.x + el.width, zoom), right: mmToPx(el.x + el.width, displayZoomRef.current),
bottom: mmToPx(el.y + el.height, zoom), bottom: mmToPx(el.y + el.height, displayZoomRef.current),
}; };
return rectFullyContains(marqueeRect, elRect); return rectFullyContains(marqueeRect, elRect);
}) })
@@ -558,7 +682,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp);
}; };
}, [marqueeActive, zoom, onSelectElements]); }, [marqueeActive, displayZoom, onSelectElements]);
useEffect(() => { useEffect(() => {
if (!dragState) return; if (!dragState) return;
@@ -578,7 +702,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (dragState.type === 'drag' && pastDragThreshold) { if (dragState.type === 'drag' && pastDragThreshold) {
dragMovedRef.current = true; dragMovedRef.current = true;
} }
const MM_PER_PX = 1 / (3.78 * zoom); const MM_PER_PX = 1 / (3.78 * displayZoomRef.current);
const deltaXMm = deltaX * MM_PER_PX; const deltaXMm = deltaX * MM_PER_PX;
const deltaYMm = deltaY * MM_PER_PX; const deltaYMm = deltaY * MM_PER_PX;
@@ -610,6 +734,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const elem = template.elements.find((el) => el.id === dragState.elementId); const elem = template.elements.find((el) => el.id === dragState.elementId);
if (!elem || elem.locked) return; if (!elem || elem.locked) return;
dragMovedRef.current = true;
const corner = dragState.resizeCorner ?? 'se'; const corner = dragState.resizeCorner ?? 'se';
const rect = const rect =
elem.type === 'qrcode' elem.type === 'qrcode'
@@ -664,8 +789,12 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
localElementsRef.current = pendingDragElementsRef.current; localElementsRef.current = pendingDragElementsRef.current;
pendingDragElementsRef.current = null; pendingDragElementsRef.current = null;
} }
if (localElementsRef.current && dragMovedRef.current) { if (localElementsRef.current) {
onChange({ ...template, elements: localElementsRef.current }); const shouldCommit =
dragState.type === 'resize' || dragMovedRef.current;
if (shouldCommit) {
onChange({ ...template, elements: localElementsRef.current });
}
} }
localElementsRef.current = null; localElementsRef.current = null;
setLocalElements(null); setLocalElements(null);
@@ -681,7 +810,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp);
}; };
}, [dragState, template, zoom, onChange, cancelActiveInteraction]); }, [dragState, template, displayZoom, onChange, cancelActiveInteraction]);
const buildGroupStartPositions = (element: TemplateElement) => { const buildGroupStartPositions = (element: TemplateElement) => {
const moveIds = const moveIds =
@@ -855,7 +984,6 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
frozenCanvasElements ?? template.elements frozenCanvasElements ?? template.elements
); );
const overlayElements = filterVisibleElements(localElements ?? template.elements); const overlayElements = filterVisibleElements(localElements ?? template.elements);
const canvasRenderScale = Math.max(1, Math.round(MM_TO_PX * zoom * 100) / 100);
const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [ const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动工具', shortcut: 'V' }, { id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动工具', shortcut: 'V' },
@@ -897,7 +1025,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
selectedElementIds.length > 0 ? ( selectedElementIds.length > 0 ? (
<button <button
className={`ps-tool-btn hover:!bg-red-900/40 hover:!text-red-400 ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`} className={`ps-tool-btn hover:!bg-red-900/40 hover:!text-red-400 ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`}
onClick={deleteSelectedElements} onClick={() => void deleteSelectedElements()}
title="删除选中元素" title="删除选中元素"
> >
<Trash2 className="w-[18px] h-[18px]" /> <Trash2 className="w-[18px] h-[18px]" />
@@ -907,20 +1035,29 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const zoomControls = ( const zoomControls = (
<div className="flex items-center gap-1.5 shrink-0"> <div className="flex items-center gap-1.5 shrink-0">
<div className="ps-zoom-control"> <div className="ps-zoom-control">
<button className="ps-zoom-btn" onClick={() => setZoom(Math.max(0.5, zoom - 0.25))} title="缩小"> <button
className="ps-zoom-btn"
onClick={() => setZoom((z) => clampZoomValue(z - 0.25, maxZoom))}
title="缩小"
>
<ZoomOut className="w-3 h-3" /> <ZoomOut className="w-3 h-3" />
</button> </button>
<span className="px-1.5 text-[10px] font-mono text-[#ccc] min-w-[42px] text-center select-none"> <span className="px-1.5 text-[10px] font-mono text-[#ccc] min-w-[42px] text-center select-none">
{Math.round(zoom * 100)}% {Math.round(displayZoom * 100)}%
</span> </span>
<button className="ps-zoom-btn" onClick={() => setZoom(Math.min(5, zoom + 0.25))} title="放大"> <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" /> <ZoomIn className="w-3 h-3" />
</button> </button>
</div> </div>
<button <button
type="button" type="button"
onClick={fitToView} onClick={fitToView}
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer" 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="适合窗口" title="适合窗口"
> >
@@ -947,7 +1084,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<div className="flex flex-col flex-1 min-w-0 min-h-0"> <div className="flex flex-col flex-1 min-w-0 min-h-0">
<div <div
ref={canvasAreaRef} ref={canvasAreaRef}
className="flex-1 overflow-auto ps-workspace-bg ps-design-canvas-touch flex items-center justify-center" className="flex-1 overflow-auto ps-workspace-bg ps-design-canvas-touch flex items-center justify-center min-h-0 min-w-0 p-4"
onPointerDown={handleWorkspacePointerDown} onPointerDown={handleWorkspacePointerDown}
onClick={(e) => { onClick={(e) => {
if (ignoreCanvasClickRef.current) { if (ignoreCanvasClickRef.current) {
@@ -961,10 +1098,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
> >
<div <div
ref={canvasRef} ref={canvasRef}
className="relative bg-white shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none" className="relative shrink-0 grow-0 bg-white shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none"
style={{ style={{
width: `${mmToPx(template.width, zoom)}px`, width: `${canvasDisplayW}px`,
height: `${mmToPx(template.height, zoom)}px`, height: `${canvasDisplayH}px`,
}} }}
> >
{/* <div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]"> {/* <div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]">
@@ -1015,10 +1152,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const isLocked = !!element.locked; const isLocked = !!element.locked;
const isSelected = !isLocked && selectedElementIds.includes(element.id); const isSelected = !isLocked && selectedElementIds.includes(element.id);
const isPrimary = selectedElementIds.indexOf(element.id) === 0; const isPrimary = selectedElementIds.indexOf(element.id) === 0;
const elemX = mmToPx(element.x, zoom); const elemX = mmToPx(element.x, displayZoom);
const elemY = mmToPx(element.y, zoom); const elemY = mmToPx(element.y, displayZoom);
const elemW = mmToPx(element.width, zoom); const elemW = mmToPx(element.width, displayZoom);
const elemH = mmToPx(element.height, zoom); const elemH = mmToPx(element.height, displayZoom);
return ( return (
<div <div

File diff suppressed because it is too large Load Diff

View File

@@ -649,6 +649,15 @@ body {
color: #fff; color: #fff;
} }
.ps-layer-item-locked {
cursor: default;
color: #888;
}
.ps-layer-item-locked:hover {
background: transparent;
}
.ps-marquee-select { .ps-marquee-select {
position: absolute; position: absolute;
border: 1px solid #31a8ff; border: 1px solid #31a8ff;