适配
This commit is contained in:
18
src/App.tsx
18
src/App.tsx
@@ -290,7 +290,7 @@ export default function App() {
|
|||||||
const handleExportTemplate = (tmpl: LabelTemplate) => {
|
const handleExportTemplate = (tmpl: LabelTemplate) => {
|
||||||
const dataStr = JSON.stringify(normalizeTemplate(tmpl), null, 2);
|
const dataStr = JSON.stringify(normalizeTemplate(tmpl), null, 2);
|
||||||
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
|
const dataUri = 'data:application/json;charset=utf-8,' + encodeURIComponent(dataStr);
|
||||||
const exportFileDefaultName = `模板_${tmpl.name?.replace(/\s+/g, '_') || tmpl.id}.json`;
|
const exportFileDefaultName = `模板_${tmpl.name?.replace(/\s+/g, '_') || tmpl.id}.lad`;
|
||||||
|
|
||||||
const linkElement = document.createElement('a');
|
const linkElement = document.createElement('a');
|
||||||
linkElement.setAttribute('href', dataUri);
|
linkElement.setAttribute('href', dataUri);
|
||||||
@@ -325,12 +325,12 @@ export default function App() {
|
|||||||
handleSelectTemplate(newId);
|
handleSelectTemplate(newId);
|
||||||
await showAlert('模板读取解析成功,已追加至库中。', { variant: 'success' });
|
await showAlert('模板读取解析成功,已追加至库中。', { variant: 'success' });
|
||||||
} else {
|
} else {
|
||||||
await showAlert('JSON格式错误:外部模板必须至少包含 width, height, 以及 elements 分组', {
|
await showAlert('模板格式错误:外部模板必须至少包含 width, height, 以及 elements 分组', {
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
await showAlert('文件解码失败,请确保您上传的是合法的 json 模板结构文件。', {
|
await showAlert('文件解码失败,请确保您上传的是合法的模板文件。', {
|
||||||
variant: 'error',
|
variant: 'error',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -648,10 +648,10 @@ export default function App() {
|
|||||||
</button>
|
</button>
|
||||||
<label className="inline-flex items-center gap-1.5 px-4 py-2 bg-white/10 hover:bg-white/15 border border-slate-700 text-slate-100 rounded-xl text-xs font-semibold whitespace-nowrap transition shadow-sm cursor-pointer">
|
<label className="inline-flex items-center gap-1.5 px-4 py-2 bg-white/10 hover:bg-white/15 border border-slate-700 text-slate-100 rounded-xl text-xs font-semibold whitespace-nowrap transition shadow-sm cursor-pointer">
|
||||||
<Upload className="w-4 h-4 shrink-0" />
|
<Upload className="w-4 h-4 shrink-0" />
|
||||||
导入 JSON 模板
|
导入模板
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".json"
|
accept=".lad"
|
||||||
onChange={handleImportTemplate}
|
onChange={handleImportTemplate}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
@@ -692,7 +692,7 @@ export default function App() {
|
|||||||
<p className="text-sm text-gray-500 mt-2 max-w-md leading-relaxed">
|
<p className="text-sm text-gray-500 mt-2 max-w-md leading-relaxed">
|
||||||
{isMobile
|
{isMobile
|
||||||
? '导入已有模板,或在电脑端设计标签模板'
|
? '导入已有模板,或在电脑端设计标签模板'
|
||||||
: '创建模板开始可视化设计,或导入已有的 JSON 模板文件。'}
|
: '创建模板开始可视化设计,或导入已有的模板文件。'}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-3">
|
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 mt-3">
|
||||||
{!isMobile && (
|
{!isMobile && (
|
||||||
@@ -707,10 +707,10 @@ export default function App() {
|
|||||||
)}
|
)}
|
||||||
<label className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-white border border-gray-200 hover:border-indigo-300 text-gray-700 rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer shrink-0">
|
<label className="inline-flex items-center justify-center gap-2 px-5 py-2.5 bg-white border border-gray-200 hover:border-indigo-300 text-gray-700 rounded-xl text-sm font-semibold transition shadow-sm cursor-pointer shrink-0">
|
||||||
<Upload className="w-4 h-4 shrink-0" />
|
<Upload className="w-4 h-4 shrink-0" />
|
||||||
导入 JSON 模板
|
导入模板
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept=".json"
|
accept=".lad"
|
||||||
onChange={handleImportTemplate}
|
onChange={handleImportTemplate}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
/>
|
/>
|
||||||
@@ -814,7 +814,7 @@ export default function App() {
|
|||||||
handleExportTemplate(tmpl);
|
handleExportTemplate(tmpl);
|
||||||
}}
|
}}
|
||||||
className="shrink-0 p-1.5 bg-gray-50 text-gray-500 hover:bg-gray-100 border border-gray-100 hover:border-gray-300 rounded-lg inline-flex items-center justify-center transition cursor-pointer"
|
className="shrink-0 p-1.5 bg-gray-50 text-gray-500 hover:bg-gray-100 border border-gray-100 hover:border-gray-300 rounded-lg inline-flex items-center justify-center transition cursor-pointer"
|
||||||
title="导出 JSON"
|
title="导出"
|
||||||
>
|
>
|
||||||
<Download className="w-3.5 h-3.5" />
|
<Download className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
import React, { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { LabelTemplate, TemplateElement } from '../types';
|
import { LabelTemplate, TemplateElement } from '../types';
|
||||||
import { mmToPx, shouldRenderElement } from '../utils';
|
import { mmToPx, shouldRenderElement } from '../utils';
|
||||||
|
import { useIsNarrowScreen } from '../hooks/useIsMobile';
|
||||||
import { CanvasLabelImage } from './CanvasLabelImage';
|
import { CanvasLabelImage } from './CanvasLabelImage';
|
||||||
import {
|
import {
|
||||||
ZoomIn,
|
ZoomIn,
|
||||||
@@ -70,6 +71,7 @@ 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 MODERATE_ZOOM_MAX = 2.0;
|
||||||
|
|
||||||
function rectFullyContains(
|
function rectFullyContains(
|
||||||
outer: { left: number; top: number; right: number; bottom: number },
|
outer: { left: number; top: number; right: number; bottom: number },
|
||||||
@@ -123,7 +125,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
onSelectElements,
|
onSelectElements,
|
||||||
}) => {
|
}) => {
|
||||||
const selectedElementId = selectedElementIds[0] || null;
|
const selectedElementId = selectedElementIds[0] || null;
|
||||||
const [zoom, setZoom] = useState<number>(2.5);
|
const isNarrow = useIsNarrowScreen();
|
||||||
|
const [zoom, setZoom] = useState<number>(MODERATE_ZOOM_MAX);
|
||||||
const [activeTool, setActiveTool] = useState<ActiveTool>('move');
|
const [activeTool, setActiveTool] = useState<ActiveTool>('move');
|
||||||
const canvasAreaRef = useRef<HTMLDivElement | null>(null);
|
const canvasAreaRef = useRef<HTMLDivElement | null>(null);
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||||
@@ -378,6 +381,23 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
deleteSelectedElements,
|
deleteSelectedElements,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const applyModerateZoom = useCallback(() => {
|
||||||
|
const area = canvasAreaRef.current;
|
||||||
|
if (!area) return;
|
||||||
|
const pad = isNarrow ? 32 : 80;
|
||||||
|
const availW = area.clientWidth - pad;
|
||||||
|
const availH = area.clientHeight - pad;
|
||||||
|
if (availW <= 0 || availH <= 0) return;
|
||||||
|
const docW = mmToPx(template.width, 1);
|
||||||
|
const docH = mmToPx(template.height, 1);
|
||||||
|
const fitZoom = Math.min(availW / docW, availH / docH, MODERATE_ZOOM_MAX);
|
||||||
|
setZoom(Math.max(0.5, Math.round(fitZoom * 100) / 100));
|
||||||
|
}, [template.width, template.height, isNarrow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyModerateZoom();
|
||||||
|
}, [template.id, applyModerateZoom]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const area = canvasAreaRef.current;
|
const area = canvasAreaRef.current;
|
||||||
if (!area) return;
|
if (!area) return;
|
||||||
@@ -399,7 +419,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!marqueeActive) return;
|
if (!marqueeActive) return;
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handlePointerMove = (e: PointerEvent) => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
if (!canvas) return;
|
if (!canvas) return;
|
||||||
const rect = canvas.getBoundingClientRect();
|
const rect = canvas.getBoundingClientRect();
|
||||||
@@ -408,7 +428,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
setMarqueeState((prev) => (prev ? { ...prev, currentX, currentY } : null));
|
setMarqueeState((prev) => (prev ? { ...prev, currentX, currentY } : null));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handlePointerUp = () => {
|
||||||
const state = marqueeRef.current;
|
const state = marqueeRef.current;
|
||||||
setMarqueeState(null);
|
setMarqueeState(null);
|
||||||
|
|
||||||
@@ -453,11 +473,13 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('mousemove', handleMouseMove);
|
window.addEventListener('pointermove', handlePointerMove);
|
||||||
window.addEventListener('mouseup', handleMouseUp);
|
window.addEventListener('pointerup', handlePointerUp);
|
||||||
|
window.addEventListener('pointercancel', handlePointerUp);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', handleMouseMove);
|
window.removeEventListener('pointermove', handlePointerMove);
|
||||||
window.removeEventListener('mouseup', handleMouseUp);
|
window.removeEventListener('pointerup', handlePointerUp);
|
||||||
|
window.removeEventListener('pointercancel', handlePointerUp);
|
||||||
};
|
};
|
||||||
}, [marqueeActive, zoom, onSelectElements]);
|
}, [marqueeActive, zoom, onSelectElements]);
|
||||||
|
|
||||||
@@ -465,7 +487,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
if (!dragState) return;
|
if (!dragState) return;
|
||||||
dragMovedRef.current = false;
|
dragMovedRef.current = false;
|
||||||
|
|
||||||
const handleMouseMove = (e: MouseEvent) => {
|
const handlePointerMove = (e: PointerEvent) => {
|
||||||
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 (
|
if (
|
||||||
@@ -547,7 +569,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMouseUp = () => {
|
const handlePointerUp = () => {
|
||||||
if (dragRafRef.current !== null) {
|
if (dragRafRef.current !== null) {
|
||||||
cancelAnimationFrame(dragRafRef.current);
|
cancelAnimationFrame(dragRafRef.current);
|
||||||
dragRafRef.current = null;
|
dragRafRef.current = null;
|
||||||
@@ -565,11 +587,13 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
setDragState(null);
|
setDragState(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('mousemove', handleMouseMove);
|
window.addEventListener('pointermove', handlePointerMove);
|
||||||
window.addEventListener('mouseup', handleMouseUp);
|
window.addEventListener('pointerup', handlePointerUp);
|
||||||
|
window.addEventListener('pointercancel', handlePointerUp);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener('mousemove', handleMouseMove);
|
window.removeEventListener('pointermove', handlePointerMove);
|
||||||
window.removeEventListener('mouseup', handleMouseUp);
|
window.removeEventListener('pointerup', handlePointerUp);
|
||||||
|
window.removeEventListener('pointercancel', handlePointerUp);
|
||||||
};
|
};
|
||||||
}, [dragState, template, zoom, onChange]);
|
}, [dragState, template, zoom, onChange]);
|
||||||
|
|
||||||
@@ -588,7 +612,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
return groupStartPositions;
|
return groupStartPositions;
|
||||||
};
|
};
|
||||||
|
|
||||||
const startMarquee = (e: React.MouseEvent) => {
|
const startMarquee = (e: React.PointerEvent) => {
|
||||||
if (activeTool !== 'move' || e.button !== 0) return;
|
if (activeTool !== 'move' || e.button !== 0) return;
|
||||||
|
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -600,6 +624,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
|
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
|
|
||||||
setMarqueeState({
|
setMarqueeState({
|
||||||
startX,
|
startX,
|
||||||
@@ -610,7 +635,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleWorkspaceMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleWorkspacePointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
||||||
if (activeTool !== 'move' || e.button !== 0) return;
|
if (activeTool !== 'move' || e.button !== 0) return;
|
||||||
const target = e.target as HTMLElement;
|
const target = e.target as HTMLElement;
|
||||||
if (target.closest('[data-label-element]') || target.closest('.ps-handle')) return;
|
if (target.closest('[data-label-element]') || target.closest('.ps-handle')) return;
|
||||||
@@ -618,8 +643,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
startMarquee(e);
|
startMarquee(e);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleElementMouseDown = (
|
const handleElementPointerDown = (
|
||||||
e: React.MouseEvent,
|
e: React.PointerEvent,
|
||||||
element: TemplateElement,
|
element: TemplateElement,
|
||||||
actionType: 'drag' | 'resize'
|
actionType: 'drag' | 'resize'
|
||||||
) => {
|
) => {
|
||||||
@@ -652,13 +677,18 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDragStart = (e: React.MouseEvent, element: TemplateElement, actionType: 'drag' | 'resize') => {
|
const handleDragStart = (
|
||||||
|
e: React.PointerEvent,
|
||||||
|
element: TemplateElement,
|
||||||
|
actionType: 'drag' | 'resize'
|
||||||
|
) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!selectedElementIds.includes(element.id)) {
|
if (!selectedElementIds.includes(element.id)) {
|
||||||
onSelectElements([element.id]);
|
onSelectElements([element.id]);
|
||||||
}
|
}
|
||||||
if (element.locked) return;
|
if (element.locked) return;
|
||||||
|
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
setFrozenCanvasElements(template.elements);
|
setFrozenCanvasElements(template.elements);
|
||||||
setDragState({
|
setDragState({
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
@@ -674,7 +704,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleResizeStart = (
|
const handleResizeStart = (
|
||||||
e: React.MouseEvent,
|
e: React.PointerEvent,
|
||||||
element: TemplateElement,
|
element: TemplateElement,
|
||||||
corner: ResizeCorner
|
corner: ResizeCorner
|
||||||
) => {
|
) => {
|
||||||
@@ -683,6 +713,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
onSelectElements([element.id]);
|
onSelectElements([element.id]);
|
||||||
if (element.locked) return;
|
if (element.locked) return;
|
||||||
|
|
||||||
|
e.currentTarget.setPointerCapture(e.pointerId);
|
||||||
setFrozenCanvasElements(template.elements);
|
setFrozenCanvasElements(template.elements);
|
||||||
setDragState({
|
setDragState({
|
||||||
elementId: element.id,
|
elementId: element.id,
|
||||||
@@ -775,8 +806,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
<div className="flex flex-col flex-1 min-w-0 min-h-0">
|
||||||
<div
|
<div
|
||||||
ref={canvasAreaRef}
|
ref={canvasAreaRef}
|
||||||
className="flex-1 overflow-auto ps-workspace-bg flex items-center justify-center"
|
className="flex-1 overflow-auto ps-workspace-bg ps-design-canvas-touch flex items-center justify-center"
|
||||||
onMouseDown={handleWorkspaceMouseDown}
|
onPointerDown={handleWorkspacePointerDown}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
if (ignoreCanvasClickRef.current) {
|
if (ignoreCanvasClickRef.current) {
|
||||||
ignoreCanvasClickRef.current = false;
|
ignoreCanvasClickRef.current = false;
|
||||||
@@ -820,9 +851,9 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
|
|
||||||
{activeTool === 'move' && (
|
{activeTool === 'move' && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 z-[1] cursor-crosshair"
|
className="absolute inset-0 z-[1] cursor-crosshair touch-none"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
onMouseDown={startMarquee}
|
onPointerDown={startMarquee}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -851,7 +882,7 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
<div
|
<div
|
||||||
key={element.id}
|
key={element.id}
|
||||||
data-label-element
|
data-label-element
|
||||||
className={`absolute group ${
|
className={`absolute group touch-none ${
|
||||||
isLocked
|
isLocked
|
||||||
? 'pointer-events-none'
|
? 'pointer-events-none'
|
||||||
: isSelected
|
: isSelected
|
||||||
@@ -867,8 +898,8 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
transform: element.rotation ? `rotate(${element.rotation}deg)` : undefined,
|
transform: element.rotation ? `rotate(${element.rotation}deg)` : undefined,
|
||||||
transformOrigin: 'center center',
|
transformOrigin: 'center center',
|
||||||
}}
|
}}
|
||||||
onMouseDown={
|
onPointerDown={
|
||||||
isLocked ? undefined : (e) => handleElementMouseDown(e, element, 'drag')
|
isLocked ? undefined : (e) => handleElementPointerDown(e, element, 'drag')
|
||||||
}
|
}
|
||||||
onClick={isLocked ? undefined : (e) => handleElementClick(e, element)}
|
onClick={isLocked ? undefined : (e) => handleElementClick(e, element)}
|
||||||
>
|
>
|
||||||
@@ -886,22 +917,22 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="ps-handle top-0 left-0 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize"
|
className="ps-handle top-0 left-0 -translate-x-1/2 -translate-y-1/2 cursor-nw-resize"
|
||||||
onMouseDown={(e) => handleResizeStart(e, element, 'nw')}
|
onPointerDown={(e) => handleResizeStart(e, element, 'nw')}
|
||||||
title="拖拽调整尺寸"
|
title="拖拽调整尺寸"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="ps-handle top-0 right-0 translate-x-1/2 -translate-y-1/2 cursor-ne-resize"
|
className="ps-handle top-0 right-0 translate-x-1/2 -translate-y-1/2 cursor-ne-resize"
|
||||||
onMouseDown={(e) => handleResizeStart(e, element, 'ne')}
|
onPointerDown={(e) => handleResizeStart(e, element, 'ne')}
|
||||||
title="拖拽调整尺寸"
|
title="拖拽调整尺寸"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="ps-handle bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-sw-resize"
|
className="ps-handle bottom-0 left-0 -translate-x-1/2 translate-y-1/2 cursor-sw-resize"
|
||||||
onMouseDown={(e) => handleResizeStart(e, element, 'sw')}
|
onPointerDown={(e) => handleResizeStart(e, element, 'sw')}
|
||||||
title="拖拽调整尺寸"
|
title="拖拽调整尺寸"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className="ps-handle bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-se-resize"
|
className="ps-handle bottom-0 right-0 translate-x-1/2 translate-y-1/2 cursor-se-resize"
|
||||||
onMouseDown={(e) => handleResizeStart(e, element, 'se')}
|
onPointerDown={(e) => handleResizeStart(e, element, 'se')}
|
||||||
title="拖拽调整尺寸"
|
title="拖拽调整尺寸"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
@@ -918,32 +949,44 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ps-status-bar">
|
<div className={`ps-status-bar ${isNarrow ? 'ps-status-bar-compact' : ''}`}>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 min-w-0 truncate">
|
||||||
<span>
|
{isNarrow ? (
|
||||||
文档: {template.width} × {template.height} mm
|
<>
|
||||||
</span>
|
<span className="truncate">
|
||||||
{selectedElemObj && (
|
{template.width}×{template.height}
|
||||||
<span className="text-[#31a8ff]">
|
{selectedElemObj ? ` · ${selectedElemObj.name}` : ''}
|
||||||
| {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
|
{selectedElementIds.length > 1 ? ` · ${selectedElementIds.length}选` : ''}
|
||||||
{selectedElemObj.width} H:{selectedElemObj.height} mm
|
</span>
|
||||||
</span>
|
</>
|
||||||
)}
|
) : (
|
||||||
{selectedElementIds.length > 1 && (
|
<>
|
||||||
<span className="text-emerald-400">
|
<span>
|
||||||
| 已选 {selectedElementIds.length} 个
|
文档: {template.width} × {template.height} mm
|
||||||
</span>
|
</span>
|
||||||
)}
|
{selectedElemObj && (
|
||||||
{activeTool === 'move' && (
|
<span className="text-[#31a8ff]">
|
||||||
<span className="text-[#888] hidden sm:inline">
|
| {selectedElemObj.name} — X:{selectedElemObj.x} Y:{selectedElemObj.y} W:
|
||||||
| 框选 · Shift/Ctrl+点选 · Ctrl+A 全选
|
{selectedElemObj.width} H:{selectedElemObj.height} mm
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
{selectedElementIds.length > 1 && (
|
||||||
|
<span className="text-emerald-400">| 已选 {selectedElementIds.length} 个</span>
|
||||||
|
)}
|
||||||
|
{activeTool === 'move' && (
|
||||||
|
<span className="text-[#888] hidden sm:inline">
|
||||||
|
| 框选 · Shift/Ctrl+点选 · Ctrl+A 全选
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
<span className="hidden sm:inline text-[10px]">
|
{!isNarrow && (
|
||||||
预览使用变量默认值 · 方向键微调 · Ctrl+滚轮缩放
|
<span className="hidden sm:inline text-[10px]">
|
||||||
</span>
|
预览使用变量默认值 · 方向键微调 · Ctrl+滚轮缩放
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<div className="ps-zoom-control">
|
<div className="ps-zoom-control">
|
||||||
<button className="ps-zoom-btn" onClick={() => setZoom(Math.max(0.5, zoom - 0.25))} title="缩小">
|
<button className="ps-zoom-btn" onClick={() => setZoom(Math.max(0.5, zoom - 0.25))} title="缩小">
|
||||||
<ZoomOut className="w-3 h-3" />
|
<ZoomOut className="w-3 h-3" />
|
||||||
@@ -955,6 +998,13 @@ export const LabelDesigner: React.FC<LabelDesignerProps> = ({
|
|||||||
<ZoomIn className="w-3 h-3" />
|
<ZoomIn className="w-3 h-3" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={applyModerateZoom}
|
||||||
|
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"
|
||||||
|
title="适中缩放"
|
||||||
|
>
|
||||||
|
适中
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={fitToView}
|
onClick={fitToView}
|
||||||
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"
|
className="text-[10px] text-[#aaa] hover:text-white px-1.5 py-0.5 border border-[#4a4a4a] hover:border-[#666] transition cursor-pointer"
|
||||||
|
|||||||
@@ -867,22 +867,47 @@ export const PropSidebar: React.FC<PropSidebarProps> = ({
|
|||||||
{(selectedElem.textFormat === 'number' ||
|
{(selectedElem.textFormat === 'number' ||
|
||||||
selectedElem.textFormat === 'percent' ||
|
selectedElem.textFormat === 'percent' ||
|
||||||
selectedElem.textFormat === 'currency') && (
|
selectedElem.textFormat === 'currency') && (
|
||||||
<div>
|
<>
|
||||||
<label className="ps-field-label">小数位数</label>
|
<div>
|
||||||
<PropInput
|
<label className="ps-field-label">小数位数</label>
|
||||||
type="number"
|
<PropInput
|
||||||
min="0"
|
type="number"
|
||||||
max="6"
|
min="0"
|
||||||
value={selectedElem.decimalPlaces ?? 2}
|
max="6"
|
||||||
onCommit={(val) =>
|
value={selectedElem.decimalPlaces ?? 2}
|
||||||
handleElemChange({
|
onCommit={(val) =>
|
||||||
...selectedElem,
|
handleElemChange({
|
||||||
decimalPlaces: Math.max(0, Math.min(6, Number(val) || 0)),
|
...selectedElem,
|
||||||
})
|
decimalPlaces: Math.max(0, Math.min(6, Number(val) || 0)),
|
||||||
}
|
})
|
||||||
className="ps-field font-mono"
|
}
|
||||||
/>
|
className="ps-field font-mono"
|
||||||
</div>
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-2">
|
||||||
|
<label className="ps-field-label">数值显示部分</label>
|
||||||
|
<select
|
||||||
|
value={selectedElem.numberPartDisplay ?? 'full'}
|
||||||
|
onChange={(e) =>
|
||||||
|
handleElemChange({
|
||||||
|
...selectedElem,
|
||||||
|
numberPartDisplay: e.target.value as
|
||||||
|
| 'full'
|
||||||
|
| 'integer'
|
||||||
|
| 'fraction',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
className="ps-field"
|
||||||
|
>
|
||||||
|
<option value="full">完整数值</option>
|
||||||
|
<option value="integer">仅整数部分(如 19.9 → 19)</option>
|
||||||
|
<option value="fraction">仅小数部分(如 19.9 → .9)</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-[9px] text-[#666] mt-1 leading-relaxed">
|
||||||
|
可将价格拆成两个文本元素分别设置字号,如「19」与「.9」
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,8 @@ export function useMediaQuery(query: string): boolean {
|
|||||||
export function useIsMobile(): boolean {
|
export function useIsMobile(): boolean {
|
||||||
return useMediaQuery('(max-width: 767px)');
|
return useMediaQuery('(max-width: 767px)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 平板等窄屏:< 1024px */
|
||||||
|
export function useIsNarrowScreen(): boolean {
|
||||||
|
return useMediaQuery('(max-width: 1023px)');
|
||||||
|
}
|
||||||
|
|||||||
@@ -634,6 +634,34 @@ body,
|
|||||||
background: #fff;
|
background: #fff;
|
||||||
border: 1px solid var(--color-ps-accent);
|
border: 1px solid var(--color-ps-accent);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-design-canvas-touch {
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-design-canvas-touch [data-label-element],
|
||||||
|
.ps-design-canvas-touch .ps-handle,
|
||||||
|
.ps-design-canvas-touch .touch-none {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-status-bar-compact {
|
||||||
|
font-size: 10px;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1023px) {
|
||||||
|
.ps-handle {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ps-status-bar {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.ps-zoom-control {
|
.ps-zoom-control {
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
export type TextFormat = 'text' | 'number' | 'percent' | 'currency';
|
export type TextFormat = 'text' | 'number' | 'percent' | 'currency';
|
||||||
|
/** 数值格式下的显示部分:完整 / 仅整数 / 仅小数(含小数点) */
|
||||||
|
export type NumberPartDisplay = 'full' | 'integer' | 'fraction';
|
||||||
export type ElementVisibilityMode = 'always' | 'conditional';
|
export type ElementVisibilityMode = 'always' | 'conditional';
|
||||||
export type VisibilityOperator = 'empty' | 'not_empty' | 'gt' | 'lt' | 'eq' | 'neq';
|
export type VisibilityOperator = 'empty' | 'not_empty' | 'gt' | 'lt' | 'eq' | 'neq';
|
||||||
|
|
||||||
@@ -82,6 +84,8 @@ export interface TemplateElement {
|
|||||||
textFormat?: TextFormat;
|
textFormat?: TextFormat;
|
||||||
/** 数值/百分比/货币的小数位数,默认 2 */
|
/** 数值/百分比/货币的小数位数,默认 2 */
|
||||||
decimalPlaces?: number;
|
decimalPlaces?: number;
|
||||||
|
/** 数值格式下显示整数或小数部分,仅 type=text 且 textFormat≠text */
|
||||||
|
numberPartDisplay?: NumberPartDisplay;
|
||||||
/** 边框宽度 (mm),0 为无边框;各边留空时沿用此值 */
|
/** 边框宽度 (mm),0 为无边框;各边留空时沿用此值 */
|
||||||
borderWidth?: number;
|
borderWidth?: number;
|
||||||
borderColor?: string;
|
borderColor?: string;
|
||||||
|
|||||||
51
src/utils.ts
51
src/utils.ts
@@ -6,6 +6,7 @@ import {
|
|||||||
DataSourceMeta,
|
DataSourceMeta,
|
||||||
TemplateElement,
|
TemplateElement,
|
||||||
TextFormat,
|
TextFormat,
|
||||||
|
NumberPartDisplay,
|
||||||
ExportRangeConfig,
|
ExportRangeConfig,
|
||||||
CutLineConfig,
|
CutLineConfig,
|
||||||
VisibilityRule,
|
VisibilityRule,
|
||||||
@@ -643,6 +644,51 @@ export function formatDisplayValue(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 按整数/小数部分拆分数值显示(如价格 19.9 →「19」与「.9」分元素展示) */
|
||||||
|
export function applyNumberPartDisplay(
|
||||||
|
value: string,
|
||||||
|
format: Exclude<TextFormat, 'text'>,
|
||||||
|
decimalPlaces: number | undefined,
|
||||||
|
part: NumberPartDisplay
|
||||||
|
): string {
|
||||||
|
if (part === 'full') {
|
||||||
|
return formatDisplayValue(value, format, decimalPlaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = value.trim().replace(/,/g, '');
|
||||||
|
if (!trimmed) return '';
|
||||||
|
const num = Number(trimmed);
|
||||||
|
if (Number.isNaN(num)) {
|
||||||
|
return formatDisplayValue(value, format, decimalPlaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
const places = decimalPlaces ?? 2;
|
||||||
|
const sign = num < 0 ? '-' : '';
|
||||||
|
const abs = Math.abs(num);
|
||||||
|
const intVal = Math.trunc(abs);
|
||||||
|
const intStr = intVal.toLocaleString('zh-CN');
|
||||||
|
|
||||||
|
if (part === 'integer') {
|
||||||
|
switch (format) {
|
||||||
|
case 'number':
|
||||||
|
return `${sign}${intStr}`;
|
||||||
|
case 'percent':
|
||||||
|
return `${sign}${intStr}%`;
|
||||||
|
case 'currency':
|
||||||
|
return `${sign}¥${intStr}`;
|
||||||
|
default:
|
||||||
|
return `${sign}${intStr}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fracNum = abs - intVal;
|
||||||
|
let fracStr = fracNum.toFixed(places).slice(1);
|
||||||
|
if (!fracStr || fracStr === '.' || fracStr === '.' + '0'.repeat(places)) {
|
||||||
|
fracStr = places > 0 ? '.' + '0'.repeat(places) : '';
|
||||||
|
}
|
||||||
|
return fracStr;
|
||||||
|
}
|
||||||
|
|
||||||
/** 读取变量原始值(仅行数据;设计模式可选用默认值) */
|
/** 读取变量原始值(仅行数据;设计模式可选用默认值) */
|
||||||
export function getVariableRawValue(
|
export function getVariableRawValue(
|
||||||
varName: string,
|
varName: string,
|
||||||
@@ -693,10 +739,11 @@ export function resolveElementValue(
|
|||||||
const formatSubst =
|
const formatSubst =
|
||||||
elem.type === 'text' && elem.textFormat && elem.textFormat !== 'text'
|
elem.type === 'text' && elem.textFormat && elem.textFormat !== 'text'
|
||||||
? (raw: string) =>
|
? (raw: string) =>
|
||||||
formatDisplayValue(
|
applyNumberPartDisplay(
|
||||||
raw,
|
raw,
|
||||||
elem.textFormat as Exclude<TextFormat, 'text'>,
|
elem.textFormat as Exclude<TextFormat, 'text'>,
|
||||||
elem.decimalPlaces
|
elem.decimalPlaces,
|
||||||
|
elem.numberPartDisplay ?? 'full'
|
||||||
)
|
)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user