优化布局和交互

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

@@ -33,6 +33,7 @@ interface DialogRequest {
interface AppDialogContextValue {
showAlert: (message: string, options?: AlertOptions) => Promise<void>;
showConfirm: (message: string, options?: ConfirmOptions) => Promise<boolean>;
showToast: (message: string, options?: { variant?: AppDialogVariant; duration?: number }) => void;
}
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 }) {
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 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) => {
setDialog((current) => {
current?.resolve(confirmed);
@@ -188,9 +243,12 @@ export function AppDialogProvider({ children }: { children: React.ReactNode }) {
}, []);
return (
<AppDialogContext.Provider value={{ showAlert, showConfirm }}>
<AppDialogContext.Provider value={{ showAlert, showConfirm, showToast }}>
{children}
{dialog && <AppDialogModal dialog={dialog} onClose={closeDialog} />}
{toast && (
<AppToast message={toast.message} variant={toast.variant} onDismiss={dismissToast} />
)}
</AppDialogContext.Provider>
);
}

View File

@@ -2,6 +2,7 @@ import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'
import { createPortal } from 'react-dom';
import { LabelTemplate, TemplateElement } from '../types';
import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils';
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
import {
createDefaultTableElement,
getTableColWidths,
@@ -18,6 +19,7 @@ import { CanvasLabelImage } from './CanvasLabelImage';
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
import { renderLabelToDataUrl } from '../labelRenderer';
import { useAppDialog } from './AppDialog';
import { MobileMainToolButtons, type MobileToolButtonItem } from './MobileMainToolButtons';
import {
ZoomIn,
ZoomOut,
@@ -40,6 +42,10 @@ interface LabelDesignerProps {
variant?: 'desktop' | 'mobile';
/** 微调等场景下隐藏选区框与缩放手柄 */
suppressSelectionChrome?: boolean;
elementClipboard?: Pick<
ElementClipboardActions,
'copySelectedElements' | 'pasteClipboardElements' | 'canCopy' | 'canPaste' | 'clipboardCount'
>;
}
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode' | 'table';
@@ -212,6 +218,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
onSelectTableCells,
variant = 'desktop',
suppressSelectionChrome = false,
elementClipboard,
}) => {
const { showConfirm } = useAppDialog();
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));
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') {
e.preventDefault();
void deleteSelectedElements();
@@ -539,6 +557,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
addElement,
onSelectElements,
deleteSelectedElements,
elementClipboard,
]);
const clearInteractionCanvasState = useCallback(() => {
@@ -1430,6 +1449,28 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
{ 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 = (
<>
{tools.map((tool) => (
@@ -1922,9 +1963,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
{isMobileLayout ? (
<div className="mobile-design-toolstrip">
<div className="mobile-design-toolstrip-tools">
{mainToolButtons}
</div>
<MobileMainToolButtons items={mobileToolItems} />
<input
ref={imageInputRef}
type="file"
accept="image/*"
className="hidden"
onChange={handleImageFile}
/>
{zoomControls}
</div>
) : (
@@ -1952,7 +1998,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
)}
{activeTool === 'move' && (
<span className="text-[#888] hidden sm:inline">
| · Shift/Ctrl+ · Ctrl+A
| · Shift/Ctrl+ · Ctrl+A · Ctrl+C/V
</span>
)}
</>

View File

@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { ChevronLeft, Printer, Undo2, Redo2 } from 'lucide-react';
import { LabelTemplate, PaperConfig } from '../types';
import type { TableCellCoord } from '../tableUtils';
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
import { LabelDesigner } from './LabelDesigner';
import { PropSidebar, MobilePropModule } from './PropSidebar';
@@ -19,6 +20,7 @@ interface MobileDesignViewProps {
onRedo: () => void;
onBack: () => void;
onExport: () => void;
elementClipboard: ElementClipboardActions;
}
export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
@@ -35,6 +37,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
onRedo,
onBack,
onExport,
elementClipboard,
}) => {
const [mobileModule, setMobileModule] = useState<MobilePropModule>('properties');
@@ -90,6 +93,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
selectedTableCells={selectedTableCells}
onSelectTableCells={onSelectTableCells}
suppressSelectionChrome={mobileModule === 'nudge'}
elementClipboard={elementClipboard}
/>
</div>
@@ -107,6 +111,7 @@ export const MobileDesignView: React.FC<MobileDesignViewProps> = ({
paper={paper}
mobileModule={mobileModule}
onMobileModuleChange={setMobileModule}
elementClipboard={elementClipboard}
/>
</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 { useAppDialog } from './AppDialog';
import { useIsNarrowScreen } from '../hooks/useIsMobile';
import type { ElementClipboardActions } from '../hooks/useElementClipboard';
import {
TablePropertyFields,
TableCellContentFields,
@@ -53,6 +54,8 @@ import {
Move,
Layers,
Trash2,
Copy,
ClipboardPaste,
AlignHorizontalJustifyStart,
AlignHorizontalJustifyCenter,
AlignHorizontalJustifyEnd,
@@ -180,6 +183,7 @@ interface PropSidebarProps {
variant?: 'desktop' | 'mobile';
mobileModule?: MobilePropModule;
onMobileModuleChange?: (module: MobilePropModule) => void;
elementClipboard?: ElementClipboardActions;
}
const MOBILE_PROP_MODULES: { id: MobilePropModule; label: string; icon: React.ReactNode }[] = [
@@ -202,6 +206,7 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
variant = 'desktop',
mobileModule: mobileModuleProp,
onMobileModuleChange,
elementClipboard,
}) => {
const { showConfirm } = useAppDialog();
const isNarrowScreen = useIsNarrowScreen();
@@ -2307,6 +2312,29 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
</button>
))}
<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
type="button"
onClick={() => void deleteSelectedElements()}