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 { TemplateElement } from '../types';
import { mmToPx } from '../utils'; import { mmToPx } from '../utils';
import { renderLabelToDataUrl } from '../labelRenderer'; import { renderLabelToDataUrl } from '../labelRenderer';
@@ -8,6 +8,28 @@ interface ElementFloatPreviewProps {
renderScale: number; renderScale: number;
displayZoom: number; displayZoom: number;
variableDefaults?: Record<string, string> | null; 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, renderScale,
displayZoom, displayZoom,
variableDefaults = null, variableDefaults = null,
dataUrl: dataUrlProp,
}) => { }) => {
const [dataUrl, setDataUrl] = useState(''); const [dataUrl, setDataUrl] = useState(dataUrlProp ?? '');
const elementKey = useMemo(() => JSON.stringify(element), [element]);
useEffect(() => { useEffect(() => {
let active = true; if (dataUrlProp) {
const normalized = { ...element, x: 0, y: 0 }; setDataUrl(dataUrlProp);
return;
}
renderLabelToDataUrl({ let active = true;
widthMm: element.width, renderElementFloatPreview(element, renderScale, variableDefaults)
heightMm: element.height,
elements: [normalized],
rowData: null,
rowIndex: 0,
showBorder: false,
transparentBackground: true,
variableDefaults,
mode: 'design',
scale: renderScale,
})
.then((url) => { .then((url) => {
if (active) setDataUrl(url); if (active) setDataUrl(url);
}) })
@@ -47,7 +60,7 @@ export const ElementFloatPreview: React.FC<ElementFloatPreviewProps> = ({
return () => { return () => {
active = false; active = false;
}; };
}, [element, elementKey, renderScale, variableDefaults]); }, [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);

View File

@@ -1,9 +1,11 @@
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react'; import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
import { createPortal } from 'react-dom';
import { LabelTemplate, TemplateElement } from '../types'; import { LabelTemplate, TemplateElement } from '../types';
import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils'; import { mmToPx, MM_TO_PX, shouldRenderElement } from '../utils';
import { useIsNarrowScreen } from '../hooks/useIsMobile'; import { useIsNarrowScreen } from '../hooks/useIsMobile';
import { CanvasLabelImage } from './CanvasLabelImage'; import { CanvasLabelImage } from './CanvasLabelImage';
import { ElementFloatPreview } from './ElementFloatPreview'; import { ElementFloatPreview, renderElementFloatPreview } from './ElementFloatPreview';
import { renderLabelToDataUrl } from '../labelRenderer';
import { useAppDialog } from './AppDialog'; import { useAppDialog } from './AppDialog';
import { import {
ZoomIn, ZoomIn,
@@ -76,6 +78,9 @@ function applyCornerResize(
const MARQUEE_MIN_PX = 4; const MARQUEE_MIN_PX = 4;
const DRAG_START_THRESHOLD_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; const MODERATE_ZOOM_MAX = 2.0;
function blurActiveField() { function blurActiveField() {
@@ -219,7 +224,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
>(null); >(null);
const dragRafRef = useRef<number | null>(null); const dragRafRef = useRef<number | null>(null);
const pendingDragElementsRef = useRef<TemplateElement[] | 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 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 interactionFloatPaintRef = useRef(0);
const interactionFloatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const interactionFloatTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@@ -487,8 +506,18 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (dragFloatLayerRef.current) { if (dragFloatLayerRef.current) {
dragFloatLayerRef.current.style.transform = ''; dragFloatLayerRef.current.style.transform = '';
} }
if (dragTransformLoopRef.current !== null) {
cancelAnimationFrame(dragTransformLoopRef.current);
dragTransformLoopRef.current = null;
}
setInteractionSnapshot(null); setInteractionSnapshot(null);
setInteractionFloatElements(null); setInteractionFloatElements(null);
setDragFloatElements([]);
dragFloatImgRefs.current.clear();
dragFloatUrlsRef.current = {};
pendingDragDeltaRef.current = { x: 0, y: 0 };
dragCommitHistoryRef.current = null;
dragCommitHistoryPrevRef.current = null;
}, []); }, []);
const cancelActiveInteraction = useCallback(() => { const cancelActiveInteraction = useCallback(() => {
@@ -712,11 +741,45 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (!dragState) return; if (!dragState) return;
dragMovedRef.current = false; 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) => { const handlePointerMove = (e: PointerEvent) => {
if (multiTouchActiveRef.current) return; if (multiTouchActiveRef.current) return;
const deltaX = e.clientX - dragState.startX; const deltaX = e.clientX - dragState.startX;
const deltaY = e.clientY - dragState.startY; const deltaY = e.clientY - dragState.startY;
if (dragState.type === 'drag') {
pendingDragDeltaRef.current = { x: deltaX, y: deltaY };
}
const pastDragThreshold = const pastDragThreshold =
Math.abs(deltaX) > DRAG_START_THRESHOLD_PX || Math.abs(deltaY) > DRAG_START_THRESHOLD_PX; 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; pendingDragElementsRef.current = updated;
if (dragFloatLayerRef.current) { dragCommitHistoryPrevRef.current = dragCommitHistoryRef.current;
dragFloatLayerRef.current.style.transform = `translate(${deltaX}px, ${deltaY}px)`; dragCommitHistoryRef.current = {
} elements: updated,
if (dragRafRef.current === null) { delta: { x: deltaX, y: deltaY },
dragRafRef.current = requestAnimationFrame(() => { time: performance.now(),
dragRafRef.current = null; };
const next = pendingDragElementsRef.current;
if (next) {
localElementsRef.current = next;
}
});
}
} else if (dragState.type === 'resize') { } else if (dragState.type === 'resize') {
const elem = template.elements.find((el) => el.id === dragState.elementId); const elem = template.elements.find((el) => el.id === dragState.elementId);
if (!elem || elem.locked) return; 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 = () => { const handlePointerUp = () => {
if (multiTouchActiveRef.current) { if (multiTouchActiveRef.current) {
cancelActiveInteraction(); cancelActiveInteraction();
@@ -830,10 +904,11 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
cancelAnimationFrame(dragRafRef.current); cancelAnimationFrame(dragRafRef.current);
dragRafRef.current = null; dragRafRef.current = null;
} }
if (pendingDragElementsRef.current) { const commitElements = resolveDragCommitElements();
localElementsRef.current = pendingDragElementsRef.current; if (commitElements) {
pendingDragElementsRef.current = null; localElementsRef.current = commitElements;
} }
pendingDragElementsRef.current = null;
if (localElementsRef.current) { if (localElementsRef.current) {
const shouldCommit = const shouldCommit =
dragState.type === 'resize' || dragMovedRef.current; dragState.type === 'resize' || dragMovedRef.current;
@@ -850,6 +925,10 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
window.addEventListener('pointerup', handlePointerUp); window.addEventListener('pointerup', handlePointerUp);
window.addEventListener('pointercancel', handlePointerUp); window.addEventListener('pointercancel', handlePointerUp);
return () => { return () => {
if (dragTransformLoopRef.current !== null) {
cancelAnimationFrame(dragTransformLoopRef.current);
dragTransformLoopRef.current = null;
}
window.removeEventListener('pointermove', handlePointerMove); window.removeEventListener('pointermove', handlePointerMove);
window.removeEventListener('pointerup', handlePointerUp); window.removeEventListener('pointerup', handlePointerUp);
window.removeEventListener('pointercancel', handlePointerUp); window.removeEventListener('pointercancel', handlePointerUp);
@@ -954,9 +1033,22 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
if (element.locked) return; if (element.locked) return;
e.currentTarget.setPointerCapture(e.pointerId); 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); setInteractionSnapshot(template.elements);
setInteractionFloatElements(null); setInteractionFloatElements(null);
interactionFloatPaintRef.current = 0; interactionFloatPaintRef.current = 0;
dragFloatUrlsRef.current = {};
dragFloatImgRefs.current.clear();
dragCommitHistoryRef.current = null;
dragCommitHistoryPrevRef.current = null;
setDragFloatElements(floating);
setDragState({ setDragState({
elementId: element.id, elementId: element.id,
type: actionType, type: actionType,
@@ -966,8 +1058,34 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
startElemY: element.y, startElemY: element.y,
startElemW: element.width, startElemW: element.width,
startElemH: element.height, 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 = ( const handleResizeStart = (
@@ -1052,23 +1170,40 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
filterVisibleElements, 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(() => { const resizeFloatElements = useMemo(() => {
if (dragState?.type !== 'resize' || !interactionFloatElements?.length) return null; if (dragState?.type !== 'resize' || !interactionFloatElements?.length) return null;
return filterVisibleElements(interactionFloatElements); return filterVisibleElements(interactionFloatElements);
}, [dragState?.type, interactionFloatElements, filterVisibleElements]); }, [dragState?.type, interactionFloatElements, filterVisibleElements]);
const interactionFloatCanvasElements = dragFloatElements ?? resizeFloatElements;
const allowInteractionOverflow = dragState !== null; 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); const overlayElements = filterVisibleElements(template.elements);
@@ -1152,7 +1287,103 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
</div> </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 ( return (
<>
<div <div
className={ className={
isMobileLayout isMobileLayout
@@ -1189,6 +1420,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
}} }}
> >
<div <div
ref={canvasWrapperRef}
className="relative shrink-0 grow-0" className="relative shrink-0 grow-0"
style={{ style={{
width: `${canvasDisplayW}px`, width: `${canvasDisplayW}px`,
@@ -1252,17 +1484,22 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
dragState?.type === 'drag' && !!dragMovingIdSet?.has(element.id); 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 || isBeingResized) { if (isBeingDragged) {
return null; return null;
} }
const overlayElement =
isBeingResized && resizingPreviewElement ? resizingPreviewElement : element;
const showSelectionRing = isSelected && !hideSelectionChrome; const showSelectionRing = isSelected && !hideSelectionChrome;
const showResizeHandles = const showResizeHandles =
showSelectionRing && selectedElementIds.length === 1; showSelectionRing &&
const elemX = mmToPx(element.x, displayZoom); selectedElementIds.length === 1 &&
const elemY = mmToPx(element.y, displayZoom); dragState?.type !== 'resize';
const elemW = mmToPx(element.width, displayZoom); const elemX = mmToPx(overlayElement.x, displayZoom);
const elemH = mmToPx(element.height, displayZoom); const elemY = mmToPx(overlayElement.y, displayZoom);
const elemW = mmToPx(overlayElement.width, displayZoom);
const elemH = mmToPx(overlayElement.height, displayZoom);
return ( return (
<div <div
@@ -1282,12 +1519,21 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
top: `${elemY}px`, top: `${elemY}px`,
width: `${elemW}px`, width: `${elemW}px`,
height: `${elemH}px`, height: `${elemH}px`,
zIndex: isSelected ? index + 1000 : index + 10, zIndex: isBeingResized
transform: element.rotation ? `rotate(${element.rotation}deg)` : undefined, ? index + 2000
: isSelected
? index + 1000
: index + 10,
transform: overlayElement.rotation
? `rotate(${overlayElement.rotation}deg)`
: undefined,
transformOrigin: 'center center', transformOrigin: 'center center',
pointerEvents: isBeingResized ? 'none' : undefined,
}} }}
onPointerDown={ onPointerDown={
isLocked ? undefined : (e) => handleElementPointerDown(e, element, 'drag') isLocked || isBeingResized
? undefined
: (e) => handleElementPointerDown(e, element, 'drag')
} }
onClick={isLocked ? undefined : (e) => handleElementClick(e, element)} onClick={isLocked ? undefined : (e) => handleElementClick(e, element)}
> >
@@ -1336,28 +1582,6 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
})} })}
</div> </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>
</div> </div>
@@ -1420,5 +1644,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
)} )}
</div> </div>
</div> </div>
{interactionFloatPortal}
</>
); );
}; };

View File

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

View File

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