This commit is contained in:
iqudoo
2026-06-13 04:12:57 +08:00
parent 3b9724beb3
commit e579da5d6b
4 changed files with 325 additions and 81 deletions

View File

@@ -1,4 +1,4 @@
import React, { useState, useEffect, useMemo } from 'react';
import React, { useState, useEffect } from 'react';
import { TemplateElement } from '../types';
import { mmToPx } from '../utils';
import { renderLabelToDataUrl } from '../labelRenderer';
@@ -8,6 +8,28 @@ interface ElementFloatPreviewProps {
renderScale: number;
displayZoom: number;
variableDefaults?: Record<string, string> | null;
/** 已离屏绘制的位图,传入后不再重复渲染 */
dataUrl?: string;
}
export async function renderElementFloatPreview(
element: TemplateElement,
renderScale: number,
variableDefaults?: Record<string, string> | null
): Promise<string> {
return renderLabelToDataUrl({
widthMm: element.width,
heightMm: element.height,
elements: [{ ...element, x: 0, y: 0 }],
rowData: null,
rowIndex: 0,
showBorder: false,
transparentBackground: true,
variableDefaults,
mode: 'design',
scale: renderScale,
clipToCanvas: false,
});
}
/** 单元素离屏绘制(不裁切到标签页边界),用于拖动/缩放浮层 */
@@ -16,27 +38,18 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
renderScale,
displayZoom,
variableDefaults = null,
dataUrl: dataUrlProp,
}) => {
const [dataUrl, setDataUrl] = useState('');
const elementKey = useMemo(() => JSON.stringify(element), [element]);
const [dataUrl, setDataUrl] = useState(dataUrlProp ?? '');
useEffect(() => {
let active = true;
const normalized = { ...element, x: 0, y: 0 };
if (dataUrlProp) {
setDataUrl(dataUrlProp);
return;
}
renderLabelToDataUrl({
widthMm: element.width,
heightMm: element.height,
elements: [normalized],
rowData: null,
rowIndex: 0,
showBorder: false,
transparentBackground: true,
variableDefaults,
mode: 'design',
scale: renderScale,
})
let active = true;
renderElementFloatPreview(element, renderScale, variableDefaults)
.then((url) => {
if (active) setDataUrl(url);
})
@@ -47,7 +60,7 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
return () => {
active = false;
};
}, [element, elementKey, renderScale, variableDefaults]);
}, [element, renderScale, variableDefaults, dataUrlProp]);
const left = mmToPx(element.x, displayZoom);
const top = mmToPx(element.y, displayZoom);

View File

@@ -1,9 +1,11 @@
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 { useIsNarrowScreen } from '../hooks/useIsMobile';
import { CanvasLabelImage } from './CanvasLabelImage';
import { ElementFloatPreview } from './ElementFloatPreview';
import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
import { renderLabelToDataUrl } from '../labelRenderer';
import { useAppDialog } from './AppDialog';
import {
ZoomIn,
@@ -76,6 +78,9 @@ function applyCornerResize(
const MARQUEE_MIN_PX = 4;
const DRAG_START_THRESHOLD_PX = 4;
/** 松手前极短时间内的微小位移视为抖动,提交上一稳定位置 */
const DRAG_RELEASE_JITTER_PX = 4;
const DRAG_RELEASE_JITTER_MS = 48;
const MODERATE_ZOOM_MAX = 2.0;
function blurActiveField() {
@@ -219,7 +224,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
>(null);
const dragRafRef = useRef<number | null>(null);
const pendingDragElementsRef = useRef<TemplateElement[] | null>(null);
const pendingDragDeltaRef = useRef({ x: 0, y: 0 });
type DragCommitSnapshot = {
elements: TemplateElement[];
delta: { x: number; y: number };
time: number;
};
const dragCommitHistoryRef = useRef<DragCommitSnapshot | null>(null);
const dragCommitHistoryPrevRef = useRef<DragCommitSnapshot | null>(null);
const dragFloatLayerRef = useRef<HTMLDivElement | null>(null);
const canvasWrapperRef = useRef<HTMLDivElement | null>(null);
const interactionPortalRef = useRef<HTMLDivElement | null>(null);
const dragFloatImgRefs = useRef<Map<string, HTMLImageElement>>(new Map());
const dragFloatUrlsRef = useRef<Record<string, string>>({});
const dragTransformLoopRef = useRef<number | null>(null);
const [dragFloatElements, setDragFloatElements] = useState<TemplateElement[]>([]);
const interactionFloatPaintRef = useRef(0);
const interactionFloatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -487,8 +506,18 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (dragFloatLayerRef.current) {
dragFloatLayerRef.current.style.transform = '';
}
if (dragTransformLoopRef.current !== null) {
cancelAnimationFrame(dragTransformLoopRef.current);
dragTransformLoopRef.current = null;
}
setInteractionSnapshot(null);
setInteractionFloatElements(null);
setDragFloatElements([]);
dragFloatImgRefs.current.clear();
dragFloatUrlsRef.current = {};
pendingDragDeltaRef.current = { x: 0, y: 0 };
dragCommitHistoryRef.current = null;
dragCommitHistoryPrevRef.current = null;
}, []);
const cancelActiveInteraction = useCallback(() => {
@@ -712,11 +741,45 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (!dragState) return;
dragMovedRef.current = false;
const syncInteractionPortalLayout = () => {
const wrapper = canvasWrapperRef.current;
const portal = interactionPortalRef.current;
if (wrapper && portal) {
const rect = wrapper.getBoundingClientRect();
portal.style.left = `${rect.left}px`;
portal.style.top = `${rect.top}px`;
portal.style.width = `${rect.width}px`;
portal.style.height = `${rect.height}px`;
}
};
const runDragTransformLoop = () => {
const tick = () => {
syncInteractionPortalLayout();
const layer = dragFloatLayerRef.current;
if (layer) {
const { x, y } = pendingDragDeltaRef.current;
layer.style.transform = `translate3d(${x}px, ${y}px, 0)`;
}
dragTransformLoopRef.current = requestAnimationFrame(tick);
};
dragTransformLoopRef.current = requestAnimationFrame(tick);
};
if (dragState.type === 'drag') {
runDragTransformLoop();
}
const handlePointerMove = (e: PointerEvent) => {
if (multiTouchActiveRef.current) return;
const deltaX = e.clientX - dragState.startX;
const deltaY = e.clientY - dragState.startY;
if (dragState.type === 'drag') {
pendingDragDeltaRef.current = { x: deltaX, y: deltaY };
}
const pastDragThreshold =
Math.abs(deltaX) > DRAG_START_THRESHOLD_PX || Math.abs(deltaY) > DRAG_START_THRESHOLD_PX;
@@ -744,18 +807,12 @@ 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) {
localElementsRef.current = next;
}
});
}
dragCommitHistoryPrevRef.current = dragCommitHistoryRef.current;
dragCommitHistoryRef.current = {
elements: updated,
delta: { x: deltaX, y: deltaY },
time: performance.now(),
};
} else if (dragState.type === 'resize') {
const elem = template.elements.find((el) => el.id === dragState.elementId);
if (!elem || elem.locked) return;
@@ -821,6 +878,23 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}
};
const resolveDragCommitElements = (): TemplateElement[] | null => {
if (dragState.type !== 'drag') {
return pendingDragElementsRef.current ?? localElementsRef.current;
}
const cur = dragCommitHistoryRef.current;
const prev = dragCommitHistoryPrevRef.current;
if (cur && prev) {
const micro = Math.hypot(cur.delta.x - prev.delta.x, cur.delta.y - prev.delta.y);
const dt = cur.time - prev.time;
if (micro > 0 && micro <= DRAG_RELEASE_JITTER_PX && dt <= DRAG_RELEASE_JITTER_MS) {
return prev.elements;
}
return cur.elements;
}
return cur?.elements ?? pendingDragElementsRef.current ?? localElementsRef.current;
};
const handlePointerUp = () => {
if (multiTouchActiveRef.current) {
cancelActiveInteraction();
@@ -830,10 +904,11 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
cancelAnimationFrame(dragRafRef.current);
dragRafRef.current = null;
}
if (pendingDragElementsRef.current) {
localElementsRef.current = pendingDragElementsRef.current;
pendingDragElementsRef.current = null;
const commitElements = resolveDragCommitElements();
if (commitElements) {
localElementsRef.current = commitElements;
}
pendingDragElementsRef.current = null;
if (localElementsRef.current) {
const shouldCommit =
dragState.type === 'resize' || dragMovedRef.current;
@@ -850,6 +925,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp);
return () => {
if (dragTransformLoopRef.current !== null) {
cancelAnimationFrame(dragTransformLoopRef.current);
dragTransformLoopRef.current = null;
}
window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp);
@@ -954,9 +1033,22 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (element.locked) return;
e.currentTarget.setPointerCapture(e.pointerId);
const groupStartPositions = buildGroupStartPositions(element);
const moveIds = new Set(Object.keys(groupStartPositions));
const floating = template.elements.filter(
(el) =>
moveIds.has(el.id) &&
shouldRenderElement(el, null, 0, previewDefaults, 'design')
);
setInteractionSnapshot(template.elements);
setInteractionFloatElements(null);
interactionFloatPaintRef.current = 0;
dragFloatUrlsRef.current = {};
dragFloatImgRefs.current.clear();
dragCommitHistoryRef.current = null;
dragCommitHistoryPrevRef.current = null;
setDragFloatElements(floating);
setDragState({
elementId: element.id,
type: actionType,
@@ -966,8 +1058,34 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
startElemY: element.y,
startElemW: element.width,
startElemH: element.height,
groupStartPositions: buildGroupStartPositions(element),
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 = (
@@ -1052,23 +1170,40 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
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 syncInteractionPortalLayout = useCallback(() => {
const wrapper = canvasWrapperRef.current;
const portal = interactionPortalRef.current;
if (!wrapper || !portal) return;
const rect = wrapper.getBoundingClientRect();
portal.style.left = `${rect.left}px`;
portal.style.top = `${rect.top}px`;
portal.style.width = `${rect.width}px`;
portal.style.height = `${rect.height}px`;
}, []);
useEffect(() => {
if (!allowInteractionOverflow) return;
syncInteractionPortalLayout();
window.addEventListener('scroll', syncInteractionPortalLayout, true);
window.addEventListener('resize', syncInteractionPortalLayout);
return () => {
window.removeEventListener('scroll', syncInteractionPortalLayout, true);
window.removeEventListener('resize', syncInteractionPortalLayout);
};
}, [allowInteractionOverflow, syncInteractionPortalLayout]);
const hideSelectionChrome = suppressSelectionChrome || dragState?.type === 'drag';
const resizingPreviewElement =
dragState?.type === 'resize'
? interactionFloatElements?.find((el) => el.id === dragState.elementId)
: null;
const overlayElements = filterVisibleElements(template.elements);
@@ -1152,7 +1287,103 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
</div>
);
const showInteractionFloatPortal =
typeof document !== 'undefined' &&
((dragState?.type === 'drag' && dragFloatElements.length > 0) ||
(resizeFloatElements && resizeFloatElements.length > 0));
const interactionFloatPortal = showInteractionFloatPortal
? createPortal(
<div
ref={interactionPortalRef}
className="pointer-events-none"
style={{
position: 'fixed',
left: 0,
top: 0,
width: 0,
height: 0,
overflow: 'visible',
zIndex: 10000,
}}
>
{dragState?.type === 'drag' && dragFloatElements.length > 0 && (
<div
ref={dragFloatLayerRef}
className="absolute inset-0 pointer-events-none"
style={{
overflow: 'visible',
willChange: 'transform',
}}
>
{dragFloatElements.map((el) => (
<div
key={el.id}
className="absolute pointer-events-none"
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`,
}}
>
<div
data-drag-float-placeholder={el.id}
className="absolute inset-0 bg-white border border-[#31a8ff]/40 shadow-[0_1px_4px_rgba(0,0,0,0.12)]"
/>
<img
ref={(node) => {
if (node) {
dragFloatImgRefs.current.set(el.id, node);
const url = dragFloatUrlsRef.current[el.id];
if (url) {
node.src = url;
if (node.complete) {
node.style.opacity = '1';
const placeholder = node.previousElementSibling as HTMLElement | null;
if (placeholder) placeholder.style.display = 'none';
}
}
} else {
dragFloatImgRefs.current.delete(el.id);
}
}}
alt=""
className="absolute inset-0 w-full h-full pointer-events-none select-none opacity-0"
onLoad={(e) => {
const img = e.currentTarget;
img.style.opacity = '1';
const placeholder = img.previousElementSibling as HTMLElement | null;
if (placeholder) placeholder.style.display = 'none';
}}
/>
</div>
))}
</div>
)}
{resizeFloatElements && resizeFloatElements.length > 0 && (
<div
className="absolute inset-0 pointer-events-none"
style={{ overflow: 'visible' }}
>
{resizeFloatElements.map((el) => (
<ElementFloatPreview
key={el.id}
element={el}
renderScale={canvasRenderScale}
displayZoom={displayZoom}
variableDefaults={previewDefaults}
/>
))}
</div>
)}
</div>,
document.body
)
: null;
return (
<>
<div
className={
isMobileLayout
@@ -1189,6 +1420,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}}
>
<div
ref={canvasWrapperRef}
className="relative shrink-0 grow-0"
style={{
width: `${canvasDisplayW}px`,
@@ -1252,17 +1484,22 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
dragState?.type === 'drag' && !!dragMovingIdSet?.has(element.id);
const isBeingResized =
dragState?.type === 'resize' && dragState.elementId === element.id;
if (isBeingDragged || isBeingResized) {
if (isBeingDragged) {
return null;
}
const overlayElement =
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
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);
const elemH = mmToPx(element.height, displayZoom);
showSelectionRing &&
selectedElementIds.length === 1 &&
dragState?.type !== 'resize';
const elemX = mmToPx(overlayElement.x, displayZoom);
const elemY = mmToPx(overlayElement.y, displayZoom);
const elemW = mmToPx(overlayElement.width, displayZoom);
const elemH = mmToPx(overlayElement.height, displayZoom);
return (
<div
@@ -1282,12 +1519,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
top: `${elemY}px`,
width: `${elemW}px`,
height: `${elemH}px`,
zIndex: isSelected ? index + 1000 : index + 10,
transform: element.rotation ? `rotate(${element.rotation}deg)` : undefined,
zIndex: isBeingResized
? index + 2000
: isSelected
? index + 1000
: index + 10,
transform: overlayElement.rotation
? `rotate(${overlayElement.rotation}deg)`
: undefined,
transformOrigin: 'center center',
pointerEvents: isBeingResized ? 'none' : undefined,
}}
onPointerDown={
isLocked ? undefined : (e) => handleElementPointerDown(e, element, 'drag')
isLocked || isBeingResized
? undefined
: (e) => handleElementPointerDown(e, element, 'drag')
}
onClick={isLocked ? undefined : (e) => handleElementClick(e, element)}
>
@@ -1336,28 +1582,6 @@ 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>
@@ -1420,5 +1644,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
)}
</div>
</div>
{interactionFloatPortal}
</>
);
};

View File

@@ -153,7 +153,7 @@ const PropTextarea: React.FC<PropTextareaProps> = ({ value, onCommit, ...props }
export type MobilePropModule = 'content' | 'properties' | 'layers' | 'nudge';
const MOBILE_NUDGE_STEP_MM = 0.5;
const MOBILE_NUDGE_STEP_MM = 0.1;
interface PropSidebarProps {
template: LabelTemplate;

View File

@@ -252,6 +252,8 @@ export interface RenderLabelOptions {
/** 输出格式,默认 png */
imageFormat?: 'png' | 'jpeg';
jpegQuality?: number;
/** 为 false 时不裁剪到画布边界(用于拖动浮层完整显示元素) */
clipToCanvas?: boolean;
}
/** 离屏渲染标签为 PNG data URL */
@@ -269,6 +271,7 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
scale: scaleOption = 16,
imageFormat = 'png',
jpegQuality = 0.92,
clipToCanvas = true,
} = options;
const scale = scaleOption;
@@ -295,9 +298,11 @@ export async function renderLabelToDataUrl(options: RenderLabelOptions): Promise
}
ctx.save();
if (clipToCanvas) {
ctx.beginPath();
ctx.rect(0, 0, canvas.width, canvas.height);
ctx.clip();
}
for (const elem of elements) {
const ex = Math.round(elem.x * scale);