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 { useIsNarrowScreen } from '../hooks/useIsMobile';
import { CanvasLabelImage } from './CanvasLabelImage';
import { useAppDialog } from './AppDialog';
import {
ZoomIn,
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(
outer: { 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,
variant = 'desktop',
}) => {
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);
@@ -296,20 +349,36 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
[template, onChange, onSelectElements]
);
const deleteSelectedElements = useCallback(() => {
const idSet = new Set(
const deleteSelectedElements = useCallback(async () => {
const ids =
selectedElementIds.length > 0
? selectedElementIds
: selectedElementId
? [selectedElementId]
: []
);
if (idSet.size === 0) return;
: [];
const deletable = ids.filter((id) => {
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 });
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) => {
if (tool === 'move') {
@@ -352,7 +421,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}
if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault();
deleteSelectedElements();
void deleteSelectedElements();
return;
}
@@ -470,6 +539,61 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
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;
@@ -478,7 +602,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (e.ctrlKey || e.metaKey) {
e.preventDefault();
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) => {
if (el.locked) return false;
const elRect = {
left: mmToPx(el.x, zoom),
top: mmToPx(el.y, zoom),
right: mmToPx(el.x + el.width, zoom),
bottom: mmToPx(el.y + el.height, zoom),
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);
})
@@ -558,7 +682,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
}, [marqueeActive, zoom, onSelectElements]);
}, [marqueeActive, displayZoom, onSelectElements]);
useEffect(() => {
if (!dragState) return;
@@ -578,7 +702,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (dragState.type === 'drag' && pastDragThreshold) {
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 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);
if (!elem || elem.locked) return;
dragMovedRef.current = true;
const corner = dragState.resizeCorner ?? 'se';
const rect =
elem.type === 'qrcode'
@@ -664,9 +789,13 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
localElementsRef.current = pendingDragElementsRef.current;
pendingDragElementsRef.current = null;
}
if (localElementsRef.current && dragMovedRef.current) {
if (localElementsRef.current) {
const shouldCommit =
dragState.type === 'resize' || dragMovedRef.current;
if (shouldCommit) {
onChange({ ...template, elements: localElementsRef.current });
}
}
localElementsRef.current = null;
setLocalElements(null);
setFrozenCanvasElements(null);
@@ -681,7 +810,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
}, [dragState, template, zoom, onChange, cancelActiveInteraction]);
}, [dragState, template, displayZoom, onChange, cancelActiveInteraction]);
const buildGroupStartPositions = (element: TemplateElement) => {
const moveIds =
@@ -855,7 +984,6 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
frozenCanvasElements ?? 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 }[] = [
{ 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 ? (
<button
className={`ps-tool-btn hover:!bg-red-900/40 hover:!text-red-400 ${isMobileLayout ? 'mobile-design-tool-btn' : ''}`}
onClick={deleteSelectedElements}
onClick={() => void deleteSelectedElements()}
title="删除选中元素"
>
<Trash2 className="w-[18px] h-[18px]" />
@@ -907,20 +1035,29 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const zoomControls = (
<div className="flex items-center gap-1.5 shrink-0">
<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" />
</button>
<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>
<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" />
</button>
</div>
<button
type="button"
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="适合窗口"
>
@@ -947,7 +1084,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<div className="flex flex-col flex-1 min-w-0 min-h-0">
<div
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}
onClick={(e) => {
if (ignoreCanvasClickRef.current) {
@@ -961,10 +1098,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
>
<div
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={{
width: `${mmToPx(template.width, zoom)}px`,
height: `${mmToPx(template.height, zoom)}px`,
width: `${canvasDisplayW}px`,
height: `${canvasDisplayH}px`,
}}
>
{/* <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 isSelected = !isLocked && selectedElementIds.includes(element.id);
const isPrimary = selectedElementIds.indexOf(element.id) === 0;
const elemX = mmToPx(element.x, zoom);
const elemY = mmToPx(element.y, zoom);
const elemW = mmToPx(element.width, zoom);
const elemH = mmToPx(element.height, zoom);
const elemX = mmToPx(element.x, displayZoom);
const elemY = mmToPx(element.y, displayZoom);
const elemW = mmToPx(element.width, displayZoom);
const elemH = mmToPx(element.height, displayZoom);
return (
<div

View File

@@ -19,6 +19,7 @@ import {
} from '../elementAlign';
import { CollapsiblePanelSection } from './CollapsiblePanelSection';
import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog';
import { useIsNarrowScreen } from '../hooks/useIsMobile';
import {
Sliders,
@@ -176,6 +177,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
onSelectElements,
variant = 'desktop',
}) => {
const { showConfirm } = useAppDialog();
const isNarrowScreen = useIsNarrowScreen();
const selectedElementId = selectedElementIds[0] || null;
const selectedElem = template.elements.find((el) => el.id === selectedElementId);
@@ -409,7 +411,12 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
{templateVariables.length === 0 ? (
<p className="text-[10px] text-[#888]"></p>
) : (
<div className="space-y-1">
<div className="space-y-3">
<div className="text-[10px] font-bold text-[#31a8ff]"></div>
<p className="text-[10px] text-[#666] leading-relaxed mb-3">
</p>
{templateVariables.map((v) => {
const inUse = isTemplateVariableInUse(template, v);
return (
@@ -494,15 +501,26 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
onChangeTemplate({ ...template, elements: list });
};
const deleteLayer = (id: string) => {
const el = template.elements.find(x => x.id === id);
if (el?.locked) return; // Cannot delete locked elements
const deleteLayer = async (id: string) => {
const el = template.elements.find((x) => x.id === id);
if (el?.locked) return;
const confirmed = await showConfirm(
`确定删除图层「${el?.name ?? '未命名'}」吗?此操作无法撤销。`,
{
title: '删除图层',
confirmLabel: '删除',
variant: 'error',
}
);
if (!confirmed) return;
onChangeTemplate({
...template,
elements: template.elements.filter((el) => el.id !== id),
elements: template.elements.filter((item) => item.id !== id),
});
if (onSelectElements && selectedElementIds.includes(id)) {
onSelectElements([]);
onSelectElements(selectedElementIds.filter((selectedId) => selectedId !== id));
}
};
@@ -673,7 +691,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<div className="space-y-4">
<div className="space-y-3">
<h4 className="text-[10px] font-bold uppercase tracking-widest mb-1">
</h4>
<div className="grid grid-cols-2 gap-3">
<div>
@@ -1327,8 +1345,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
const hVal = Math.max(2, Number(val) || 2);
handleElemChange({ ...selectedElem, height: hVal });
}}
className={`ps-field font-mono ${
selectedElem.type === 'qrcode' ? 'opacity-50 select-none' : ''
className={`ps-field font-mono ${selectedElem.type === 'qrcode' ? 'opacity-50 select-none' : ''
}`}
/>
</div>
@@ -1731,22 +1748,34 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
return (
<div
key={elem.id}
onClick={(e) => {
onClick={
isLocked
? undefined
: (e) => {
if (!onSelectElements) return;
if (isSelected) {
onSelectElements(selectedElementIds.filter((id) => id !== elem.id));
return;
}
if (variant === 'mobile') {
onSelectElements([...selectedElementIds, elem.id]);
return;
}
const isMulti = e.shiftKey || e.ctrlKey || e.metaKey;
if (isMulti) {
if (selectedElementIds.includes(elem.id)) {
onSelectElements(selectedElementIds.filter((id) => id !== elem.id));
} else {
onSelectElements([...selectedElementIds, elem.id]);
}
} else {
onSelectElements([elem.id]);
}
}}
className={`ps-layer-item group ${isSelected ? 'selected' : ''}`}
}
}
className={`ps-layer-item group ${isSelected && !isLocked ? 'selected' : ''} ${isLocked ? 'ps-layer-item-locked' : ''
}`}
>
<div className="flex items-center gap-2 min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0 flex-1 pointer-events-none">
<span className="shrink-0 w-3.5 flex items-center justify-center">
{elem.type === 'text' && <Type className="w-3 h-3 text-[#666]" />}
{elem.type === 'barcode' && <Barcode className="w-3 h-3 text-[#666]" />}
@@ -1759,12 +1788,13 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</div>
<div
className={`flex items-center gap-0.5 shrink-0 ${
isNarrowScreen || variant === 'mobile'
className={`flex items-center gap-0.5 shrink-0 pointer-events-auto ${isNarrowScreen || variant === 'mobile' || isLocked
? 'opacity-100'
: 'opacity-0 group-hover:opacity-100 transition-opacity'
}`}
>
{!isLocked && (
<>
<button
type="button"
onClick={(e) => { e.stopPropagation(); moveLayer(actualIdx, 'up'); }}
@@ -1783,6 +1813,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
>
<ArrowDown className="w-3 h-3" />
</button>
</>
)}
<button
type="button"
onClick={(e) => { e.stopPropagation(); toggleLockLayer(actualIdx); }}
@@ -1791,15 +1823,16 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
>
{isLocked ? <Lock className="w-3 h-3" /> : <Unlock className="w-3 h-3" />}
</button>
{!isLocked && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); deleteLayer(elem.id); }}
disabled={isLocked}
onClick={(e) => { e.stopPropagation(); void deleteLayer(elem.id); }}
title="删除"
className="p-0.5 hover:text-red-400 text-[#666] disabled:opacity-20 cursor-pointer"
className="p-0.5 hover:text-red-400 text-[#666] cursor-pointer"
>
<Trash2 className="w-3 h-3" />
</button>
)}
</div>
</div>
);

View File

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