优化布局和交互

This commit is contained in:
iqudoo
2026-06-14 12:03:05 +08:00
parent 55867ae12d
commit 4955b1f2e2
9 changed files with 454 additions and 15 deletions

View File

@@ -35,6 +35,7 @@ import {
} from './labelPrint'; } from './labelPrint';
import { useIsMobile } from './hooks/useIsMobile'; import { useIsMobile } from './hooks/useIsMobile';
import { useUndoRedo } from './hooks/useUndoRedo'; import { useUndoRedo } from './hooks/useUndoRedo';
import { useElementClipboard } from './hooks/useElementClipboard';
import { useAppDialog } from './components/AppDialog'; import { useAppDialog } from './components/AppDialog';
import { AnchoredMenu } from './components/AnchoredMenu'; import { AnchoredMenu } from './components/AnchoredMenu';
import { import {
@@ -78,7 +79,7 @@ function fitTemplatePreviewSize(widthMm: number, heightMm: number) {
} }
export default function App() { export default function App() {
const { showAlert, showConfirm } = useAppDialog(); const { showAlert, showConfirm, showToast } = useAppDialog();
// --- 1. Templates Storage & Initial States --- // --- 1. Templates Storage & Initial States ---
const [templates, setTemplates] = useState<LabelTemplate[]>(() => { const [templates, setTemplates] = useState<LabelTemplate[]>(() => {
@@ -249,6 +250,22 @@ export default function App() {
[activeTemplate] [activeTemplate]
); );
const handleCopyElementsSuccess = useCallback(
(count: number) => {
showToast(`已复制 ${count} 个元素`, { variant: 'success' });
},
[showToast]
);
const elementClipboard = useElementClipboard({
template: activeTemplate ?? EMPTY_TEMPLATE_PLACEHOLDER,
onChange: handleDesignTemplateChange,
selectedElementIds,
onSelectElements: handleSelectElements,
onSelectTableCells: setSelectedTableCells,
onCopySuccess: handleCopyElementsSuccess,
});
useEffect(() => { useEffect(() => {
if (workflowStep !== 'template-design' || !activeTemplate?.id) return; if (workflowStep !== 'template-design' || !activeTemplate?.id) return;
const needReset = const needReset =
@@ -1343,6 +1360,7 @@ export default function App() {
onRedo={handleDesignRedo} onRedo={handleDesignRedo}
onBack={() => setWorkflowStep('template-select')} onBack={() => setWorkflowStep('template-select')}
onExport={() => openPrintView()} onExport={() => openPrintView()}
elementClipboard={elementClipboard}
/> />
)} )}
@@ -1385,7 +1403,7 @@ export default function App() {
</button> </button>
</div> </div>
<span className="hidden md:inline text-[10px] text-[#666]"> <span className="hidden md:inline text-[10px] text-[#666]">
Shift/Ctrl+ Shift/Ctrl+ · Ctrl+C/V
</span> </span>
<button <button
onClick={() => openPrintView()} onClick={() => openPrintView()}
@@ -1405,6 +1423,7 @@ export default function App() {
onSelectElements={handleSelectElements} onSelectElements={handleSelectElements}
selectedTableCells={selectedTableCells} selectedTableCells={selectedTableCells}
onSelectTableCells={setSelectedTableCells} onSelectTableCells={setSelectedTableCells}
elementClipboard={elementClipboard}
/> />
<PropSidebar <PropSidebar
template={activeTemplate} template={activeTemplate}
@@ -1416,6 +1435,7 @@ export default function App() {
activeTab="element" activeTab="element"
onChangeTab={() => { }} onChangeTab={() => { }}
paper={paper} paper={paper}
elementClipboard={elementClipboard}
/> />
</div> </div>
</div> </div>

View File

@@ -33,6 +33,7 @@ interface DialogRequest {
interface AppDialogContextValue { interface AppDialogContextValue {
showAlert: (message: string, options?: AlertOptions) => Promise<void>; showAlert: (message: string, options?: AlertOptions) => Promise<void>;
showConfirm: (message: string, options?: ConfirmOptions) => Promise<boolean>; showConfirm: (message: string, options?: ConfirmOptions) => Promise<boolean>;
showToast: (message: string, options?: { variant?: AppDialogVariant; duration?: number }) => void;
} }
const AppDialogContext = createContext<AppDialogContextValue | null>(null); const AppDialogContext = createContext<AppDialogContextValue | null>(null);
@@ -147,8 +148,40 @@ function AppDialogModal({
); );
} }
function AppToast({
message,
variant = 'success',
onDismiss,
}: {
message: string;
variant: AppDialogVariant;
onDismiss: () => void;
}) {
const meta = VARIANT_META[variant];
useEffect(() => {
const timer = window.setTimeout(onDismiss, 2000);
return () => window.clearTimeout(timer);
}, [onDismiss]);
return (
<div
className="fixed left-1/2 bottom-[max(1.25rem,env(safe-area-inset-bottom))] z-[10001] -translate-x-1/2 pointer-events-none"
role="status"
aria-live="polite"
>
<div className="flex items-center gap-2 px-4 py-2.5 rounded-xl shadow-lg border border-black/10 bg-[#2a2a2a] text-[#f0f0f0] text-[12px] font-medium max-w-[min(90vw,20rem)]">
<span className="shrink-0">{meta.icon}</span>
<span className="truncate">{message}</span>
</div>
</div>
);
}
export function AppDialogProvider({ children }: { children: React.ReactNode }) { export function AppDialogProvider({ children }: { children: React.ReactNode }) {
const [dialog, setDialog] = useState<DialogRequest | null>(null); const [dialog, setDialog] = useState<DialogRequest | null>(null);
const [toast, setToast] = useState<{ message: string; variant: AppDialogVariant } | null>(null);
const toastTimerRef = useRef<number | null>(null);
const showAlert = useCallback((message: string, options?: AlertOptions) => { const showAlert = useCallback((message: string, options?: AlertOptions) => {
const variant = options?.variant ?? 'info'; const variant = options?.variant ?? 'info';
@@ -180,6 +213,28 @@ export function AppDialogProvider({ children }: { children: React.ReactNode }) {
}); });
}, []); }, []);
const dismissToast = useCallback(() => {
if (toastTimerRef.current !== null) {
window.clearTimeout(toastTimerRef.current);
toastTimerRef.current = null;
}
setToast(null);
}, []);
const showToast = useCallback(
(message: string, options?: { variant?: AppDialogVariant; duration?: number }) => {
dismissToast();
const variant = options?.variant ?? 'success';
setToast({ message, variant });
const duration = options?.duration ?? 2000;
toastTimerRef.current = window.setTimeout(() => {
toastTimerRef.current = null;
setToast(null);
}, duration);
},
[dismissToast]
);
const closeDialog = useCallback((confirmed: boolean) => { const closeDialog = useCallback((confirmed: boolean) => {
setDialog((current) => { setDialog((current) => {
current?.resolve(confirmed); current?.resolve(confirmed);
@@ -188,9 +243,12 @@ export function AppDialogProvider({ children }: { children: React.ReactNode }) {
}, []); }, []);
return ( return (
<AppDialogContext.Provider value={{ showAlert, showConfirm }}> <AppDialogContext.Provider value={{ showAlert, showConfirm, showToast }}>
{children} {children}
{dialog && <AppDialogModal dialog={dialog} onClose={closeDialog} />} {dialog && <AppDialogModal dialog={dialog} onClose={closeDialog} />}
{toast && (
<AppToast message={toast.message} variant={toast.variant} onDismiss={dismissToast} />
)}
</AppDialogContext.Provider> </AppDialogContext.Provider>
); );
} }

View File

@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { LabelTemplate, TemplateElement } from '../types'; import { LabelTemplate, TemplateElement } from '../types';
import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils'; import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils';
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
import { import {
createDefaultTableElement, createDefaultTableElement,
getTableColWidths, getTableColWidths,
@@ -18,6 +19,7 @@ import { CanvasLabelImage } from './CanvasLabelImage';
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview'; import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
import { renderLabelToDataUrl } from '../labelRenderer'; import { renderLabelToDataUrl } from '../labelRenderer';
import { useAppDialog } from './AppDialog'; import { useAppDialog } from './AppDialog';
import { MobileMainToolButtons, type MobileToolButtonItem } from './MobileMainToolButtons';
import { import {
ZoomIn, ZoomIn,
ZoomOut, ZoomOut,
@@ -40,6 +42,10 @@ interface LabelDesignerProps {
variant?: 'desktop' | 'mobile'; variant?: 'desktop' | 'mobile';
/** 微调等场景下隐藏选区框与缩放手柄 */ /** 微调等场景下隐藏选区框与缩放手柄 */
suppressSelectionChrome?: boolean; suppressSelectionChrome?: boolean;
elementClipboard?: Pick<
ElementClipboardActions,
'copySelectedElements' | 'pasteClipboardElements' | 'canCopy' | 'canPaste' | 'clipboardCount'
>;
} }
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table'; type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table';
@@ -212,6 +218,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onSelectTableCells, onSelectTableCells,
variant = 'desktop', variant = 'desktop',
suppressSelectionChrome = false, suppressSelectionChrome = false,
elementClipboard,
}) => { }) => {
const { showConfirm } = useAppDialog(); const { showConfirm } = useAppDialog();
const selectedElementId = selectedElementIds[0] || null; const selectedElementId = selectedElementIds[0] || null;
@@ -494,6 +501,17 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onSelectElements(template.elements.filter((el) => !el.locked).map((el) => el.id)); onSelectElements(template.elements.filter((el) => !el.locked).map((el) => el.id));
return; return;
} }
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c') {
e.preventDefault();
elementClipboard?.copySelectedElements();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
e.preventDefault();
elementClipboard?.pasteClipboardElements();
setActiveTool('move');
return;
}
if (e.key === 'Delete' || e.key === 'Backspace') { if (e.key === 'Delete' || e.key === 'Backspace') {
e.preventDefault(); e.preventDefault();
void deleteSelectedElements(); void deleteSelectedElements();
@@ -539,6 +557,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
addElement, addElement,
onSelectElements, onSelectElements,
deleteSelectedElements, deleteSelectedElements,
elementClipboard,
]); ]);
const clearInteractionCanvasState = useCallback(() => { const clearInteractionCanvasState = useCallback(() => {
@@ -1430,6 +1449,28 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
{ id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' }, { id: 'qrcode', icon: <QrCode className="w-[18px] h-[18px]" />, label: '二维码' },
]; ];
const mobileToolItems = useMemo<MobileToolButtonItem[]>(
() => [
...tools.map((tool) => ({
id: tool.id,
icon: tool.icon,
label: tool.label,
shortcut: tool.shortcut,
active: activeTool === tool.id,
onClick: () => handleToolClick(tool.id),
})),
{
id: 'image',
icon: <ImageIcon className="w-[18px] h-[18px]" />,
label: '图片',
active: false,
onClick: handleImageToolClick,
},
],
// eslint-disable-next-line react-hooks/exhaustive-deps -- tools 为静态配置,随 activeTool 更新即可
[activeTool]
);
const mainToolButtons = ( const mainToolButtons = (
<> <>
{tools.map((tool) => ( {tools.map((tool) => (
@@ -1922,9 +1963,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
{isMobileLayout ? ( {isMobileLayout ? (
<div className="mobile-design-toolstrip"> <div className="mobile-design-toolstrip">
<div className="mobile-design-toolstrip-tools"> <MobileMainToolButtons items={mobileToolItems} />
{mainToolButtons} <input
</div> ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFile}
/>
{zoomControls} {zoomControls}
</div> </div>
) : ( ) : (
@@ -1952,7 +1998,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
)} )}
{activeTool === 'move' && ( {activeTool === 'move' && (
<span className="text-[#888] hidden sm:inline"> <span className="text-[#888] hidden sm:inline">
| · Shift/Ctrl+ · Ctrl+A | · Shift/Ctrl+ · Ctrl+A · Ctrl+C/V
</span> </span>
)} )}
</> </>

View File

@@ -2,6 +2,7 @@ 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 type { TableCellCoord } from '../tableUtils'; import type { TableCellCoord } from '../tableUtils';
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
import { LabelDesigner } from './LabelDesigner'; import { LabelDesigner } from './LabelDesigner';
import { PropSidebar, MobilePropModule } from './PropSidebar'; import { PropSidebar, MobilePropModule } from './PropSidebar';
@@ -19,6 +20,7 @@ interface MobileDesignViewProps {
onRedo: () => void; onRedo: () => void;
onBack: () => void; onBack: () => void;
onExport: () => void; onExport: () => void;
elementClipboard: ElementClipboardActions;
} }
export const MobileDesignView: React.FC<MobileDesignViewProps> = ({ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
@@ -35,6 +37,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
onRedo, onRedo,
onBack, onBack,
onExport, onExport,
elementClipboard,
}) => { }) => {
const [mobileModule, setMobileModule] = useState<MobilePropModule>('properties'); const [mobileModule, setMobileModule] = useState<MobilePropModule>('properties');
@@ -90,6 +93,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
selectedTableCells={selectedTableCells} selectedTableCells={selectedTableCells}
onSelectTableCells={onSelectTableCells} onSelectTableCells={onSelectTableCells}
suppressSelectionChrome={mobileModule === 'nudge'} suppressSelectionChrome={mobileModule === 'nudge'}
elementClipboard={elementClipboard}
/> />
</div> </div>
@@ -107,6 +111,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
paper={paper} paper={paper}
mobileModule={mobileModule} mobileModule={mobileModule}
onMobileModuleChange={setMobileModule} onMobileModuleChange={setMobileModule}
elementClipboard={elementClipboard}
/> />
</div> </div>
</div> </div>

View File

@@ -0,0 +1,123 @@
import React, { useLayoutEffect, useMemo, useRef, useState } from 'react';
import { MoreHorizontal } from 'lucide-react';
import { AnchoredMenu } from './AnchoredMenu';
const TOOL_BTN_SIZE_PX = 40;
const TOOL_BTN_GAP_PX = 4;
const TOOL_BTN_SLOT_PX = TOOL_BTN_SIZE_PX + TOOL_BTN_GAP_PX;
export interface MobileToolButtonItem {
id: string;
icon: React.ReactNode;
label: string;
shortcut?: string;
active?: boolean;
onClick: () => void;
}
interface MobileMainToolButtonsProps {
items: MobileToolButtonItem[];
}
function computeVisibleCount(containerWidth: number, total: number): number {
if (total <= 0 || containerWidth <= 0) return total;
if (containerWidth >= total * TOOL_BTN_SLOT_PX) return total;
const withMore = Math.floor((containerWidth - TOOL_BTN_SLOT_PX) / TOOL_BTN_SLOT_PX);
return Math.max(1, Math.min(withMore, total - 1));
}
function reorderItemsForActive(items: MobileToolButtonItem[], visibleCount: number): MobileToolButtonItem[] {
if (visibleCount >= items.length) return items;
const activeIndex = items.findIndex((item) => item.active);
if (activeIndex < 0 || activeIndex < visibleCount) return items;
const next = [...items];
const [activeItem] = next.splice(activeIndex, 1);
next.splice(visibleCount - 1, 0, activeItem);
return next;
}
export const MobileMainToolButtons: React.FC<MobileMainToolButtonsProps> = ({ items }) => {
const containerRef = useRef<HTMLDivElement | null>(null);
const moreButtonRef = useRef<HTMLButtonElement | null>(null);
const [visibleCount, setVisibleCount] = useState(items.length);
const [moreOpen, setMoreOpen] = useState(false);
useLayoutEffect(() => {
const container = containerRef.current;
if (!container) return;
const measure = () => {
setVisibleCount(computeVisibleCount(container.clientWidth, items.length));
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(container);
return () => observer.disconnect();
}, [items.length]);
const orderedItems = useMemo(
() => reorderItemsForActive(items, visibleCount),
[items, visibleCount]
);
const visibleItems = orderedItems.slice(0, visibleCount);
const overflowItems = orderedItems.slice(visibleCount);
const overflowHasActive = overflowItems.some((item) => item.active);
const renderToolButton = (item: MobileToolButtonItem) => (
<button
key={item.id}
type="button"
className={`ps-tool-btn mobile-design-tool-btn ${item.active ? 'active' : ''}`}
onClick={item.onClick}
title={`${item.label}${item.shortcut ? ` (${item.shortcut})` : ''}`}
aria-label={item.label}
>
{item.icon}
</button>
);
return (
<div ref={containerRef} className="mobile-design-toolstrip-tools">
{visibleItems.map((item) => renderToolButton(item))}
{overflowItems.length > 0 && (
<>
<button
ref={moreButtonRef}
type="button"
className={`ps-tool-btn mobile-design-tool-btn`}
onClick={() => setMoreOpen((open) => !open)}
title="更多工具"
aria-label="更多工具"
aria-expanded={moreOpen}
>
<MoreHorizontal className="w-[18px] h-[18px]" />
</button>
<AnchoredMenu
open={moreOpen}
onClose={() => setMoreOpen(false)}
anchorRef={moreButtonRef}
className="mobile-toolstrip-overflow-menu"
>
{overflowItems.map((item) => (
<button
key={item.id}
type="button"
role="menuitem"
className={`mobile-toolstrip-overflow-menu-item${item.active ? ' active' : ''}`}
onClick={() => {
item.onClick();
setMoreOpen(false);
}}
>
<span className="mobile-toolstrip-overflow-menu-icon">{item.icon}</span>
<span>{item.label}</span>
</button>
))}
</AnchoredMenu>
</>
)}
</div>
);
};

View File

@@ -25,6 +25,7 @@ import { FieldSelect } from './PsSelect';
import { CommitInput } from './CommitInput'; import { CommitInput } from './CommitInput';
import { useAppDialog } from './AppDialog'; import { useAppDialog } from './AppDialog';
import { useIsNarrowScreen } from '../hooks/useIsMobile'; import { useIsNarrowScreen } from '../hooks/useIsMobile';
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
import { import {
TablePropertyFields, TablePropertyFields,
TableCellContentFields, TableCellContentFields,
@@ -53,6 +54,8 @@ import {
Move, Move,
Layers, Layers,
Trash2, Trash2,
Copy,
ClipboardPaste,
AlignHorizontalJustifyStart, AlignHorizontalJustifyStart,
AlignHorizontalJustifyCenter, AlignHorizontalJustifyCenter,
AlignHorizontalJustifyEnd, AlignHorizontalJustifyEnd,
@@ -180,6 +183,7 @@ interface PropSidebarProps {
variant?: 'desktop' | 'mobile'; variant?: 'desktop' | 'mobile';
mobileModule?: MobilePropModule; mobileModule?: MobilePropModule;
onMobileModuleChange?: (module: MobilePropModule) => void; onMobileModuleChange?: (module: MobilePropModule) => void;
elementClipboard?: ElementClipboardActions;
} }
const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [ const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [
@@ -202,6 +206,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
variant = 'desktop', variant = 'desktop',
mobileModule: mobileModuleProp, mobileModule: mobileModuleProp,
onMobileModuleChange, onMobileModuleChange,
elementClipboard,
}) => { }) => {
const { showConfirm } = useAppDialog(); const { showConfirm } = useAppDialog();
const isNarrowScreen = useIsNarrowScreen(); const isNarrowScreen = useIsNarrowScreen();
@@ -2307,6 +2312,29 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</button> </button>
))} ))}
<div className="mobile-design-props-nav-spacer" aria-hidden /> <div className="mobile-design-props-nav-spacer" aria-hidden />
{elementClipboard?.canPaste && (
<button
type="button"
onClick={() => elementClipboard.pasteClipboardElements()}
className="mobile-design-props-nav-btn"
aria-label="粘贴元素"
title={`粘贴 ${elementClipboard.clipboardCount} 个元素`}
>
<ClipboardPaste className="w-4 h-4" />
<span></span>
</button>
)}
<button
type="button"
onClick={() => elementClipboard?.copySelectedElements()}
disabled={!elementClipboard?.canCopy}
className="mobile-design-props-nav-btn"
aria-label="复制选中元素"
title="复制选中元素"
>
<Copy className="w-4 h-4" />
<span></span>
</button>
<button <button
type="button" type="button"
onClick={() => void deleteSelectedElements()} onClick={() => void deleteSelectedElements()}

View File

@@ -0,0 +1,82 @@
import { useCallback, useState } from 'react';
import type { LabelTemplate, TemplateElement } from '../types';
import type { TableCellCoord } from '../tableUtils';
import { cloneTemplateElementsForPaste } from '../utils';
/** 应用内元素剪贴板(内存,跨模板/跨页面模块共享) */
const globalElementClipboard: { elements: TemplateElement[] | null } = { elements: null };
function getClipboardCount() {
return globalElementClipboard.elements?.length ?? 0;
}
function setGlobalClipboard(elements: TemplateElement[] | null) {
globalElementClipboard.elements = elements;
}
export interface UseElementClipboardOptions {
template: LabelTemplate;
onChange: (updated: LabelTemplate) => void;
selectedElementIds: string[];
onSelectElements: (ids: string[]) => void;
onSelectTableCells?: (cells: TableCellCoord[]) => void;
onCopySuccess?: (count: number) => void;
}
export interface ElementClipboardActions {
copySelectedElements: () => void;
pasteClipboardElements: () => void;
canCopy: boolean;
canPaste: boolean;
clipboardCount: number;
}
export function useElementClipboard({
template,
onChange,
selectedElementIds,
onSelectElements,
onSelectTableCells,
onCopySuccess,
}: UseElementClipboardOptions): ElementClipboardActions {
const [clipboardCount, setClipboardCount] = useState(getClipboardCount);
const syncClipboardCount = useCallback((elements: TemplateElement[] | null) => {
setGlobalClipboard(elements);
setClipboardCount(elements?.length ?? 0);
}, []);
const canCopy = selectedElementIds.length > 0;
const canPaste = clipboardCount > 0;
const copySelectedElements = useCallback(() => {
if (selectedElementIds.length === 0) return;
const idSet = new Set(selectedElementIds);
const toCopy = template.elements.filter((el) => idSet.has(el.id));
if (toCopy.length === 0) return;
const cloned = JSON.parse(JSON.stringify(toCopy)) as TemplateElement[];
syncClipboardCount(cloned);
onCopySuccess?.(toCopy.length);
}, [selectedElementIds, template.elements, syncClipboardCount, onCopySuccess]);
const pasteClipboardElements = useCallback(() => {
const source = globalElementClipboard.elements;
if (!source || source.length === 0) return;
const pasted = cloneTemplateElementsForPaste(source, 1);
onChange({
...template,
elements: [...template.elements, ...pasted],
});
onSelectElements(pasted.map((el) => el.id));
onSelectTableCells?.([]);
syncClipboardCount(null);
}, [template, onChange, onSelectElements, onSelectTableCells, syncClipboardCount]);
return {
copySelectedElements,
pasteClipboardElements,
canCopy,
canPaste,
clipboardCount,
};
}

View File

@@ -1506,13 +1506,25 @@ html.app-dark-shell body {
gap: 4px; gap: 4px;
width: 64px; width: 64px;
flex-shrink: 0; flex-shrink: 0;
min-height: 0;
align-self: stretch;
padding: 8px 6px; padding: 8px 6px;
background: #252525; background: #252525;
border-right: 1px solid #1a1a1a; border-right: 1px solid #1a1a1a;
overflow-y: auto;
scrollbar-width: none;
-ms-overflow-style: none;
-webkit-overflow-scrolling: touch;
}
.mobile-design-props-nav::-webkit-scrollbar {
display: none;
width: 0;
height: 0;
} }
.mobile-design-props-nav-spacer { .mobile-design-props-nav-spacer {
flex: 1; flex: 1 1 auto;
min-height: 8px; min-height: 8px;
} }
@@ -1585,21 +1597,66 @@ html.app-dark-shell body {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 4px;
overflow-x: auto; overflow: hidden;
scrollbar-width: none;
-ms-overflow-style: none;
min-width: 0; min-width: 0;
flex: 1; flex: 1;
} }
.mobile-design-toolstrip-tools::-webkit-scrollbar {
display: none;
}
.mobile-design-tool-btn { .mobile-design-tool-btn {
width: 40px !important; width: 40px !important;
height: 40px !important; height: 40px !important;
min-height: 40px !important; min-height: 40px !important;
border-radius: 10px !important;
flex-shrink: 0;
}
.mobile-design-tool-btn.active:hover {
background: #1a1a1a;
color: var(--color-ps-accent);
}
.mobile-design-tool-btn:hover {
background: transparent;
color: #fff;
}
.mobile-toolstrip-overflow-menu {
min-width: 148px;
padding: 4px;
background: #2a2a2a;
border: 1px solid #3a3a3a;
border-radius: 10px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.45);
}
.mobile-toolstrip-overflow-menu-item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 10px;
border: none;
border-radius: 8px;
background: transparent;
color: #ccc;
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-align: left;
}
.mobile-toolstrip-overflow-menu-item:hover,
.mobile-toolstrip-overflow-menu-item.active {
background: #1e3a4f;
color: #31a8ff;
}
.mobile-toolstrip-overflow-menu-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
flex-shrink: 0; flex-shrink: 0;
} }

View File

@@ -27,6 +27,26 @@ export const VARIABLE_MAPPING_USE_ROW_INDEX = '__use_row_index__';
export const VARIABLE_MAPPING_ROW_INDEX_LABEL = '数据序号'; export const VARIABLE_MAPPING_ROW_INDEX_LABEL = '数据序号';
/** 粘贴副本时相对原位置的偏移 (mm) */
export const PASTE_ELEMENT_OFFSET_MM = 2;
/** 深拷贝元素并生成新 id用于复制/粘贴 */
export function cloneTemplateElementsForPaste(
elements: TemplateElement[],
pasteRepeat: number
): TemplateElement[] {
const offset = PASTE_ELEMENT_OFFSET_MM * Math.max(1, pasteRepeat);
const stamp = Date.now();
return elements.map((element, index) => {
const cloned = JSON.parse(JSON.stringify(element)) as TemplateElement;
cloned.id = `${element.type}_${stamp}_${index}`;
cloned.name = `${element.name} 副本`;
cloned.x = Math.round((element.x + offset) * 10) / 10;
cloned.y = Math.round((element.y + offset) * 10) / 10;
return cloned;
});
}
export function toDataSourceMeta(data: ImportData): DataSourceMeta { export function toDataSourceMeta(data: ImportData): DataSourceMeta {
return { return {
fileName: data.fileName, fileName: data.fileName,