From c25319e0deb40f38bc896c29f137a7b4aa065096 Mon Sep 17 00:00:00 2001 From: iqudoo Date: Sat, 13 Jun 2026 04:40:15 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=BA=A4=E4=BA=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/CanvasLabelImage.tsx | 33 +++- src/components/ElementFloatPreview.tsx | 15 +- src/components/LabelDesigner.tsx | 254 ++++++++++++++++++++----- src/components/PropSidebar.tsx | 42 ++++ src/index.css | 57 +++++- 5 files changed, 335 insertions(+), 66 deletions(-) diff --git a/src/components/CanvasLabelImage.tsx b/src/components/CanvasLabelImage.tsx index 872b7e4..0c39334 100644 --- a/src/components/CanvasLabelImage.tsx +++ b/src/components/CanvasLabelImage.tsx @@ -35,6 +35,10 @@ export const CanvasLabelImage: React.FC = ({ const elementsKey = useMemo(() => JSON.stringify(elements), [elements]); const rowDataKey = useMemo(() => JSON.stringify(rowData), [rowData]); + const variableDefaultsKey = useMemo( + () => JSON.stringify(variableDefaults), + [variableDefaults] + ); useEffect(() => { let active = true; @@ -52,10 +56,18 @@ export const CanvasLabelImage: React.FC = ({ scale: renderScale, }) .then((url) => { - if (active) { - setDataUrl(url); - setInitialLoading(false); - } + if (!active) return; + const img = new Image(); + img.onload = () => { + if (active) { + setDataUrl(url); + setInitialLoading(false); + } + }; + img.onerror = () => { + if (active) setInitialLoading(false); + }; + img.src = url; }) .catch((err) => { console.error('Label graphics rendering error:', err); @@ -65,7 +77,18 @@ export const CanvasLabelImage: React.FC = ({ return () => { 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) { return ( diff --git a/src/components/ElementFloatPreview.tsx b/src/components/ElementFloatPreview.tsx index 65f5c68..c092cc5 100644 --- a/src/components/ElementFloatPreview.tsx +++ b/src/components/ElementFloatPreview.tsx @@ -1,4 +1,4 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useMemo } from 'react'; import { TemplateElement } from '../types'; import { mmToPx } from '../utils'; import { renderLabelToDataUrl } from '../labelRenderer'; @@ -42,6 +42,11 @@ export const ElementFloatPreview: React.FC = ({ }) => { const [dataUrl, setDataUrl] = useState(dataUrlProp ?? ''); + const elementVisualKey = useMemo(() => { + const { x: _x, y: _y, ...visual } = element; + return JSON.stringify(visual); + }, [element]); + useEffect(() => { if (dataUrlProp) { setDataUrl(dataUrlProp); @@ -60,7 +65,7 @@ export const ElementFloatPreview: React.FC = ({ return () => { active = false; }; - }, [element, renderScale, variableDefaults, dataUrlProp]); + }, [elementVisualKey, element, renderScale, variableDefaults, dataUrlProp]); const left = mmToPx(element.x, displayZoom); const top = mmToPx(element.y, displayZoom); @@ -81,6 +86,12 @@ export const ElementFloatPreview: React.FC = ({ height: `${height}px`, imageRendering: 'auto', }} + onLoad={(e) => { + const placeholder = e.currentTarget.previousElementSibling as HTMLElement | null; + if (placeholder?.classList.contains('resize-float-placeholder')) { + placeholder.style.display = 'none'; + } + }} /> ); }; diff --git a/src/components/LabelDesigner.tsx b/src/components/LabelDesigner.tsx index 08597fd..09b0ac8 100644 --- a/src/components/LabelDesigner.tsx +++ b/src/components/LabelDesigner.tsx @@ -237,8 +237,12 @@ export const LabelDesigner: React.FC = ({ const interactionPortalRef = useRef(null); const dragFloatImgRefs = useRef>(new Map()); const dragFloatUrlsRef = useRef>({}); + const dragFloatElementsRef = useRef([]); + const dragVisualActivatedRef = useRef(false); const dragTransformLoopRef = useRef(null); const [dragFloatElements, setDragFloatElements] = useState([]); + const [dragInteractionVisualActive, setDragInteractionVisualActive] = useState(false); + const [interactionCanvasReady, setInteractionCanvasReady] = useState(false); const interactionFloatPaintRef = useRef(0); const interactionFloatTimerRef = useRef | null>(null); @@ -513,6 +517,10 @@ export const LabelDesigner: React.FC = ({ 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 = ({ 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 = ({ } 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ }; }, [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 = ({ > - + {Math.round(displayZoom * 100)}% ))} +
+
{mobileModule === 'content' && ( diff --git a/src/index.css b/src/index.css index 013b7d6..ab1ead8 100644 --- a/src/index.css +++ b/src/index.css @@ -99,16 +99,6 @@ body { 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 { height: 36px; background: var(--color-ps-header); @@ -672,6 +662,33 @@ body { 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 { position: absolute; width: 8px; @@ -1350,6 +1367,26 @@ body { 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 { display: flex; flex-direction: column;