优化布局和交互
This commit is contained in:
24
src/App.tsx
24
src/App.tsx
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
123
src/components/MobileMainToolButtons.tsx
Normal file
123
src/components/MobileMainToolButtons.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -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()}
|
||||||
|
|||||||
82
src/hooks/useElementClipboard.ts
Normal file
82
src/hooks/useElementClipboard.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
20
src/utils.ts
20
src/utils.ts
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user