This commit is contained in:
iqudoo
2026-06-13 03:41:20 +08:00
parent 979a565468
commit 3b9724beb3
7 changed files with 634 additions and 129 deletions

View File

@@ -25,6 +25,7 @@ import { exportLabelsPdfAsync, type PdfExportProgress } from './pdfExport';
import { useIsMobile } from './hooks/useIsMobile'; import { useIsMobile } from './hooks/useIsMobile';
import { useUndoRedo } from './hooks/useUndoRedo'; import { useUndoRedo } from './hooks/useUndoRedo';
import { useAppDialog } from './components/AppDialog'; import { useAppDialog } from './components/AppDialog';
import { AnchoredMenu } from './components/AnchoredMenu';
import { import {
FileDown, FileDown,
Code, Code,
@@ -153,7 +154,7 @@ export default function App() {
const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null); const [editingTemplateId, setEditingTemplateId] = useState<string | null>(null);
const [editTmplName, setEditTmplName] = useState<string>(''); const [editTmplName, setEditTmplName] = useState<string>('');
const [templateMenuId, setTemplateMenuId] = useState<string | null>(null); const [templateMenuId, setTemplateMenuId] = useState<string | null>(null);
const templateMenuRef = useRef<HTMLDivElement | null>(null); const templateMenuAnchorRef = useRef<HTMLDivElement | null>(null);
const [headerTemplateMenuOpen, setHeaderTemplateMenuOpen] = useState(false); const [headerTemplateMenuOpen, setHeaderTemplateMenuOpen] = useState(false);
const headerTemplateMenuRef = useRef<HTMLDivElement | null>(null); const headerTemplateMenuRef = useRef<HTMLDivElement | null>(null);
const importTemplateInputRef = useRef<HTMLInputElement | null>(null); const importTemplateInputRef = useRef<HTMLInputElement | null>(null);
@@ -390,26 +391,6 @@ export default function App() {
const closeTemplateMenu = () => setTemplateMenuId(null); const closeTemplateMenu = () => setTemplateMenuId(null);
useEffect(() => {
if (!templateMenuId) return;
const handlePointerDown = (e: PointerEvent) => {
if (templateMenuRef.current?.contains(e.target as Node)) return;
closeTemplateMenu();
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [templateMenuId]);
useEffect(() => {
if (!headerTemplateMenuOpen) return;
const handlePointerDown = (e: PointerEvent) => {
if (headerTemplateMenuRef.current?.contains(e.target as Node)) return;
setHeaderTemplateMenuOpen(false);
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [headerTemplateMenuOpen]);
const handleEditTemplateSubmit = async (e: React.FormEvent) => { const handleEditTemplateSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!editingTemplateId) return; if (!editingTemplateId) return;
@@ -841,37 +822,37 @@ export default function App() {
> >
<MoreHorizontal className="w-5 h-5 shrink-0" /> <MoreHorizontal className="w-5 h-5 shrink-0" />
</button> </button>
{headerTemplateMenuOpen && ( <AnchoredMenu
<div open={headerTemplateMenuOpen}
role="menu" onClose={() => setHeaderTemplateMenuOpen(false)}
className="absolute right-0 top-full mt-1 z-30 min-w-[148px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg" anchorRef={headerTemplateMenuRef}
className="min-w-[148px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg"
>
<button
type="button"
role="menuitem"
onClick={() => {
setHeaderTemplateMenuOpen(false);
setShowAddTmplForm(true);
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
> >
<button <Plus className="w-4 h-4 shrink-0" />
type="button"
role="menuitem" </button>
onClick={() => { <button
setHeaderTemplateMenuOpen(false); type="button"
setShowAddTmplForm(true); role="menuitem"
}} onClick={() => {
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer" setHeaderTemplateMenuOpen(false);
> importTemplateInputRef.current?.click();
<Plus className="w-4 h-4 shrink-0" /> }}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
</button> >
<button <Upload className="w-4 h-4 shrink-0" />
type="button"
role="menuitem" </button>
onClick={() => { </AnchoredMenu>
setHeaderTemplateMenuOpen(false);
importTemplateInputRef.current?.click();
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
>
<Upload className="w-4 h-4 shrink-0" />
</button>
</div>
)}
</div> </div>
</> </>
) : ( ) : (
@@ -1067,7 +1048,7 @@ export default function App() {
{isMobile ? '生成' : '排版导出'} {isMobile ? '生成' : '排版导出'}
</button> </button>
<div <div
ref={templateMenuId === tmpl.id ? templateMenuRef : null} ref={templateMenuId === tmpl.id ? templateMenuAnchorRef : null}
className="relative shrink-0" className="relative shrink-0"
> >
<button <button
@@ -1085,53 +1066,52 @@ export default function App() {
> >
<MoreHorizontal className={`${isMobile ? 'w-5 h-5' : 'w-4 h-4'} shrink-0`} /> <MoreHorizontal className={`${isMobile ? 'w-5 h-5' : 'w-4 h-4'} shrink-0`} />
</button> </button>
{templateMenuId === tmpl.id && ( <AnchoredMenu
<div open={templateMenuId === tmpl.id}
role="menu" onClose={closeTemplateMenu}
className="absolute right-0 top-full mt-1 z-30 min-w-[90px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg" anchorRef={templateMenuAnchorRef}
onClick={(e) => e.stopPropagation()} className="min-w-[120px] py-1 bg-white border border-gray-200 rounded-xl shadow-lg"
>
<button
type="button"
role="menuitem"
onClick={(e) => {
e.stopPropagation();
closeTemplateMenu();
openEditTemplateDialog(tmpl);
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
> >
<button <Pencil className="w-4 h-4 shrink-0" />
type="button"
role="menuitem" </button>
onClick={(e) => { <button
e.stopPropagation(); type="button"
closeTemplateMenu(); role="menuitem"
openEditTemplateDialog(tmpl); onClick={(e) => {
}} e.stopPropagation();
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer" closeTemplateMenu();
> handleExportTemplate(tmpl);
<Pencil className="w-4 h-4 shrink-0" /> }}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer"
</button> >
<button <FileDown className="w-4 h-4 shrink-0" />
type="button"
role="menuitem" </button>
onClick={(e) => { <button
e.stopPropagation(); type="button"
closeTemplateMenu(); role="menuitem"
handleExportTemplate(tmpl); onClick={(e) => {
}} e.stopPropagation();
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-gray-700 hover:bg-gray-50 active:bg-gray-100 cursor-pointer" closeTemplateMenu();
> void handleDeleteTemplate(tmpl.id!);
<FileDown className="w-4 h-4 shrink-0" /> }}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-rose-600 hover:bg-rose-50 active:bg-rose-100 cursor-pointer"
</button> >
<button <Trash2 className="w-4 h-4 shrink-0" />
type="button"
role="menuitem" </button>
onClick={(e) => { </AnchoredMenu>
e.stopPropagation();
closeTemplateMenu();
void handleDeleteTemplate(tmpl.id!);
}}
className="inline-flex items-center gap-3 w-full px-3 py-2.5 text-left text-sm text-rose-600 hover:bg-rose-50 active:bg-rose-100 cursor-pointer"
>
<Trash2 className="w-4 h-4 shrink-0" />
</button>
</div>
)}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -0,0 +1,110 @@
import React, { useLayoutEffect, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
const MENU_GAP = 4;
const VIEWPORT_PAD = 8;
function useAnchoredMenuPosition(
open: boolean,
anchorRef: React.RefObject<HTMLElement | null>,
menuRef: React.RefObject<HTMLElement | null>
) {
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null);
useLayoutEffect(() => {
if (!open) {
setCoords(null);
return;
}
const update = () => {
const anchor = anchorRef.current;
const menu = menuRef.current;
if (!anchor || !menu) return;
const anchorRect = anchor.getBoundingClientRect();
const menuWidth = menu.offsetWidth;
const menuHeight = menu.offsetHeight;
let top = anchorRect.bottom + MENU_GAP;
if (top + menuHeight > window.innerHeight - VIEWPORT_PAD) {
top = anchorRect.top - menuHeight - MENU_GAP;
}
top = Math.max(
VIEWPORT_PAD,
Math.min(top, window.innerHeight - menuHeight - VIEWPORT_PAD)
);
let left = anchorRect.right - menuWidth;
left = Math.max(
VIEWPORT_PAD,
Math.min(left, window.innerWidth - menuWidth - VIEWPORT_PAD)
);
setCoords({ top, left });
};
update();
const raf = requestAnimationFrame(update);
window.addEventListener('resize', update);
window.addEventListener('scroll', update, true);
return () => {
cancelAnimationFrame(raf);
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, true);
};
}, [open, anchorRef, menuRef]);
return coords;
}
interface AnchoredMenuProps {
open: boolean;
onClose: () => void;
anchorRef: React.RefObject<HTMLElement | null>;
children: React.ReactNode;
className?: string;
}
export const AnchoredMenu: React.FC<AnchoredMenuProps> = ({
open,
onClose,
anchorRef,
children,
className = '',
}) => {
const menuRef = useRef<HTMLDivElement | null>(null);
const coords = useAnchoredMenuPosition(open, anchorRef, menuRef);
useEffect(() => {
if (!open) return;
const handlePointerDown = (e: PointerEvent) => {
const target = e.target as Node;
if (menuRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
onClose();
};
document.addEventListener('pointerdown', handlePointerDown);
return () => document.removeEventListener('pointerdown', handlePointerDown);
}, [open, onClose, anchorRef]);
if (!open) return null;
return createPortal(
<div
ref={menuRef}
role="menu"
className={className}
style={{
position: 'fixed',
top: coords?.top ?? 0,
left: coords?.left ?? 0,
visibility: coords ? 'visible' : 'hidden',
zIndex: 9999,
}}
onClick={(e) => e.stopPropagation()}
>
{children}
</div>,
document.body
);
};

View File

@@ -0,0 +1,73 @@
import React, { useState, useEffect, useMemo } from 'react';
import { TemplateElement } from '../types';
import { mmToPx } from '../utils';
import { renderLabelToDataUrl } from '../labelRenderer';
interface ElementFloatPreviewProps {
element: TemplateElement;
renderScale: number;
displayZoom: number;
variableDefaults?: Record<string, string> | null;
}
/** 单元素离屏绘制(不裁切到标签页边界),用于拖动/缩放浮层 */
export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
element,
renderScale,
displayZoom,
variableDefaults = null,
}) => {
const [dataUrl, setDataUrl] = useState('');
const elementKey = useMemo(() => JSON.stringify(element), [element]);
useEffect(() => {
let active = true;
const normalized = { ...element, x: 0, y: 0 };
renderLabelToDataUrl({
widthMm: element.width,
heightMm: element.height,
elements: [normalized],
rowData: null,
rowIndex: 0,
showBorder: false,
transparentBackground: true,
variableDefaults,
mode: 'design',
scale: renderScale,
})
.then((url) => {
if (active) setDataUrl(url);
})
.catch((err) => {
console.error('Element float preview error:', err);
});
return () => {
active = false;
};
}, [element, elementKey, renderScale, variableDefaults]);
const left = mmToPx(element.x, displayZoom);
const top = mmToPx(element.y, displayZoom);
const width = mmToPx(element.width, displayZoom);
const height = mmToPx(element.height, displayZoom);
if (!dataUrl) return null;
return (
<img
src={dataUrl}
alt=""
className="absolute pointer-events-none select-none"
style={{
left: `${left}px`,
top: `${top}px`,
width: `${width}px`,
height: `${height}px`,
imageRendering: 'auto',
}}
/>
);
};

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 { ElementFloatPreview } from './ElementFloatPreview';
import { useAppDialog } from './AppDialog'; import { useAppDialog } from './AppDialog';
import { import {
ZoomIn, ZoomIn,
@@ -21,6 +22,8 @@ interface LabelDesignerProps {
selectedElementIds: string[]; selectedElementIds: string[];
onSelectElements: (ids: string[]) => void; onSelectElements: (ids: string[]) => void;
variant?: 'desktop' | 'mobile'; variant?: 'desktop' | 'mobile';
/** 微调等场景下隐藏选区框与缩放手柄 */
suppressSelectionChrome?: boolean;
} }
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode'; type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
@@ -187,6 +190,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
selectedElementIds, selectedElementIds,
onSelectElements, onSelectElements,
variant = 'desktop', variant = 'desktop',
suppressSelectionChrome = false,
}) => { }) => {
const { showConfirm } = useAppDialog(); const { showConfirm } = useAppDialog();
const selectedElementId = selectedElementIds[0] || null; const selectedElementId = selectedElementIds[0] || null;
@@ -204,12 +208,20 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
[template.variableDefaults] [template.variableDefaults]
); );
const [localElements, setLocalElements] = useState<TemplateElement[] | null>(null);
const localElementsRef = useRef<TemplateElement[] | null>(null); const localElementsRef = useRef<TemplateElement[] | null>(null);
/** 拖拽/缩放过程中冻结底图合成,仅更新选区框位置,避免每帧重绘闪动 */ /** 交互开始时的元素快照:拖动底图分层、缩放节流重绘 */
const [frozenCanvasElements, setFrozenCanvasElements] = useState<TemplateElement[] | null>(null); const [interactionSnapshot, setInteractionSnapshot] = useState<TemplateElement[] | null>(
null
);
/** 缩放时仅重绘当前元素的浮层内容 */
const [interactionFloatElements, setInteractionFloatElements] = useState<
TemplateElement[] | null
>(null);
const dragRafRef = useRef<number | null>(null); const dragRafRef = useRef<number | null>(null);
const pendingDragElementsRef = useRef<TemplateElement[] | null>(null); const pendingDragElementsRef = useRef<TemplateElement[] | null>(null);
const dragFloatLayerRef = useRef<HTMLDivElement | null>(null);
const interactionFloatPaintRef = useRef(0);
const interactionFloatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [dragState, setDragState] = useState<{ const [dragState, setDragState] = useState<{
elementId: string; elementId: string;
@@ -466,6 +478,19 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
deleteSelectedElements, deleteSelectedElements,
]); ]);
const clearInteractionCanvasState = useCallback(() => {
if (interactionFloatTimerRef.current !== null) {
clearTimeout(interactionFloatTimerRef.current);
interactionFloatTimerRef.current = null;
}
interactionFloatPaintRef.current = 0;
if (dragFloatLayerRef.current) {
dragFloatLayerRef.current.style.transform = '';
}
setInteractionSnapshot(null);
setInteractionFloatElements(null);
}, []);
const cancelActiveInteraction = useCallback(() => { const cancelActiveInteraction = useCallback(() => {
if (dragRafRef.current !== null) { if (dragRafRef.current !== null) {
cancelAnimationFrame(dragRafRef.current); cancelAnimationFrame(dragRafRef.current);
@@ -473,12 +498,11 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
} }
pendingDragElementsRef.current = null; pendingDragElementsRef.current = null;
localElementsRef.current = null; localElementsRef.current = null;
setLocalElements(null); clearInteractionCanvasState();
setFrozenCanvasElements(null);
setDragState(null); setDragState(null);
dragMovedRef.current = false; dragMovedRef.current = false;
setMarqueeState(null); setMarqueeState(null);
}, []); }, [clearInteractionCanvasState]);
const shouldIgnoreTouchPointer = (e: { pointerType: string; isPrimary: boolean }) => { const shouldIgnoreTouchPointer = (e: { pointerType: string; isPrimary: boolean }) => {
if (multiTouchActiveRef.current) return true; if (multiTouchActiveRef.current) return true;
@@ -720,12 +744,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}; };
}); });
pendingDragElementsRef.current = updated; pendingDragElementsRef.current = updated;
if (dragFloatLayerRef.current) {
dragFloatLayerRef.current.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
}
if (dragRafRef.current === null) { if (dragRafRef.current === null) {
dragRafRef.current = requestAnimationFrame(() => { dragRafRef.current = requestAnimationFrame(() => {
dragRafRef.current = null; dragRafRef.current = null;
const next = pendingDragElementsRef.current; const next = pendingDragElementsRef.current;
if (next) { if (next) {
setLocalElements(next);
localElementsRef.current = next; localElementsRef.current = next;
} }
}); });
@@ -763,13 +789,32 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
: el : el
); );
pendingDragElementsRef.current = updated; 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) { if (dragRafRef.current === null) {
dragRafRef.current = requestAnimationFrame(() => { dragRafRef.current = requestAnimationFrame(() => {
dragRafRef.current = null; dragRafRef.current = null;
const next = pendingDragElementsRef.current; const next = pendingDragElementsRef.current;
if (next) { if (next) {
setLocalElements(next);
localElementsRef.current = 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
);
}
} }
}); });
} }
@@ -797,8 +842,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
} }
} }
localElementsRef.current = null; localElementsRef.current = null;
setLocalElements(null); clearInteractionCanvasState();
setFrozenCanvasElements(null);
setDragState(null); setDragState(null);
}; };
@@ -810,7 +854,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, displayZoom, onChange, cancelActiveInteraction]); }, [dragState, template, displayZoom, onChange, cancelActiveInteraction, clearInteractionCanvasState]);
const buildGroupStartPositions = (element: TemplateElement) => { const buildGroupStartPositions = (element: TemplateElement) => {
const moveIds = const moveIds =
@@ -910,7 +954,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (element.locked) return; if (element.locked) return;
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
setFrozenCanvasElements(template.elements); setInteractionSnapshot(template.elements);
setInteractionFloatElements(null);
interactionFloatPaintRef.current = 0;
setDragState({ setDragState({
elementId: element.id, elementId: element.id,
type: actionType, type: actionType,
@@ -936,7 +982,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (element.locked) return; if (element.locked) return;
e.currentTarget.setPointerCapture(e.pointerId); e.currentTarget.setPointerCapture(e.pointerId);
setFrozenCanvasElements(template.elements); setInteractionSnapshot(template.elements);
setInteractionFloatElements([element]);
interactionFloatPaintRef.current = 0;
setDragState({ setDragState({
elementId: element.id, elementId: element.id,
type: 'resize', type: 'resize',
@@ -968,9 +1016,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
return () => window.clearTimeout(timer); return () => window.clearTimeout(timer);
}, [isMobileLayout, template.width, template.height]); }, [isMobileLayout, template.width, template.height]);
const selectedElemObj = (localElements || template.elements).find( const selectedElemObj = template.elements.find((el) => el.id === selectedElementId);
(el) => el.id === selectedElementId
);
const filterVisibleElements = useCallback( const filterVisibleElements = useCallback(
(elements: TemplateElement[]) => (elements: TemplateElement[]) =>
@@ -980,10 +1026,51 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
[previewDefaults] [previewDefaults]
); );
const canvasPreviewElements = filterVisibleElements( const dragMovingIdSet = useMemo(() => {
frozenCanvasElements ?? template.elements if (dragState?.type !== 'drag' || !dragState.groupStartPositions) return null;
); return new Set(Object.keys(dragState.groupStartPositions));
const overlayElements = filterVisibleElements(localElements ?? template.elements); }, [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 dragFloatElements = useMemo(() => {
if (dragState?.type !== 'drag' || !interactionSnapshot || !dragMovingIdSet) {
return null;
}
const moving = interactionSnapshot.filter((el) => dragMovingIdSet.has(el.id));
return moving.length > 0 ? filterVisibleElements(moving) : null;
}, [dragState?.type, interactionSnapshot, dragMovingIdSet, filterVisibleElements]);
const resizeFloatElements = useMemo(() => {
if (dragState?.type !== 'resize' || !interactionFloatElements?.length) return null;
return filterVisibleElements(interactionFloatElements);
}, [dragState?.type, interactionFloatElements, filterVisibleElements]);
const interactionFloatCanvasElements = dragFloatElements ?? resizeFloatElements;
const allowInteractionOverflow = dragState !== null;
const hideSelectionChrome = suppressSelectionChrome || dragState !== null;
const overlayElements = filterVisibleElements(template.elements);
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' },
@@ -1069,9 +1156,12 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<div <div
className={ className={
isMobileLayout isMobileLayout
? 'mobile-design-canvas-shell flex flex-col flex-1 min-w-0 min-h-0' ? `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' : 'flex flex-1 min-w-0 min-h-0'
} }
data-interaction-active={allowInteractionOverflow ? '' : undefined}
> >
{!isMobileLayout && ( {!isMobileLayout && (
<div className="ps-toolbar flex flex-col"> <div className="ps-toolbar flex flex-col">
@@ -1084,7 +1174,9 @@ 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 min-h-0 min-w-0 p-4" 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} onPointerDown={handleWorkspacePointerDown}
onClick={(e) => { onClick={(e) => {
if (ignoreCanvasClickRef.current) { if (ignoreCanvasClickRef.current) {
@@ -1097,12 +1189,16 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}} }}
> >
<div <div
ref={canvasRef} className="relative shrink-0 grow-0"
className="relative shrink-0 grow-0 bg-white shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none"
style={{ style={{
width: `${canvasDisplayW}px`, width: `${canvasDisplayW}px`,
height: `${canvasDisplayH}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]"> {/* <div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]">
{template.width} mm {template.width} mm
@@ -1119,7 +1215,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<CanvasLabelImage <CanvasLabelImage
widthMm={template.width} widthMm={template.width}
heightMm={template.height} heightMm={template.height}
elements={canvasPreviewElements} elements={staticCanvasElements}
rowData={null} rowData={null}
rowIndex={0} rowIndex={0}
showBorder={false} showBorder={false}
@@ -1152,6 +1248,17 @@ 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 isBeingDragged =
dragState?.type === 'drag' && !!dragMovingIdSet?.has(element.id);
const isBeingResized =
dragState?.type === 'resize' && dragState.elementId === element.id;
if (isBeingDragged || isBeingResized) {
return null;
}
const showSelectionRing = isSelected && !hideSelectionChrome;
const showResizeHandles =
showSelectionRing && selectedElementIds.length === 1;
const elemX = mmToPx(element.x, displayZoom); const elemX = mmToPx(element.x, displayZoom);
const elemY = mmToPx(element.y, displayZoom); const elemY = mmToPx(element.y, displayZoom);
const elemW = mmToPx(element.width, displayZoom); const elemW = mmToPx(element.width, displayZoom);
@@ -1164,8 +1271,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
className={`absolute group touch-none ${ className={`absolute group touch-none ${
isLocked isLocked
? 'pointer-events-none' ? 'pointer-events-none'
: isSelected : showSelectionRing
? 'ps-selection-ring cursor-move' ? 'ps-selection-ring cursor-move'
: isSelected
? 'cursor-move'
: 'cursor-move hover:outline hover:outline-1 hover:outline-[#31a8ff]/40' : 'cursor-move hover:outline hover:outline-1 hover:outline-[#31a8ff]/40'
}`} }`}
style={{ style={{
@@ -1192,7 +1301,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
</div> </div>
)} )}
{isSelected && ( {showResizeHandles && (
<> <>
<div <div
className="ps-handle top-0 left-0 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize" className="ps-handle top-0 left-0 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize"
@@ -1226,6 +1335,30 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
); );
})} })}
</div> </div>
{interactionFloatCanvasElements && interactionFloatCanvasElements.length > 0 && (
<div
ref={dragFloatLayerRef}
className="absolute top-0 left-0 pointer-events-none z-[10]"
style={{
width: `${canvasDisplayW}px`,
height: `${canvasDisplayH}px`,
overflow: 'visible',
willChange: dragState?.type === 'drag' ? 'transform' : undefined,
}}
>
{interactionFloatCanvasElements.map((el) => (
<ElementFloatPreview
key={el.id}
element={el}
renderScale={canvasRenderScale}
displayZoom={displayZoom}
variableDefaults={previewDefaults}
/>
))}
</div>
)}
</div>
</div> </div>
{isMobileLayout ? ( {isMobileLayout ? (

View File

@@ -1,8 +1,8 @@
import React from 'react'; import React, { useState } from 'react';
import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react'; import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react';
import { LabelTemplate, PaperConfig } from '../types'; import { LabelTemplate, PaperConfig } from '../types';
import { LabelDesigner } from './LabelDesigner'; import { LabelDesigner } from './LabelDesigner';
import { PropSidebar } from './PropSidebar'; import { PropSidebar, MobilePropModule } from './PropSidebar';
interface MobileDesignViewProps { interface MobileDesignViewProps {
template: LabelTemplate; template: LabelTemplate;
@@ -31,6 +31,8 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
onBack, onBack,
onExport, onExport,
}) => { }) => {
const [mobileModule, setMobileModule] = useState<MobilePropModule>('properties');
return ( return (
<div className="mobile-design-view"> <div className="mobile-design-view">
<header className="mobile-design-header"> <header className="mobile-design-header">
@@ -80,6 +82,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
onChange={onChangeTemplate} onChange={onChangeTemplate}
selectedElementIds={selectedElementIds} selectedElementIds={selectedElementIds}
onSelectElements={onSelectElements} onSelectElements={onSelectElements}
suppressSelectionChrome={mobileModule === 'nudge'}
/> />
</div> </div>
@@ -93,6 +96,8 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
activeTab="element" activeTab="element"
onChangeTab={() => {}} onChangeTab={() => {}}
paper={paper} paper={paper}
mobileModule={mobileModule}
onMobileModuleChange={setMobileModule}
/> />
</div> </div>
</div> </div>

View File

@@ -37,6 +37,9 @@ import {
Unlock, Unlock,
ArrowUp, ArrowUp,
ArrowDown, ArrowDown,
ArrowLeft,
ArrowRight,
Move,
Layers, Layers,
Trash2, Trash2,
AlignHorizontalJustifyStart, AlignHorizontalJustifyStart,
@@ -148,7 +151,9 @@ const PropTextarea: React.FC<PropTextareaProps> = ({ value, onCommit, ...props }
); );
}; };
type MobilePropModule = 'content' | 'properties' | 'layers'; export type MobilePropModule = 'content' | 'properties' | 'layers' | 'nudge';
const MOBILE_NUDGE_STEP_MM = 0.5;
interface PropSidebarProps { interface PropSidebarProps {
template: LabelTemplate; template: LabelTemplate;
@@ -159,10 +164,13 @@ interface PropSidebarProps {
paper: PaperConfig; paper: PaperConfig;
onSelectElements?: (ids: string[]) => void; onSelectElements?: (ids: string[]) => void;
variant?: 'desktop' | 'mobile'; variant?: 'desktop' | 'mobile';
mobileModule?: MobilePropModule;
onMobileModuleChange?: (module: MobilePropModule) => void;
} }
const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [ const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [
{ id: 'properties', label: '属性', icon: <Sliders className="w-4 h-4" /> }, { id: 'properties', label: '属性', icon: <Sliders className="w-4 h-4" /> },
{ id: 'nudge', label: '微调', icon: <Move className="w-4 h-4" /> },
{ id: 'content', label: '内容', icon: <Link2 className="w-4 h-4" /> }, { id: 'content', label: '内容', icon: <Link2 className="w-4 h-4" /> },
{ id: 'layers', label: '图层', icon: <Layers className="w-4 h-4" /> }, { id: 'layers', label: '图层', icon: <Layers className="w-4 h-4" /> },
]; ];
@@ -176,6 +184,8 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
paper, paper,
onSelectElements, onSelectElements,
variant = 'desktop', variant = 'desktop',
mobileModule: mobileModuleProp,
onMobileModuleChange,
}) => { }) => {
const { showConfirm } = useAppDialog(); const { showConfirm } = useAppDialog();
const isNarrowScreen = useIsNarrowScreen(); const isNarrowScreen = useIsNarrowScreen();
@@ -186,7 +196,15 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
{ mode: 'add' } | { mode: 'edit'; index: number } | null { mode: 'add' } | { mode: 'edit'; index: number } | null
>(null); >(null);
const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState); const [panelCollapsed, setPanelCollapsed] = useState<PanelCollapseState>(loadPanelCollapseState);
const [mobileModule, setMobileModule] = useState<MobilePropModule>('properties'); const [internalMobileModule, setInternalMobileModule] =
useState<MobilePropModule>('properties');
const mobileModule = mobileModuleProp ?? internalMobileModule;
const setMobileModule = (module: MobilePropModule) => {
onMobileModuleChange?.(module);
if (mobileModuleProp === undefined) {
setInternalMobileModule(module);
}
};
const togglePanel = (key: keyof PanelCollapseState) => { const togglePanel = (key: keyof PanelCollapseState) => {
setPanelCollapsed((prev) => { setPanelCollapsed((prev) => {
@@ -242,6 +260,29 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
}); });
}; };
const nudgeableElementIds = useMemo(() => {
const idSet = new Set(selectedElementIds);
return template.elements
.filter((el) => idSet.has(el.id) && !el.locked)
.map((el) => el.id);
}, [selectedElementIds, template.elements]);
const nudgeSelectedElements = (deltaX: number, deltaY: number) => {
if (nudgeableElementIds.length === 0) return;
const idSet = new Set(nudgeableElementIds);
onChangeTemplate({
...template,
elements: template.elements.map((el) => {
if (!idSet.has(el.id)) return el;
return {
...el,
x: Math.round((el.x + deltaX) * 10) / 10,
y: Math.round((el.y + deltaY) * 10) / 10,
};
}),
});
};
const applyVisibilityRules = (next: VisibilityRule[]) => { const applyVisibilityRules = (next: VisibilityRule[]) => {
if (!selectedElem) return; if (!selectedElem) return;
handleElemChange({ handleElemChange({
@@ -1841,6 +1882,82 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</> </>
); );
const renderNudgePanelBody = () => {
const canNudge = nudgeableElementIds.length > 0;
const primaryElem =
selectedElem && nudgeableElementIds.includes(selectedElem.id) ? selectedElem : null;
return (
<div className="mobile-nudge-panel">
<div className="mobile-nudge-header space-y-1 mb-4">
<h4 className="text-[10px] font-bold uppercase tracking-widest text-[#ccc]"></h4>
<p className="text-[10px] text-[#777]"> {MOBILE_NUDGE_STEP_MM} mm</p>
</div>
{!canNudge ? (
<div className="mobile-nudge-empty">
<Move className="w-8 h-8 text-[#555] mb-2" />
<p className="text-[12px] text-[#888]"></p>
</div>
) : (
<>
<div className="mobile-nudge-pad" role="group" aria-label="位置微调">
<button
type="button"
className="mobile-nudge-btn"
onClick={() => nudgeSelectedElements(0, -MOBILE_NUDGE_STEP_MM)}
aria-label="向上微调"
>
<ArrowUp className="w-5 h-5" />
</button>
<div className="mobile-nudge-pad-row">
<button
type="button"
className="mobile-nudge-btn"
onClick={() => nudgeSelectedElements(-MOBILE_NUDGE_STEP_MM, 0)}
aria-label="向左微调"
>
<ArrowLeft className="w-5 h-5" />
</button>
<div className="mobile-nudge-pad-center" aria-hidden="true" />
<button
type="button"
className="mobile-nudge-btn"
onClick={() => nudgeSelectedElements(MOBILE_NUDGE_STEP_MM, 0)}
aria-label="向右微调"
>
<ArrowRight className="w-5 h-5" />
</button>
</div>
<button
type="button"
className="mobile-nudge-btn"
onClick={() => nudgeSelectedElements(0, MOBILE_NUDGE_STEP_MM)}
aria-label="向下微调"
>
<ArrowDown className="w-5 h-5" />
</button>
</div>
{primaryElem && (
<div className="mobile-nudge-info">
<p className="text-[12px] font-medium text-[#ddd] truncate">{primaryElem.name}</p>
<p className="text-[11px] font-mono text-[#31a8ff] mt-1">
X {primaryElem.x} · Y {primaryElem.y} mm
</p>
{nudgeableElementIds.length > 1 && (
<p className="text-[10px] text-[#888] mt-1">
{nudgeableElementIds.length}
</p>
)}
</div>
)}
</>
)}
</div>
);
};
const visibilityRuleDialogNode = ( const visibilityRuleDialogNode = (
<VisibilityRuleDialog <VisibilityRuleDialog
open={visibilityRuleDialog !== null} open={visibilityRuleDialog !== null}
@@ -1876,6 +1993,9 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
{mobileModule === 'properties' && ( {mobileModule === 'properties' && (
<div className="ps-panel-body p-3 space-y-4 min-h-0">{renderPropertiesPanelBody()}</div> <div className="ps-panel-body p-3 space-y-4 min-h-0">{renderPropertiesPanelBody()}</div>
)} )}
{mobileModule === 'nudge' && (
<div className="ps-panel-body p-3 min-h-0">{renderNudgePanelBody()}</div>
)}
{mobileModule === 'layers' && ( {mobileModule === 'layers' && (
<div className="ps-panel-body min-h-0 mobile-layers-panel">{renderLayersPanelBody()}</div> <div className="ps-panel-body min-h-0 mobile-layers-panel">{renderLayersPanelBody()}</div>
)} )}

View File

@@ -1317,6 +1317,11 @@ body {
min-height: 0; min-height: 0;
} }
.mobile-design-canvas-wrap:has([data-interaction-active]),
.mobile-design-canvas-shell-interaction {
overflow: visible;
}
.mobile-design-props-wrap { .mobile-design-props-wrap {
flex: 1 1 48%; flex: 1 1 48%;
min-height: 220px; min-height: 220px;
@@ -1416,6 +1421,85 @@ body {
min-height: 40px !important; min-height: 40px !important;
flex-shrink: 0; flex-shrink: 0;
} }
.mobile-nudge-panel {
display: flex;
flex-direction: column;
align-items: stretch;
min-height: 100%;
padding-bottom: 12px;
}
.mobile-nudge-header {
width: 100%;
text-align: left;
}
.mobile-nudge-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex: 1;
min-height: 160px;
text-align: center;
}
.mobile-nudge-pad {
display: flex;
flex-direction: column;
align-items: center;
align-self: center;
gap: 8px;
margin: 8px 0 20px;
}
.mobile-nudge-pad-row {
display: flex;
align-items: center;
gap: 8px;
}
.mobile-nudge-pad-center {
width: 52px;
height: 52px;
border-radius: 12px;
background: #333;
border: 1px dashed #4a4a4a;
}
.mobile-nudge-btn {
display: flex;
align-items: center;
justify-content: center;
width: 52px;
height: 52px;
border: 1px solid #4a4a4a;
border-radius: 12px;
background: #383838;
color: #ddd;
cursor: pointer;
touch-action: manipulation;
-webkit-tap-highlight-color: transparent;
transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.mobile-nudge-btn:active {
background: #1e3a4f;
border-color: #31a8ff;
color: #fff;
}
.mobile-nudge-info {
align-self: center;
width: 100%;
max-width: 240px;
padding: 12px 14px;
border-radius: 10px;
background: #222222;
/* border: 1px solid #1f1f1f; */
text-align: center;
}
} }
@media (min-width: 768px) { @media (min-width: 768px) {