优化交互
This commit is contained in:
@@ -35,6 +35,10 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
|||||||
|
|
||||||
const elementsKey = useMemo(() => JSON.stringify(elements), [elements]);
|
const elementsKey = useMemo(() => JSON.stringify(elements), [elements]);
|
||||||
const rowDataKey = useMemo(() => JSON.stringify(rowData), [rowData]);
|
const rowDataKey = useMemo(() => JSON.stringify(rowData), [rowData]);
|
||||||
|
const variableDefaultsKey = useMemo(
|
||||||
|
() => JSON.stringify(variableDefaults),
|
||||||
|
[variableDefaults]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let active = true;
|
let active = true;
|
||||||
@@ -52,10 +56,18 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
|||||||
scale: renderScale,
|
scale: renderScale,
|
||||||
})
|
})
|
||||||
.then((url) => {
|
.then((url) => {
|
||||||
if (active) {
|
if (!active) return;
|
||||||
setDataUrl(url);
|
const img = new Image();
|
||||||
setInitialLoading(false);
|
img.onload = () => {
|
||||||
}
|
if (active) {
|
||||||
|
setDataUrl(url);
|
||||||
|
setInitialLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
img.onerror = () => {
|
||||||
|
if (active) setInitialLoading(false);
|
||||||
|
};
|
||||||
|
img.src = url;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('Label graphics rendering error:', err);
|
console.error('Label graphics rendering error:', err);
|
||||||
@@ -65,7 +77,18 @@ export const CanvasLabelImage: React.FC<CanvasLabelImageProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
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) {
|
if (initialLoading && !dataUrl) {
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect, useMemo } from 'react';
|
||||||
import { TemplateElement } from '../types';
|
import { TemplateElement } from '../types';
|
||||||
import { mmToPx } from '../utils';
|
import { mmToPx } from '../utils';
|
||||||
import { renderLabelToDataUrl } from '../labelRenderer';
|
import { renderLabelToDataUrl } from '../labelRenderer';
|
||||||
@@ -42,6 +42,11 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [dataUrl, setDataUrl] = useState(dataUrlProp ?? '');
|
const [dataUrl, setDataUrl] = useState(dataUrlProp ?? '');
|
||||||
|
|
||||||
|
const elementVisualKey = useMemo(() => {
|
||||||
|
const { x: _x, y: _y, ...visual } = element;
|
||||||
|
return JSON.stringify(visual);
|
||||||
|
}, [element]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (dataUrlProp) {
|
if (dataUrlProp) {
|
||||||
setDataUrl(dataUrlProp);
|
setDataUrl(dataUrlProp);
|
||||||
@@ -60,7 +65,7 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
|
|||||||
return () => {
|
return () => {
|
||||||
active = false;
|
active = false;
|
||||||
};
|
};
|
||||||
}, [element, renderScale, variableDefaults, dataUrlProp]);
|
}, [elementVisualKey, element, renderScale, variableDefaults, dataUrlProp]);
|
||||||
|
|
||||||
const left = mmToPx(element.x, displayZoom);
|
const left = mmToPx(element.x, displayZoom);
|
||||||
const top = mmToPx(element.y, displayZoom);
|
const top = mmToPx(element.y, displayZoom);
|
||||||
@@ -81,6 +86,12 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
|
|||||||
height: `${height}px`,
|
height: `${height}px`,
|
||||||
imageRendering: 'auto',
|
imageRendering: 'auto',
|
||||||
}}
|
}}
|
||||||
|
onLoad={(e) => {
|
||||||
|
const placeholder = e.currentTarget.previousElementSibling as HTMLElement | null;
|
||||||
|
if (placeholder?.classList.contains('resize-float-placeholder')) {
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -237,8 +237,12 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
const interactionPortalRef = useRef<HTMLDivElement | null>(null);
|
const interactionPortalRef = useRef<HTMLDivElement | null>(null);
|
||||||
const dragFloatImgRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
const dragFloatImgRefs = useRef<Map<string, HTMLImageElement>>(new Map());
|
||||||
const dragFloatUrlsRef = useRef<Record<string, string>>({});
|
const dragFloatUrlsRef = useRef<Record<string, string>>({});
|
||||||
|
const dragFloatElementsRef = useRef<TemplateElement[]>([]);
|
||||||
|
const dragVisualActivatedRef = useRef(false);
|
||||||
const dragTransformLoopRef = useRef<number | null>(null);
|
const dragTransformLoopRef = useRef<number | null>(null);
|
||||||
const [dragFloatElements, setDragFloatElements] = useState<TemplateElement[]>([]);
|
const [dragFloatElements, setDragFloatElements] = useState<TemplateElement[]>([]);
|
||||||
|
const [dragInteractionVisualActive, setDragInteractionVisualActive] = useState(false);
|
||||||
|
const [interactionCanvasReady, setInteractionCanvasReady] = useState(false);
|
||||||
const interactionFloatPaintRef = useRef(0);
|
const interactionFloatPaintRef = useRef(0);
|
||||||
const interactionFloatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const interactionFloatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
@@ -513,6 +517,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
setInteractionSnapshot(null);
|
setInteractionSnapshot(null);
|
||||||
setInteractionFloatElements(null);
|
setInteractionFloatElements(null);
|
||||||
setDragFloatElements([]);
|
setDragFloatElements([]);
|
||||||
|
dragFloatElementsRef.current = [];
|
||||||
|
dragVisualActivatedRef.current = false;
|
||||||
|
setDragInteractionVisualActive(false);
|
||||||
|
setInteractionCanvasReady(false);
|
||||||
dragFloatImgRefs.current.clear();
|
dragFloatImgRefs.current.clear();
|
||||||
dragFloatUrlsRef.current = {};
|
dragFloatUrlsRef.current = {};
|
||||||
pendingDragDeltaRef.current = { x: 0, y: 0 };
|
pendingDragDeltaRef.current = { x: 0, y: 0 };
|
||||||
@@ -643,6 +651,36 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
const maxZoomRef = useRef(maxZoom);
|
const maxZoomRef = useRef(maxZoom);
|
||||||
maxZoomRef.current = 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(() => {
|
useEffect(() => {
|
||||||
setZoom((z) => (z > maxZoom ? clampZoomValue(z, maxZoom) : z));
|
setZoom((z) => (z > maxZoom ? clampZoomValue(z, maxZoom) : z));
|
||||||
}, [maxZoom]);
|
}, [maxZoom]);
|
||||||
@@ -788,6 +826,11 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
}
|
}
|
||||||
if (dragState.type === 'drag' && pastDragThreshold) {
|
if (dragState.type === 'drag' && pastDragThreshold) {
|
||||||
dragMovedRef.current = true;
|
dragMovedRef.current = true;
|
||||||
|
if (!dragVisualActivatedRef.current) {
|
||||||
|
dragVisualActivatedRef.current = true;
|
||||||
|
setDragInteractionVisualActive(true);
|
||||||
|
prefetchDragFloatImages(dragFloatElementsRef.current);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
const MM_PER_PX = 1 / (3.78 * displayZoomRef.current);
|
const MM_PER_PX = 1 / (3.78 * displayZoomRef.current);
|
||||||
const deltaXMm = deltaX * MM_PER_PX;
|
const deltaXMm = deltaX * MM_PER_PX;
|
||||||
@@ -933,7 +976,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
window.removeEventListener('pointerup', handlePointerUp);
|
window.removeEventListener('pointerup', handlePointerUp);
|
||||||
window.removeEventListener('pointercancel', handlePointerUp);
|
window.removeEventListener('pointercancel', handlePointerUp);
|
||||||
};
|
};
|
||||||
}, [dragState, template, displayZoom, onChange, cancelActiveInteraction, clearInteractionCanvasState]);
|
}, [dragState, template, displayZoom, onChange, cancelActiveInteraction, clearInteractionCanvasState, prefetchDragFloatImages]);
|
||||||
|
|
||||||
const buildGroupStartPositions = (element: TemplateElement) => {
|
const buildGroupStartPositions = (element: TemplateElement) => {
|
||||||
const moveIds =
|
const moveIds =
|
||||||
@@ -1048,6 +1091,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
dragFloatImgRefs.current.clear();
|
dragFloatImgRefs.current.clear();
|
||||||
dragCommitHistoryRef.current = null;
|
dragCommitHistoryRef.current = null;
|
||||||
dragCommitHistoryPrevRef.current = null;
|
dragCommitHistoryPrevRef.current = null;
|
||||||
|
dragVisualActivatedRef.current = false;
|
||||||
|
setDragInteractionVisualActive(false);
|
||||||
|
setInteractionCanvasReady(false);
|
||||||
|
dragFloatElementsRef.current = floating;
|
||||||
setDragFloatElements(floating);
|
setDragFloatElements(floating);
|
||||||
setDragState({
|
setDragState({
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
@@ -1060,32 +1107,6 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
startElemH: element.height,
|
startElemH: element.height,
|
||||||
groupStartPositions,
|
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 = (
|
const handleResizeStart = (
|
||||||
@@ -1103,6 +1124,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
setInteractionSnapshot(template.elements);
|
setInteractionSnapshot(template.elements);
|
||||||
setInteractionFloatElements([element]);
|
setInteractionFloatElements([element]);
|
||||||
interactionFloatPaintRef.current = 0;
|
interactionFloatPaintRef.current = 0;
|
||||||
|
setInteractionCanvasReady(false);
|
||||||
setDragState({
|
setDragState({
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
type: 'resize',
|
type: 'resize',
|
||||||
@@ -1149,25 +1171,72 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
return new Set(Object.keys(dragState.groupStartPositions));
|
return new Set(Object.keys(dragState.groupStartPositions));
|
||||||
}, [dragState]);
|
}, [dragState]);
|
||||||
|
|
||||||
|
const liftSelectedAboveDim = activeTool === 'move' && selectedElementIds.length > 0;
|
||||||
|
|
||||||
const staticCanvasElements = useMemo(() => {
|
const staticCanvasElements = useMemo(() => {
|
||||||
if (dragState?.type === 'drag' && interactionSnapshot && dragMovingIdSet) {
|
let elements: TemplateElement[];
|
||||||
return filterVisibleElements(
|
if (
|
||||||
|
dragState?.type === 'drag' &&
|
||||||
|
interactionSnapshot &&
|
||||||
|
dragMovingIdSet &&
|
||||||
|
dragInteractionVisualActive &&
|
||||||
|
interactionCanvasReady
|
||||||
|
) {
|
||||||
|
elements = filterVisibleElements(
|
||||||
interactionSnapshot.filter((el) => !dragMovingIdSet.has(el.id))
|
interactionSnapshot.filter((el) => !dragMovingIdSet.has(el.id))
|
||||||
);
|
);
|
||||||
}
|
} else if (dragState?.type === 'resize' && interactionSnapshot && interactionCanvasReady) {
|
||||||
if (dragState?.type === 'resize' && interactionSnapshot) {
|
elements = filterVisibleElements(
|
||||||
return filterVisibleElements(
|
|
||||||
interactionSnapshot.filter((el) => el.id !== dragState.elementId)
|
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?.type,
|
||||||
dragState?.elementId,
|
dragState?.elementId,
|
||||||
interactionSnapshot,
|
interactionSnapshot,
|
||||||
dragMovingIdSet,
|
dragMovingIdSet,
|
||||||
|
dragInteractionVisualActive,
|
||||||
|
interactionCanvasReady,
|
||||||
template.elements,
|
template.elements,
|
||||||
filterVisibleElements,
|
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(() => {
|
const resizeFloatElements = useMemo(() => {
|
||||||
@@ -1199,7 +1268,30 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
};
|
};
|
||||||
}, [allowInteractionOverflow, syncInteractionPortalLayout]);
|
}, [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 =
|
const resizingPreviewElement =
|
||||||
dragState?.type === 'resize'
|
dragState?.type === 'resize'
|
||||||
? interactionFloatElements?.find((el) => el.id === dragState.elementId)
|
? interactionFloatElements?.find((el) => el.id === dragState.elementId)
|
||||||
@@ -1264,7 +1356,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
>
|
>
|
||||||
<ZoomOut className="w-3 h-3" />
|
<ZoomOut className="w-3 h-3" />
|
||||||
</button>
|
</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)}%
|
{Math.round(displayZoom * 100)}%
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
@@ -1289,7 +1381,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
|
|
||||||
const showInteractionFloatPortal =
|
const showInteractionFloatPortal =
|
||||||
typeof document !== 'undefined' &&
|
typeof document !== 'undefined' &&
|
||||||
((dragState?.type === 'drag' && dragFloatElements.length > 0) ||
|
((dragState?.type === 'drag' &&
|
||||||
|
dragInteractionVisualActive &&
|
||||||
|
dragFloatElements.length > 0) ||
|
||||||
(resizeFloatElements && resizeFloatElements.length > 0));
|
(resizeFloatElements && resizeFloatElements.length > 0));
|
||||||
|
|
||||||
const interactionFloatPortal = showInteractionFloatPortal
|
const interactionFloatPortal = showInteractionFloatPortal
|
||||||
@@ -1367,13 +1461,25 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
style={{ overflow: 'visible' }}
|
style={{ overflow: 'visible' }}
|
||||||
>
|
>
|
||||||
{resizeFloatElements.map((el) => (
|
{resizeFloatElements.map((el) => (
|
||||||
<ElementFloatPreview
|
<React.Fragment key={el.id}>
|
||||||
key={el.id}
|
<div
|
||||||
element={el}
|
className="absolute pointer-events-none border border-dashed border-[#31a8ff]/40 resize-float-placeholder"
|
||||||
renderScale={canvasRenderScale}
|
style={{
|
||||||
displayZoom={displayZoom}
|
left: `${mmToPx(el.x, displayZoom)}px`,
|
||||||
variableDefaults={previewDefaults}
|
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>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -1456,6 +1562,56 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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' && (
|
{activeTool === 'move' && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-[1] cursor-crosshair touch-none"
|
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 isSelected = !isLocked && selectedElementIds.includes(element.id);
|
||||||
const isPrimary = selectedElementIds.indexOf(element.id) === 0;
|
const isPrimary = selectedElementIds.indexOf(element.id) === 0;
|
||||||
const isBeingDragged =
|
const isBeingDragged =
|
||||||
dragState?.type === 'drag' && !!dragMovingIdSet?.has(element.id);
|
dragInteractionVisualActive &&
|
||||||
|
dragState?.type === 'drag' &&
|
||||||
|
!!dragMovingIdSet?.has(element.id);
|
||||||
const isBeingResized =
|
const isBeingResized =
|
||||||
dragState?.type === 'resize' && dragState.elementId === element.id;
|
dragState?.type === 'resize' && dragState.elementId === element.id;
|
||||||
if (isBeingDragged) {
|
if (isBeingDragged) {
|
||||||
@@ -1491,9 +1649,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
const overlayElement =
|
const overlayElement =
|
||||||
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
|
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
|
||||||
|
|
||||||
const showSelectionRing = isSelected && !hideSelectionChrome;
|
|
||||||
const showResizeHandles =
|
const showResizeHandles =
|
||||||
showSelectionRing &&
|
isSelected &&
|
||||||
|
!hideSelectionChrome &&
|
||||||
|
!isLocked &&
|
||||||
selectedElementIds.length === 1 &&
|
selectedElementIds.length === 1 &&
|
||||||
dragState?.type !== 'resize';
|
dragState?.type !== 'resize';
|
||||||
const elemX = mmToPx(overlayElement.x, displayZoom);
|
const elemX = mmToPx(overlayElement.x, displayZoom);
|
||||||
@@ -1508,8 +1667,6 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
className={`absolute group touch-none ${
|
className={`absolute group touch-none ${
|
||||||
isLocked
|
isLocked
|
||||||
? 'pointer-events-none'
|
? 'pointer-events-none'
|
||||||
: showSelectionRing
|
|
||||||
? 'ps-selection-ring cursor-move'
|
|
||||||
: isSelected
|
: isSelected
|
||||||
? 'cursor-move'
|
? 'cursor-move'
|
||||||
: 'cursor-move hover:outline hover:outline-1 hover:outline-[#31a8ff]/40'
|
: '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">
|
||||||
<div className="mobile-design-toolstrip-tools">
|
<div className="mobile-design-toolstrip-tools">
|
||||||
{mainToolButtons}
|
{mainToolButtons}
|
||||||
{deleteToolButton}
|
|
||||||
</div>
|
</div>
|
||||||
{zoomControls}
|
{zoomControls}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 = () => {
|
const renderContentPanelBody = () => {
|
||||||
if (!selectedElem) {
|
if (!selectedElem) {
|
||||||
return (
|
return (
|
||||||
@@ -1985,6 +2015,18 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
</button>
|
</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>
|
</nav>
|
||||||
<div className="mobile-design-props-panel scrollbar-hide">
|
<div className="mobile-design-props-panel scrollbar-hide">
|
||||||
{mobileModule === 'content' && (
|
{mobileModule === 'content' && (
|
||||||
|
|||||||
@@ -99,16 +99,6 @@ body {
|
|||||||
color: var(--color-ps-accent);
|
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 {
|
.ps-options-bar {
|
||||||
height: 36px;
|
height: 36px;
|
||||||
background: var(--color-ps-header);
|
background: var(--color-ps-header);
|
||||||
@@ -672,6 +662,33 @@ body {
|
|||||||
outline-offset: 0;
|
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 {
|
.ps-handle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
@@ -1350,6 +1367,26 @@ body {
|
|||||||
border-right: 1px solid #1a1a1a;
|
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 {
|
.mobile-design-props-nav-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user