修复
This commit is contained in:
176
src/App.tsx
176
src/App.tsx
@@ -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>
|
||||||
|
|||||||
110
src/components/AnchoredMenu.tsx
Normal file
110
src/components/AnchoredMenu.tsx
Normal 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
|
||||||
|
);
|
||||||
|
};
|
||||||
73
src/components/ElementFloatPreview.tsx
Normal file
73
src/components/ElementFloatPreview.tsx
Normal 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',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user