diff --git a/index.html b/index.html index 45f43e3..4cc45f5 100644 --- a/index.html +++ b/index.html @@ -4,6 +4,7 @@ + 标签设计生成器 diff --git a/package-lock.json b/package-lock.json index fa4a4f4..9aa0f42 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "express": "^4.21.2", "jsbarcode": "^3.12.3", "jspdf": "^4.2.1", + "jszip": "^3.10.1", "lucide-react": "^0.546.0", "motion": "^12.23.24", "qrcode": "^1.5.4", @@ -2082,6 +2083,12 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/crc-32": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz", @@ -2836,6 +2843,12 @@ "node": ">=0.10.0" } }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -2866,6 +2879,12 @@ "node": ">=8" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/jiti": { "version": "2.7.0", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", @@ -2937,6 +2956,24 @@ "html2canvas": "^1.0.0-rc.5" } }, + "node_modules/jszip": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", + "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==", + "license": "(MIT OR GPL-3.0-or-later)", + "dependencies": { + "lie": "~3.3.0", + "pako": "~1.0.2", + "readable-stream": "~2.3.6", + "setimmediate": "^1.0.5" + } + }, + "node_modules/jszip/node_modules/pako": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz", + "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", + "license": "(MIT AND Zlib)" + }, "node_modules/jwa": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", @@ -2958,6 +2995,15 @@ "safe-buffer": "^5.0.1" } }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, "node_modules/lightningcss": { "version": "1.32.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", @@ -3614,6 +3660,12 @@ "dev": true, "license": "MIT" }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/protobufjs": { "version": "7.6.3", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.3.tgz", @@ -3747,6 +3799,27 @@ "node": ">=0.10.0" } }, + "node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/readable-stream/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/regenerator-runtime": { "version": "0.13.11", "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", @@ -3933,6 +4006,12 @@ "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", "license": "ISC" }, + "node_modules/setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==", + "license": "MIT" + }, "node_modules/setprototypeof": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", @@ -4051,6 +4130,21 @@ "node": ">= 0.8" } }, + "node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/string_decoder/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -4696,6 +4790,12 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index f7c829f..55a0179 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "express": "^4.21.2", "jsbarcode": "^3.12.3", "jspdf": "^4.2.1", + "jszip": "^3.10.1", "lucide-react": "^0.546.0", "motion": "^12.23.24", "qrcode": "^1.5.4", diff --git a/src/App.tsx b/src/App.tsx index b166b9b..0fbd43d 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import React, { useState, useMemo, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; -import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig } from './types'; +import { LabelTemplate, PaperConfig, ImportData, RecordRow, ExportRangeConfig, CutLineConfig, ElementVisibilityMode, VisibilityRule } from './types'; import { stripTemplateForStorage, normalizeTemplate, @@ -11,6 +11,7 @@ import { extractTemplateVariables, buildAutoVariableMapping, applyRowVariableMapping, + filterLabelsForImageExport, type PrintableLabel, } from './utils'; import { LabelDesigner } from './components/LabelDesigner'; @@ -21,8 +22,12 @@ import { MobileDesignView } from './components/MobileDesignView'; import type { TableCellCoord } from './tableUtils'; import { LayoutPreview } from './components/PreviewGrid'; import { CanvasLabelImage } from './components/CanvasLabelImage'; -import { renderLabelToDataUrl } from './labelRenderer'; +import { renderLabelToDataUrl, resolveReferenceBackgroundForOutput, resolveReferenceBackgroundForPreview } from './labelRenderer'; import { exportLabelsPdfAsync, exportLabelsPdfBlobAsync, type PdfExportProgress } from './pdfExport'; +import { + exportLabelsImagesZipAsync, + type LabelImagesExportProgress, +} from './labelImagesExport'; import { getAvailablePrinters, saveSelectedPrinterId, @@ -142,6 +147,7 @@ export default function App() { const [pdfGenerating, setPdfGenerating] = useState(false); const [printing, setPrinting] = useState(false); + const [imagesZipGenerating, setImagesZipGenerating] = useState(false); const [pdfProgress, setPdfProgress] = useState({ done: 0, total: 0, @@ -149,6 +155,11 @@ export default function App() { totalPages: 0, phase: 'render', }); + const [imagesZipProgress, setImagesZipProgress] = useState({ + done: 0, + total: 0, + phase: 'render', + }); // --- 5. Selection and Active item states --- const [selectedElementIds, setSelectedElementIds] = useState([]); @@ -533,7 +544,14 @@ export default function App() { ); const layoutResult = useMemo( - () => buildPrintablePages(mappedRows, paper, workingTemplate, draftExportRange), + () => + buildPrintablePages( + mappedRows, + paper, + workingTemplate, + draftExportRange, + workingTemplate.variableDefaults ?? null + ), [mappedRows, paper, workingTemplate, draftExportRange] ); @@ -567,7 +585,14 @@ export default function App() { const appliedExportRange = appliedExportData?.exportRange ?? DEFAULT_EXPORT_RANGE; const appliedLayoutResult = useMemo( - () => buildPrintablePages(appliedMappedRows, paper, workingTemplate, appliedExportRange), + () => + buildPrintablePages( + appliedMappedRows, + paper, + workingTemplate, + appliedExportRange, + workingTemplate.variableDefaults ?? null + ), [appliedMappedRows, paper, workingTemplate, appliedExportRange] ); @@ -688,7 +713,8 @@ export default function App() { appliedMappedRows, paper, workingTemplate, - appliedExportData.exportRange + appliedExportData.exportRange, + workingTemplate.variableDefaults ?? null ); setPreviewSnapshot({ @@ -719,6 +745,49 @@ export default function App() { : { imageFormat: 'jpeg' as const, jsPdfFormat: 'JPEG' as const, scale: 10 }; }, [workingTemplate.elements]); + const handleExportImageFileNamePatternChange = (pattern: string) => { + if (!activeTemplate) return; + handleTemplateChange({ + ...activeTemplate, + exportImageFileNamePattern: pattern.trim() || undefined, + }); + }; + + const handleExportImageFilterModeChange = (mode: ElementVisibilityMode) => { + if (!activeTemplate) return; + handleTemplateChange({ + ...activeTemplate, + exportImageFilterMode: mode === 'always' ? undefined : 'conditional', + }); + }; + + const handleExportImageFilterRulesChange = (rules: VisibilityRule[]) => { + if (!activeTemplate) return; + handleTemplateChange({ + ...activeTemplate, + exportImageFilterMode: 'conditional', + exportImageFilterRules: rules, + }); + }; + + const renderAppliedLabelToDataUrl = useCallback( + (label: PrintableLabel) => + renderLabelToDataUrl({ + widthMm: activeTemplate!.width, + heightMm: activeTemplate!.height, + elements: activeTemplate!.elements, + rowData: label.row, + rowIndex: label.sourceIndex, + showBorder: false, + mode: 'output', + scale: pdfExportImage.scale, + imageFormat: pdfExportImage.imageFormat, + jpegQuality: 0.9, + ...resolveReferenceBackgroundForOutput(activeTemplate!), + }), + [activeTemplate, pdfExportImage] + ); + const exportPdf = async () => { if (!activeTemplate) return; if (!appliedExportData) { @@ -754,19 +823,7 @@ export default function App() { cutLine, filename: `${safeName}_${exportTotalPages}页.pdf`, imageFormat: pdfExportImage.jsPdfFormat, - renderLabel: (label) => - renderLabelToDataUrl({ - widthMm: activeTemplate.width, - heightMm: activeTemplate.height, - elements: activeTemplate.elements, - rowData: label.row, - rowIndex: label.sourceIndex, - showBorder: false, - mode: 'output', - scale: pdfExportImage.scale, - imageFormat: pdfExportImage.imageFormat, - jpegQuality: 0.9, - }), + renderLabel: renderAppliedLabelToDataUrl, onProgress: (progress) => { const now = Date.now(); const isDone = progress.done >= progress.total || progress.phase === 'save'; @@ -784,6 +841,70 @@ export default function App() { } }; + const exportLabelsImagesZip = async () => { + if (!activeTemplate) return; + if (!appliedExportData) { + await showAlert('请先应用数据后再导出图片。', { variant: 'warning' }); + return; + } + if (appliedLayoutResult.selectedCount === 0) { + await showAlert('当前数据范围内没有可排版的标签,请调整范围设置。', { variant: 'warning' }); + return; + } + + const exportableLabels = filterLabelsForImageExport( + appliedLayoutResult.pages, + activeTemplate, + activeTemplate.variableDefaults ?? null + ); + if (exportableLabels.length === 0) { + await showAlert('没有符合导出过滤条件的标签。', { variant: 'warning' }); + return; + } + + const labelTotal = exportableLabels.length; + const safeName = + activeTemplate.name.replace(/[^\w\u4e00-\u9fa5-]+/g, '_').slice(0, 40) || 'labels'; + const progressLastUpdate = { current: 0 }; + + setImagesZipGenerating(true); + setImagesZipProgress({ + done: 0, + total: labelTotal, + phase: 'render', + }); + + try { + await exportLabelsImagesZipAsync({ + printablePages: appliedLayoutResult.pages, + filename: `${safeName}_${labelTotal}枚_${Date.now()}.zip`, + imageExtension: pdfExportImage.imageFormat === 'jpeg' ? 'jpg' : 'png', + fileNamePattern: activeTemplate.exportImageFileNamePattern, + variableDefaults: activeTemplate.variableDefaults ?? null, + exportFilter: { + exportImageFilterMode: activeTemplate.exportImageFilterMode, + exportImageFilterRules: activeTemplate.exportImageFilterRules, + }, + renderLabel: renderAppliedLabelToDataUrl, + onProgress: (progress) => { + const now = Date.now(); + const isDone = + progress.phase === 'save' || + (progress.phase === 'render' && progress.done >= progress.total); + if (isDone || now - progressLastUpdate.current >= 150) { + progressLastUpdate.current = now; + setImagesZipProgress(progress); + } + }, + }); + } catch (e) { + console.error('Images zip export failed', e); + await showAlert('图片导出失败,请重试。', { variant: 'error' }); + } finally { + setImagesZipGenerating(false); + } + }; + const printLabels = async (printerId: string) => { if (!activeTemplate) return; if (!appliedExportData) { @@ -807,19 +928,7 @@ export default function App() { gridCols: appliedLayoutResult.gridCols, cutLine, imageFormat: pdfExportImage.jsPdfFormat, - renderLabel: (label) => - renderLabelToDataUrl({ - widthMm: activeTemplate.width, - heightMm: activeTemplate.height, - elements: activeTemplate.elements, - rowData: label.row, - rowIndex: label.sourceIndex, - showBorder: false, - mode: 'output', - scale: pdfExportImage.scale, - imageFormat: pdfExportImage.imageFormat, - jpegQuality: 0.9, - }), + renderLabel: renderAppliedLabelToDataUrl, }); const mode = await submitLabelsPrint(blob, printerId, printers); @@ -843,14 +952,24 @@ export default function App() { ? '排版已变更,请重新绘制预览' : null; + const isDarkWorkspace = + workflowStep === 'template-design' || workflowStep === 'print-view'; + const isMobileDarkShell = isMobile && isDarkWorkspace; + + useEffect(() => { + document.documentElement.classList.toggle('app-dark-shell', isMobileDarkShell); + return () => { + document.documentElement.classList.remove('app-dark-shell'); + }; + }, [isMobileDarkShell]); + return (
{/* ========================================= @@ -1084,6 +1203,7 @@ export default function App() { rowIndex={0} showBorder={true} variableDefaults={tmpl.variableDefaults ?? null} + {...resolveReferenceBackgroundForPreview(tmpl)} />
@@ -1335,6 +1455,7 @@ export default function App() { activeRowIndex={activeRowIndex} onSelectRowIndex={setActiveRowIndex} pdfGenerating={pdfGenerating} + imagesZipGenerating={imagesZipGenerating} printing={printing} onBack={() => setWorkflowStep('template-select')} onExportPdf={exportPdf} @@ -1344,9 +1465,15 @@ export default function App() { appliedLayoutResult.selectedCount === 0 || previewLayoutStale || printing || - pdfGenerating + pdfGenerating || + imagesZipGenerating } printDisabledReason={printBlockedReason} + onExportImages={exportLabelsImagesZip} + imagesExporting={imagesZipGenerating} + onChangeExportImageFileNamePattern={handleExportImageFileNamePatternChange} + onChangeExportImageFilterMode={handleExportImageFilterModeChange} + onChangeExportImageFilterRules={handleExportImageFilterRulesChange} /> )} @@ -1382,9 +1509,12 @@ export default function App() { type="button" onClick={exportPdf} disabled={ - !appliedExportData || appliedLayoutResult.selectedCount === 0 || pdfGenerating + !appliedExportData || + appliedLayoutResult.selectedCount === 0 || + pdfGenerating || + imagesZipGenerating } - className={`inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold transition cursor-pointer ${appliedExportData && appliedLayoutResult.selectedCount > 0 && !pdfGenerating + className={`inline-flex items-center gap-1.5 px-3 py-1 text-[11px] font-semibold transition cursor-pointer ${appliedExportData && appliedLayoutResult.selectedCount > 0 && !pdfGenerating && !imagesZipGenerating ? 'bg-[#31a8ff] hover:bg-[#2890d8] text-white' : 'bg-[#444] text-[#666] cursor-not-allowed' }`} @@ -1442,12 +1572,21 @@ export default function App() { draftSelectedCount={selectedCount} printing={printing} onPrint={printLabels} + onExportImages={exportLabelsImagesZip} + imagesExporting={imagesZipGenerating} + exportImageFileNamePattern={activeTemplate.exportImageFileNamePattern ?? ''} + onChangeExportImageFileNamePattern={handleExportImageFileNamePatternChange} + exportImageFilterMode={activeTemplate.exportImageFilterMode ?? 'always'} + exportImageFilterRules={activeTemplate.exportImageFilterRules ?? []} + onChangeExportImageFilterMode={handleExportImageFilterModeChange} + onChangeExportImageFilterRules={handleExportImageFilterRulesChange} printDisabled={ !appliedExportData || appliedLayoutResult.selectedCount === 0 || previewLayoutStale || printing || - pdfGenerating + pdfGenerating || + imagesZipGenerating } printDisabledReason={printBlockedReason} /> @@ -1598,27 +1737,56 @@ export default function App() { )} - {pdfGenerating && ( + {(pdfGenerating || imagesZipGenerating) && (
-

- {pdfProgress.phase === 'save' ? '正在保存 PDF 文件…' : '正在生成 PDF'} -

-

- {pdfProgress.phase === 'save' - ? '即将完成,请稍候' - : `第 ${pdfProgress.page} / ${pdfProgress.totalPages} 页 · ${pdfProgress.done} / ${pdfProgress.total} 张标签`} -

+ {pdfGenerating ? ( + <> +

+ {pdfProgress.phase === 'save' ? '正在保存 PDF 文件…' : '正在生成 PDF'} +

+

+ {pdfProgress.phase === 'save' + ? '即将完成,请稍候' + : `第 ${pdfProgress.page} / ${pdfProgress.totalPages} 页 · ${pdfProgress.done} / ${pdfProgress.total} 张标签`} +

+ + ) : ( + <> +

+ {imagesZipProgress.phase === 'save' + ? '正在下载 ZIP 文件…' + : imagesZipProgress.phase === 'zip' + ? '正在打包压缩…' + : '正在生成标签图片'} +

+

+ {imagesZipProgress.phase === 'save' + ? '即将完成,请稍候' + : imagesZipProgress.phase === 'zip' + ? `压缩进度 ${Math.round(imagesZipProgress.zipPercent ?? 0)}%` + : `${imagesZipProgress.done} / ${imagesZipProgress.total} 张`} +

+ + )}
diff --git a/src/components/CanvasLabelImage.tsx b/src/components/CanvasLabelImage.tsx index 0c39334..c16e0f8 100644 --- a/src/components/CanvasLabelImage.tsx +++ b/src/components/CanvasLabelImage.tsx @@ -14,6 +14,9 @@ interface CanvasLabelImageProps { variableDefaults?: Record | null; /** design=设计预览(空变量显示占位);output=排版/打印(空值隐藏图形元素) */ mode?: ContentResolveMode; + referenceBackground?: string; + referenceBackgroundOpacity?: number; + referenceBackgroundFit?: 'contain' | 'cover' | 'fill'; /** 渲染倍率,设计画布应传 MM_TO_PX * zoom 与显示尺寸 1:1 对齐 */ renderScale?: number; } @@ -28,6 +31,9 @@ export const CanvasLabelImage: React.FC = ({ transparentBackground = false, variableDefaults = null, mode = 'design' as ContentResolveMode, + referenceBackground, + referenceBackgroundOpacity, + referenceBackgroundFit, renderScale = 16, }) => { const [dataUrl, setDataUrl] = useState(''); @@ -54,6 +60,9 @@ export const CanvasLabelImage: React.FC = ({ variableDefaults, mode, scale: renderScale, + referenceBackground, + referenceBackgroundOpacity, + referenceBackgroundFit, }) .then((url) => { if (!active) return; @@ -88,6 +97,9 @@ export const CanvasLabelImage: React.FC = ({ transparentBackground, mode, renderScale, + referenceBackground, + referenceBackgroundOpacity, + referenceBackgroundFit, ]); if (initialLoading && !dataUrl) { diff --git a/src/components/CommitInput.tsx b/src/components/CommitInput.tsx index e69b93c..c92026a 100644 --- a/src/components/CommitInput.tsx +++ b/src/components/CommitInput.tsx @@ -86,3 +86,75 @@ export const CommitInput: React.FC = ({ /> ); }; + +export interface CommitTextareaProps + extends Omit, 'onChange' | 'value'> { + value: string; + onCommit: (val: string) => void; +} + +/** 失焦提交型多行输入框 */ +export const CommitTextarea: React.FC = ({ + value, + onCommit, + onFocus, + onBlur, + onKeyDown, + ...props +}) => { + const [localVal, setLocalVal] = useState(value); + const focusedRef = useRef(false); + const localValRef = useRef(localVal); + const valueRef = useRef(value); + const onCommitRef = useRef(onCommit); + localValRef.current = localVal; + valueRef.current = value; + onCommitRef.current = onCommit; + + useEffect(() => { + if (!focusedRef.current) { + setLocalVal(value); + } + }, [value]); + + useEffect(() => { + return () => { + if (focusedRef.current && localValRef.current !== valueRef.current) { + onCommitRef.current(localValRef.current); + } + }; + }, []); + + const commitIfChanged = () => { + if (localValRef.current !== valueRef.current) { + onCommitRef.current(localValRef.current); + } + }; + + const handleBlur = (e: React.FocusEvent) => { + focusedRef.current = false; + onBlur?.(e); + commitIfChanged(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { + (e.target as HTMLTextAreaElement).blur(); + } + onKeyDown?.(e); + }; + + return ( +