优化交互

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

@@ -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) => (
<ElementFloatPreview
key={el.id}
element={el}
renderScale={canvasRenderScale}
displayZoom={displayZoom}
variableDefaults={previewDefaults}
/>
<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
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>