修复
This commit is contained in:
@@ -3,6 +3,7 @@ import { LabelTemplate, TemplateElement } from '../types';
|
||||
import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils';
|
||||
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||
import { ElementFloatPreview } from './ElementFloatPreview';
|
||||
import { useAppDialog } from './AppDialog';
|
||||
import {
|
||||
ZoomIn,
|
||||
@@ -21,6 +22,8 @@ interface LabelDesignerProps {
|
||||
selectedElementIds: string[];
|
||||
onSelectElements: (ids: string[]) => void;
|
||||
variant?: 'desktop' | 'mobile';
|
||||
/** 微调等场景下隐藏选区框与缩放手柄 */
|
||||
suppressSelectionChrome?: boolean;
|
||||
}
|
||||
|
||||
type ActiveTool = 'move' | 'text' | 'barcode' | 'qrcode';
|
||||
@@ -187,6 +190,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
selectedElementIds,
|
||||
onSelectElements,
|
||||
variant = 'desktop',
|
||||
suppressSelectionChrome = false,
|
||||
}) => {
|
||||
const { showConfirm } = useAppDialog();
|
||||
const selectedElementId = selectedElementIds[0] || null;
|
||||
@@ -204,12 +208,20 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
[template.variableDefaults]
|
||||
);
|
||||
|
||||
const [localElements, setLocalElements] = useState<TemplateElement[] | null>(null);
|
||||
const localElementsRef = useRef<TemplateElement[] | null>(null);
|
||||
/** 拖拽/缩放过程中冻结底图合成,仅更新选区框位置,避免每帧重绘闪动 */
|
||||
const [frozenCanvasElements, setFrozenCanvasElements] = useState<TemplateElement[] | null>(null);
|
||||
/** 交互开始时的元素快照:拖动底图分层、缩放节流重绘 */
|
||||
const [interactionSnapshot, setInteractionSnapshot] = useState<TemplateElement[] | null>(
|
||||
null
|
||||
);
|
||||
/** 缩放时仅重绘当前元素的浮层内容 */
|
||||
const [interactionFloatElements, setInteractionFloatElements] = useState<
|
||||
TemplateElement[] | null
|
||||
>(null);
|
||||
const dragRafRef = useRef<number | null>(null);
|
||||
const pendingDragElementsRef = useRef<TemplateElement[] | null>(null);
|
||||
const dragFloatLayerRef = useRef<HTMLDivElement | null>(null);
|
||||
const interactionFloatPaintRef = useRef(0);
|
||||
const interactionFloatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const [dragState, setDragState] = useState<{
|
||||
elementId: string;
|
||||
@@ -466,6 +478,19 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
deleteSelectedElements,
|
||||
]);
|
||||
|
||||
const clearInteractionCanvasState = useCallback(() => {
|
||||
if (interactionFloatTimerRef.current !== null) {
|
||||
clearTimeout(interactionFloatTimerRef.current);
|
||||
interactionFloatTimerRef.current = null;
|
||||
}
|
||||
interactionFloatPaintRef.current = 0;
|
||||
if (dragFloatLayerRef.current) {
|
||||
dragFloatLayerRef.current.style.transform = '';
|
||||
}
|
||||
setInteractionSnapshot(null);
|
||||
setInteractionFloatElements(null);
|
||||
}, []);
|
||||
|
||||
const cancelActiveInteraction = useCallback(() => {
|
||||
if (dragRafRef.current !== null) {
|
||||
cancelAnimationFrame(dragRafRef.current);
|
||||
@@ -473,12 +498,11 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
}
|
||||
pendingDragElementsRef.current = null;
|
||||
localElementsRef.current = null;
|
||||
setLocalElements(null);
|
||||
setFrozenCanvasElements(null);
|
||||
clearInteractionCanvasState();
|
||||
setDragState(null);
|
||||
dragMovedRef.current = false;
|
||||
setMarqueeState(null);
|
||||
}, []);
|
||||
}, [clearInteractionCanvasState]);
|
||||
|
||||
const shouldIgnoreTouchPointer = (e: { pointerType: string; isPrimary: boolean }) => {
|
||||
if (multiTouchActiveRef.current) return true;
|
||||
@@ -720,12 +744,14 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
};
|
||||
});
|
||||
pendingDragElementsRef.current = updated;
|
||||
if (dragFloatLayerRef.current) {
|
||||
dragFloatLayerRef.current.style.transform = `translate(${deltaX}px, ${deltaY}px)`;
|
||||
}
|
||||
if (dragRafRef.current === null) {
|
||||
dragRafRef.current = requestAnimationFrame(() => {
|
||||
dragRafRef.current = null;
|
||||
const next = pendingDragElementsRef.current;
|
||||
if (next) {
|
||||
setLocalElements(next);
|
||||
localElementsRef.current = next;
|
||||
}
|
||||
});
|
||||
@@ -763,13 +789,32 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
: el
|
||||
);
|
||||
pendingDragElementsRef.current = updated;
|
||||
|
||||
const paintInteractionFloat = () => {
|
||||
interactionFloatPaintRef.current = performance.now();
|
||||
interactionFloatTimerRef.current = null;
|
||||
const pending = pendingDragElementsRef.current?.find(
|
||||
(el) => el.id === dragState.elementId
|
||||
);
|
||||
if (pending) setInteractionFloatElements([pending]);
|
||||
};
|
||||
|
||||
if (dragRafRef.current === null) {
|
||||
dragRafRef.current = requestAnimationFrame(() => {
|
||||
dragRafRef.current = null;
|
||||
const next = pendingDragElementsRef.current;
|
||||
if (next) {
|
||||
setLocalElements(next);
|
||||
localElementsRef.current = next;
|
||||
const now = performance.now();
|
||||
const elapsed = now - interactionFloatPaintRef.current;
|
||||
if (elapsed >= 66 || interactionFloatPaintRef.current === 0) {
|
||||
paintInteractionFloat();
|
||||
} else if (interactionFloatTimerRef.current === null) {
|
||||
interactionFloatTimerRef.current = setTimeout(
|
||||
paintInteractionFloat,
|
||||
66 - elapsed
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -797,8 +842,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
}
|
||||
}
|
||||
localElementsRef.current = null;
|
||||
setLocalElements(null);
|
||||
setFrozenCanvasElements(null);
|
||||
clearInteractionCanvasState();
|
||||
setDragState(null);
|
||||
};
|
||||
|
||||
@@ -810,7 +854,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
window.removeEventListener('pointerup', handlePointerUp);
|
||||
window.removeEventListener('pointercancel', handlePointerUp);
|
||||
};
|
||||
}, [dragState, template, displayZoom, onChange, cancelActiveInteraction]);
|
||||
}, [dragState, template, displayZoom, onChange, cancelActiveInteraction, clearInteractionCanvasState]);
|
||||
|
||||
const buildGroupStartPositions = (element: TemplateElement) => {
|
||||
const moveIds =
|
||||
@@ -910,7 +954,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
if (element.locked) return;
|
||||
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
setFrozenCanvasElements(template.elements);
|
||||
setInteractionSnapshot(template.elements);
|
||||
setInteractionFloatElements(null);
|
||||
interactionFloatPaintRef.current = 0;
|
||||
setDragState({
|
||||
elementId: element.id,
|
||||
type: actionType,
|
||||
@@ -936,7 +982,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
if (element.locked) return;
|
||||
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
setFrozenCanvasElements(template.elements);
|
||||
setInteractionSnapshot(template.elements);
|
||||
setInteractionFloatElements([element]);
|
||||
interactionFloatPaintRef.current = 0;
|
||||
setDragState({
|
||||
elementId: element.id,
|
||||
type: 'resize',
|
||||
@@ -968,9 +1016,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isMobileLayout, template.width, template.height]);
|
||||
|
||||
const selectedElemObj = (localElements || template.elements).find(
|
||||
(el) => el.id === selectedElementId
|
||||
);
|
||||
const selectedElemObj = template.elements.find((el) => el.id === selectedElementId);
|
||||
|
||||
const filterVisibleElements = useCallback(
|
||||
(elements: TemplateElement[]) =>
|
||||
@@ -980,10 +1026,51 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
[previewDefaults]
|
||||
);
|
||||
|
||||
const canvasPreviewElements = filterVisibleElements(
|
||||
frozenCanvasElements ?? template.elements
|
||||
);
|
||||
const overlayElements = filterVisibleElements(localElements ?? template.elements);
|
||||
const dragMovingIdSet = useMemo(() => {
|
||||
if (dragState?.type !== 'drag' || !dragState.groupStartPositions) return null;
|
||||
return new Set(Object.keys(dragState.groupStartPositions));
|
||||
}, [dragState]);
|
||||
|
||||
const staticCanvasElements = useMemo(() => {
|
||||
if (dragState?.type === 'drag' && interactionSnapshot && dragMovingIdSet) {
|
||||
return filterVisibleElements(
|
||||
interactionSnapshot.filter((el) => !dragMovingIdSet.has(el.id))
|
||||
);
|
||||
}
|
||||
if (dragState?.type === 'resize' && interactionSnapshot) {
|
||||
return filterVisibleElements(
|
||||
interactionSnapshot.filter((el) => el.id !== dragState.elementId)
|
||||
);
|
||||
}
|
||||
return filterVisibleElements(template.elements);
|
||||
}, [
|
||||
dragState?.type,
|
||||
dragState?.elementId,
|
||||
interactionSnapshot,
|
||||
dragMovingIdSet,
|
||||
template.elements,
|
||||
filterVisibleElements,
|
||||
]);
|
||||
|
||||
const dragFloatElements = useMemo(() => {
|
||||
if (dragState?.type !== 'drag' || !interactionSnapshot || !dragMovingIdSet) {
|
||||
return null;
|
||||
}
|
||||
const moving = interactionSnapshot.filter((el) => dragMovingIdSet.has(el.id));
|
||||
return moving.length > 0 ? filterVisibleElements(moving) : null;
|
||||
}, [dragState?.type, interactionSnapshot, dragMovingIdSet, filterVisibleElements]);
|
||||
|
||||
const resizeFloatElements = useMemo(() => {
|
||||
if (dragState?.type !== 'resize' || !interactionFloatElements?.length) return null;
|
||||
return filterVisibleElements(interactionFloatElements);
|
||||
}, [dragState?.type, interactionFloatElements, filterVisibleElements]);
|
||||
|
||||
const interactionFloatCanvasElements = dragFloatElements ?? resizeFloatElements;
|
||||
const allowInteractionOverflow = dragState !== null;
|
||||
|
||||
const hideSelectionChrome = suppressSelectionChrome || dragState !== null;
|
||||
|
||||
const overlayElements = filterVisibleElements(template.elements);
|
||||
|
||||
const tools: { id: ActiveTool; icon: React.ReactNode; label: string; shortcut?: string }[] = [
|
||||
{ id: 'move', icon: <Move className="w-[18px] h-[18px]" />, label: '移动工具', shortcut: 'V' },
|
||||
@@ -1069,9 +1156,12 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
<div
|
||||
className={
|
||||
isMobileLayout
|
||||
? 'mobile-design-canvas-shell flex flex-col flex-1 min-w-0 min-h-0'
|
||||
? `mobile-design-canvas-shell flex flex-col flex-1 min-w-0 min-h-0${
|
||||
allowInteractionOverflow ? ' mobile-design-canvas-shell-interaction' : ''
|
||||
}`
|
||||
: 'flex flex-1 min-w-0 min-h-0'
|
||||
}
|
||||
data-interaction-active={allowInteractionOverflow ? '' : undefined}
|
||||
>
|
||||
{!isMobileLayout && (
|
||||
<div className="ps-toolbar flex flex-col">
|
||||
@@ -1084,7 +1174,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||
<div
|
||||
ref={canvasAreaRef}
|
||||
className="flex-1 overflow-auto ps-workspace-bg ps-design-canvas-touch flex items-center justify-center min-h-0 min-w-0 p-4"
|
||||
className={`flex-1 ps-workspace-bg ps-design-canvas-touch flex items-center justify-center min-h-0 min-w-0 p-4 ${
|
||||
allowInteractionOverflow ? 'overflow-visible' : 'overflow-auto'
|
||||
}`}
|
||||
onPointerDown={handleWorkspacePointerDown}
|
||||
onClick={(e) => {
|
||||
if (ignoreCanvasClickRef.current) {
|
||||
@@ -1097,12 +1189,16 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="relative shrink-0 grow-0 bg-white shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none"
|
||||
className="relative shrink-0 grow-0"
|
||||
style={{
|
||||
width: `${canvasDisplayW}px`,
|
||||
height: `${canvasDisplayH}px`,
|
||||
overflow: allowInteractionOverflow ? 'visible' : 'hidden',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 bg-white shadow-[0_2px_20px_rgba(0,0,0,0.5)] select-none overflow-hidden"
|
||||
>
|
||||
{/* <div className="absolute -top-5 left-0 text-[10px] font-mono text-[#aaa]">
|
||||
{template.width} mm
|
||||
@@ -1119,7 +1215,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
<CanvasLabelImage
|
||||
widthMm={template.width}
|
||||
heightMm={template.height}
|
||||
elements={canvasPreviewElements}
|
||||
elements={staticCanvasElements}
|
||||
rowData={null}
|
||||
rowIndex={0}
|
||||
showBorder={false}
|
||||
@@ -1152,6 +1248,17 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
const isLocked = !!element.locked;
|
||||
const isSelected = !isLocked && selectedElementIds.includes(element.id);
|
||||
const isPrimary = selectedElementIds.indexOf(element.id) === 0;
|
||||
const isBeingDragged =
|
||||
dragState?.type === 'drag' && !!dragMovingIdSet?.has(element.id);
|
||||
const isBeingResized =
|
||||
dragState?.type === 'resize' && dragState.elementId === element.id;
|
||||
if (isBeingDragged || isBeingResized) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const showSelectionRing = isSelected && !hideSelectionChrome;
|
||||
const showResizeHandles =
|
||||
showSelectionRing && selectedElementIds.length === 1;
|
||||
const elemX = mmToPx(element.x, displayZoom);
|
||||
const elemY = mmToPx(element.y, displayZoom);
|
||||
const elemW = mmToPx(element.width, displayZoom);
|
||||
@@ -1164,8 +1271,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
className={`absolute group touch-none ${
|
||||
isLocked
|
||||
? 'pointer-events-none'
|
||||
: isSelected
|
||||
: showSelectionRing
|
||||
? 'ps-selection-ring cursor-move'
|
||||
: isSelected
|
||||
? 'cursor-move'
|
||||
: 'cursor-move hover:outline hover:outline-1 hover:outline-[#31a8ff]/40'
|
||||
}`}
|
||||
style={{
|
||||
@@ -1192,7 +1301,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isSelected && (
|
||||
{showResizeHandles && (
|
||||
<>
|
||||
<div
|
||||
className="ps-handle top-0 left-0 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize"
|
||||
@@ -1226,6 +1335,30 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{interactionFloatCanvasElements && interactionFloatCanvasElements.length > 0 && (
|
||||
<div
|
||||
ref={dragFloatLayerRef}
|
||||
className="absolute top-0 left-0 pointer-events-none z-[10]"
|
||||
style={{
|
||||
width: `${canvasDisplayW}px`,
|
||||
height: `${canvasDisplayH}px`,
|
||||
overflow: 'visible',
|
||||
willChange: dragState?.type === 'drag' ? 'transform' : undefined,
|
||||
}}
|
||||
>
|
||||
{interactionFloatCanvasElements.map((el) => (
|
||||
<ElementFloatPreview
|
||||
key={el.id}
|
||||
element={el}
|
||||
renderScale={canvasRenderScale}
|
||||
displayZoom={displayZoom}
|
||||
variableDefaults={previewDefaults}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMobileLayout ? (
|
||||
|
||||
Reference in New Issue
Block a user