优化交互

This commit is contained in:
iqudoo
2026-06-13 04:40:15 +08:00
parent 3d945f9947
commit c25319e0de
5 changed files with 335 additions and 66 deletions

View File

@@ -35,6 +35,10 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
const elementsKey = useMemo(() => JSON.stringify(elements), [elements]);
const rowDataKey = useMemo(() => JSON.stringify(rowData), [rowData]);
const variableDefaultsKey = useMemo(
() => JSON.stringify(variableDefaults),
[variableDefaults]
);
useEffect(() => {
let active = true;
@@ -52,10 +56,18 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
scale: renderScale,
})
.then((url) => {
if (!active) return;
const img = new Image();
img.onload = () => {
if (active) {
setDataUrl(url);
setInitialLoading(false);
}
};
img.onerror = () => {
if (active) setInitialLoading(false);
};
img.src = url;
})
.catch((err) => {
console.error('Label graphics rendering error:', err);
@@ -65,7 +77,18 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
return () => {
active = false;
};
}, [widthMm, heightMm, elementsKey, rowDataKey, rowIndex, showBorder, transparentBackground, mode, renderScale, elements, variableDefaults]);
}, [
widthMm,
heightMm,
elementsKey,
rowDataKey,
variableDefaultsKey,
rowIndex,
showBorder,
transparentBackground,
mode,
renderScale,
]);
if (initialLoading && !dataUrl) {
return (

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { TemplateElement } from '../types';
import { mmToPx } from '../utils';
import { renderLabelToDataUrl } from '../labelRenderer';
@@ -42,6 +42,11 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
}) => {
const [dataUrl, setDataUrl] = useState(dataUrlProp ?? '');
const elementVisualKey = useMemo(() => {
const { x: _x, y: _y, ...visual } = element;
return JSON.stringify(visual);
}, [element]);
useEffect(() => {
if (dataUrlProp) {
setDataUrl(dataUrlProp);
@@ -60,7 +65,7 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
return () => {
active = false;
};
}, [element, renderScale, variableDefaults, dataUrlProp]);
}, [elementVisualKey, element, renderScale, variableDefaults, dataUrlProp]);
const left = mmToPx(element.x, displayZoom);
const top = mmToPx(element.y, displayZoom);
@@ -81,6 +86,12 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
height: `${height}px`,
imageRendering: 'auto',
}}
onLoad={(e) => {
const placeholder = e.currentTarget.previousElementSibling as HTMLElement | null;
if (placeholder?.classList.contains('resize-float-placeholder')) {
placeholder.style.display = 'none';
}
}}
/>
);
};

View File

@@ -237,8 +237,12 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const interactionPortalRef = useRef<HTMLDivElement | null>(null);
const dragFloatImgRefs = useRef<Map<string, HTMLImageElement>>(new Map());
const dragFloatUrlsRef = useRef<Record<string, string>>({});
const dragFloatElementsRef = useRef<TemplateElement[]>([]);
const dragVisualActivatedRef = useRef(false);
const dragTransformLoopRef = useRef<number | null>(null);
const [dragFloatElements, setDragFloatElements] = useState<TemplateElement[]>([]);
const [dragInteractionVisualActive, setDragInteractionVisualActive] = useState(false);
const [interactionCanvasReady, setInteractionCanvasReady] = useState(false);
const interactionFloatPaintRef = useRef(0);
const interactionFloatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -513,6 +517,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
setInteractionSnapshot(null);
setInteractionFloatElements(null);
setDragFloatElements([]);
dragFloatElementsRef.current = [];
dragVisualActivatedRef.current = false;
setDragInteractionVisualActive(false);
setInteractionCanvasReady(false);
dragFloatImgRefs.current.clear();
dragFloatUrlsRef.current = {};
pendingDragDeltaRef.current = { x: 0, y: 0 };
@@ -643,6 +651,36 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const maxZoomRef = useRef(maxZoom);
maxZoomRef.current = maxZoom;
const prefetchDragFloatImages = useCallback(
(elements: TemplateElement[]) => {
if (elements.length === 0) return;
void Promise.all(
elements.map(async (el) => {
try {
const url = await renderElementFloatPreview(
el,
canvasRenderScale,
previewDefaults
);
dragFloatUrlsRef.current[el.id] = url;
const img = dragFloatImgRefs.current.get(el.id);
if (img) {
img.src = url;
if (img.complete) {
img.style.opacity = '1';
const placeholder = img.previousElementSibling as HTMLElement | null;
if (placeholder) placeholder.style.display = 'none';
}
}
} catch (err) {
console.error('Drag float preview error:', err);
}
})
);
},
[canvasRenderScale, previewDefaults]
);
useEffect(() => {
setZoom((z) => (z > maxZoom ? clampZoomValue(z, maxZoom) : z));
}, [maxZoom]);
@@ -788,6 +826,11 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}
if (dragState.type === 'drag' && pastDragThreshold) {
dragMovedRef.current = true;
if (!dragVisualActivatedRef.current) {
dragVisualActivatedRef.current = true;
setDragInteractionVisualActive(true);
prefetchDragFloatImages(dragFloatElementsRef.current);
}
}
const MM_PER_PX = 1 / (3.78 * displayZoomRef.current);
const deltaXMm = deltaX * MM_PER_PX;
@@ -933,7 +976,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
};
}, [dragState, template, displayZoom, onChange, cancelActiveInteraction, clearInteractionCanvasState]);
}, [dragState, template, displayZoom, onChange, cancelActiveInteraction, clearInteractionCanvasState, prefetchDragFloatImages]);
const buildGroupStartPositions = (element: TemplateElement) => {
const moveIds =
@@ -1048,6 +1091,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
dragFloatImgRefs.current.clear();
dragCommitHistoryRef.current = null;
dragCommitHistoryPrevRef.current = null;
dragVisualActivatedRef.current = false;
setDragInteractionVisualActive(false);
setInteractionCanvasReady(false);
dragFloatElementsRef.current = floating;
setDragFloatElements(floating);
setDragState({
elementId: element.id,
@@ -1060,32 +1107,6 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
startElemH: element.height,
groupStartPositions,
});
if (actionType === 'drag' && floating.length > 0) {
void Promise.all(
floating.map(async (el) => {
try {
const url = await renderElementFloatPreview(
el,
canvasRenderScale,
previewDefaults
);
dragFloatUrlsRef.current[el.id] = url;
const img = dragFloatImgRefs.current.get(el.id);
if (img) {
img.src = url;
if (img.complete) {
img.style.opacity = '1';
const placeholder = img.previousElementSibling as HTMLElement | null;
if (placeholder) placeholder.style.display = 'none';
}
}
} catch (err) {
console.error('Drag float preview error:', err);
}
})
);
}
};
const handleResizeStart = (
@@ -1103,6 +1124,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
setInteractionSnapshot(template.elements);
setInteractionFloatElements([element]);
interactionFloatPaintRef.current = 0;
setInteractionCanvasReady(false);
setDragState({
elementId: element.id,
type: 'resize',
@@ -1149,25 +1171,72 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
return new Set(Object.keys(dragState.groupStartPositions));
}, [dragState]);
const liftSelectedAboveDim = activeTool === 'move' && selectedElementIds.length > 0;
const staticCanvasElements = useMemo(() => {
if (dragState?.type === 'drag' && interactionSnapshot && dragMovingIdSet) {
return filterVisibleElements(
let elements: TemplateElement[];
if (
dragState?.type === 'drag' &&
interactionSnapshot &&
dragMovingIdSet &&
dragInteractionVisualActive &&
interactionCanvasReady
) {
elements = filterVisibleElements(
interactionSnapshot.filter((el) => !dragMovingIdSet.has(el.id))
);
}
if (dragState?.type === 'resize' && interactionSnapshot) {
return filterVisibleElements(
} else if (dragState?.type === 'resize' && interactionSnapshot && interactionCanvasReady) {
elements = filterVisibleElements(
interactionSnapshot.filter((el) => el.id !== dragState.elementId)
);
} else {
elements = filterVisibleElements(template.elements);
}
return filterVisibleElements(template.elements);
if (liftSelectedAboveDim) {
const selectedSet = new Set(selectedElementIds);
elements = elements.filter((el) => !selectedSet.has(el.id));
}
return elements;
}, [
dragState?.type,
dragState?.elementId,
interactionSnapshot,
dragMovingIdSet,
dragInteractionVisualActive,
interactionCanvasReady,
template.elements,
filterVisibleElements,
liftSelectedAboveDim,
selectedElementIds,
]);
const selectedLiftElements = useMemo(() => {
if (!liftSelectedAboveDim) return null;
let elements = filterVisibleElements(
template.elements.filter((el) => selectedElementIds.includes(el.id))
);
if (dragState?.type === 'drag' && dragInteractionVisualActive && dragMovingIdSet) {
elements = elements.filter((el) => !dragMovingIdSet.has(el.id));
}
if (dragState?.type === 'resize' && interactionCanvasReady && dragState.elementId) {
elements = elements.filter((el) => el.id !== dragState.elementId);
}
return elements.length > 0 ? elements : null;
}, [
liftSelectedAboveDim,
selectedElementIds,
template.elements,
filterVisibleElements,
dragState?.type,
dragState?.elementId,
dragInteractionVisualActive,
dragMovingIdSet,
interactionCanvasReady,
]);
const resizeFloatElements = useMemo(() => {
@@ -1199,7 +1268,30 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
};
}, [allowInteractionOverflow, syncInteractionPortalLayout]);
const hideSelectionChrome = suppressSelectionChrome || dragState?.type === 'drag';
useEffect(() => {
const shouldPrepareCanvas =
(dragState?.type === 'drag' && dragInteractionVisualActive) ||
dragState?.type === 'resize';
if (!shouldPrepareCanvas) {
setInteractionCanvasReady(false);
return;
}
setInteractionCanvasReady(false);
let cancelled = false;
requestAnimationFrame(() => {
requestAnimationFrame(() => {
if (!cancelled) setInteractionCanvasReady(true);
});
});
return () => {
cancelled = true;
};
}, [dragState, dragInteractionVisualActive]);
const hideSelectionChrome =
suppressSelectionChrome || (dragState?.type === 'drag' && dragInteractionVisualActive);
const showUnselectedDimLayer = activeTool === 'move';
const showUnselectedDim = showUnselectedDimLayer && selectedElementIds.length > 0;
const resizingPreviewElement =
dragState?.type === 'resize'
? interactionFloatElements?.find((el) => el.id === dragState.elementId)
@@ -1264,7 +1356,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
>
<ZoomOut className="w-3 h-3" />
</button>
<span className="px-1.5 text-[10px] font-mono text-[#ccc] min-w-[42px] text-center select-none">
<span className="px-1 text-[10px] font-mono text-[#ccc] min-w-[32px] text-center select-none">
{Math.round(displayZoom * 100)}%
</span>
<button
@@ -1289,7 +1381,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const showInteractionFloatPortal =
typeof document !== 'undefined' &&
((dragState?.type === 'drag' && dragFloatElements.length > 0) ||
((dragState?.type === 'drag' &&
dragInteractionVisualActive &&
dragFloatElements.length > 0) ||
(resizeFloatElements && resizeFloatElements.length > 0));
const interactionFloatPortal = showInteractionFloatPortal
@@ -1367,13 +1461,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
style={{ overflow: 'visible' }}
>
{resizeFloatElements.map((el) => (
<React.Fragment key={el.id}>
<div
className="absolute pointer-events-none border border-dashed border-[#31a8ff]/40 resize-float-placeholder"
style={{
left: `${mmToPx(el.x, displayZoom)}px`,
top: `${mmToPx(el.y, displayZoom)}px`,
width: `${mmToPx(el.width, displayZoom)}px`,
height: `${mmToPx(el.height, displayZoom)}px`,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
transformOrigin: 'center center',
}}
/>
<ElementFloatPreview
key={el.id}
element={el}
renderScale={canvasRenderScale}
displayZoom={displayZoom}
variableDefaults={previewDefaults}
/>
</React.Fragment>
))}
</div>
)}
@@ -1456,6 +1562,56 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
/>
</div>
{showUnselectedDimLayer && (
<div
className={`ps-unselected-dim-layer${
showUnselectedDim ? ' ps-unselected-dim-layer--visible' : ''
}`}
aria-hidden={!showUnselectedDim}
>
{overlayElements.map((element) => {
if (selectedElementIds.includes(element.id)) return null;
if (dragState?.type === 'drag' && dragMovingIdSet?.has(element.id)) return null;
const elemX = mmToPx(element.x, displayZoom);
const elemY = mmToPx(element.y, displayZoom);
const elemW = mmToPx(element.width, displayZoom);
const elemH = mmToPx(element.height, displayZoom);
return (
<div
key={`dim-${element.id}`}
className="ps-unselected-dim absolute"
style={{
left: `${elemX}px`,
top: `${elemY}px`,
width: `${elemW}px`,
height: `${elemH}px`,
transform: element.rotation
? `rotate(${element.rotation}deg)`
: undefined,
transformOrigin: 'center center',
}}
/>
);
})}
</div>
)}
{selectedLiftElements && (
<div className="ps-selected-lift-layer">
<CanvasLabelImage
widthMm={template.width}
heightMm={template.height}
elements={selectedLiftElements}
rowData={null}
rowIndex={0}
showBorder={false}
transparentBackground
variableDefaults={previewDefaults}
renderScale={canvasRenderScale}
/>
</div>
)}
{activeTool === 'move' && (
<div
className="absolute inset-0 z-[1] cursor-crosshair touch-none"
@@ -1481,7 +1637,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const isSelected = !isLocked && selectedElementIds.includes(element.id);
const isPrimary = selectedElementIds.indexOf(element.id) === 0;
const isBeingDragged =
dragState?.type === 'drag' && !!dragMovingIdSet?.has(element.id);
dragInteractionVisualActive &&
dragState?.type === 'drag' &&
!!dragMovingIdSet?.has(element.id);
const isBeingResized =
dragState?.type === 'resize' && dragState.elementId === element.id;
if (isBeingDragged) {
@@ -1491,9 +1649,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
const overlayElement =
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
const showSelectionRing = isSelected && !hideSelectionChrome;
const showResizeHandles =
showSelectionRing &&
isSelected &&
!hideSelectionChrome &&
!isLocked &&
selectedElementIds.length === 1 &&
dragState?.type !== 'resize';
const elemX = mmToPx(overlayElement.x, displayZoom);
@@ -1508,8 +1667,6 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
className={`absolute group touch-none ${
isLocked
? 'pointer-events-none'
: showSelectionRing
? 'ps-selection-ring cursor-move'
: isSelected
? 'cursor-move'
: 'cursor-move hover:outline hover:outline-1 hover:outline-[#31a8ff]/40'
@@ -1589,7 +1746,6 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
<div className="mobile-design-toolstrip">
<div className="mobile-design-toolstrip-tools">
{mainToolButtons}
{deleteToolButton}
</div>
{zoomControls}
</div>

View File

@@ -565,6 +565,36 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
}
};
const deletableSelectedIds = useMemo(
() =>
selectedElementIds.filter((id) => {
const el = template.elements.find((item) => item.id === id);
return el && !el.locked;
}),
[selectedElementIds, template.elements]
);
const deleteSelectedElements = async () => {
if (deletableSelectedIds.length === 0) return;
const message =
deletableSelectedIds.length === 1
? `确定删除「${template.elements.find((item) => item.id === deletableSelectedIds[0])?.name ?? '未命名'}」吗?此操作无法撤销。`
: `确定删除选中的 ${deletableSelectedIds.length} 个元素吗?此操作无法撤销。`;
const confirmed = await showConfirm(message, {
title: '删除元素',
confirmLabel: '删除',
variant: 'error',
});
if (!confirmed) return;
const idSet = new Set(deletableSelectedIds);
const remaining = template.elements.filter((el) => !idSet.has(el.id));
onChangeTemplate({ ...template, elements: remaining });
onSelectElements?.(selectedElementIds.filter((id) => remaining.some((el) => el.id === id)));
};
const renderContentPanelBody = () => {
if (!selectedElem) {
return (
@@ -1985,6 +2015,18 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
<span>{item.label}</span>
</button>
))}
<div className="mobile-design-props-nav-spacer" aria-hidden />
<button
type="button"
onClick={() => void deleteSelectedElements()}
disabled={deletableSelectedIds.length === 0}
className="mobile-design-props-nav-btn mobile-design-props-nav-delete"
aria-label="删除选中元素"
title="删除选中元素"
>
<Trash2 className="w-4 h-4" />
<span></span>
</button>
</nav>
<div className="mobile-design-props-panel scrollbar-hide">
{mobileModule === 'content' && (

View File

@@ -99,16 +99,6 @@ body {
color: var(--color-ps-accent);
}
.ps-tool-btn.active::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 2px;
background: var(--color-ps-accent);
}
.ps-options-bar {
height: 36px;
background: var(--color-ps-header);
@@ -672,6 +662,33 @@ body {
outline-offset: 0;
}
.ps-unselected-dim-layer {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 2;
opacity: 0;
transition: opacity 0.12s ease;
}
.ps-unselected-dim-layer--visible {
opacity: 1;
}
.ps-unselected-dim {
position: absolute;
background: rgba(255, 255, 255, 0.3);
}
.ps-selected-lift-layer {
position: absolute;
inset: 0;
overflow: hidden;
pointer-events: none;
z-index: 3;
}
.ps-handle {
position: absolute;
width: 8px;
@@ -1350,6 +1367,26 @@ body {
border-right: 1px solid #1a1a1a;
}
.mobile-design-props-nav-spacer {
flex: 1;
min-height: 8px;
}
.mobile-design-props-nav-delete:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.mobile-design-props-nav-delete:not(:disabled) {
color: #e57373;
}
.mobile-design-props-nav-delete:not(:disabled).active,
.mobile-design-props-nav-delete:not(:disabled):active {
background: rgba(239, 68, 68, 0.18);
color: #fca5a5;
}
.mobile-design-props-nav-btn {
display: flex;
flex-direction: column;