diff --git a/src/components/ElementFloatPreview.tsx b/src/components/ElementFloatPreview.tsx index cb8f675..65f5c68 100644 --- a/src/components/ElementFloatPreview.tsx +++ b/src/components/ElementFloatPreview.tsx @@ -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 | null; + /** 已离屏绘制的位图,传入后不再重复渲染 */ + dataUrl?: string; +} + +export async function renderElementFloatPreview( + element: TemplateElement, + renderScale: number, + variableDefaults?: Record | null +): Promise { + 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 = ({ 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 = ({ return () => { active = false; }; - }, [element, elementKey, renderScale, variableDefaults]); + }, [element, renderScale, variableDefaults, dataUrlProp]); const left = mmToPx(element.x, displayZoom); const top = mmToPx(element.y, displayZoom); diff --git a/src/components/LabelDesigner.tsx b/src/components/LabelDesigner.tsx index 637670a..7ce8cab 100644 --- a/src/components/LabelDesigner.tsx +++ b/src/components/LabelDesigner.tsx @@ -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 = ({ >(null); const dragRafRef = useRef(null); const pendingDragElementsRef = useRef(null); + const pendingDragDeltaRef = useRef({ x: 0, y: 0 }); + type DragCommitSnapshot = { + elements: TemplateElement[]; + delta: { x: number; y: number }; + time: number; + }; + const dragCommitHistoryRef = useRef(null); + const dragCommitHistoryPrevRef = useRef(null); const dragFloatLayerRef = useRef(null); + const canvasWrapperRef = useRef(null); + const interactionPortalRef = useRef(null); + const dragFloatImgRefs = useRef>(new Map()); + const dragFloatUrlsRef = useRef>({}); + const dragTransformLoopRef = useRef(null); + const [dragFloatElements, setDragFloatElements] = useState([]); const interactionFloatPaintRef = useRef(0); const interactionFloatTimerRef = useRef | null>(null); @@ -487,8 +506,18 @@ export const LabelDesigner: React.FC = ({ 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 = ({ 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 = ({ }; }); 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 = ({ } }; + 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ ); + const showInteractionFloatPortal = + typeof document !== 'undefined' && + ((dragState?.type === 'drag' && dragFloatElements.length > 0) || + (resizeFloatElements && resizeFloatElements.length > 0)); + + const interactionFloatPortal = showInteractionFloatPortal + ? createPortal( +
+ {dragState?.type === 'drag' && dragFloatElements.length > 0 && ( +
+ {dragFloatElements.map((el) => ( +
+
+ { + 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'; + }} + /> +
+ ))} +
+ )} + {resizeFloatElements && resizeFloatElements.length > 0 && ( +
+ {resizeFloatElements.map((el) => ( + + ))} +
+ )} +
, + document.body + ) + : null; + return ( + <>
= ({ }} >
= ({ 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 (
= ({ 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 = ({ })}
- {interactionFloatCanvasElements && interactionFloatCanvasElements.length > 0 && ( -
- {interactionFloatCanvasElements.map((el) => ( - - ))} -
- )}
@@ -1420,5 +1644,7 @@ export const LabelDesigner: React.FC = ({ )}
+ {interactionFloatPortal} + ); }; diff --git a/src/components/PropSidebar.tsx b/src/components/PropSidebar.tsx index c6f969d..8ee7337 100644 --- a/src/components/PropSidebar.tsx +++ b/src/components/PropSidebar.tsx @@ -153,7 +153,7 @@ const PropTextarea: React.FC = ({ 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; diff --git a/src/labelRenderer.ts b/src/labelRenderer.ts index 39daa60..3caac62 100644 --- a/src/labelRenderer.ts +++ b/src/labelRenderer.ts @@ -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(); - ctx.beginPath(); - ctx.rect(0, 0, canvas.width, canvas.height); - ctx.clip(); + 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);