优化布局和交互
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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 { 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()}
|
||||
|
||||
Reference in New Issue
Block a user