@spacecorexdev/core 0.0.2 → 0.0.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +20 -38
- package/public/cmaps/78-EUC-H.bcmap +0 -0
- package/public/cmaps/78-EUC-V.bcmap +0 -0
- package/public/cmaps/78-H.bcmap +0 -0
- package/public/cmaps/78-RKSJ-H.bcmap +0 -0
- package/public/cmaps/78-RKSJ-V.bcmap +0 -0
- package/public/cmaps/78-V.bcmap +0 -0
- package/public/cmaps/78ms-RKSJ-H.bcmap +0 -0
- package/public/cmaps/78ms-RKSJ-V.bcmap +0 -0
- package/public/cmaps/83pv-RKSJ-H.bcmap +0 -0
- package/public/cmaps/90ms-RKSJ-H.bcmap +0 -0
- package/public/cmaps/90ms-RKSJ-V.bcmap +0 -0
- package/public/cmaps/90msp-RKSJ-H.bcmap +0 -0
- package/public/cmaps/90msp-RKSJ-V.bcmap +0 -0
- package/public/cmaps/90pv-RKSJ-H.bcmap +0 -0
- package/public/cmaps/90pv-RKSJ-V.bcmap +0 -0
- package/public/cmaps/Add-H.bcmap +0 -0
- package/public/cmaps/Add-RKSJ-H.bcmap +0 -0
- package/public/cmaps/Add-RKSJ-V.bcmap +0 -0
- package/public/cmaps/Add-V.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-0.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-1.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-2.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-3.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-4.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-5.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-6.bcmap +0 -0
- package/public/cmaps/Adobe-CNS1-UCS2.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-0.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-1.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-2.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-3.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-4.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-5.bcmap +0 -0
- package/public/cmaps/Adobe-GB1-UCS2.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-0.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-1.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-2.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-3.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-4.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-5.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-6.bcmap +0 -0
- package/public/cmaps/Adobe-Japan1-UCS2.bcmap +0 -0
- package/public/cmaps/Adobe-Korea1-0.bcmap +0 -0
- package/public/cmaps/Adobe-Korea1-1.bcmap +0 -0
- package/public/cmaps/Adobe-Korea1-2.bcmap +0 -0
- package/public/cmaps/Adobe-Korea1-UCS2.bcmap +0 -0
- package/public/cmaps/B5-H.bcmap +0 -0
- package/public/cmaps/B5-V.bcmap +0 -0
- package/public/cmaps/B5pc-H.bcmap +0 -0
- package/public/cmaps/B5pc-V.bcmap +0 -0
- package/public/cmaps/CNS-EUC-H.bcmap +0 -0
- package/public/cmaps/CNS-EUC-V.bcmap +0 -0
- package/public/cmaps/CNS1-H.bcmap +0 -0
- package/public/cmaps/CNS1-V.bcmap +0 -0
- package/public/cmaps/CNS2-H.bcmap +0 -0
- package/public/cmaps/CNS2-V.bcmap +3 -0
- package/public/cmaps/ETHK-B5-H.bcmap +0 -0
- package/public/cmaps/ETHK-B5-V.bcmap +0 -0
- package/public/cmaps/ETen-B5-H.bcmap +0 -0
- package/public/cmaps/ETen-B5-V.bcmap +0 -0
- package/public/cmaps/ETenms-B5-H.bcmap +3 -0
- package/public/cmaps/ETenms-B5-V.bcmap +0 -0
- package/public/cmaps/EUC-H.bcmap +0 -0
- package/public/cmaps/EUC-V.bcmap +0 -0
- package/public/cmaps/Ext-H.bcmap +0 -0
- package/public/cmaps/Ext-RKSJ-H.bcmap +0 -0
- package/public/cmaps/Ext-RKSJ-V.bcmap +0 -0
- package/public/cmaps/Ext-V.bcmap +0 -0
- package/public/cmaps/GB-EUC-H.bcmap +0 -0
- package/public/cmaps/GB-EUC-V.bcmap +0 -0
- package/public/cmaps/GB-H.bcmap +4 -0
- package/public/cmaps/GB-V.bcmap +0 -0
- package/public/cmaps/GBK-EUC-H.bcmap +0 -0
- package/public/cmaps/GBK-EUC-V.bcmap +0 -0
- package/public/cmaps/GBK2K-H.bcmap +0 -0
- package/public/cmaps/GBK2K-V.bcmap +0 -0
- package/public/cmaps/GBKp-EUC-H.bcmap +0 -0
- package/public/cmaps/GBKp-EUC-V.bcmap +0 -0
- package/public/cmaps/GBT-EUC-H.bcmap +0 -0
- package/public/cmaps/GBT-EUC-V.bcmap +0 -0
- package/public/cmaps/GBT-H.bcmap +0 -0
- package/public/cmaps/GBT-V.bcmap +0 -0
- package/public/cmaps/GBTpc-EUC-H.bcmap +0 -0
- package/public/cmaps/GBTpc-EUC-V.bcmap +0 -0
- package/public/cmaps/GBpc-EUC-H.bcmap +0 -0
- package/public/cmaps/GBpc-EUC-V.bcmap +0 -0
- package/public/cmaps/H.bcmap +0 -0
- package/public/cmaps/HKdla-B5-H.bcmap +0 -0
- package/public/cmaps/HKdla-B5-V.bcmap +0 -0
- package/public/cmaps/HKdlb-B5-H.bcmap +0 -0
- package/public/cmaps/HKdlb-B5-V.bcmap +0 -0
- package/public/cmaps/HKgccs-B5-H.bcmap +0 -0
- package/public/cmaps/HKgccs-B5-V.bcmap +0 -0
- package/public/cmaps/HKm314-B5-H.bcmap +0 -0
- package/public/cmaps/HKm314-B5-V.bcmap +0 -0
- package/public/cmaps/HKm471-B5-H.bcmap +0 -0
- package/public/cmaps/HKm471-B5-V.bcmap +0 -0
- package/public/cmaps/HKscs-B5-H.bcmap +0 -0
- package/public/cmaps/HKscs-B5-V.bcmap +0 -0
- package/public/cmaps/Hankaku.bcmap +0 -0
- package/public/cmaps/Hiragana.bcmap +0 -0
- package/public/cmaps/KSC-EUC-H.bcmap +0 -0
- package/public/cmaps/KSC-EUC-V.bcmap +0 -0
- package/public/cmaps/KSC-H.bcmap +0 -0
- package/public/cmaps/KSC-Johab-H.bcmap +0 -0
- package/public/cmaps/KSC-Johab-V.bcmap +0 -0
- package/public/cmaps/KSC-V.bcmap +0 -0
- package/public/cmaps/KSCms-UHC-H.bcmap +0 -0
- package/public/cmaps/KSCms-UHC-HW-H.bcmap +0 -0
- package/public/cmaps/KSCms-UHC-HW-V.bcmap +0 -0
- package/public/cmaps/KSCms-UHC-V.bcmap +0 -0
- package/public/cmaps/KSCpc-EUC-H.bcmap +0 -0
- package/public/cmaps/KSCpc-EUC-V.bcmap +0 -0
- package/public/cmaps/Katakana.bcmap +0 -0
- package/public/cmaps/LICENSE +36 -0
- package/public/cmaps/NWP-H.bcmap +0 -0
- package/public/cmaps/NWP-V.bcmap +0 -0
- package/public/cmaps/RKSJ-H.bcmap +0 -0
- package/public/cmaps/RKSJ-V.bcmap +0 -0
- package/public/cmaps/Roman.bcmap +0 -0
- package/public/cmaps/UniCNS-UCS2-H.bcmap +0 -0
- package/public/cmaps/UniCNS-UCS2-V.bcmap +0 -0
- package/public/cmaps/UniCNS-UTF16-H.bcmap +0 -0
- package/public/cmaps/UniCNS-UTF16-V.bcmap +0 -0
- package/public/cmaps/UniCNS-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniCNS-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniCNS-UTF8-H.bcmap +0 -0
- package/public/cmaps/UniCNS-UTF8-V.bcmap +0 -0
- package/public/cmaps/UniGB-UCS2-H.bcmap +0 -0
- package/public/cmaps/UniGB-UCS2-V.bcmap +0 -0
- package/public/cmaps/UniGB-UTF16-H.bcmap +0 -0
- package/public/cmaps/UniGB-UTF16-V.bcmap +0 -0
- package/public/cmaps/UniGB-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniGB-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniGB-UTF8-H.bcmap +0 -0
- package/public/cmaps/UniGB-UTF8-V.bcmap +0 -0
- package/public/cmaps/UniJIS-UCS2-H.bcmap +0 -0
- package/public/cmaps/UniJIS-UCS2-HW-H.bcmap +0 -0
- package/public/cmaps/UniJIS-UCS2-HW-V.bcmap +0 -0
- package/public/cmaps/UniJIS-UCS2-V.bcmap +0 -0
- package/public/cmaps/UniJIS-UTF16-H.bcmap +0 -0
- package/public/cmaps/UniJIS-UTF16-V.bcmap +0 -0
- package/public/cmaps/UniJIS-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniJIS-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniJIS-UTF8-H.bcmap +0 -0
- package/public/cmaps/UniJIS-UTF8-V.bcmap +0 -0
- package/public/cmaps/UniJIS2004-UTF16-H.bcmap +0 -0
- package/public/cmaps/UniJIS2004-UTF16-V.bcmap +0 -0
- package/public/cmaps/UniJIS2004-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniJIS2004-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniJIS2004-UTF8-H.bcmap +0 -0
- package/public/cmaps/UniJIS2004-UTF8-V.bcmap +0 -0
- package/public/cmaps/UniJISPro-UCS2-HW-V.bcmap +0 -0
- package/public/cmaps/UniJISPro-UCS2-V.bcmap +0 -0
- package/public/cmaps/UniJISPro-UTF8-V.bcmap +0 -0
- package/public/cmaps/UniJISX0213-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniJISX0213-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniJISX02132004-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniJISX02132004-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniKS-UCS2-H.bcmap +0 -0
- package/public/cmaps/UniKS-UCS2-V.bcmap +0 -0
- package/public/cmaps/UniKS-UTF16-H.bcmap +0 -0
- package/public/cmaps/UniKS-UTF16-V.bcmap +0 -0
- package/public/cmaps/UniKS-UTF32-H.bcmap +0 -0
- package/public/cmaps/UniKS-UTF32-V.bcmap +0 -0
- package/public/cmaps/UniKS-UTF8-H.bcmap +0 -0
- package/public/cmaps/UniKS-UTF8-V.bcmap +0 -0
- package/public/cmaps/V.bcmap +0 -0
- package/public/cmaps/WP-Symbol.bcmap +0 -0
- package/public/polyfills/polyfills.js +802 -0
- package/public/vendor/docx/docx.worker.js +5 -0
- package/public/vendor/docx/jszip.min.js +13 -0
- package/public/vendor/libarchive/libarchive.wasm +0 -0
- package/public/vendor/libarchive/worker-bundle.js +6 -0
- package/public/vendor/pptx/pptx.worker.js +26 -0
- package/public/wasm/cad/0.8.2/dwfv-render.wasm +0 -0
- package/public/wasm/cad/0.8.2/dwg-worker.js +24 -0
- package/public/wasm/cad/0.8.2/dwg-worker.js.map +1 -0
- package/public/wasm/cad/0.8.2/libredwg-web.js +2 -0
- package/public/wasm/cad/0.8.2/libredwg-web.wasm +0 -0
- package/public/wasm/cad/dwfv-render.wasm +0 -0
- package/public/wasm/cad/dwg-worker.js +24 -0
- package/public/wasm/cad/dwg-worker.js.map +1 -0
- package/public/wasm/cad/libredwg-web.js +2 -0
- package/public/wasm/cad/libredwg-web.wasm +0 -0
- package/public/wasm/data/sql-wasm.wasm +0 -0
- package/public/wasm/hdiff.wasm +0 -0
- package/public/wasm/model/LICENSE.occt-import-js.txt +504 -0
- package/public/wasm/model/LICENSE.occt.txt +502 -0
- package/public/wasm/model/occt-import-js.js +19 -0
- package/public/wasm/model/occt-import-js.wasm +0 -0
- package/public/wasm/model/occt-worker.js +83 -0
- package/public/wasm/typst/fonts/DejaVuSansMono-Bold.ttf +0 -0
- package/public/wasm/typst/fonts/DejaVuSansMono-BoldOblique.ttf +0 -0
- package/public/wasm/typst/fonts/DejaVuSansMono-Oblique.ttf +0 -0
- package/public/wasm/typst/fonts/DejaVuSansMono.ttf +0 -0
- package/public/wasm/typst/fonts/LibertinusSerif-Bold.otf +0 -0
- package/public/wasm/typst/fonts/LibertinusSerif-BoldItalic.otf +0 -0
- package/public/wasm/typst/fonts/LibertinusSerif-Italic.otf +0 -0
- package/public/wasm/typst/fonts/LibertinusSerif-Regular.otf +0 -0
- package/public/wasm/typst/fonts/LibertinusSerif-Semibold.otf +0 -0
- package/public/wasm/typst/fonts/LibertinusSerif-SemiboldItalic.otf +0 -0
- package/public/wasm/typst/fonts/NewCM10-Bold.otf +0 -0
- package/public/wasm/typst/fonts/NewCM10-BoldItalic.otf +0 -0
- package/public/wasm/typst/fonts/NewCM10-Italic.otf +0 -0
- package/public/wasm/typst/fonts/NewCM10-Regular.otf +0 -0
- package/public/wasm/typst/fonts/NewCMMath-Bold.otf +0 -0
- package/public/wasm/typst/fonts/NewCMMath-Book.otf +0 -0
- package/public/wasm/typst/fonts/NewCMMath-Regular.otf +0 -0
- package/public/wasm/typst/fonts/README.md +7 -0
- package/public/wasm/typst/typst_ts_renderer_bg.wasm +0 -0
- package/public/wasm/typst/typst_ts_web_compiler_bg.wasm +0 -0
- package/scripts/patch-docx-grid.cjs +412 -0
- package/src/api/_base.ts +243 -0
- package/src/api/index.ts +17 -0
- package/src/api/project.ts +1345 -0
- package/src/api/user.ts +1174 -0
- package/src/contract.ts +50 -0
- package/src/hooks/index.ts +2 -0
- package/{dist/hooks/index.js → src/hooks/useLongPress.ts} +53 -49
- package/src/hooks/useOnlineStatus.ts +22 -0
- package/src/index.ts +14 -0
- package/src/types/id.ts +2 -0
- package/src/types/index.ts +5 -0
- package/src/types/project/base.ts +63 -0
- package/src/types/project/contract.ts +79 -0
- package/src/types/project/contractStatus.tsx +15 -0
- package/src/types/project/index.ts +5 -0
- package/src/types/project/member.ts +46 -0
- package/src/types/project/progress.ts +43 -0
- package/src/types/projectRole.ts +17 -0
- package/src/types/theme.ts +1 -0
- package/src/types/user/index.ts +1 -0
- package/src/types/user/role.ts +7 -0
- package/src/ui/CommentTimeline/index.tsx +330 -0
- package/src/ui/FileManager/FileContextMenu.tsx +628 -0
- package/src/ui/FileManager/FileGridView.tsx +529 -0
- package/src/ui/FileManager/FileTableView.tsx +405 -0
- package/src/ui/FileManager/FileToolbar.tsx +119 -0
- package/src/ui/FileManager/FileVersionHistoryModal.tsx +175 -0
- package/src/ui/FileManager/MoveCopyToProjectModal.tsx +119 -0
- package/src/ui/FileManager/ShareToWechatModal.tsx +419 -0
- package/src/ui/FileManager/hooks/useFileManagerActions.ts +2532 -0
- package/src/ui/FileManager/hooks/useFileManagerState.ts +419 -0
- package/src/ui/FileManager/index.tsx +580 -0
- package/src/ui/FileManager/projectFileManagerApi.ts +57 -0
- package/src/ui/FileManager/types.ts +184 -0
- package/src/ui/FileManager/utils/fileDeltaWorker.ts +193 -0
- package/src/ui/FileManager/utils/fileTree.ts +178 -0
- package/src/ui/FileManager/utils/fileUpload.ts +714 -0
- package/src/ui/FileManager/utils/fileUtils.ts +65 -0
- package/src/ui/InvoiceRecordsList/index.tsx +813 -0
- package/src/ui/MyFileSelectorModal.tsx +261 -0
- package/src/ui/ProjectSelector/index.tsx +472 -0
- package/src/ui/ProjectSelector/projectCache.ts +40 -0
- package/src/ui/RoleSelector/index.tsx +473 -0
- package/src/ui/RoleSelector/roleCache.ts +40 -0
- package/src/ui/UIAlert.tsx +399 -0
- package/src/ui/UIAlertExample.tsx +129 -0
- package/src/ui/UIConfirm.tsx +335 -0
- package/src/ui/UIConfirmExample.tsx +165 -0
- package/src/ui/UIDatePicker.tsx +146 -0
- package/src/ui/UIDateRangePicker.tsx +38 -0
- package/src/ui/UIFilePreview/ExcelEditor/index.tsx +617 -0
- package/src/ui/UIFilePreview/ExcelEditor/types.ts +24 -0
- package/src/ui/UIFilePreview/ExportDropdown.css +85 -0
- package/src/ui/UIFilePreview/ExportDropdown.tsx +109 -0
- package/src/ui/UIFilePreview/IndexedDBUtils.ts +564 -0
- package/src/ui/UIFilePreview/MarkdownEditor/exportMarkdownPdf.ts +576 -0
- package/src/ui/UIFilePreview/MarkdownEditor/index.tsx +677 -0
- package/src/ui/UIFilePreview/MarkdownEditor/markdownEditor.css +1005 -0
- package/src/ui/UIFilePreview/MarkdownEditor/types.ts +15 -0
- package/src/ui/UIFilePreview/PPTXEditor/index.tsx +602 -0
- package/src/ui/UIFilePreview/RichDocEditor/exportRDocMarkdown.ts +77 -0
- package/src/ui/UIFilePreview/RichDocEditor/exportRDocPdf.ts +374 -0
- package/src/ui/UIFilePreview/RichDocEditor/index.tsx +730 -0
- package/src/ui/UIFilePreview/RichDocEditor/richdoc-editor.css +59 -0
- package/src/ui/UIFilePreview/UIFilePreview.tsx +611 -0
- package/src/ui/UIFilePreview/WordEditor/index.tsx +3933 -0
- package/src/ui/UIFilePreview/WordEditor/style.ts +1213 -0
- package/src/ui/UIFilePreview/WordEditor/word-editor.css +999 -0
- package/src/ui/UIFilePreview/filePreview.css +193 -0
- package/src/ui/UIFilePreview/filetypes/archive.tsx +66 -0
- package/src/ui/UIFilePreview/filetypes/cad.tsx +287 -0
- package/src/ui/UIFilePreview/filetypes/excel.tsx +355 -0
- package/src/ui/UIFilePreview/filetypes/image.tsx +20 -0
- package/src/ui/UIFilePreview/filetypes/imageLoader.ts +63 -0
- package/src/ui/UIFilePreview/filetypes/pdf.tsx +156 -0
- package/src/ui/UIFilePreview/filetypes/ppt.tsx +535 -0
- package/src/ui/UIFilePreview/filetypes/pptExportPdf.ts +523 -0
- package/src/ui/UIFilePreview/filetypes/video.tsx +482 -0
- package/src/ui/UIFilePreview/filetypes/word.tsx +245 -0
- package/src/ui/UIFilePreview/filetypes/wordExportMarkdown.ts +86 -0
- package/src/ui/UIFilePreview/filetypes/wordExportPdf.tsx +427 -0
- package/src/ui/UIFilePreview/index.ts +35 -0
- package/src/ui/UIFilePreview/pdfPagination.ts +143 -0
- package/src/ui/UIFilePreview/previewUtils.tsx +531 -0
- package/src/ui/UIFileUpload.tsx +382 -0
- package/src/ui/UIImageUpload.tsx +195 -0
- package/src/ui/UILoading.tsx +255 -0
- package/src/ui/UILoadingExample.tsx +139 -0
- package/src/ui/UISelect.tsx +105 -0
- package/src/ui/UIToast.tsx +381 -0
- package/src/ui/UIToastExample.tsx +69 -0
- package/src/ui/UIToastSingle.tsx +279 -0
- package/src/ui/UIViewToggle.tsx +64 -0
- package/src/ui/UserSelector/index.tsx +583 -0
- package/src/ui/index.ts +84 -0
- package/src/ui/upload/image.ts +135 -0
- package/src/ui/upload/index.ts +73 -0
- package/src/ui/upload/submitFile.ts +106 -0
- package/src/ui/upload/submitForm.ts +124 -0
- package/src/ui/upload/upload.err.ts +15 -0
- package/src/ui/upload/upload.ts +250 -0
- package/src/utils/CacheDepartmentInfo.ts +372 -0
- package/src/utils/CacheFileContent.ts +315 -0
- package/src/utils/CachePermissionInfo.ts +235 -0
- package/src/utils/CacheRoleInfo.ts +303 -0
- package/src/utils/CacheUserInfo.ts +818 -0
- package/src/utils/CacheViewToggleInfo.ts +182 -0
- package/src/utils/MessageContentUtils.ts +268 -0
- package/src/utils/clipboard.ts +70 -0
- package/src/utils/companyEmail.ts +19 -0
- package/src/utils/forbidDebugger.ts +59 -0
- package/src/utils/formatters.ts +94 -0
- package/src/utils/htmlUtils.tsx +59 -0
- package/src/utils/index.ts +18 -0
- package/src/utils/keyboard.ts +114 -0
- package/src/utils/mammothApi.ts +16 -0
- package/src/utils/polyfills.ts +928 -0
- package/src/utils/regex.ts +14 -0
- package/src/utils/storage.ts +95 -0
- package/src/utils/workerBootstrap.ts +87 -0
- package/dist/api/index.js +0 -1553
- package/dist/contract.js +0 -5
- package/dist/index.js +0 -4492
- package/dist/types/index.js +0 -49
- package/dist/ui/index.css +0 -1876
- package/dist/ui/index.js +0 -22245
- package/dist/utils/index.js +0 -4296
- /package/{dist → src}/styles/ant-modal.css +0 -0
- /package/{dist → src}/styles/ant-spin.css +0 -0
- /package/{dist → src}/styles/ant-table.css +0 -0
- /package/{dist → src}/styles/index.css +0 -0
|
@@ -0,0 +1,3933 @@
|
|
|
1
|
+
import { useCallback, useRef, useEffect, useState, useMemo, forwardRef, useImperativeHandle } from 'react';
|
|
2
|
+
import { createPortal } from 'react-dom';
|
|
3
|
+
import { Modal, Button, Space, message, Popconfirm, Tag, Select, InputNumber } from 'antd';
|
|
4
|
+
import { SaveOutlined, FileWordFilled, UploadOutlined, CloseOutlined, CloudServerOutlined, CloudOutlined, FormatPainterOutlined, FileMarkdownOutlined, AlignLeftOutlined, SnippetsOutlined, DownOutlined } from '@ant-design/icons';
|
|
5
|
+
import { DocxEditor, type DocxEditorRef } from '@eigenpal/docx-editor-react';
|
|
6
|
+
import { PluginHost, type PluginHostRef, type ReactEditorPlugin } from '@eigenpal/docx-editor-react/plugin-api';
|
|
7
|
+
import type { Document as DocxDocument, Style } from '@eigenpal/docx-editor-core';
|
|
8
|
+
import { setGoogleFontsEnabled } from '@eigenpal/docx-editor-core';
|
|
9
|
+
import {
|
|
10
|
+
applyStyle,
|
|
11
|
+
getStyleId,
|
|
12
|
+
getDocumentStyleResolver,
|
|
13
|
+
extractSelectionState,
|
|
14
|
+
setAlignment,
|
|
15
|
+
setIndentLeft,
|
|
16
|
+
setIndentRight,
|
|
17
|
+
setIndentFirstLine,
|
|
18
|
+
setLineSpacing,
|
|
19
|
+
getParagraphAlignment,
|
|
20
|
+
} from '@eigenpal/docx-editor-core/prosemirror';
|
|
21
|
+
import { setSpaceBefore, setSpaceAfter } from '@eigenpal/docx-editor-core/prosemirror/commands/paragraph';
|
|
22
|
+
import { insertPageBreak } from '@eigenpal/docx-editor-core/prosemirror/commands/pageBreak';
|
|
23
|
+
import { parseDocx } from '@eigenpal/docx-editor-core/docx';
|
|
24
|
+
import JSZip from 'jszip';
|
|
25
|
+
import { zhCN } from '@eigenpal/docx-editor-i18n/zh-CN';
|
|
26
|
+
import '@eigenpal/docx-editor-react/styles.css';
|
|
27
|
+
import './word-editor.css';
|
|
28
|
+
import { saveFile, loadFile, deleteFile } from '../IndexedDBUtils';
|
|
29
|
+
import { exportWordPdf } from '../filetypes/wordExportPdf';
|
|
30
|
+
import { exportWordMarkdown } from '../filetypes/wordExportMarkdown';
|
|
31
|
+
import { ExportDropdown } from '../ExportDropdown';
|
|
32
|
+
import { CacheUserInfo } from '../../../utils/CacheUserInfo';
|
|
33
|
+
import {
|
|
34
|
+
createDocumentWithStyles,
|
|
35
|
+
applyPresetStylesToDocument,
|
|
36
|
+
applyDocGridLineSpacing,
|
|
37
|
+
applyCjkFontFallbackToDocument,
|
|
38
|
+
CUSTOM_FONT_FAMILIES,
|
|
39
|
+
mergePresetStylesIntoStylesXml,
|
|
40
|
+
ensureInlineSpacingInDocumentXml,
|
|
41
|
+
ensureHongTouSeparatorBorderInDocumentXml,
|
|
42
|
+
PRESET_STYLES, // ← 新增:导出预设样式集合,用于自定义样式选择器下拉 options
|
|
43
|
+
normalizeStyleName, // ← 新增:样式名强归一化,用于下拉框按 name 去重(标题1/Heading1/标题1都算同一个)
|
|
44
|
+
} from './style';
|
|
45
|
+
import uiConfirm from '../../UIConfirm';
|
|
46
|
+
import { useEffectResize } from '../../../utils/htmlUtils';
|
|
47
|
+
import type { Mark, Node } from 'prosemirror-model';
|
|
48
|
+
import type { EditorView } from 'prosemirror-view';
|
|
49
|
+
import { TextSelection, type EditorState } from 'prosemirror-state';
|
|
50
|
+
import { keymap } from 'prosemirror-keymap';
|
|
51
|
+
import { splitBlockAs } from 'prosemirror-commands';
|
|
52
|
+
import { isSuggestionModeActive } from '@eigenpal/docx-editor-core/prosemirror/plugins';
|
|
53
|
+
|
|
54
|
+
const DOCX_MIME_TYPE = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
|
|
55
|
+
const REVISION_CONTENT_XML_PATTERN = /^word\/(?:document|header\d+|footer\d+|footnotes|endnotes)\.xml$/;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* 从已解析的文档模型里直接提取可见段落样式(文件自带的 + 预设的)。
|
|
59
|
+
*
|
|
60
|
+
* 为什么不只依赖 DocxEditor 内部的 StyleResolver:
|
|
61
|
+
* StyleResolver 存在 ProseMirror 插件里,只能在编辑器就绪后通过 state 读取,
|
|
62
|
+
* 且依赖 onSelectionChange 触发同步。若插件未挂载 / 选区事件未触发,
|
|
63
|
+
* 下拉框就只剩 PRESET_STYLES,用户会觉得"docx 自带的样式不见了"。
|
|
64
|
+
* 这里在打开文件时直接从 parseDocx 的结果里取,保证一定有值。
|
|
65
|
+
*/
|
|
66
|
+
function extractParagraphStyles(doc: DocxDocument | null | undefined): Style[] {
|
|
67
|
+
const all = doc?.package?.styles?.styles ?? [];
|
|
68
|
+
return all.filter(s => s.type === 'paragraph' && !s.hidden && !s.semiHidden);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 保存时「不可变 part 白名单」:正文编辑永远不会改动这些 part。
|
|
72
|
+
// 与 DRAWING GUARD 配合使用:打开文件时将这些 part 做字节级快照,保存时把 lib serializer
|
|
73
|
+
// 输出的对应 part 用快照覆盖回去,100% 保证页脚签章/水印 drawing 字节与原始文件一致,
|
|
74
|
+
// 彻底绕开 lib serializer 把 footer drawing 序列化错误的 bug(导致保存重进出现
|
|
75
|
+
// <img src="" width=192 height=192 style=position:absolute> 空占位图)。
|
|
76
|
+
const IMMUTABLE_PART_REGEXES = [
|
|
77
|
+
// 页眉/页脚主 XML:word/header1.xml / word/footer2.xml
|
|
78
|
+
/^word\/header\d*\.xml$/i,
|
|
79
|
+
/^word\/footer\d*\.xml$/i,
|
|
80
|
+
// 页眉/页脚对应的 rels:word/_rels/header1.xml.rels / word/_rels/footer2.xml.rels
|
|
81
|
+
// (rels 里存了 drawing 的 r:embed → Target 映射,必须和主 XML 一起镜像,否则 rId 错配)
|
|
82
|
+
/^word\/_rels\/header\d*\.xml\.rels$/i,
|
|
83
|
+
/^word\/_rels\/footer\d*\.xml\.rels$/i,
|
|
84
|
+
// 媒体/嵌入物/图表/SmartArt
|
|
85
|
+
/^word\/media\//i,
|
|
86
|
+
/^word\/embeddings\//i,
|
|
87
|
+
/^word\/charts\//i,
|
|
88
|
+
/^word\/diagrams\//i,
|
|
89
|
+
// 主题色/字体主题(编辑器不提供主题设置功能,主题通常不变)
|
|
90
|
+
/^word\/theme\/theme\d+\.xml$/i,
|
|
91
|
+
];
|
|
92
|
+
function isImmutablePart(path: string): boolean {
|
|
93
|
+
return IMMUTABLE_PART_REGEXES.some((re) => re.test(path));
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// 快捷键映射:Ctrl/Cmd + Alt + key → styleId
|
|
97
|
+
const STYLE_SHORTCUT_MAP: Record<string, string> = {
|
|
98
|
+
'1': 'Heading1',
|
|
99
|
+
'2': 'Heading2',
|
|
100
|
+
'3': 'Heading3',
|
|
101
|
+
'4': 'Heading4',
|
|
102
|
+
'9': 'Title',
|
|
103
|
+
'0': 'Normal',
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// 格式刷不应复制/清除的语义 marks:批注(comment) 与修订增删(insertion/deletion)
|
|
107
|
+
const SEMANTIC_MARK_DENYLIST = new Set(['comment', 'insertion', 'deletion']);
|
|
108
|
+
|
|
109
|
+
// 格式刷图标:使用 antd 内置的 FormatPainterOutlined,风格与工具栏其他按钮一致
|
|
110
|
+
const FormatPainterIcon = FormatPainterOutlined;
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 将源格式 marks 以"替换式"应用到范围 [from,to]:
|
|
114
|
+
* schema 中每个格式 mark 按"源有则添加、源无且目标有则移除"的规则处理;
|
|
115
|
+
* 语义 marks(comment/insertion/deletion) 始终保留不动。
|
|
116
|
+
* 最后同步 storedMarks,让应用后继续输入也会继承这些 marks。
|
|
117
|
+
*/
|
|
118
|
+
const applySourceMarks = (view: EditorView, from: number, to: number, sourceMarks: Mark[]) => {
|
|
119
|
+
const schema = view.state.schema;
|
|
120
|
+
const sourceMap = new Map(sourceMarks.map((m) => [m.type.name, m]));
|
|
121
|
+
let tr = view.state.tr;
|
|
122
|
+
let changed = false;
|
|
123
|
+
for (const name of Object.keys(schema.marks)) {
|
|
124
|
+
if (SEMANTIC_MARK_DENYLIST.has(name)) continue;
|
|
125
|
+
const markType = schema.marks[name];
|
|
126
|
+
const src = sourceMap.get(name);
|
|
127
|
+
if (src) {
|
|
128
|
+
tr = tr.addMark(from, to, src);
|
|
129
|
+
changed = true;
|
|
130
|
+
} else if (view.state.doc.rangeHasMark(from, to, markType)) {
|
|
131
|
+
tr = tr.removeMark(from, to, markType);
|
|
132
|
+
changed = true;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
if (!changed) return;
|
|
136
|
+
const keepSemantic = (view.state.storedMarks ?? []).filter((m) => SEMANTIC_MARK_DENYLIST.has(m.type.name));
|
|
137
|
+
tr = tr.setStoredMarks([...sourceMarks, ...keepSemantic]);
|
|
138
|
+
view.dispatch(tr.setMeta('addToHistory', true));
|
|
139
|
+
view.focus();
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
interface FormatPainterApi {
|
|
143
|
+
active: boolean;
|
|
144
|
+
toggle: () => void;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* 格式刷:
|
|
149
|
+
* 1) 用户先在文档中选中带格式的文本,点击按钮 → 复制选区字符格式并进入待命态;
|
|
150
|
+
* 2) 待命态下选中文本,松手(pointerup)后才应用一次并退出(避免拖选过程中连续触发);
|
|
151
|
+
* 3) 再次点击按钮或 Esc 取消待命。
|
|
152
|
+
*
|
|
153
|
+
* 关键:用 window 的 pointerup 而非 onSelectionChange 触发应用——
|
|
154
|
+
* onSelectionChange 在拖选过程中会随选区变化连续触发,导致只有前几个字符被刷上格式。
|
|
155
|
+
*/
|
|
156
|
+
const useFormatPainter = (getView: () => EditorView | null): FormatPainterApi => {
|
|
157
|
+
const [active, setActive] = useState(false);
|
|
158
|
+
const sourceMarksRef = useRef<Mark[]>([]);
|
|
159
|
+
const sourceRangeRef = useRef<{ from: number; to: number } | null>(null);
|
|
160
|
+
const getViewRef = useRef(getView);
|
|
161
|
+
getViewRef.current = getView;
|
|
162
|
+
|
|
163
|
+
const collectFormatMarks = (view: EditorView): Mark[] => {
|
|
164
|
+
const { selection, storedMarks } = view.state;
|
|
165
|
+
const baseMarks = selection.empty
|
|
166
|
+
? (storedMarks ?? selection.$from.marks())
|
|
167
|
+
: unionMarksAcrossRange(view, selection.from, selection.to);
|
|
168
|
+
return baseMarks.filter((m) => !SEMANTIC_MARK_DENYLIST.has(m.type.name));
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const arm = useCallback(() => {
|
|
172
|
+
const view = getViewRef.current();
|
|
173
|
+
if (!view) {
|
|
174
|
+
message.warning('编辑器尚未就绪,请稍后再试');
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
view.focus();
|
|
178
|
+
const marks = collectFormatMarks(view);
|
|
179
|
+
if (marks.length === 0) {
|
|
180
|
+
message.warning('当前选区没有可复制的字符格式,请选中带格式的文本后再试');
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
const { selection } = view.state;
|
|
184
|
+
sourceMarksRef.current = marks;
|
|
185
|
+
sourceRangeRef.current = { from: selection.from, to: selection.to };
|
|
186
|
+
setActive(true);
|
|
187
|
+
message.success(`已复制 ${marks.length} 项字符格式,请选中文本应用`);
|
|
188
|
+
}, []);
|
|
189
|
+
|
|
190
|
+
const toggle = useCallback(() => {
|
|
191
|
+
if (active) {
|
|
192
|
+
setActive(false);
|
|
193
|
+
sourceMarksRef.current = [];
|
|
194
|
+
sourceRangeRef.current = null;
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
arm();
|
|
198
|
+
}, [active, arm]);
|
|
199
|
+
|
|
200
|
+
// 待命态:监听 window pointerup,松手后等 ProseMirror 处理完选区再读取最终选区并应用
|
|
201
|
+
useEffect(() => {
|
|
202
|
+
if (!active) return;
|
|
203
|
+
const onPointerUp = () => {
|
|
204
|
+
requestAnimationFrame(() => {
|
|
205
|
+
const view = getViewRef.current();
|
|
206
|
+
if (!view) return;
|
|
207
|
+
const { selection } = view.state;
|
|
208
|
+
if (selection.empty) return; // 仅点击未形成选区,继续待命
|
|
209
|
+
const src = sourceRangeRef.current;
|
|
210
|
+
if (src && selection.from === src.from && selection.to === src.to) return; // 仍是源选区
|
|
211
|
+
applySourceMarks(view, selection.from, selection.to, sourceMarksRef.current);
|
|
212
|
+
setActive(false);
|
|
213
|
+
sourceMarksRef.current = [];
|
|
214
|
+
sourceRangeRef.current = null;
|
|
215
|
+
});
|
|
216
|
+
};
|
|
217
|
+
window.addEventListener('pointerup', onPointerUp);
|
|
218
|
+
return () => window.removeEventListener('pointerup', onPointerUp);
|
|
219
|
+
}, [active]);
|
|
220
|
+
|
|
221
|
+
// Esc 取消待命态
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (!active) return;
|
|
224
|
+
const onKey = (e: KeyboardEvent) => {
|
|
225
|
+
if (e.key === 'Escape') {
|
|
226
|
+
setActive(false);
|
|
227
|
+
sourceMarksRef.current = [];
|
|
228
|
+
sourceRangeRef.current = null;
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
window.addEventListener('keydown', onKey);
|
|
232
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
233
|
+
}, [active]);
|
|
234
|
+
|
|
235
|
+
return { active, toggle };
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** 扫描 [from,to] 范围内所有节点携带的 marks,做并集返回(保留每项首次出现的实例) */
|
|
239
|
+
const unionMarksAcrossRange = (view: EditorView, from: number, to: number): Mark[] => {
|
|
240
|
+
const seen = new Set<string>();
|
|
241
|
+
const out: Mark[] = [];
|
|
242
|
+
view.state.doc.nodesBetween(from, to, (_node, _pos, parent, index) => {
|
|
243
|
+
if (parent == null || index == null) return true;
|
|
244
|
+
const node = parent.child(index);
|
|
245
|
+
if (!node || !node.isText) return true;
|
|
246
|
+
for (const m of node.marks) {
|
|
247
|
+
const key = `${m.type.name}:${JSON.stringify(m.attrs)}`;
|
|
248
|
+
if (seen.has(key)) continue;
|
|
249
|
+
seen.add(key);
|
|
250
|
+
out.push(m);
|
|
251
|
+
}
|
|
252
|
+
return true;
|
|
253
|
+
});
|
|
254
|
+
// 空选区或无 marks 时,回退到 $from.marks()
|
|
255
|
+
if (out.length === 0) return [...view.state.selection.$from.marks()];
|
|
256
|
+
return out;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const decodeXmlAttribute = (value: string) =>
|
|
260
|
+
value
|
|
261
|
+
.replace(/&/g, '&')
|
|
262
|
+
.replace(/"/g, '"')
|
|
263
|
+
.replace(/'/g, "'")
|
|
264
|
+
.replace(/</g, '<')
|
|
265
|
+
.replace(/>/g, '>');
|
|
266
|
+
|
|
267
|
+
const getXmlAttribute = (xmlTag: string, attributeName: string) => {
|
|
268
|
+
const match = xmlTag.match(new RegExp(`\\b${attributeName}="([^"]*)"`));
|
|
269
|
+
return match ? decodeXmlAttribute(match[1]) : '';
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
const normalizeZipPath = (path: string) => {
|
|
273
|
+
const parts: string[] = [];
|
|
274
|
+
path.replace(/\\/g, '/').split('/').forEach((part) => {
|
|
275
|
+
if (!part || part === '.') return;
|
|
276
|
+
if (part === '..') {
|
|
277
|
+
parts.pop();
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
parts.push(part);
|
|
281
|
+
});
|
|
282
|
+
return parts.join('/');
|
|
283
|
+
};
|
|
284
|
+
|
|
285
|
+
const getRelationshipOwnerDir = (relsPath: string) => {
|
|
286
|
+
const normalizedPath = normalizeZipPath(relsPath);
|
|
287
|
+
const match = normalizedPath.match(/^(.*)\/_rels\/([^/]+)\.rels$/);
|
|
288
|
+
if (!match) return '';
|
|
289
|
+
|
|
290
|
+
const ownerPath = normalizeZipPath(`${match[1]}/${match[2]}`);
|
|
291
|
+
const lastSlashIndex = ownerPath.lastIndexOf('/');
|
|
292
|
+
return lastSlashIndex >= 0 ? ownerPath.slice(0, lastSlashIndex) : '';
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const resolveRelationshipTarget = (relsPath: string, target: string) => {
|
|
296
|
+
const decodedTarget = decodeXmlAttribute(target).split('#')[0].split('?')[0];
|
|
297
|
+
if (!decodedTarget || /^[a-z][a-z\d+\-.]*:/i.test(decodedTarget)) {
|
|
298
|
+
return '';
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (decodedTarget.startsWith('/')) {
|
|
302
|
+
return normalizeZipPath(decodedTarget.slice(1));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const ownerDir = getRelationshipOwnerDir(relsPath);
|
|
306
|
+
return normalizeZipPath(ownerDir ? `${ownerDir}/${decodedTarget}` : decodedTarget);
|
|
307
|
+
};
|
|
308
|
+
|
|
309
|
+
const getZipEntrySize = (file: JSZip.JSZipObject) => {
|
|
310
|
+
const internalData = (file as { _data?: { uncompressedSize?: number } })._data;
|
|
311
|
+
return internalData?.uncompressedSize ?? 0;
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
const getRelationshipOwnerPath = (relsPath: string) => {
|
|
315
|
+
const normalizedPath = normalizeZipPath(relsPath);
|
|
316
|
+
const match = normalizedPath.match(/^(.*)\/_rels\/([^/]+)\.rels$/);
|
|
317
|
+
if (!match) return '';
|
|
318
|
+
|
|
319
|
+
return normalizeZipPath(`${match[1]}/${match[2]}`);
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
const collectUsedRelationshipIds = (xml: string) => {
|
|
323
|
+
const usedIds = new Set<string>();
|
|
324
|
+
const relationshipIdRegExp = /(?:\b\w+:)?(?:embed|link|id)="([^"]+)"/g;
|
|
325
|
+
let match: RegExpExecArray | null;
|
|
326
|
+
|
|
327
|
+
while ((match = relationshipIdRegExp.exec(xml))) {
|
|
328
|
+
usedIds.add(match[1]);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return usedIds;
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
const collectUsedMediaTargets = async (zip: JSZip) => {
|
|
335
|
+
const usedMediaTargets = new Set<string>();
|
|
336
|
+
const relationshipEntries = Object.values(zip.files).filter((file) => !file.dir && file.name.endsWith('.rels'));
|
|
337
|
+
|
|
338
|
+
await Promise.all(relationshipEntries.map(async (file) => {
|
|
339
|
+
const ownerPath = getRelationshipOwnerPath(file.name);
|
|
340
|
+
const ownerFile = ownerPath ? zip.file(ownerPath) : null;
|
|
341
|
+
if (!ownerFile) return;
|
|
342
|
+
|
|
343
|
+
const ownerXml = await ownerFile.async('text');
|
|
344
|
+
const usedRelationshipIds = collectUsedRelationshipIds(ownerXml);
|
|
345
|
+
if (usedRelationshipIds.size === 0) return;
|
|
346
|
+
|
|
347
|
+
const relsXml = await file.async('text');
|
|
348
|
+
const relationshipRegExp = /<Relationship\b[^>]*\/>/g;
|
|
349
|
+
let match: RegExpExecArray | null;
|
|
350
|
+
|
|
351
|
+
while ((match = relationshipRegExp.exec(relsXml))) {
|
|
352
|
+
const relationshipId = getXmlAttribute(match[0], 'Id');
|
|
353
|
+
const target = getXmlAttribute(match[0], 'Target');
|
|
354
|
+
const targetPath = resolveRelationshipTarget(file.name, target);
|
|
355
|
+
if (usedRelationshipIds.has(relationshipId) && targetPath.startsWith('word/media/')) {
|
|
356
|
+
usedMediaTargets.add(targetPath);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}));
|
|
360
|
+
|
|
361
|
+
return usedMediaTargets;
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
const pruneUnusedMediaRelationships = async (zip: JSZip, usedMediaTargets: Set<string>) => {
|
|
365
|
+
const relationshipEntries = Object.values(zip.files).filter((file) => !file.dir && file.name.endsWith('.rels'));
|
|
366
|
+
let removedCount = 0;
|
|
367
|
+
|
|
368
|
+
await Promise.all(relationshipEntries.map(async (file) => {
|
|
369
|
+
const relsXml = await file.async('text');
|
|
370
|
+
const nextXml = relsXml.replace(/<Relationship\b[^>]*\bTarget="([^"]+)"[^>]*\/>/g, (relationship, target) => {
|
|
371
|
+
const targetPath = resolveRelationshipTarget(file.name, target);
|
|
372
|
+
if (!targetPath.startsWith('word/media/') || usedMediaTargets.has(targetPath)) {
|
|
373
|
+
return relationship;
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
removedCount += 1;
|
|
377
|
+
return '';
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
if (removedCount > 0 && nextXml !== relsXml) {
|
|
381
|
+
zip.file(file.name, nextXml);
|
|
382
|
+
}
|
|
383
|
+
}));
|
|
384
|
+
|
|
385
|
+
return removedCount;
|
|
386
|
+
};
|
|
387
|
+
|
|
388
|
+
const removeRelationshipByTargetPrefix = async (zip: JSZip, relsPath: string, targetPrefix: string) => {
|
|
389
|
+
const relsFile = zip.file(relsPath);
|
|
390
|
+
if (!relsFile) return 0;
|
|
391
|
+
|
|
392
|
+
const relsXml = await relsFile.async('text');
|
|
393
|
+
let removedCount = 0;
|
|
394
|
+
const nextXml = relsXml.replace(/<Relationship\b[^>]*\bTarget="([^"]+)"[^>]*\/>/g, (relationship, target) => {
|
|
395
|
+
const targetPath = resolveRelationshipTarget(relsPath, target);
|
|
396
|
+
if (!targetPath.startsWith(targetPrefix)) return relationship;
|
|
397
|
+
|
|
398
|
+
removedCount += 1;
|
|
399
|
+
return '';
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
if (removedCount > 0) {
|
|
403
|
+
zip.file(relsPath, nextXml);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
return removedCount;
|
|
407
|
+
};
|
|
408
|
+
|
|
409
|
+
const removeContentTypeOverridesByPrefix = async (zip: JSZip, partPrefix: string) => {
|
|
410
|
+
const contentTypesFile = zip.file('[Content_Types].xml');
|
|
411
|
+
if (!contentTypesFile) return 0;
|
|
412
|
+
|
|
413
|
+
const contentTypesXml = await contentTypesFile.async('text');
|
|
414
|
+
let removedCount = 0;
|
|
415
|
+
const nextXml = contentTypesXml.replace(/<Override\b[^>]*\bPartName="([^"]+)"[^>]*\/>/g, (override, partName) => {
|
|
416
|
+
const normalizedPartName = normalizeZipPath(decodeXmlAttribute(partName).replace(/^\//, ''));
|
|
417
|
+
if (!normalizedPartName.startsWith(partPrefix)) return override;
|
|
418
|
+
|
|
419
|
+
removedCount += 1;
|
|
420
|
+
return '';
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
if (removedCount > 0) {
|
|
424
|
+
zip.file('[Content_Types].xml', nextXml);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
return removedCount;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
const stripEmbeddedFonts = async (zip: JSZip) => {
|
|
431
|
+
const fontEntries = Object.values(zip.files).filter((file) => !file.dir && file.name.startsWith('word/fonts/'));
|
|
432
|
+
if (fontEntries.length === 0) {
|
|
433
|
+
return { removedCount: 0, removedSize: 0 };
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
let removedSize = 0;
|
|
437
|
+
fontEntries.forEach((file) => {
|
|
438
|
+
removedSize += getZipEntrySize(file);
|
|
439
|
+
zip.remove(file.name);
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
await removeRelationshipByTargetPrefix(zip, 'word/_rels/fontTable.xml.rels', 'word/fonts/');
|
|
443
|
+
await removeContentTypeOverridesByPrefix(zip, 'word/fonts/');
|
|
444
|
+
|
|
445
|
+
const fontTableFile = zip.file('word/fontTable.xml');
|
|
446
|
+
if (fontTableFile) {
|
|
447
|
+
const fontTableXml = await fontTableFile.async('text');
|
|
448
|
+
zip.file(
|
|
449
|
+
'word/fontTable.xml',
|
|
450
|
+
fontTableXml.replace(/<w:embed(?:Regular|Bold|Italic|BoldItalic)\b[^>]*\/>/g, '')
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
return { removedCount: fontEntries.length, removedSize };
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const optimizeDocxBlob = async (blob: Blob): Promise<Blob> => {
|
|
458
|
+
try {
|
|
459
|
+
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
|
460
|
+
const usedMediaTargets = await collectUsedMediaTargets(zip);
|
|
461
|
+
const mediaEntries = Object.values(zip.files).filter((file) => !file.dir && file.name.startsWith('word/media/'));
|
|
462
|
+
let removedCount = 0;
|
|
463
|
+
let removedSize = 0;
|
|
464
|
+
|
|
465
|
+
mediaEntries.forEach((file) => {
|
|
466
|
+
if (usedMediaTargets.has(file.name)) return;
|
|
467
|
+
|
|
468
|
+
removedCount += 1;
|
|
469
|
+
removedSize += getZipEntrySize(file);
|
|
470
|
+
zip.remove(file.name);
|
|
471
|
+
});
|
|
472
|
+
|
|
473
|
+
const removedMediaRelationships = await pruneUnusedMediaRelationships(zip, usedMediaTargets);
|
|
474
|
+
const strippedFonts = await stripEmbeddedFonts(zip);
|
|
475
|
+
removedCount += strippedFonts.removedCount;
|
|
476
|
+
removedSize += strippedFonts.removedSize;
|
|
477
|
+
|
|
478
|
+
if (removedCount === 0) {
|
|
479
|
+
console.log(`[Word优化] 未发现可清理资源,文件大小 ${(blob.size / 1024 / 1024).toFixed(2)}MB`);
|
|
480
|
+
return blob;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const optimizedBuffer = await zip.generateAsync({
|
|
484
|
+
type: 'arraybuffer',
|
|
485
|
+
compression: 'DEFLATE',
|
|
486
|
+
compressionOptions: { level: 9 },
|
|
487
|
+
});
|
|
488
|
+
const optimizedBlob = new Blob([optimizedBuffer], { type: DOCX_MIME_TYPE });
|
|
489
|
+
|
|
490
|
+
console.log(
|
|
491
|
+
`[Word优化] 清理未使用媒体 ${removedCount - strippedFonts.removedCount} 个、媒体关系 ${removedMediaRelationships} 个、` +
|
|
492
|
+
`嵌入字体 ${strippedFonts.removedCount} 个,约 ${(removedSize / 1024 / 1024).toFixed(2)}MB,` +
|
|
493
|
+
`文件大小 ${(blob.size / 1024 / 1024).toFixed(2)}MB -> ${(optimizedBlob.size / 1024 / 1024).toFixed(2)}MB`
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
return optimizedBlob;
|
|
497
|
+
} catch (error) {
|
|
498
|
+
console.warn('[Word优化] 清理未引用媒体失败,将使用原始导出文件', error);
|
|
499
|
+
return blob;
|
|
500
|
+
}
|
|
501
|
+
};
|
|
502
|
+
|
|
503
|
+
const downloadBlob = (blob: Blob, name: string) => {
|
|
504
|
+
const url = URL.createObjectURL(blob);
|
|
505
|
+
const link = document.createElement('a');
|
|
506
|
+
link.href = url;
|
|
507
|
+
link.download = name;
|
|
508
|
+
link.dataset.optimizedDocxDownload = 'true';
|
|
509
|
+
link.style.display = 'none';
|
|
510
|
+
document.body.appendChild(link);
|
|
511
|
+
link.click();
|
|
512
|
+
document.body.removeChild(link);
|
|
513
|
+
setTimeout(() => URL.revokeObjectURL(url), 0);
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
const parseXmlDocument = (xml: string) => {
|
|
517
|
+
const document = new DOMParser().parseFromString(xml, 'application/xml');
|
|
518
|
+
const parseError = document.getElementsByTagName('parsererror')[0];
|
|
519
|
+
|
|
520
|
+
if (parseError) {
|
|
521
|
+
throw new Error(parseError.textContent || 'XML 解析失败');
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
return document;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
const getXmlElementsByLocalName = (document: globalThis.Document, tagNames: string[]) => {
|
|
528
|
+
const tagNameSet = new Set(tagNames);
|
|
529
|
+
return Array.from(document.getElementsByTagName('*')).filter((element) => tagNameSet.has(element.localName));
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
const removeXmlElementsByLocalName = (document: globalThis.Document, tagNames: string[]) => {
|
|
533
|
+
getXmlElementsByLocalName(document, tagNames).forEach((element) => {
|
|
534
|
+
element.parentNode?.removeChild(element);
|
|
535
|
+
});
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
const unwrapXmlElementsByLocalName = (document: globalThis.Document, tagNames: string[]) => {
|
|
539
|
+
getXmlElementsByLocalName(document, tagNames).forEach((element) => {
|
|
540
|
+
const parent = element.parentNode;
|
|
541
|
+
if (!parent) return;
|
|
542
|
+
|
|
543
|
+
while (element.firstChild) {
|
|
544
|
+
parent.insertBefore(element.firstChild, element);
|
|
545
|
+
}
|
|
546
|
+
parent.removeChild(element);
|
|
547
|
+
});
|
|
548
|
+
};
|
|
549
|
+
|
|
550
|
+
const serializeXmlDocument = (document: globalThis.Document) => new XMLSerializer().serializeToString(document);
|
|
551
|
+
|
|
552
|
+
const stripRevisionMarkupFromXml = (xml: string) => {
|
|
553
|
+
const document = parseXmlDocument(xml);
|
|
554
|
+
|
|
555
|
+
// Accept inserted/moved-to content and reject deleted/moved-from content for a temporary final-view preview.
|
|
556
|
+
removeXmlElementsByLocalName(document, ['del', 'moveFrom']);
|
|
557
|
+
unwrapXmlElementsByLocalName(document, ['ins', 'moveTo']);
|
|
558
|
+
removeXmlElementsByLocalName(document, [
|
|
559
|
+
'pPrChange',
|
|
560
|
+
'rPrChange',
|
|
561
|
+
'tblPrChange',
|
|
562
|
+
'trPrChange',
|
|
563
|
+
'tcPrChange',
|
|
564
|
+
'sectPrChange',
|
|
565
|
+
'tblGridChange',
|
|
566
|
+
'cellIns',
|
|
567
|
+
'cellDel',
|
|
568
|
+
'cellMerge',
|
|
569
|
+
]);
|
|
570
|
+
|
|
571
|
+
return serializeXmlDocument(document);
|
|
572
|
+
};
|
|
573
|
+
|
|
574
|
+
const stripRevisionSettingsFromXml = (xml: string) => {
|
|
575
|
+
const document = parseXmlDocument(xml);
|
|
576
|
+
removeXmlElementsByLocalName(document, ['trackRevisions', 'revisionView']);
|
|
577
|
+
|
|
578
|
+
return serializeXmlDocument(document);
|
|
579
|
+
};
|
|
580
|
+
|
|
581
|
+
const createFinalRevisionPreviewBlob = async (blob: Blob): Promise<Blob> => {
|
|
582
|
+
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
|
583
|
+
const xmlEntries = Object.values(zip.files).filter((file) => !file.dir && REVISION_CONTENT_XML_PATTERN.test(file.name));
|
|
584
|
+
|
|
585
|
+
await Promise.all(xmlEntries.map(async (file) => {
|
|
586
|
+
const xml = await file.async('text');
|
|
587
|
+
zip.file(file.name, stripRevisionMarkupFromXml(xml));
|
|
588
|
+
}));
|
|
589
|
+
|
|
590
|
+
const settingsFile = zip.file('word/settings.xml');
|
|
591
|
+
if (settingsFile) {
|
|
592
|
+
const settingsXml = await settingsFile.async('text');
|
|
593
|
+
zip.file(
|
|
594
|
+
'word/settings.xml',
|
|
595
|
+
stripRevisionSettingsFromXml(settingsXml)
|
|
596
|
+
);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
const buffer = await zip.generateAsync({
|
|
600
|
+
type: 'arraybuffer',
|
|
601
|
+
compression: 'DEFLATE',
|
|
602
|
+
compressionOptions: { level: 9 },
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
return new Blob([buffer], { type: DOCX_MIME_TYPE });
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* 修复 docx 中 TOC(目录)字段的显示问题。
|
|
610
|
+
*
|
|
611
|
+
* 问题根因:docx-editor-core 库的段落解析函数(U)不支持嵌套复杂字段。当遇到嵌套
|
|
612
|
+
* 字段的 begin 时,会重置外层字段的状态(指令、fieldCode、fieldResult 全部清空),
|
|
613
|
+
* 导致外层 TOC 字段和中间 HYPERLINK 字段的 fieldResult 丢失,只有最内层的 PAGEREF
|
|
614
|
+
* 字段(页码)被正确解析。最终 TOC 条目只显示页码,标题文本丢失。
|
|
615
|
+
*
|
|
616
|
+
* TOC 的典型嵌套结构:
|
|
617
|
+
* TOC begin → TOC instrText → TOC separate
|
|
618
|
+
* → HYPERLINK begin → HYPERLINK instrText → HYPERLINK separate
|
|
619
|
+
* → "标题文本" → tab
|
|
620
|
+
* → PAGEREF begin → PAGEREF instrText → PAGEREF separate → "页码" → PAGEREF end
|
|
621
|
+
* → HYPERLINK end
|
|
622
|
+
* → TOC end
|
|
623
|
+
*
|
|
624
|
+
* 修复方案:在加载 docx 前,预处理 XML,移除 TOC 字段范围(begin 到 end)内的所有
|
|
625
|
+
* fldChar 和 instrText 元素(包括 TOC 本身的和嵌套的 HYPERLINK、PAGEREF 等),只保留
|
|
626
|
+
* 内容元素(w:t、w:tab 等)。这样 TOC 条目的标题文本和页码就能作为普通文本正确显示。
|
|
627
|
+
* 同时处理 fldSimple 形式的 TOC,将其内部的 w:hyperlink 标签 unwrap。
|
|
628
|
+
*/
|
|
629
|
+
const fixTocFieldsInXml = (xml: string): string => {
|
|
630
|
+
const doc = parseXmlDocument(xml);
|
|
631
|
+
|
|
632
|
+
// 1. 处理 fldSimple 形式的 TOC:将内部的 w:hyperlink 标签 unwrap(替换为其内容)
|
|
633
|
+
const fldSimples = Array.from(doc.getElementsByTagName('*')).filter(
|
|
634
|
+
(el) => el.localName === 'fldSimple'
|
|
635
|
+
);
|
|
636
|
+
for (const fldSimple of fldSimples) {
|
|
637
|
+
const instr = fldSimple.getAttribute('w:instr') || fldSimple.getAttribute('instr') || '';
|
|
638
|
+
if (!instr.includes('TOC')) continue;
|
|
639
|
+
|
|
640
|
+
const hyperlinks = Array.from(fldSimple.getElementsByTagName('*')).filter(
|
|
641
|
+
(el) => el.localName === 'hyperlink'
|
|
642
|
+
);
|
|
643
|
+
for (const hyperlink of hyperlinks) {
|
|
644
|
+
const parent = hyperlink.parentNode;
|
|
645
|
+
if (!parent) continue;
|
|
646
|
+
while (hyperlink.firstChild) {
|
|
647
|
+
parent.insertBefore(hyperlink.firstChild, hyperlink);
|
|
648
|
+
}
|
|
649
|
+
parent.removeChild(hyperlink);
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// 2. 处理 fldChar 形式的 TOC:移除 TOC 字段范围内的所有 fldChar 和 instrText
|
|
654
|
+
// 收集所有元素(getElementsByTagName 按文档顺序返回,Array.from 创建静态快照)
|
|
655
|
+
const allElements = Array.from(doc.getElementsByTagName('*'));
|
|
656
|
+
|
|
657
|
+
// 2a. 第一遍:用栈跟踪字段嵌套,找到所有 TOC 字段的 begin 和 end 元素
|
|
658
|
+
const fieldStack: Array<{ type: string; beginEl: Element }> = [];
|
|
659
|
+
const tocRanges: Array<{ beginIdx: number; endIdx: number }> = [];
|
|
660
|
+
|
|
661
|
+
for (let i = 0; i < allElements.length; i++) {
|
|
662
|
+
const el = allElements[i];
|
|
663
|
+
if (el.localName === 'fldChar') {
|
|
664
|
+
const charType = el.getAttribute('w:fldCharType') || el.getAttribute('fldCharType') || '';
|
|
665
|
+
if (charType === 'begin') {
|
|
666
|
+
fieldStack.push({ type: '', beginEl: el });
|
|
667
|
+
} else if (charType === 'end' && fieldStack.length > 0) {
|
|
668
|
+
const field = fieldStack.pop()!;
|
|
669
|
+
if (field.type === 'TOC') {
|
|
670
|
+
const beginIdx = allElements.indexOf(field.beginEl);
|
|
671
|
+
const endIdx = i;
|
|
672
|
+
if (beginIdx >= 0) {
|
|
673
|
+
tocRanges.push({ beginIdx, endIdx });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
} else if (el.localName === 'instrText') {
|
|
678
|
+
const text = el.textContent || '';
|
|
679
|
+
if (fieldStack.length > 0 && !fieldStack[fieldStack.length - 1].type) {
|
|
680
|
+
const match = text.trim().match(/^\\?([A-Z][A-Z0-9]*)/i);
|
|
681
|
+
if (match) {
|
|
682
|
+
fieldStack[fieldStack.length - 1].type = match[1].toUpperCase();
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
// 2b. 第二遍:在每个 TOC 范围内,移除所有 fldChar 和 instrText 元素
|
|
689
|
+
const elementsToRemove: Element[] = [];
|
|
690
|
+
for (const range of tocRanges) {
|
|
691
|
+
for (let i = range.beginIdx; i <= range.endIdx; i++) {
|
|
692
|
+
const el = allElements[i];
|
|
693
|
+
if (el.localName === 'fldChar' || el.localName === 'instrText') {
|
|
694
|
+
elementsToRemove.push(el);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
for (const el of elementsToRemove) {
|
|
699
|
+
const parent = el.parentNode;
|
|
700
|
+
if (parent) {
|
|
701
|
+
parent.removeChild(el);
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
// 3. 移除空的 run 元素(移除 fldChar/instrText 后只剩余 rPr 的 run)
|
|
706
|
+
const allRuns = Array.from(doc.getElementsByTagName('*')).filter((el) => el.localName === 'r');
|
|
707
|
+
for (const run of allRuns) {
|
|
708
|
+
const hasContent = Array.from(run.children).some((child) => child.localName !== 'rPr');
|
|
709
|
+
if (!hasContent) {
|
|
710
|
+
const parent = run.parentNode;
|
|
711
|
+
if (parent) {
|
|
712
|
+
parent.removeChild(run);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
return serializeXmlDocument(doc);
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const fixTocFieldsInDocx = async (blob: Blob): Promise<Blob> => {
|
|
721
|
+
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
|
722
|
+
const xmlEntries = Object.values(zip.files).filter((file) => !file.dir && REVISION_CONTENT_XML_PATTERN.test(file.name));
|
|
723
|
+
|
|
724
|
+
await Promise.all(xmlEntries.map(async (file) => {
|
|
725
|
+
const xml = await file.async('text');
|
|
726
|
+
zip.file(file.name, fixTocFieldsInXml(xml));
|
|
727
|
+
}));
|
|
728
|
+
|
|
729
|
+
const buffer = await zip.generateAsync({
|
|
730
|
+
type: 'arraybuffer',
|
|
731
|
+
compression: 'DEFLATE',
|
|
732
|
+
compressionOptions: { level: 9 },
|
|
733
|
+
});
|
|
734
|
+
|
|
735
|
+
return new Blob([buffer], { type: DOCX_MIME_TYPE });
|
|
736
|
+
};
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* 将预设样式(含 spaceBefore/spaceAfter 等段间距)合并进 docx 的 styles.xml。
|
|
740
|
+
*
|
|
741
|
+
* 必须在 parseDocx 之前调用:解析器在解析阶段就会根据 styles.xml 解析样式继承,
|
|
742
|
+
* 把段间距等属性固化到段落模型中。repack / selective save 都不会改写 styles.xml,
|
|
743
|
+
* 因此这里修正后的 styles.xml 会被原样保留,保存后再次打开依然生效。
|
|
744
|
+
*/
|
|
745
|
+
const mergePresetStylesIntoDocx = async (blob: Blob): Promise<Blob> => {
|
|
746
|
+
const zip = await JSZip.loadAsync(await blob.arrayBuffer());
|
|
747
|
+
|
|
748
|
+
let modified = false;
|
|
749
|
+
|
|
750
|
+
// 1. 合并预设样式到 styles.xml(替换同 styleId 的样式定义)
|
|
751
|
+
const stylesFile = zip.file('word/styles.xml');
|
|
752
|
+
if (stylesFile) {
|
|
753
|
+
const stylesXml = await stylesFile.async('text');
|
|
754
|
+
const mergedXml = mergePresetStylesIntoStylesXml(stylesXml);
|
|
755
|
+
if (mergedXml !== stylesXml) {
|
|
756
|
+
zip.file('word/styles.xml', mergedXml);
|
|
757
|
+
modified = true;
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// 2. 为 document.xml 中引用带间距样式的段落添加内联 <w:spacing> 标签。
|
|
762
|
+
// styles.xml 中样式定义的间距是"继承"属性,布局引擎的 spacingExplicit 机制
|
|
763
|
+
// 会折叠未标记为显式的继承间距。内联 <w:spacing> 会被解析器标记为显式设置,
|
|
764
|
+
// 确保重载后 spaceBefore/spaceAfter 生效。
|
|
765
|
+
const docFile = zip.file('word/document.xml');
|
|
766
|
+
if (docFile) {
|
|
767
|
+
let docXml = await docFile.async('text');
|
|
768
|
+
let mergedDocXml = ensureInlineSpacingInDocumentXml(docXml);
|
|
769
|
+
|
|
770
|
+
// —— 3. WPS 兼容:为 styleId=HongTouSeparator 的段落强制内联 4 边 <w:pBdr>
|
|
771
|
+
// WPS 2019/2021 部分版本不稳定读取 styles.xml 中 style 级继承的 pBdr,
|
|
772
|
+
// 只有直接写在 <w:p>/<w:pPr> 内联标签上的 <w:pBdr> 才显示红线。
|
|
773
|
+
// 因此这里做"解继承",给每个红头分隔线段落都补一段标准 4 边 pBdr 标签。
|
|
774
|
+
mergedDocXml = ensureHongTouSeparatorBorderInDocumentXml(mergedDocXml);
|
|
775
|
+
|
|
776
|
+
if (mergedDocXml !== docXml) {
|
|
777
|
+
zip.file('word/document.xml', mergedDocXml);
|
|
778
|
+
modified = true;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
if (modified) {
|
|
783
|
+
const buffer = await zip.generateAsync({
|
|
784
|
+
type: 'arraybuffer',
|
|
785
|
+
compression: 'DEFLATE',
|
|
786
|
+
compressionOptions: { level: 9 },
|
|
787
|
+
});
|
|
788
|
+
return new Blob([buffer], { type: DOCX_MIME_TYPE });
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
return blob;
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
interface WordEditorProps {
|
|
796
|
+
visible: boolean;
|
|
797
|
+
hiddenClose?: boolean; // 是否隐藏关闭按钮
|
|
798
|
+
isCreate?: boolean; // 是否为新建模式
|
|
799
|
+
onClose: () => void;
|
|
800
|
+
fileName: string;
|
|
801
|
+
onUpload?: (initialBlob: Blob | undefined, blob: Blob, fileName: string) => Promise<void>;
|
|
802
|
+
initialBlob?: Blob;
|
|
803
|
+
readOnly?: boolean;
|
|
804
|
+
uploadPercent?: number;
|
|
805
|
+
// ---- 新增:本地文件 / 离线模式支持 ----
|
|
806
|
+
isOffline?: boolean;
|
|
807
|
+
isLocalOnly?: boolean;
|
|
808
|
+
hasUnsyncedChanges?: boolean;
|
|
809
|
+
onSaveLocalBlob?: (blob: Blob, fileName: string, markUnsynced?: boolean) => Promise<void>;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
// docx 内部单位换算:1 厘米 = 567 twips,1 磅 = 20 twips(1 inch = 1440 twips)
|
|
813
|
+
const TWIPS_PER_CM = 567;
|
|
814
|
+
const TWIPS_PER_PT = 20;
|
|
815
|
+
|
|
816
|
+
type ParagraphAlignmentValue = 'left' | 'center' | 'right' | 'both';
|
|
817
|
+
type LineSpacingRuleValue = 'auto' | 'exact' | 'atLeast';
|
|
818
|
+
|
|
819
|
+
/** 段落设置对话框的表单值(对外用厘米/磅/倍数,应用时再换算成 twips) */
|
|
820
|
+
interface ParagraphDialogValues {
|
|
821
|
+
alignment: ParagraphAlignmentValue;
|
|
822
|
+
indentLeftCm: number;
|
|
823
|
+
indentRightCm: number;
|
|
824
|
+
indentSpecial: 'none' | 'firstLine' | 'hanging';
|
|
825
|
+
indentSpecialCm: number;
|
|
826
|
+
spaceBeforePt: number;
|
|
827
|
+
spaceAfterPt: number;
|
|
828
|
+
lineRule: LineSpacingRuleValue;
|
|
829
|
+
/** lineRule 为 auto 时是倍数(1 = 单倍行距),否则是磅值 */
|
|
830
|
+
lineValue: number;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const DEFAULT_PARAGRAPH_DIALOG_VALUES: ParagraphDialogValues = {
|
|
834
|
+
alignment: 'left',
|
|
835
|
+
indentLeftCm: 0,
|
|
836
|
+
indentRightCm: 0,
|
|
837
|
+
indentSpecial: 'none',
|
|
838
|
+
indentSpecialCm: 0,
|
|
839
|
+
spaceBeforePt: 0,
|
|
840
|
+
spaceAfterPt: 0,
|
|
841
|
+
lineRule: 'auto',
|
|
842
|
+
lineValue: 1,
|
|
843
|
+
};
|
|
844
|
+
|
|
845
|
+
// 回车换段时应沿用当前段落的格式(缩进、段前段后、行距、样式、编号)。
|
|
846
|
+
// prosemirror 的 baseKeymap 用 splitBlock 新建段落时使用默认属性,段落的
|
|
847
|
+
// 首行缩进因此会丢失;这里通过外部插件覆盖 Enter。
|
|
848
|
+
// 不复制段落标识/修订/书签等「每个段落唯一」的属性,避免出现重复 id。
|
|
849
|
+
const ENTER_IGNORED_ATTRS = new Set([
|
|
850
|
+
'paraId',
|
|
851
|
+
'textId',
|
|
852
|
+
'bookmarks',
|
|
853
|
+
'sectionBreakType',
|
|
854
|
+
'pPrIns',
|
|
855
|
+
'pPrDel',
|
|
856
|
+
'pPrChange',
|
|
857
|
+
'renderedPageBreakBefore',
|
|
858
|
+
'_originalFormatting',
|
|
859
|
+
'_originalRunBoundaries',
|
|
860
|
+
'_sectionProperties',
|
|
861
|
+
]);
|
|
862
|
+
|
|
863
|
+
const splitBlockKeepingParagraphFormatting = splitBlockAs((node) => ({
|
|
864
|
+
type: node.type,
|
|
865
|
+
attrs: Object.fromEntries(
|
|
866
|
+
Object.entries(node.attrs ?? {}).filter(([key]) => !ENTER_IGNORED_ATTRS.has(key)),
|
|
867
|
+
),
|
|
868
|
+
}));
|
|
869
|
+
|
|
870
|
+
const paragraphFormattingEnterPlugin: ReactEditorPlugin = {
|
|
871
|
+
id: 'paragraph-formatting-enter',
|
|
872
|
+
name: '回车保留段落格式 / Ctrl+Enter 分页',
|
|
873
|
+
proseMirrorPlugins: [
|
|
874
|
+
keymap({
|
|
875
|
+
Enter: (state, dispatch) => {
|
|
876
|
+
// 修订模式下交给编辑器自身的 Enter 处理,保证段落拆分被记录为修订
|
|
877
|
+
if (isSuggestionModeActive(state)) return false;
|
|
878
|
+
return splitBlockKeepingParagraphFormatting(state, dispatch);
|
|
879
|
+
},
|
|
880
|
+
// Ctrl/Cmd + Enter 插入分页符(与 Word 一致)
|
|
881
|
+
'Mod-Enter': (state, dispatch) => insertPageBreak(state, dispatch),
|
|
882
|
+
}),
|
|
883
|
+
],
|
|
884
|
+
};
|
|
885
|
+
|
|
886
|
+
/** 需要注册到编辑器的插件(PluginHost 会把 proseMirrorPlugins 合并进编辑器) */
|
|
887
|
+
const wordEditorPlugins: ReactEditorPlugin[] = [paragraphFormattingEnterPlugin];
|
|
888
|
+
|
|
889
|
+
export const WordEditor = forwardRef<DocxEditorRef | null, WordEditorProps>(({
|
|
890
|
+
visible,
|
|
891
|
+
hiddenClose = false,
|
|
892
|
+
isCreate = false,
|
|
893
|
+
onClose,
|
|
894
|
+
fileName,
|
|
895
|
+
onUpload,
|
|
896
|
+
initialBlob,
|
|
897
|
+
readOnly,
|
|
898
|
+
uploadPercent,
|
|
899
|
+
isOffline,
|
|
900
|
+
isLocalOnly,
|
|
901
|
+
hasUnsyncedChanges,
|
|
902
|
+
onSaveLocalBlob,
|
|
903
|
+
}, ref) => {
|
|
904
|
+
const [isMobile, setIsMobile] = useState(false);
|
|
905
|
+
|
|
906
|
+
const editorRef = useRef<DocxEditorRef | null>(null);
|
|
907
|
+
const pluginHostRef = useRef<PluginHostRef | null>(null);
|
|
908
|
+
const saveTimerRef = useRef<NodeJS.Timeout | null>(null);
|
|
909
|
+
const suppressEditorSaveRef = useRef(false);
|
|
910
|
+
// 在 exportToBlob()(调用 editorRef.current.save())期间抑制 onChange,
|
|
911
|
+
// 防止 save() 内部触发的 transaction 导致不必要的状态更新和重新渲染(移动端闪烁)。
|
|
912
|
+
const suppressChangeRef = useRef(false);
|
|
913
|
+
const pendingEditorDownloadRef = useRef<Promise<Blob> | null>(null);
|
|
914
|
+
const revisionPreviewSourceBufferRef = useRef<ArrayBuffer | null>(null);
|
|
915
|
+
// 跟踪编辑器是否已完成初始加载。编辑器初始化时会触发 onChange,需抑制以免误标"有修改未同步"。
|
|
916
|
+
const isEditorReadyRef = useRef(false);
|
|
917
|
+
// 跟踪当前差分上传的基准 blob(即服务器上最新的文件内容)。
|
|
918
|
+
// 上传成功后会更新为本次上传的 blob,确保下次差分基准与服务器一致。
|
|
919
|
+
const baseBlobRef = useRef<Blob | undefined>(initialBlob);
|
|
920
|
+
// 跟踪"标记为未同步"的调用是否已在本次编辑会话中执行过,避免每次 onChange 都写 IndexedDB
|
|
921
|
+
const markUnsyncedTriggeredRef = useRef(false);
|
|
922
|
+
// —— 新增:把 useEffect 内部的 applyAllFixes 函数引用暴露到组件级闭包,
|
|
923
|
+
// 供 onChange/onSelect/setInterval 等外部逻辑调用(因为 markHongTouSeparatorParagraphs
|
|
924
|
+
// 定义在 useEffect 内部,外部拿不到它的闭包作用域)。
|
|
925
|
+
// applyAllFixes 内部所有函数都是幂等的,多次调用无副作用。
|
|
926
|
+
const applyAllFixesRef = useRef<() => void>(() => {});
|
|
927
|
+
// —— 新增:打开文件时的「原始 docx 所有 part 字节快照」,用于保存时镜像回写
|
|
928
|
+
// 绝不可能通过正文编辑改动的 part(header/footer/media/embeddings 及对应 rels)。
|
|
929
|
+
// 背景:@eigenpal/docx-editor-core 的 save() serializer 在处理这些 part 时
|
|
930
|
+
// 会丢失 r:embed / 破坏 drawing 命名空间,导致保存后重新打开页脚的签章/水印
|
|
931
|
+
// 图 fallback 成 <img src=""> 空占位图。通过在 save 输出的 docx 里把这些 part
|
|
932
|
+
// 直接覆盖成打开时的原始字节,彻底绕开 lib serializer 对这些 part 的损坏。
|
|
933
|
+
// Map key: docx 内部 part 路径(如 "word/footer1.xml"、"word/media/image1.png")
|
|
934
|
+
// Map value: 原始 part 的 Uint8Array(字节级完全一致)
|
|
935
|
+
const rawDocxPartsRef = useRef<Map<string, Uint8Array>>(new Map());
|
|
936
|
+
// 空白新建文档(无 initialBlob / 点击"新建")时 snapshot 为空,镜像回写自动跳过,
|
|
937
|
+
// 不会影响空白文档正常保存。
|
|
938
|
+
|
|
939
|
+
// 用 ref 跟踪这些"状态标记"属性,避免它们的变化(如保存/上传后更新标记)
|
|
940
|
+
// 触发编辑器相关 useEffect 重新执行,导致画面闪动。
|
|
941
|
+
const hasUnsyncedChangesRef = useRef(hasUnsyncedChanges);
|
|
942
|
+
const onSaveLocalBlobRef = useRef(onSaveLocalBlob);
|
|
943
|
+
hasUnsyncedChangesRef.current = hasUnsyncedChanges;
|
|
944
|
+
onSaveLocalBlobRef.current = onSaveLocalBlob;
|
|
945
|
+
const [initialBuffer, setInitialBuffer] = useState<ArrayBuffer | null>(null);
|
|
946
|
+
const [initialDocument, setInitialDocument] = useState<DocxDocument | null>(null);
|
|
947
|
+
const [editorKey, setEditorKey] = useState(0);
|
|
948
|
+
const [userId, setUserId] = useState<string | null>(null);
|
|
949
|
+
const [hasLocalCache, setHasLocalCache] = useState(false);
|
|
950
|
+
const [hasUploaded, setHasUploaded] = useState(!isCreate);
|
|
951
|
+
const [loadingInitialBlob, setLoadingInitialBlob] = useState(false);
|
|
952
|
+
const [showFinalRevisionPreview, setShowFinalRevisionPreview] = useState(false);
|
|
953
|
+
|
|
954
|
+
// ============================================================================
|
|
955
|
+
// 自定义段落样式选择器(受控)
|
|
956
|
+
//
|
|
957
|
+
// 为什么不用 DocxEditor 自带的工具栏样式选择器:
|
|
958
|
+
// 用户反馈其存在双向同步 Bug:
|
|
959
|
+
// Bug 1) 全选一段"标题 1"时,选择框显示为"正文",与实际样式不符,
|
|
960
|
+
// 导致用户点击"正文"想应用样式也无效(因为 UI 以为已经是正文)。
|
|
961
|
+
// Bug 2) 选择了其他样式后,文档视觉确实变化了,但选择框仍然显示"正文"。
|
|
962
|
+
// 说明 DocxEditor 自带样式选择器的 value 受控同步逻辑缺失 / 有 bug,
|
|
963
|
+
// 我们改用自己的 antd Select 严格受控,彻底修复这两个问题。
|
|
964
|
+
//
|
|
965
|
+
// currentStyleId 来源优先级:
|
|
966
|
+
// 1) 选区变化时 → 从 ProseMirror state 用官方 extractSelectionState/getStyleId 读取(最可靠)
|
|
967
|
+
// 2) 应用样式成功时 → 直接 setCurrentStyleId(新 styleId)
|
|
968
|
+
// 3) 快捷键应用成功时 → 同样 setCurrentStyleId(styleId)
|
|
969
|
+
// ============================================================================
|
|
970
|
+
const [currentStyleId, setCurrentStyleId] = useState<string>('Normal');
|
|
971
|
+
|
|
972
|
+
// —— 新增:从文件 styles.xml 里动态获取到的所有段落样式列表
|
|
973
|
+
// 解决用户反馈"样式框里缺少了文件本身的样式(如 footer/header/Normal(Web))":
|
|
974
|
+
// PRESET_STYLES 里只有我们写死的几个预设,但 docx 文件里自带的自定义样式
|
|
975
|
+
// (页脚/页眉/模板标题等)我们也必须显示在下拉框里,让用户能选到。
|
|
976
|
+
// 每次 onSelectionChange / onChange 时我们从 StyleResolver 里 getParagraphStyles()
|
|
977
|
+
// 拿一次,存在这里。
|
|
978
|
+
const [documentStylesFromFile, setDocumentStylesFromFile] = useState<Style[]>([]);
|
|
979
|
+
// 同步到 ref,避免 useCallback 的依赖数组抖个不停
|
|
980
|
+
const documentStylesFromFileRef = useRef<Style[]>([]);
|
|
981
|
+
documentStylesFromFileRef.current = documentStylesFromFile;
|
|
982
|
+
|
|
983
|
+
/**
|
|
984
|
+
* 从 ProseMirror EditorState 里拿当前文档的 StyleResolver,拿到所有段落样式
|
|
985
|
+
* (文件自带的 + DocxEditor 内部注册的都在这里)。拿到后存入 state,
|
|
986
|
+
* 供 getStyleSelectOptions 用在下拉 options 里。
|
|
987
|
+
*/
|
|
988
|
+
const syncDocumentStylesFromFile = useCallback((state: EditorState) => {
|
|
989
|
+
try {
|
|
990
|
+
const resolver = getDocumentStyleResolver(state);
|
|
991
|
+
if (!resolver) return;
|
|
992
|
+
const paraStyles = resolver.getParagraphStyles() ?? [];
|
|
993
|
+
// 过滤掉 hidden 的样式(避免下拉框里塞一大堆 Word 内部隐藏样式),
|
|
994
|
+
// 但保留 semiHidden/unhideWhenUsed(因为 Word 实际上会把这些样式展示出来)
|
|
995
|
+
const visibleStyles = paraStyles.filter(s => !s.hidden);
|
|
996
|
+
// 拿到空列表时不要覆盖已有的文件样式:
|
|
997
|
+
// 打开文件时已用 extractParagraphStyles 灌过一遍,这里回填空值会把下拉框
|
|
998
|
+
// 打回"只剩预设样式"的状态。
|
|
999
|
+
if (visibleStyles.length === 0) return;
|
|
1000
|
+
setDocumentStylesFromFile(prev => {
|
|
1001
|
+
// 简单同值比较,避免无限重渲染
|
|
1002
|
+
if (prev.length === visibleStyles.length
|
|
1003
|
+
&& prev.every((p, i) => p.styleId === visibleStyles[i].styleId && p.name === visibleStyles[i].name)) {
|
|
1004
|
+
return prev;
|
|
1005
|
+
}
|
|
1006
|
+
return visibleStyles;
|
|
1007
|
+
});
|
|
1008
|
+
} catch {
|
|
1009
|
+
/* 忽略异常(StyleResolver 插件没挂时返回空就好了) */
|
|
1010
|
+
}
|
|
1011
|
+
}, []);
|
|
1012
|
+
|
|
1013
|
+
// ============================================================================
|
|
1014
|
+
// —— 修复"点击一次样式下拉框立马消失,按住拖动才不消失"的终极方案:
|
|
1015
|
+
// 把 antd Select 的 open/close 完全由我们自己控制(受控 open 模式),
|
|
1016
|
+
// 不再信任 antd 默认的"blur / 点击外部自动关闭"逻辑。
|
|
1017
|
+
//
|
|
1018
|
+
// 为什么需要这么做:
|
|
1019
|
+
// DocxEditor/ProseMirror 在 document 上挂了 mousedown 全局监听 + 编辑器 blur 检测,
|
|
1020
|
+
// 用户在我们自定义 Select 上点击一下 → mousedown → selector 内部的 input 抢 focus →
|
|
1021
|
+
// DocxEditor 判定为"editor 失去焦点" → onSelectionChange 触发 → 重渲染 → antd Select
|
|
1022
|
+
// 默认的 blur 关闭逻辑同时命中 → popup 刚打开立马被关(闪现)。
|
|
1023
|
+
// 但用户"按住鼠标不放在 popup 上拖动"时,因为 mousedown → mouseup 中间跨了两个元素,
|
|
1024
|
+
// DocxEditor 的 blur 检测被绕过,所以 popup 不消失。
|
|
1025
|
+
//
|
|
1026
|
+
// 修复思路(完全夺回开闭控制权):
|
|
1027
|
+
// 1) 新增 styleDropdownOpen state:唯一真源,控制 Select 的 open 属性
|
|
1028
|
+
// 2) onDropdownVisibleChange:只接受 open=true 请求(anrdo 想打开就允许)
|
|
1029
|
+
// open=false 请求一律忽略!(因为那是 antd 的默认 blur/外点检测,会被 editor 干扰)
|
|
1030
|
+
// 3) 自己在 document 上挂 mouseup 监听:只有当点击位置
|
|
1031
|
+
// a) 不在 .vendorsys-custom-style-picker (trigger 本身)
|
|
1032
|
+
// b) 不在 .vendorsys-word-style-dropdown (popup 面板)
|
|
1033
|
+
// 内时,才视为真的想关闭,调用 setStyleDropdownOpen(false)
|
|
1034
|
+
// 4) 用户选完样式(handleStyleSelectChange 结束)后,主动 setStyleDropdownOpen(false)
|
|
1035
|
+
// 5) 加 onMouseDown={e => e.preventDefault()} 在 Select 外层,进一步阻止默认 focus 行为,
|
|
1036
|
+
// 从根上避免 editor blur 检测误触发。
|
|
1037
|
+
// ============================================================================
|
|
1038
|
+
const [styleDropdownOpen, setStyleDropdownOpen] = useState(false);
|
|
1039
|
+
|
|
1040
|
+
// 在 editor 打开时挂 document mouseup 监听(受控关闭),editor 关闭时清理
|
|
1041
|
+
useEffect(() => {
|
|
1042
|
+
if (!visible) return;
|
|
1043
|
+
|
|
1044
|
+
const handleDocMouseUp = (e: MouseEvent) => {
|
|
1045
|
+
// 只有 popup 是打开状态才需要判断是否要关
|
|
1046
|
+
setStyleDropdownOpen((prevOpen) => {
|
|
1047
|
+
if (!prevOpen) return prevOpen; // 本来就关了,不动
|
|
1048
|
+
|
|
1049
|
+
const target = e.target as HTMLElement | null;
|
|
1050
|
+
if (!target) return prevOpen;
|
|
1051
|
+
|
|
1052
|
+
// 判断 target 是否在 trigger(自定义 Select 容器)里
|
|
1053
|
+
const inTrigger = target.closest(
|
|
1054
|
+
'.vendorsys-custom-style-picker, [data-vendorsys-custom="true"]'
|
|
1055
|
+
);
|
|
1056
|
+
// 判断 target 是否在 popup(下拉面板)里 —— 注意 popup 是挂到 .ep-root 下的
|
|
1057
|
+
const inDropdown = target.closest('.ant-select-dropdown.vendorsys-word-style-dropdown');
|
|
1058
|
+
|
|
1059
|
+
// 只有当两者都不在时 → 真的想关闭
|
|
1060
|
+
if (!inTrigger && !inDropdown) {
|
|
1061
|
+
return false;
|
|
1062
|
+
}
|
|
1063
|
+
return prevOpen;
|
|
1064
|
+
});
|
|
1065
|
+
};
|
|
1066
|
+
|
|
1067
|
+
// 用 capture:true + passive 保证第一时间响应,不被 editor 自己的监听吞掉
|
|
1068
|
+
document.addEventListener('mouseup', handleDocMouseUp, { capture: true, passive: true });
|
|
1069
|
+
return () => {
|
|
1070
|
+
document.removeEventListener('mouseup', handleDocMouseUp, { capture: true } as unknown as EventListenerOptions);
|
|
1071
|
+
};
|
|
1072
|
+
}, [visible]);
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* —— 新增:样式预览 inline style 换算工具
|
|
1076
|
+
* 把 DocxEditor Style 里的 rPr (TextFormatting) + pPr (ParagraphFormatting)
|
|
1077
|
+
* 从 DocxEditor 内部单位换算成 React 的 CSSProperties,用于自定义下拉框
|
|
1078
|
+
* 里每一个 option 的行内样式 —— 让 option 里的「标题 1」等文字,
|
|
1079
|
+
* 真的用"标题 1"的字号/加粗/字体/颜色/行高显示,不再用 antd 默认小字体,
|
|
1080
|
+
* 对齐 DocxEditor 自带样式选择器(图二)的视觉效果。
|
|
1081
|
+
*
|
|
1082
|
+
* 只处理 option 预览最需要的 6-7 项常用 CSS 属性,段前段后/缩进等在单行
|
|
1083
|
+
* option 里意义不大的属性直接跳过。
|
|
1084
|
+
*/
|
|
1085
|
+
const styleToPreviewCSSProperties = useCallback((style?: Style | null): React.CSSProperties => {
|
|
1086
|
+
const empty: React.CSSProperties = {};
|
|
1087
|
+
if (!style) return empty;
|
|
1088
|
+
const rPr = style.rPr;
|
|
1089
|
+
const pPr = style.pPr;
|
|
1090
|
+
|
|
1091
|
+
const out: React.CSSProperties = {};
|
|
1092
|
+
|
|
1093
|
+
// —— 1. 字号:half-point → px
|
|
1094
|
+
// DocxEditor fontSize 单位是 half-point:例如 30 = 15pt。
|
|
1095
|
+
// 1 pt = 96 / 72 px ≈ 1.3333 px,所以 half-point → px:
|
|
1096
|
+
// px = halfPoint / 2 * (96 / 72) = halfPoint * (2 / 3)。
|
|
1097
|
+
// 例如:30 half-points → 20px;32 → ~21.33px
|
|
1098
|
+
const fontSizeHalfPt = rPr?.fontSize ?? rPr?.fontSizeCs;
|
|
1099
|
+
if (typeof fontSizeHalfPt === 'number' && fontSizeHalfPt > 0) {
|
|
1100
|
+
const px = (fontSizeHalfPt * 2) / 3;
|
|
1101
|
+
// 最小字号 11px(避免某些隐藏样式字号极小,用户看不清),最大不限制
|
|
1102
|
+
out.fontSize = `${Math.max(11, px)}px`;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// —— 2. 加粗 / 复杂脚本加粗
|
|
1106
|
+
if (rPr?.bold === true || rPr?.boldCs === true) {
|
|
1107
|
+
out.fontWeight = 700;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
// —— 3. 斜体
|
|
1111
|
+
if (rPr?.italic === true || rPr?.italicCs === true) {
|
|
1112
|
+
out.fontStyle = 'italic';
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
// —— 4. 字体族:优先东亚字体(eastAsia 更贴合中文文档实际)
|
|
1116
|
+
// 其次 ascii / hAnsi,最后回退系统默认字体栈
|
|
1117
|
+
const ff = rPr?.fontFamily;
|
|
1118
|
+
if (ff) {
|
|
1119
|
+
const familyNames: string[] = [];
|
|
1120
|
+
if (ff.eastAsia) familyNames.push(`"${ff.eastAsia}"`);
|
|
1121
|
+
if (ff.ascii && ff.ascii !== ff.eastAsia) familyNames.push(`"${ff.ascii}"`);
|
|
1122
|
+
if (ff.hAnsi && ff.hAnsi !== ff.ascii && ff.hAnsi !== ff.eastAsia) familyNames.push(`"${ff.hAnsi}"`);
|
|
1123
|
+
// 加回退字体栈,避免用户系统缺字体时变成默认宋体还没抗锯齿
|
|
1124
|
+
familyNames.push(
|
|
1125
|
+
'system-ui',
|
|
1126
|
+
'-apple-system',
|
|
1127
|
+
'BlinkMacSystemFont',
|
|
1128
|
+
'"Segoe UI"',
|
|
1129
|
+
'Roboto',
|
|
1130
|
+
'"Helvetica Neue"',
|
|
1131
|
+
'Arial',
|
|
1132
|
+
'sans-serif'
|
|
1133
|
+
);
|
|
1134
|
+
out.fontFamily = familyNames.join(', ');
|
|
1135
|
+
}
|
|
1136
|
+
|
|
1137
|
+
// —— 5. 行高:pPr.lineSpacing / 240(240 = 单倍行距)
|
|
1138
|
+
// 240 → 1.0;360 → 1.5;480 → 2.0
|
|
1139
|
+
// 如果没有 lineSpacing,但我们设置了字号,给个 1.4 的 minimum 行高,避免
|
|
1140
|
+
// antd Select 默认 22px 行高跟大字字号打架(文字被裁剪)
|
|
1141
|
+
if (typeof pPr?.lineSpacing === 'number' && pPr.lineSpacing > 0) {
|
|
1142
|
+
out.lineHeight = pPr.lineSpacing / 240;
|
|
1143
|
+
} else if (out.fontSize) {
|
|
1144
|
+
out.lineHeight = 1.4;
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// —— 6. 文字颜色:rPr.color 可能是 string(纯 hex "FF0000"),
|
|
1148
|
+
// 也可能是 { rgb: "FF0000" } / { color: "FF0000", theme?: ... } 等对象。
|
|
1149
|
+
// 这里把所有常见形态都命中,防止「副标题/强调引用」这类预设样式用 {rgb: '...'}
|
|
1150
|
+
// 结果颜色在下拉框预览里出不来的 bug。
|
|
1151
|
+
const colorVal = rPr?.color as unknown;
|
|
1152
|
+
let hexColor: string | null = null;
|
|
1153
|
+
if (typeof colorVal === 'string' && colorVal) {
|
|
1154
|
+
hexColor = colorVal.trim();
|
|
1155
|
+
} else if (colorVal && typeof colorVal === 'object') {
|
|
1156
|
+
const colorObj = colorVal as Record<string, unknown>;
|
|
1157
|
+
// .rgb 是 docx-editor / 预设样式里最常见的字段命名(比如 Subtitle 的 {rgb:'666666'})
|
|
1158
|
+
if (typeof colorObj.rgb === 'string') hexColor = colorObj.rgb.trim();
|
|
1159
|
+
// .color 是另一种常见形态(某些 ColorValue 的 TS 定义)
|
|
1160
|
+
else if (typeof colorObj.color === 'string') hexColor = colorObj.color.trim();
|
|
1161
|
+
}
|
|
1162
|
+
if (hexColor) {
|
|
1163
|
+
if (/^[0-9a-fA-F]{6}$/.test(hexColor)) {
|
|
1164
|
+
out.color = `#${hexColor}`;
|
|
1165
|
+
} else if (/^[0-9a-fA-F]{3,8}$/.test(hexColor)) {
|
|
1166
|
+
out.color = `#${hexColor}`;
|
|
1167
|
+
} else if (hexColor.startsWith('#')) {
|
|
1168
|
+
out.color = hexColor;
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// —— 7. 下划线:rPr.underline.style != 'none' 时加 text-decoration: underline
|
|
1173
|
+
if (rPr?.underline && rPr.underline.style && rPr.underline.style !== 'none') {
|
|
1174
|
+
out.textDecoration = 'underline';
|
|
1175
|
+
if (rPr.underline.color) {
|
|
1176
|
+
// 如果下划线有单独颜色,也加上(浏览器对 text-decoration-color 支持很好)
|
|
1177
|
+
const uColor = rPr.underline.color as unknown;
|
|
1178
|
+
if (typeof uColor === 'string' && /^[0-9a-fA-F]{6}$/.test(uColor)) {
|
|
1179
|
+
(out as React.CSSProperties & { textDecorationColor?: string }).textDecorationColor = `#${uColor}`;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// —— 8. 删除线
|
|
1185
|
+
if (rPr?.strike === true || rPr?.doubleStrike === true) {
|
|
1186
|
+
out.textDecoration = out.textDecoration
|
|
1187
|
+
? `${out.textDecoration} line-through`
|
|
1188
|
+
: 'line-through';
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
// —— 9. 段落边框:把 pPr.borders 映射成 CSS 的 border-*,专门为「红头分隔线」
|
|
1192
|
+
// 这种用段落底边框画横线的样式服务。否则下拉框里预览只会显示"黑色仿宋居中文字",
|
|
1193
|
+
// 和正文看起来差不多,用户根本找不到「红头分隔线」是哪一个。
|
|
1194
|
+
// 支持四个方向(top/bottom/left/right),最常用的就是 bottom。
|
|
1195
|
+
if (pPr?.borders && typeof pPr.borders === 'object') {
|
|
1196
|
+
const borders = pPr.borders as Record<string, unknown>;
|
|
1197
|
+
const mapBorderSide = (side: 'top' | 'right' | 'bottom' | 'left', cssKey: 'borderTop' | 'borderRight' | 'borderBottom' | 'borderLeft') => {
|
|
1198
|
+
const b = borders[side];
|
|
1199
|
+
if (!b || typeof b !== 'object') return;
|
|
1200
|
+
const spec = b as Record<string, unknown>;
|
|
1201
|
+
// a) style:Word BorderSpec.style → CSS border-style
|
|
1202
|
+
const rawStyle = spec.style as string | undefined;
|
|
1203
|
+
if (!rawStyle || rawStyle === 'none' || rawStyle === 'nil') return;
|
|
1204
|
+
let cssStyle: string = 'solid';
|
|
1205
|
+
switch (rawStyle) {
|
|
1206
|
+
case 'single': case 'thick': case 'triple': case 'outset': case 'inset':
|
|
1207
|
+
cssStyle = 'solid'; break;
|
|
1208
|
+
case 'double': case 'doubleWave':
|
|
1209
|
+
cssStyle = 'double'; break;
|
|
1210
|
+
case 'dotted':
|
|
1211
|
+
cssStyle = 'dotted'; break;
|
|
1212
|
+
case 'dashed': case 'dashSmallGap': case 'dashDotStroked':
|
|
1213
|
+
cssStyle = 'dashed'; break;
|
|
1214
|
+
case 'wave':
|
|
1215
|
+
cssStyle = 'solid'; break; // 浏览器没有标准 wave border,预览用实线兜底
|
|
1216
|
+
default:
|
|
1217
|
+
cssStyle = 'solid';
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// b) width:size 单位 1/8 pt → px。size=12(1.5pt)≈ 2px;size=8(1pt)≈ 1.33px
|
|
1221
|
+
let widthPx = 1;
|
|
1222
|
+
const rawSize = spec.size;
|
|
1223
|
+
if (typeof rawSize === 'number' && rawSize > 0) {
|
|
1224
|
+
widthPx = Math.max(1, Math.round(rawSize * 0.1667)); // * (2/12) = * 1/6 ≈ 0.1667
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// c) color:BorderSpec.color = ColorValue,支持 rgb/themeColor
|
|
1228
|
+
let cssColor = 'currentColor';
|
|
1229
|
+
const colorVal = spec.color;
|
|
1230
|
+
if (colorVal && typeof colorVal === 'object') {
|
|
1231
|
+
const cv = colorVal as Record<string, unknown>;
|
|
1232
|
+
if (typeof cv.rgb === 'string' && /^[0-9a-fA-F]{6}$/.test(cv.rgb)) {
|
|
1233
|
+
cssColor = `#${cv.rgb}`;
|
|
1234
|
+
} else if (typeof cv.themeColor === 'string') {
|
|
1235
|
+
// theme color 没有 theme1.xml 的上下文,预览里用合理的 fallback:
|
|
1236
|
+
// hlink → 蓝色,accent1..6 → 常见配色,其他 → 黑
|
|
1237
|
+
switch (cv.themeColor) {
|
|
1238
|
+
case 'hlink': cssColor = '#2F5496'; break;
|
|
1239
|
+
case 'accent1': cssColor = '#4472C4'; break;
|
|
1240
|
+
case 'accent2': cssColor = '#ED7D31'; break;
|
|
1241
|
+
case 'accent3': cssColor = '#A5A5A5'; break;
|
|
1242
|
+
case 'accent4': cssColor = '#FFC000'; break;
|
|
1243
|
+
case 'accent5': cssColor = '#5B9BD5'; break;
|
|
1244
|
+
case 'accent6': cssColor = '#70AD47'; break;
|
|
1245
|
+
case 'lt1': case 'background1': cssColor = '#FFFFFF'; break;
|
|
1246
|
+
case 'dk1': case 'text1': case 'background2': case 'text2': default: cssColor = '#000000'; break;
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
} else if (typeof colorVal === 'string' && /^[0-9a-fA-F]{6}$/.test(colorVal)) {
|
|
1250
|
+
cssColor = `#${colorVal}`;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
(out as React.CSSProperties & Record<string, string>)[cssKey] = `${widthPx}px ${cssStyle} ${cssColor}`;
|
|
1254
|
+
};
|
|
1255
|
+
|
|
1256
|
+
mapBorderSide('top', 'borderTop');
|
|
1257
|
+
mapBorderSide('right', 'borderRight');
|
|
1258
|
+
mapBorderSide('bottom', 'borderBottom');
|
|
1259
|
+
mapBorderSide('left', 'borderLeft');
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
return out;
|
|
1263
|
+
}, []);
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* 辅助函数:给定 ProseMirror 文档节点 + 范围 [from, to],
|
|
1267
|
+
* 遍历范围内所有 paragraph 节点,返回它们的 { paraId, pos } 数组。
|
|
1268
|
+
* 用于"全选多段落时要对选中范围内的每一段都应用样式"(不能只应用$from那一段)。
|
|
1269
|
+
*/
|
|
1270
|
+
const collectParaIdsInRange = useCallback((
|
|
1271
|
+
doc: Node,
|
|
1272
|
+
from: number,
|
|
1273
|
+
to: number
|
|
1274
|
+
): Array<{ paraId: string; node: Node }> => {
|
|
1275
|
+
const result: Array<{ paraId: string; node: Node }> = [];
|
|
1276
|
+
doc.nodesBetween(from, to, (node) => {
|
|
1277
|
+
if (!node || node.type.name !== 'paragraph') return true;
|
|
1278
|
+
const paraId = node.attrs?.paraId as string | undefined;
|
|
1279
|
+
if (paraId) {
|
|
1280
|
+
result.push({ paraId, node });
|
|
1281
|
+
}
|
|
1282
|
+
return true; // 继续往下钻(paragraph 内部是 runs,不影响外层收集)
|
|
1283
|
+
});
|
|
1284
|
+
return result;
|
|
1285
|
+
}, []);
|
|
1286
|
+
|
|
1287
|
+
/**
|
|
1288
|
+
* 从 ProseMirror EditorState(或 DocxEditorRef)中读取当前选区所在段落的 styleId。
|
|
1289
|
+
* 找不到段落或段落没有 styleId 时,返回默认的 'Normal'(正文)作为兜底。
|
|
1290
|
+
*
|
|
1291
|
+
* —— 2026/08/14 重写:改用 DocxEditor 官方 API
|
|
1292
|
+
* 1) 优先 extractSelectionState(state).styleId(官方专门给 toolbar 同步用的)
|
|
1293
|
+
* 2) 其次用 getStyleId(state)(官方 paragraph commands 里的 API)
|
|
1294
|
+
* 3) 最后自己遍历 $from/$anchor/$head 找 paragraph 的 attrs.styleId
|
|
1295
|
+
* 解决用户反馈的"全选文本显示正文,但实际不是正文"的问题:
|
|
1296
|
+
* 之前我们只看了 $from.parent,如果用户 Ctrl+A 全选时 selection 锚点不在
|
|
1297
|
+
* paragraph 节点上(比如在 section 边界/表格里),我们就兜底成了 Normal,
|
|
1298
|
+
* 现在用官方 API 能正确识别。
|
|
1299
|
+
*/
|
|
1300
|
+
const readCurrentParagraphStyleId = useCallback((): string => {
|
|
1301
|
+
const state = editorRef.current?.getEditorRef()?.getState();
|
|
1302
|
+
if (!state) return 'Normal';
|
|
1303
|
+
|
|
1304
|
+
// 1) 官方 extractSelectionState:这是专门给 toolbar 取当前样式的 API
|
|
1305
|
+
try {
|
|
1306
|
+
const selState = extractSelectionState(state);
|
|
1307
|
+
if (selState && selState.styleId) return selState.styleId;
|
|
1308
|
+
} catch { /* ignore */ }
|
|
1309
|
+
|
|
1310
|
+
// 2) 官方 getStyleId:paragraph 命令模块提供的直接 getter
|
|
1311
|
+
try {
|
|
1312
|
+
const sId = getStyleId(state);
|
|
1313
|
+
if (sId) return sId;
|
|
1314
|
+
} catch { /* ignore */ }
|
|
1315
|
+
|
|
1316
|
+
// 3) 手动兜底:遍历 $from / $anchor / $head 三个位置,找最近的 paragraph
|
|
1317
|
+
const { selection } = state;
|
|
1318
|
+
const candidates = [selection.$from, selection.$anchor, selection.$head];
|
|
1319
|
+
for (const $pos of candidates) {
|
|
1320
|
+
if (!$pos) continue;
|
|
1321
|
+
for (let d = $pos.depth; d >= 0; d--) {
|
|
1322
|
+
const ancestor = $pos.node(d);
|
|
1323
|
+
if (!ancestor) continue;
|
|
1324
|
+
if (ancestor.type.name === 'paragraph') {
|
|
1325
|
+
const styleId = (ancestor.attrs?.styleId as string | undefined);
|
|
1326
|
+
if (styleId) return styleId;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
const p = $pos.parent;
|
|
1330
|
+
if (p && p.type && p.type.name === 'paragraph') {
|
|
1331
|
+
const styleId = (p.attrs?.styleId as string | undefined);
|
|
1332
|
+
if (styleId) return styleId;
|
|
1333
|
+
}
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
return 'Normal';
|
|
1337
|
+
}, []);
|
|
1338
|
+
|
|
1339
|
+
// 格式刷
|
|
1340
|
+
const formatPainter = useFormatPainter(() => editorRef.current?.getEditorRef()?.getView() ?? null);
|
|
1341
|
+
|
|
1342
|
+
// ===== 粘贴选项(WPS 风格:带格式粘贴 / 匹配当前格式 / 只粘贴文本)=====
|
|
1343
|
+
// 捕获阶段先记录剪贴板内容与插入起点,等编辑器完成默认(带格式)粘贴后,
|
|
1344
|
+
// 在插入位置下方弹出一个浮动按钮,展开后可切换粘贴方式重新粘贴。
|
|
1345
|
+
const [pasteSmartTag, setPasteSmartTag] = useState<{
|
|
1346
|
+
x: number; y: number; from: number; to: number; text: string; expanded: boolean;
|
|
1347
|
+
} | null>(null);
|
|
1348
|
+
const pasteSmartTagRef = useRef<HTMLDivElement | null>(null);
|
|
1349
|
+
// 记录最新鼠标位置(粘贴时鼠标通常在光标附近,用鼠标位置定位按钮最可靠)
|
|
1350
|
+
const lastMousePos = useRef({ x: 0, y: 0 });
|
|
1351
|
+
// 标记正在应用粘贴模式(重贴),期间的 input 事件不关闭菜单
|
|
1352
|
+
const applyingPasteModeRef = useRef(false);
|
|
1353
|
+
useEffect(() => {
|
|
1354
|
+
const onMove = (e: MouseEvent) => { lastMousePos.current = { x: e.clientX, y: e.clientY }; };
|
|
1355
|
+
window.addEventListener('mousemove', onMove, true);
|
|
1356
|
+
return () => window.removeEventListener('mousemove', onMove, true);
|
|
1357
|
+
}, []);
|
|
1358
|
+
|
|
1359
|
+
// 给 window 挂原生 paste 捕获监听(capture 阶段最外层,最不容易被 stopPropagation 拦截)。
|
|
1360
|
+
// 之前挂在 view.dom 上不生效,说明 DocxEditor 在祖先元素 capture 阶段就 stop 了事件。
|
|
1361
|
+
useEffect(() => {
|
|
1362
|
+
if (!visible || loadingInitialBlob || readOnly || showFinalRevisionPreview) return;
|
|
1363
|
+
|
|
1364
|
+
const onPaste = (e: ClipboardEvent) => {
|
|
1365
|
+
const view = editorRef.current?.getEditorRef()?.getView();
|
|
1366
|
+
// 只处理发生在编辑器内的粘贴
|
|
1367
|
+
if (!view?.dom || !view.dom.contains(e.target as HTMLElement)) return;
|
|
1368
|
+
if (isSuggestionModeActive(view.state)) return;
|
|
1369
|
+
const html = e.clipboardData?.getData('text/html') ?? '';
|
|
1370
|
+
const text = e.clipboardData?.getData('text/plain') ?? '';
|
|
1371
|
+
if (!html && !text) return;
|
|
1372
|
+
const insertFrom = view.state.selection.from;
|
|
1373
|
+
setTimeout(() => {
|
|
1374
|
+
const currentView = editorRef.current?.getEditorRef()?.getView();
|
|
1375
|
+
if (!currentView) return;
|
|
1376
|
+
const to = currentView.state.selection.head;
|
|
1377
|
+
if (to <= insertFrom) return;
|
|
1378
|
+
// 移除粘贴内容自带的文字高亮(背景色),让粘贴文字默认无背景色
|
|
1379
|
+
try {
|
|
1380
|
+
const marks = currentView.state.schema.marks;
|
|
1381
|
+
const highlightMark = marks['highlight'] ?? marks['backgroundColor'] ?? marks['background'];
|
|
1382
|
+
if (highlightMark && currentView.state.doc.rangeHasMark(insertFrom, to, highlightMark)) {
|
|
1383
|
+
currentView.dispatch(currentView.state.tr.removeMark(insertFrom, to, highlightMark));
|
|
1384
|
+
}
|
|
1385
|
+
} catch (e) { /* ignore */ }
|
|
1386
|
+
try {
|
|
1387
|
+
// 用鼠标位置定位按钮(粘贴时鼠标在光标附近,比编辑层坐标可靠)
|
|
1388
|
+
let { x, y } = lastMousePos.current;
|
|
1389
|
+
const container = document.getElementById('vendorsys-word-editor-root');
|
|
1390
|
+
const cRect = container?.getBoundingClientRect();
|
|
1391
|
+
// 钳制到容器可视区域
|
|
1392
|
+
if (cRect) {
|
|
1393
|
+
const minX = cRect.left + 8;
|
|
1394
|
+
const maxX = cRect.right - 130;
|
|
1395
|
+
const minY = cRect.top + 8;
|
|
1396
|
+
const maxY = cRect.bottom - 40;
|
|
1397
|
+
x = Math.min(Math.max(x, minX), maxX);
|
|
1398
|
+
y = Math.min(Math.max(y, minY), maxY);
|
|
1399
|
+
}
|
|
1400
|
+
setPasteSmartTag({
|
|
1401
|
+
x,
|
|
1402
|
+
y: y + 6,
|
|
1403
|
+
from: insertFrom,
|
|
1404
|
+
to,
|
|
1405
|
+
text,
|
|
1406
|
+
expanded: false,
|
|
1407
|
+
});
|
|
1408
|
+
} catch (err) { /* ignore */ }
|
|
1409
|
+
}, 50);
|
|
1410
|
+
};
|
|
1411
|
+
|
|
1412
|
+
window.addEventListener('paste', onPaste, true);
|
|
1413
|
+
return () => window.removeEventListener('paste', onPaste, true);
|
|
1414
|
+
}, [visible, loadingInitialBlob, readOnly, showFinalRevisionPreview]);
|
|
1415
|
+
|
|
1416
|
+
const applyPasteMode = useCallback((mode: 'formatted' | 'match' | 'text') => {
|
|
1417
|
+
const tag = pasteSmartTag;
|
|
1418
|
+
if (!tag) return;
|
|
1419
|
+
// 「带格式」即编辑器默认粘贴结果,无需重贴;但菜单保持展开,方便用户切换
|
|
1420
|
+
if (mode === 'formatted') {
|
|
1421
|
+
setPasteSmartTag({ ...tag, expanded: true });
|
|
1422
|
+
return;
|
|
1423
|
+
}
|
|
1424
|
+
const view = editorRef.current?.getEditorRef()?.getView();
|
|
1425
|
+
if (!view) return;
|
|
1426
|
+
view.focus();
|
|
1427
|
+
const { doc } = view.state;
|
|
1428
|
+
const from = Math.min(tag.from, doc.content.size);
|
|
1429
|
+
const to = Math.min(tag.to, doc.content.size);
|
|
1430
|
+
if (to <= from) return;
|
|
1431
|
+
// 选中刚粘贴的内容后用纯文本重贴:插入的文本继承插入点前的格式(即目标处格式)
|
|
1432
|
+
applyingPasteModeRef.current = true;
|
|
1433
|
+
view.dispatch(view.state.tr.setSelection(TextSelection.create(doc, from, to)));
|
|
1434
|
+
const insertAt = view.state.selection.from;
|
|
1435
|
+
view.pasteText(tag.text);
|
|
1436
|
+
if (mode === 'text') {
|
|
1437
|
+
const end = view.state.selection.head;
|
|
1438
|
+
if (end > insertAt) {
|
|
1439
|
+
view.dispatch(view.state.tr.removeMark(insertAt, end));
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
// 重贴后更新区间(位置沿用当前鼠标位置),菜单保持展开
|
|
1443
|
+
const newHead = view.state.selection.head;
|
|
1444
|
+
const { x, y } = lastMousePos.current;
|
|
1445
|
+
applyingPasteModeRef.current = false;
|
|
1446
|
+
setPasteSmartTag({ ...tag, from: insertAt, to: newHead, x, y: y + 6, expanded: true });
|
|
1447
|
+
}, [pasteSmartTag]);
|
|
1448
|
+
|
|
1449
|
+
// 关闭条件:点击弹层外 / Esc / 滚动 / 用户输入新文字;展开时支持 W/M/T 快捷键
|
|
1450
|
+
useEffect(() => {
|
|
1451
|
+
if (!pasteSmartTag) return;
|
|
1452
|
+
const expanded = pasteSmartTag.expanded;
|
|
1453
|
+
const onDocMouseDown = (e: MouseEvent) => {
|
|
1454
|
+
if (pasteSmartTagRef.current?.contains(e.target as HTMLElement)) return;
|
|
1455
|
+
setPasteSmartTag(null);
|
|
1456
|
+
};
|
|
1457
|
+
const onKeyDown = (e: KeyboardEvent) => {
|
|
1458
|
+
if (e.key === 'Escape') {
|
|
1459
|
+
setPasteSmartTag(null);
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
if (!expanded) return;
|
|
1463
|
+
const key = e.key.toLowerCase();
|
|
1464
|
+
const mode = key === 'w' ? 'formatted' : key === 'm' ? 'match' : key === 't' ? 'text' : null;
|
|
1465
|
+
if (mode) {
|
|
1466
|
+
e.preventDefault();
|
|
1467
|
+
e.stopPropagation();
|
|
1468
|
+
applyPasteMode(mode);
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
const onScroll = () => setPasteSmartTag(null);
|
|
1472
|
+
// 用户在编辑器内输入新文字时关闭(applyPasteMode 重贴期间的 input 不算)
|
|
1473
|
+
const onEditorInput = () => {
|
|
1474
|
+
if (!applyingPasteModeRef.current) setPasteSmartTag(null);
|
|
1475
|
+
};
|
|
1476
|
+
const viewDom = editorRef.current?.getEditorRef()?.getView()?.dom as HTMLElement | null;
|
|
1477
|
+
viewDom?.addEventListener('input', onEditorInput);
|
|
1478
|
+
|
|
1479
|
+
document.addEventListener('mousedown', onDocMouseDown, true);
|
|
1480
|
+
document.addEventListener('keydown', onKeyDown, true);
|
|
1481
|
+
window.addEventListener('scroll', onScroll, true);
|
|
1482
|
+
return () => {
|
|
1483
|
+
viewDom?.removeEventListener('input', onEditorInput);
|
|
1484
|
+
document.removeEventListener('mousedown', onDocMouseDown, true);
|
|
1485
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
1486
|
+
window.removeEventListener('scroll', onScroll, true);
|
|
1487
|
+
};
|
|
1488
|
+
}, [pasteSmartTag, applyPasteMode]);
|
|
1489
|
+
|
|
1490
|
+
// ===== 段落设置(对齐 / 缩进 / 段前段后 / 行距)=====
|
|
1491
|
+
// DocxEditor 自带工具栏只暴露了段落样式与对齐按钮,没有「段落信息」入口;
|
|
1492
|
+
// 这里用编辑器提供的 prosemirror 命令补一个 Word 风格的「段落」对话框。
|
|
1493
|
+
const [paragraphDialogOpen, setParagraphDialogOpen] = useState(false);
|
|
1494
|
+
const [paragraphForm, setParagraphForm] = useState<ParagraphDialogValues>(DEFAULT_PARAGRAPH_DIALOG_VALUES);
|
|
1495
|
+
|
|
1496
|
+
/** 打开对话框前,从当前选区所在段落读出已有的直接格式,回填到表单 */
|
|
1497
|
+
const openParagraphDialog = useCallback(() => {
|
|
1498
|
+
// 先打开对话框,保证点击一定可见;读取当前格式失败时退回默认值
|
|
1499
|
+
setParagraphDialogOpen(true);
|
|
1500
|
+
|
|
1501
|
+
try {
|
|
1502
|
+
const view = editorRef.current?.getEditorRef()?.getView();
|
|
1503
|
+
const state = view?.state;
|
|
1504
|
+
const attrs = (() => {
|
|
1505
|
+
const parent = state?.selection.$from.parent;
|
|
1506
|
+
return parent && parent.type.name === 'paragraph' ? parent.attrs : null;
|
|
1507
|
+
})();
|
|
1508
|
+
|
|
1509
|
+
const toCm = (twips?: number | null) =>
|
|
1510
|
+
twips && twips > 0 ? Number((twips / TWIPS_PER_CM).toFixed(2)) : 0;
|
|
1511
|
+
const toPt = (twips?: number | null) =>
|
|
1512
|
+
twips && twips > 0 ? Number((twips / TWIPS_PER_PT).toFixed(2)) : 0;
|
|
1513
|
+
|
|
1514
|
+
const alignment = ((state ? getParagraphAlignment(state) : null) ??
|
|
1515
|
+
(attrs?.alignment as ParagraphAlignmentValue | undefined) ??
|
|
1516
|
+
'left') as ParagraphAlignmentValue;
|
|
1517
|
+
const lineRule = (attrs?.lineSpacingRule ?? 'auto') as LineSpacingRuleValue;
|
|
1518
|
+
const lineSpacing = attrs?.lineSpacing as number | undefined;
|
|
1519
|
+
const lineValue =
|
|
1520
|
+
lineSpacing && lineSpacing > 0
|
|
1521
|
+
? lineRule === 'auto'
|
|
1522
|
+
? Number((lineSpacing / 240).toFixed(2))
|
|
1523
|
+
: Number((lineSpacing / TWIPS_PER_PT).toFixed(2))
|
|
1524
|
+
: 1;
|
|
1525
|
+
|
|
1526
|
+
const indentFirstLine = (attrs?.indentFirstLine as number | undefined) ?? 0;
|
|
1527
|
+
const hanging = Boolean(attrs?.hangingIndent);
|
|
1528
|
+
|
|
1529
|
+
setParagraphForm({
|
|
1530
|
+
alignment,
|
|
1531
|
+
indentLeftCm: toCm(attrs?.indentLeft as number | undefined),
|
|
1532
|
+
indentRightCm: toCm(attrs?.indentRight as number | undefined),
|
|
1533
|
+
indentSpecial: indentFirstLine > 0 ? (hanging ? 'hanging' : 'firstLine') : 'none',
|
|
1534
|
+
indentSpecialCm: toCm(indentFirstLine),
|
|
1535
|
+
spaceBeforePt: toPt(attrs?.spaceBefore as number | undefined),
|
|
1536
|
+
spaceAfterPt: toPt(attrs?.spaceAfter as number | undefined),
|
|
1537
|
+
lineRule,
|
|
1538
|
+
lineValue,
|
|
1539
|
+
});
|
|
1540
|
+
} catch (error) {
|
|
1541
|
+
console.warn('[WordEditor] 读取段落格式失败,使用默认值', error);
|
|
1542
|
+
setParagraphForm(DEFAULT_PARAGRAPH_DIALOG_VALUES);
|
|
1543
|
+
}
|
|
1544
|
+
}, []);
|
|
1545
|
+
|
|
1546
|
+
/** 把表单值换算成 twips,依次派发到当前选区覆盖的所有段落 */
|
|
1547
|
+
const applyParagraphSettings = useCallback(() => {
|
|
1548
|
+
const view = editorRef.current?.getEditorRef()?.getView();
|
|
1549
|
+
if (!view) {
|
|
1550
|
+
message.warning('编辑器尚未就绪,请稍候再试');
|
|
1551
|
+
return;
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
const specialTwips =
|
|
1555
|
+
paragraphForm.indentSpecial === 'none'
|
|
1556
|
+
? 0
|
|
1557
|
+
: Math.round(paragraphForm.indentSpecialCm * TWIPS_PER_CM);
|
|
1558
|
+
const lineTwips =
|
|
1559
|
+
paragraphForm.lineRule === 'auto'
|
|
1560
|
+
? Math.round(paragraphForm.lineValue * 240)
|
|
1561
|
+
: Math.round(paragraphForm.lineValue * TWIPS_PER_PT);
|
|
1562
|
+
|
|
1563
|
+
const commands = [
|
|
1564
|
+
setAlignment(paragraphForm.alignment),
|
|
1565
|
+
setIndentLeft(Math.round(paragraphForm.indentLeftCm * TWIPS_PER_CM)),
|
|
1566
|
+
setIndentRight(Math.round(paragraphForm.indentRightCm * TWIPS_PER_CM)),
|
|
1567
|
+
setIndentFirstLine(specialTwips, paragraphForm.indentSpecial === 'hanging'),
|
|
1568
|
+
setSpaceBefore(Math.round(paragraphForm.spaceBeforePt * TWIPS_PER_PT)),
|
|
1569
|
+
setSpaceAfter(Math.round(paragraphForm.spaceAfterPt * TWIPS_PER_PT)),
|
|
1570
|
+
setLineSpacing(lineTwips, paragraphForm.lineRule),
|
|
1571
|
+
];
|
|
1572
|
+
|
|
1573
|
+
// 每条命令都基于「上一次 dispatch 之后的最新 state」,保证多项设置叠加生效
|
|
1574
|
+
let applied = false;
|
|
1575
|
+
for (const command of commands) {
|
|
1576
|
+
if (command(view.state, view.dispatch, view)) applied = true;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
if (!applied) {
|
|
1580
|
+
message.warning('当前选区无法设置段落格式,请把光标放在正文段落中再试');
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
setParagraphDialogOpen(false);
|
|
1585
|
+
view.focus();
|
|
1586
|
+
}, [paragraphForm]);
|
|
1587
|
+
|
|
1588
|
+
useEffectResize(() => {
|
|
1589
|
+
setIsMobile(true);
|
|
1590
|
+
}, () => {
|
|
1591
|
+
setIsMobile(false);
|
|
1592
|
+
});
|
|
1593
|
+
|
|
1594
|
+
// 注册全局键盘快捷键(Ctrl/Cmd + Alt + 数字/反引号 → 设置段落样式)
|
|
1595
|
+
useEffect(() => {
|
|
1596
|
+
if (!visible || readOnly || showFinalRevisionPreview) return;
|
|
1597
|
+
|
|
1598
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
1599
|
+
// Ctrl 键(Windows/Linux)或 Cmd 键(macOS)
|
|
1600
|
+
const ctrlOrCmd = e.ctrlKey || e.metaKey;
|
|
1601
|
+
if (!ctrlOrCmd || !e.altKey) return;
|
|
1602
|
+
|
|
1603
|
+
const styleId = STYLE_SHORTCUT_MAP[e.key];
|
|
1604
|
+
if (!styleId) return;
|
|
1605
|
+
|
|
1606
|
+
// 避免在输入框中误触发
|
|
1607
|
+
const tag = (e.target as HTMLElement)?.tagName;
|
|
1608
|
+
if (tag === 'INPUT' || tag === 'TEXTAREA') return;
|
|
1609
|
+
|
|
1610
|
+
e.preventDefault();
|
|
1611
|
+
|
|
1612
|
+
const editorApi = editorRef.current;
|
|
1613
|
+
const view = editorApi?.getEditorRef()?.getView();
|
|
1614
|
+
if (!editorApi || !view) return;
|
|
1615
|
+
|
|
1616
|
+
const { doc, selection } = view.state;
|
|
1617
|
+
const selFrom = selection.from;
|
|
1618
|
+
const selTo = selection.to;
|
|
1619
|
+
// 无论用户选中的是一段还是多段(含 Ctrl+A 全选),
|
|
1620
|
+
// 都把 selFrom→selTo 范围内的所有 paragraph 都找出来,逐个应用样式。
|
|
1621
|
+
// 这样解决了"全选多段落后点样式只有第一段生效,其他段没变化"的 bug。
|
|
1622
|
+
const selectedParas = collectParaIdsInRange(doc, selFrom, selTo);
|
|
1623
|
+
if (selectedParas.length === 0) return;
|
|
1624
|
+
|
|
1625
|
+
// 1) 优先 DocxEditor setParagraphStyle API:会解析样式的格式属性(字体、字号、加粗、间距)
|
|
1626
|
+
// 完整应用,视觉变化同步正确
|
|
1627
|
+
let appliedCount = 0;
|
|
1628
|
+
for (const { paraId } of selectedParas) {
|
|
1629
|
+
try {
|
|
1630
|
+
if (editorApi.setParagraphStyle({ paraId, styleId })) {
|
|
1631
|
+
appliedCount++;
|
|
1632
|
+
}
|
|
1633
|
+
} catch { /* 单个段落失败不影响其他段落,继续 */ }
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// 2) 如果一个都没应用成功(极端情况下 paraId 失效等),回退到 applyStyle 命令
|
|
1637
|
+
if (appliedCount === 0) {
|
|
1638
|
+
try {
|
|
1639
|
+
applyStyle(styleId)(view.state, view.dispatch, view);
|
|
1640
|
+
appliedCount = selectedParas.length;
|
|
1641
|
+
} catch { /* 兜底失败也无所谓 */ }
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
// —— 关键新增:快捷键应用样式成功后,立即回填自定义 Select 的 value ——
|
|
1645
|
+
// 确保 Ctrl+Alt+1 设为标题 1 后,样式选择框立刻显示"标题 1",
|
|
1646
|
+
// 而不是等到下一次选区变化时才更新(否则用户看到"标题 1 生效但选择框仍正文")。
|
|
1647
|
+
if (appliedCount > 0) {
|
|
1648
|
+
setCurrentStyleId(styleId);
|
|
1649
|
+
}
|
|
1650
|
+
};
|
|
1651
|
+
|
|
1652
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
1653
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
1654
|
+
}, [visible, readOnly, showFinalRevisionPreview, collectParaIdsInRange]);
|
|
1655
|
+
|
|
1656
|
+
// ============================================================================
|
|
1657
|
+
// 自定义样式选择器 onChange:用户从下拉框里选了一个新样式
|
|
1658
|
+
// 行为尽量对齐快捷键 + DocxEditor 原工具栏选择器,优先 setParagraphStyle
|
|
1659
|
+
//
|
|
1660
|
+
// —— 2026/08/14 重要修改点(对应用户三大反馈):
|
|
1661
|
+
// 1) 不再只应用 $from 那一段 → 改为应用选中范围内的 ALL paragraphs
|
|
1662
|
+
// (解决"全选文本时点击其他样式无效"的 bug)
|
|
1663
|
+
// 2) 不再有 "newStyleId===currentStyleId 就跳过" 的逻辑
|
|
1664
|
+
// (解决"当前为样式A,点击样式A无效,我需要每次点击都重新应用"的需求)
|
|
1665
|
+
// 3) 如果 DocxEditor.setParagraphStyle 对"样式没变"的段落做了 no-op 短路,
|
|
1666
|
+
// 就用 applyStyle 兜底再强制 dispatch 一次 transaction
|
|
1667
|
+
//
|
|
1668
|
+
// —— 2026/08/14 再次修复:新增 50ms 内相同 styleId 连续调用去重(lastApplyStyleCallRef)。
|
|
1669
|
+
// 因为我们同时用了 onSelect + onChange 双通道(onSelect 保证同值点击也触发,
|
|
1670
|
+
// onChange 兼容键盘/程序化改值),antd 内部某些情况下可能先 onSelect 再 onChange,
|
|
1671
|
+
// 导致 handleStyleSelectChange 在 10ms 内被连续调用两次,造成重复 transaction。
|
|
1672
|
+
// 加一层时间窗去重,保证用户一次点击最多只执行一次应用流程。
|
|
1673
|
+
// ============================================================================
|
|
1674
|
+
const lastApplyStyleCallRef = useRef<{ styleId: string; ts: number } | null>(null);
|
|
1675
|
+
const handleStyleSelectChange = useCallback(async (newStyleId: string) => {
|
|
1676
|
+
if (!newStyleId) return;
|
|
1677
|
+
|
|
1678
|
+
// —— 50ms 去重:同一 newStyleId 在 50ms 内只跑一次
|
|
1679
|
+
// 避免 antd Select onSelect + onChange 同时触发时的重复调用
|
|
1680
|
+
const now = Date.now();
|
|
1681
|
+
const last = lastApplyStyleCallRef.current;
|
|
1682
|
+
if (last && last.styleId === newStyleId && (now - last.ts) < 50) {
|
|
1683
|
+
// 即便不重复应用,也仍然要关 dropdown(因为用户确实点了 option 完成了选择)
|
|
1684
|
+
setStyleDropdownOpen(false);
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
lastApplyStyleCallRef.current = { styleId: newStyleId, ts: now };
|
|
1688
|
+
|
|
1689
|
+
const editorApi = editorRef.current;
|
|
1690
|
+
const view = editorApi?.getEditorRef()?.getView();
|
|
1691
|
+
if (!editorApi || !view) {
|
|
1692
|
+
message.warning('编辑器尚未就绪,请稍后再试');
|
|
1693
|
+
// 即便编辑器未就绪,也先回填 value(避免卡在旧值上,下次操作会再刷)
|
|
1694
|
+
setCurrentStyleId(newStyleId);
|
|
1695
|
+
setStyleDropdownOpen(false);
|
|
1696
|
+
return;
|
|
1697
|
+
}
|
|
1698
|
+
|
|
1699
|
+
const { doc, selection } = view.state;
|
|
1700
|
+
const selFrom = selection.from;
|
|
1701
|
+
const selTo = selection.to;
|
|
1702
|
+
// 1) 收集选中范围内的所有 paragraph(含全选多段落的情况)
|
|
1703
|
+
const selectedParas = collectParaIdsInRange(doc, selFrom, selTo);
|
|
1704
|
+
// 如果真的一个 paragraph 都找不到(极端情况:光标在 section 边界空白处),
|
|
1705
|
+
// 我们就用 $from/$anchor/$head 兜底,至少尝试应用一次。
|
|
1706
|
+
let fallbackSingleParaId: string | null = null;
|
|
1707
|
+
if (selectedParas.length === 0) {
|
|
1708
|
+
const candidates = [selection.$from, selection.$anchor, selection.$head];
|
|
1709
|
+
for (const $pos of candidates) {
|
|
1710
|
+
if (!$pos) continue;
|
|
1711
|
+
for (let d = $pos.depth; d >= 0; d--) {
|
|
1712
|
+
const anc = $pos.node(d);
|
|
1713
|
+
if (anc && anc.type && anc.type.name === 'paragraph' && anc.attrs?.paraId) {
|
|
1714
|
+
fallbackSingleParaId = anc.attrs.paraId as string;
|
|
1715
|
+
break;
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1718
|
+
if (fallbackSingleParaId) break;
|
|
1719
|
+
const p = $pos.parent;
|
|
1720
|
+
if (p && p.type && p.type.name === 'paragraph' && p.attrs?.paraId) {
|
|
1721
|
+
fallbackSingleParaId = p.attrs.paraId as string;
|
|
1722
|
+
break;
|
|
1723
|
+
}
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
let appliedAny = false;
|
|
1728
|
+
// 2) 优先 DocxEditor API:会把样式的格式属性(字体/字号/加粗/间距)完整应用
|
|
1729
|
+
// —— 关键:对选中范围内的 EVERY paragraph 都调用 setParagraphStyle,
|
|
1730
|
+
// 而不是只对 $from 那一段。这样全选文本时的样式应用才真正生效。
|
|
1731
|
+
if (selectedParas.length > 0) {
|
|
1732
|
+
for (const { paraId } of selectedParas) {
|
|
1733
|
+
try {
|
|
1734
|
+
const ok = editorApi.setParagraphStyle({ paraId, styleId: newStyleId });
|
|
1735
|
+
if (ok) appliedAny = true;
|
|
1736
|
+
} catch {
|
|
1737
|
+
// 单个失败不影响其他段落
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
} else if (fallbackSingleParaId) {
|
|
1741
|
+
try {
|
|
1742
|
+
appliedAny = editorApi.setParagraphStyle({ paraId: fallbackSingleParaId, styleId: newStyleId });
|
|
1743
|
+
} catch { /* ignore */ }
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
// 3) 兜底:一个都没成功应用时,回退到 applyStyle 命令
|
|
1747
|
+
// (比如样式 ID 是文件自带的自定义样式,DocxEditor 的 setParagraphStyle
|
|
1748
|
+
// 可能内部映射失败,但 applyStyle 命令直接写 PM attrs.styleId 也能用)
|
|
1749
|
+
if (!appliedAny) {
|
|
1750
|
+
try {
|
|
1751
|
+
applyStyle(newStyleId)(view.state, view.dispatch, view);
|
|
1752
|
+
appliedAny = true;
|
|
1753
|
+
} catch (err) {
|
|
1754
|
+
console.warn('applyStyle 也失败了:', err);
|
|
1755
|
+
appliedAny = false;
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
|
|
1759
|
+
// 4) 用户需求:"当前样式A再点样式A也要生效(再应用一遍)"
|
|
1760
|
+
// 如果因为"样式本身没变化"导致上面的 setParagraphStyle 全部
|
|
1761
|
+
// 短路 no-op,我们这里再强制 dispatch 一次 applyStyle,保证
|
|
1762
|
+
// 用户每次点击都一定有一次 transaction 触发,格式属性会被重新应用。
|
|
1763
|
+
if (appliedAny && currentStyleId === newStyleId) {
|
|
1764
|
+
try {
|
|
1765
|
+
// 再次触发一次 applyStyle,强制写一次 styleId 属性
|
|
1766
|
+
applyStyle(newStyleId)(view.state, view.dispatch, view);
|
|
1767
|
+
} catch { /* ignore */ }
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
if (appliedAny) {
|
|
1771
|
+
// —— 关键:应用后立刻"重新读真实的 styleId"再回填(解决显示"2"的bug) ——
|
|
1772
|
+
// DocxEditor 内部会把 "Normal"/"Heading1" 这种预设 styleId 转成数字索引
|
|
1773
|
+
// (如 1、2、3、5...) 存在 attrs.styleId 里,所以如果我们 setCurrentStyleId(newStyleId)
|
|
1774
|
+
// 那下一次渲染时 value="Normal" 与真实 styleId="2" 不一致,
|
|
1775
|
+
// 1) options 里找不到 value 对应的 option → antd Select 会显示裸 value "2"
|
|
1776
|
+
// 2) 并且双向同步逻辑以为值变了又触发 onChange,造成死循环。
|
|
1777
|
+
// 所以应用成功后必须重新读 ProseMirror 里的真实 styleId(数字或字符串)作为回填值。
|
|
1778
|
+
setTimeout(() => {
|
|
1779
|
+
try {
|
|
1780
|
+
const realStyleId = readCurrentParagraphStyleId();
|
|
1781
|
+
setCurrentStyleId(realStyleId);
|
|
1782
|
+
} catch {
|
|
1783
|
+
setCurrentStyleId(newStyleId);
|
|
1784
|
+
}
|
|
1785
|
+
}, 0);
|
|
1786
|
+
view.focus();
|
|
1787
|
+
|
|
1788
|
+
// —— 关键新增:用户应用样式后,立即(三枪 0/80/250ms)重跑 applyAllFixes
|
|
1789
|
+
//
|
|
1790
|
+
// 背景(用户反馈 bug:编辑时选红头分隔线看不到红线,保存重进后才有):
|
|
1791
|
+
// 应用样式 → PM transaction 把 paragraph.attrs.styleId 改成 HongTouSeparator
|
|
1792
|
+
// → lib 异步更新 paragraph DOM 的 inline style(字体/颜色/border-bottom)
|
|
1793
|
+
// → 此时 childList 不变,旧的 MutationObserver 不会触发
|
|
1794
|
+
// → 如果操作发生在编辑器打开 2s 之后,setTimeout 多点也已过期
|
|
1795
|
+
// → markHongTouSeparatorParagraphs 没跑 → 没打 data-attr → CSS ::after 不画红线。
|
|
1796
|
+
//
|
|
1797
|
+
// 修复:在这里主动"戳"三枪 applyAllFixes(覆盖 PM state update → DOM update 的
|
|
1798
|
+
// 三个常见时间点),确保用户点完样式选择下拉框之后 <250ms 内红线就出来,
|
|
1799
|
+
// 不用等 attributes MutationObserver(有 ~1 frame 延迟)也不用等 500ms 轮询。
|
|
1800
|
+
[0, 80, 250].forEach((delay) => {
|
|
1801
|
+
setTimeout(() => {
|
|
1802
|
+
try {
|
|
1803
|
+
applyAllFixesRef.current?.();
|
|
1804
|
+
} catch {
|
|
1805
|
+
/* 忽略偶发异常,attributes 监听 + 轮询已兜底 */
|
|
1806
|
+
}
|
|
1807
|
+
}, delay);
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
|
|
1811
|
+
// —— 终极方案受控 open:用户选完样式后,主动关闭下拉框(符合用户预期:选完就关)
|
|
1812
|
+
setStyleDropdownOpen(false);
|
|
1813
|
+
}, [readCurrentParagraphStyleId, collectParaIdsInRange, currentStyleId]);
|
|
1814
|
+
|
|
1815
|
+
// ============================================================================
|
|
1816
|
+
// 构造自定义样式选择器的 options
|
|
1817
|
+
//
|
|
1818
|
+
// —— 2026/08/14 重要优化 1:显示文件本身自带的所有段落样式(而不仅是预设)
|
|
1819
|
+
// 来源优先级:
|
|
1820
|
+
// 1) documentStylesFromFile(从 StyleResolver 拿到的文件 styles.xml 里的全部段落样式,
|
|
1821
|
+
// 包括用户 docx 自带的 footer / header / Normal (Web) / 标题 1 / 自定义模板样式等)
|
|
1822
|
+
// 2) PRESET_STYLES(我们自己注入的几个预设,如果不在上面的列表里就补上,保证永远可选)
|
|
1823
|
+
// 最后再做 fallback:如果 currentStyleId 还是匹配不到任何 option(比如 DocxEditor
|
|
1824
|
+
// 内部存了一个"3"但我们没找到对应 name 的 style),就加一个 disabled 的 fallback
|
|
1825
|
+
// option,保证 Select 显示时不会出现裸数字 value。
|
|
1826
|
+
//
|
|
1827
|
+
// —— 2026/08/14 重要优化 2:每个 option 加 style 属性(inline 样式预览)
|
|
1828
|
+
// 用 styleToPreviewCSSProperties(style) 把每个样式的字号/加粗/斜体/字体/颜色/行高
|
|
1829
|
+
// 换算成 inline style,直接挂到 option 上。antd Select 会把这个 style 应用到
|
|
1830
|
+
// .ant-select-item-option 容器上,文字会继承这些属性 → "标题 1" 这行字就真的
|
|
1831
|
+
// 用"标题 1"的大字号+加粗显示,与 DocxEditor 自带样式选择器(用户图二)的视觉
|
|
1832
|
+
// 效果完全一致。
|
|
1833
|
+
// ============================================================================
|
|
1834
|
+
const getStyleSelectOptions = useCallback(() => {
|
|
1835
|
+
// 建立 styleId → Style 对象的查找表,用于给每个 option 找"对应的完整 Style"
|
|
1836
|
+
// 来计算预览 inline style(不管是来自文件的还是预设的)
|
|
1837
|
+
const styleMapById = new Map<string, Style>();
|
|
1838
|
+
(documentStylesFromFileRef.current ?? []).forEach(s => {
|
|
1839
|
+
if (!styleMapById.has(s.styleId)) styleMapById.set(s.styleId, s);
|
|
1840
|
+
});
|
|
1841
|
+
PRESET_STYLES.forEach(s => {
|
|
1842
|
+
if (!styleMapById.has(s.styleId)) styleMapById.set(s.styleId, s);
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
// 1) 从文件 styles.xml 动态取到的段落样式(最先放,最优先,最贴合用户实际文件情况)
|
|
1846
|
+
const fileStyleOptions = (documentStylesFromFileRef.current ?? []).map((s) => {
|
|
1847
|
+
// 用户能看懂的显示名称:优先 name(比如 "footer"、"标题 1"),
|
|
1848
|
+
// 没 name 时兜底为 styleId(或 "样式 styleId")。
|
|
1849
|
+
const displayName = s.name ? s.name.trim() : '';
|
|
1850
|
+
const label = displayName || `样式 ${s.styleId}`;
|
|
1851
|
+
return {
|
|
1852
|
+
value: s.styleId,
|
|
1853
|
+
label,
|
|
1854
|
+
isFromFile: true,
|
|
1855
|
+
className: `style-preview-file-${s.styleId}`,
|
|
1856
|
+
// —— 新增:每个 option 直接带预览 inline style
|
|
1857
|
+
style: styleToPreviewCSSProperties(s),
|
|
1858
|
+
};
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
// 用 Map 去重:按 styleId 合并,后面的预设 style 不会覆盖前面的文件 style
|
|
1862
|
+
const mergedMap = new Map<string, {
|
|
1863
|
+
value: string;
|
|
1864
|
+
label: string;
|
|
1865
|
+
className?: string;
|
|
1866
|
+
style?: React.CSSProperties;
|
|
1867
|
+
isFromFile?: boolean;
|
|
1868
|
+
isPreset?: boolean;
|
|
1869
|
+
disabled?: boolean;
|
|
1870
|
+
}>();
|
|
1871
|
+
fileStyleOptions.forEach(opt => mergedMap.set(opt.value, opt));
|
|
1872
|
+
|
|
1873
|
+
// 2) 补上我们的 PRESET_STYLES(如果文件里还没有这个 styleId 的话)
|
|
1874
|
+
// 这样即便文件是空的,用户也至少能选 "正文/标题 1/标题 2" 等基础样式。
|
|
1875
|
+
for (const s of PRESET_STYLES) {
|
|
1876
|
+
if (mergedMap.has(s.styleId)) continue; // 文件里已有同名 styleId,用文件的(name 更准)
|
|
1877
|
+
const displayName = s.name ? String(s.name).trim() : '';
|
|
1878
|
+
mergedMap.set(s.styleId, {
|
|
1879
|
+
value: s.styleId,
|
|
1880
|
+
label: displayName || s.styleId,
|
|
1881
|
+
isPreset: true,
|
|
1882
|
+
className: `style-preview-${s.styleId}`,
|
|
1883
|
+
// —— 新增:预设样式也挂预览 inline style
|
|
1884
|
+
style: styleToPreviewCSSProperties(s),
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
|
|
1888
|
+
const mergedOptions = Array.from(mergedMap.values());
|
|
1889
|
+
|
|
1890
|
+
// —— 关键新增:按「规范化 name」做二级去重(解决用户截图里"标题1/标题11/
|
|
1891
|
+
// 标题2/标题21 成对重复"的 bug)。
|
|
1892
|
+
//
|
|
1893
|
+
// 背景:用户打开的 docx 里往往同时存在两套"语义等价"的 style:
|
|
1894
|
+
// A) 文件自带的 builtin 兼容样式:styleId 是数字字符串 "1"/"2"/"11"/"21",
|
|
1895
|
+
// name 可能是中文"标题 1"或英文"Heading 1"或全角"标题1"。
|
|
1896
|
+
// B) 我们预设 PRESET_STYLES 注入的样式:styleId 是字符串 "Heading1"/"Heading2",
|
|
1897
|
+
// name 是中文"标题 1"/"标题 2"。
|
|
1898
|
+
// 因为 styleId 不同(A 是数字,B 是 HeadingN),上面按 styleId 去重的 mergedMap
|
|
1899
|
+
// 会把 A 和 B 都放进去 → 下拉框里同一个"标题 1"语义出现 2~3 次,用户看得懵。
|
|
1900
|
+
//
|
|
1901
|
+
// 修复:用 normalizeStyleName(强归一化函数:全角→半角 + 去空格 + 标题↔heading +
|
|
1902
|
+
// 正文↔normal 同义统一)把所有 option 的 label 归一化,相同 normalized name
|
|
1903
|
+
// 只保留一项(合并时保留「更适合中文用户读」的那一个,优先级:isPreset >
|
|
1904
|
+
// isFromFile(name 含中文) > isFromFile(name 纯英文) > 其他)。
|
|
1905
|
+
//
|
|
1906
|
+
// 同时建立 styleIdAliases Map:所有"被合并掉"的 styleId 都映射到「保留项」的
|
|
1907
|
+
// value,这样 syncStyleSelectValue 或 antd Select value 校验时,就算 paragraph
|
|
1908
|
+
// 存的是数字 styleId="1"也能找到对应的保留项 value="Heading1",不会显示裸数字。
|
|
1909
|
+
type OptionItem = (typeof mergedOptions)[number];
|
|
1910
|
+
const presetSet = new Set(PRESET_STYLES.map(p => p.styleId));
|
|
1911
|
+
const byNormName = new Map<string, { kept: OptionItem; allStyleIds: string[] }>();
|
|
1912
|
+
mergedOptions.forEach((opt) => {
|
|
1913
|
+
const norm = normalizeStyleName(opt.label);
|
|
1914
|
+
if (!norm) {
|
|
1915
|
+
// 规范化后为空(label="样式 XX" 且没有 name),直接保留原始项,不参与去重
|
|
1916
|
+
const bucket = byNormName.get(`__unnamed__:${opt.value}`) ?? {
|
|
1917
|
+
kept: opt,
|
|
1918
|
+
allStyleIds: [opt.value],
|
|
1919
|
+
};
|
|
1920
|
+
byNormName.set(`__unnamed__:${opt.value}`, bucket);
|
|
1921
|
+
return;
|
|
1922
|
+
}
|
|
1923
|
+
|
|
1924
|
+
const existing = byNormName.get(norm);
|
|
1925
|
+
if (!existing) {
|
|
1926
|
+
byNormName.set(norm, { kept: opt, allStyleIds: [opt.value] });
|
|
1927
|
+
return;
|
|
1928
|
+
}
|
|
1929
|
+
|
|
1930
|
+
// —— 冲突决策:决定保留 existing.kept 还是新的 opt?
|
|
1931
|
+
// 打分规则(保留分高者):
|
|
1932
|
+
// 预设项:4 分(预设永远在文件也没有对应 styleId 时都能点,优先)
|
|
1933
|
+
// 选项 label 含中文字符:2 分(用户是中文公文场景,优先显示中文名称)
|
|
1934
|
+
// isFromFile && label 完全不含 ASCII 字母/数字(纯中文):额外 +1 分
|
|
1935
|
+
// 数字 styleId(纯数字 "1"/"2"/.../11"):0 分(最不推荐保留,
|
|
1936
|
+
// 因为公文里"选 styleId=数字"不直观,且容易与 HeadingN 重复显示)
|
|
1937
|
+
const scoreOf = (o: OptionItem): number => {
|
|
1938
|
+
let score = 0;
|
|
1939
|
+
if (o.isPreset || presetSet.has(o.value)) score += 4;
|
|
1940
|
+
if (/[\u4e00-\u9fa5]/.test(String(o.label))) score += 2;
|
|
1941
|
+
if (o.isFromFile && !/[A-Za-z0-9]/.test(String(o.label).trim())) score += 1;
|
|
1942
|
+
if (/^\d+$/.test(String(o.value))) score -= 0; // 扣分不做,保持中性,靠前两项拉开差距
|
|
1943
|
+
return score;
|
|
1944
|
+
};
|
|
1945
|
+
|
|
1946
|
+
const keptScore = scoreOf(existing.kept);
|
|
1947
|
+
const newScore = scoreOf(opt);
|
|
1948
|
+
if (newScore > keptScore) {
|
|
1949
|
+
// 新的更好 → 取代 kept,原 kept 的 styleId 加入 aliases
|
|
1950
|
+
existing.allStyleIds.push(existing.kept.value);
|
|
1951
|
+
existing.kept = opt;
|
|
1952
|
+
} else {
|
|
1953
|
+
// 现有更好 → 保留现有,新的 styleId 加入 aliases
|
|
1954
|
+
existing.allStyleIds.push(opt.value);
|
|
1955
|
+
}
|
|
1956
|
+
});
|
|
1957
|
+
|
|
1958
|
+
// 把 buckets 展开成最终 options + 构建 aliases Map(供后面 value 匹配用)
|
|
1959
|
+
const deduped: OptionItem[] = [];
|
|
1960
|
+
const styleIdAliases = new Map<string, string>();
|
|
1961
|
+
byNormName.forEach(({ kept, allStyleIds }) => {
|
|
1962
|
+
deduped.push(kept);
|
|
1963
|
+
// aliases:同一规范化 name 下的所有 styleId(包括被合并掉的)都指向 kept.value
|
|
1964
|
+
// 这样 paragraph.attrs.styleId 存的是 "1" 也能匹配到 value=Heading1 的 option
|
|
1965
|
+
allStyleIds.forEach((sid) => {
|
|
1966
|
+
if (!styleIdAliases.has(sid)) styleIdAliases.set(sid, kept.value);
|
|
1967
|
+
});
|
|
1968
|
+
// kept 自己的 value 也要映射(可能不在 allStyleIds 里,比如初始化 bucket 时)
|
|
1969
|
+
if (!styleIdAliases.has(kept.value)) styleIdAliases.set(kept.value, kept.value);
|
|
1970
|
+
});
|
|
1971
|
+
|
|
1972
|
+
// 3) 检查 currentStyleId 是否已经能在最终 options 里找到。
|
|
1973
|
+
// —— 相比原逻辑增加 aliases 映射:
|
|
1974
|
+
// 如果 currentStyleId 本身不在 deduped 的 value 集合里(因为被上面的二级去重合并掉了)
|
|
1975
|
+
// 就通过 styleIdAliases 找到它对应的"保留项 canonical styleId",
|
|
1976
|
+
// 有 canonical 且 deduped 里存在这个 value → 也认为匹配成功,不加 fallback。
|
|
1977
|
+
const finalValues = new Set(deduped.map(o => o.value));
|
|
1978
|
+
const canonicalStyleId = styleIdAliases.get(currentStyleId) ?? currentStyleId;
|
|
1979
|
+
const hasMatchingOption = finalValues.has(currentStyleId) || finalValues.has(canonicalStyleId);
|
|
1980
|
+
if (hasMatchingOption) {
|
|
1981
|
+
return deduped;
|
|
1982
|
+
}
|
|
1983
|
+
|
|
1984
|
+
// 4) 完全匹配不到(比如 DocxEditor 内部存了一个 styleId="17" 但文件/Preset 都没登记)
|
|
1985
|
+
// → 加一个 disabled 的 fallback option,只用于显示(用户不能点它)。
|
|
1986
|
+
// 如果 styleMapById 里能根据 currentStyleId 找到 Style(常见于 DocxEditor 内部
|
|
1987
|
+
// 数字映射 styleId 但我们没登记在 options 里),就连 fallback 也带预览样式。
|
|
1988
|
+
const fallbackStyle = styleMapById.get(currentStyleId) ?? null;
|
|
1989
|
+
const fallbackLabel = `样式 ${currentStyleId}`;
|
|
1990
|
+
const fallbackOption = {
|
|
1991
|
+
value: currentStyleId,
|
|
1992
|
+
label: fallbackLabel,
|
|
1993
|
+
className: 'style-preview-fallback',
|
|
1994
|
+
style: styleToPreviewCSSProperties(fallbackStyle),
|
|
1995
|
+
disabled: true,
|
|
1996
|
+
};
|
|
1997
|
+
return [fallbackOption, ...deduped];
|
|
1998
|
+
}, [currentStyleId, styleToPreviewCSSProperties]);
|
|
1999
|
+
|
|
2000
|
+
// ============================================================================
|
|
2001
|
+
// 兜底方案:动态生成预览样式的 <style> 标签(三重保险)
|
|
2002
|
+
//
|
|
2003
|
+
// 虽然主方案已经是「label 最内层 span 写 inline style」(100% 不会被覆盖),
|
|
2004
|
+
// 但为了极端场景(比如未来 antd 把 options.label 包在一层额外 wrapper 里,
|
|
2005
|
+
// 或者 SSR/HMR 时 ReactNode 未正确渲染),我们再加一层兜底:
|
|
2006
|
+
// 每当 documentStylesFromFile(文件里的样式列表)变化时,就生成一份
|
|
2007
|
+
// 纯 CSS 规则,用 className 作选择器(和我们写在 option 上的 className 一致),
|
|
2008
|
+
// 挂一个 <style id="vendorsys-word-style-preview-css"> 到文档 <head> 里。
|
|
2009
|
+
// 这样即便 label span 方案某一天失效,CSS 规则也能用 !important 强行命中文字。
|
|
2010
|
+
//
|
|
2011
|
+
// 组件卸载时清除这个 <style> 标签,避免残留污染全局。
|
|
2012
|
+
// ============================================================================
|
|
2013
|
+
useEffect(() => {
|
|
2014
|
+
if (!visible) return;
|
|
2015
|
+
|
|
2016
|
+
// 先找已有的 style 标签,不存在就新建一个
|
|
2017
|
+
let styleEl = document.getElementById('vendorsys-word-style-preview-css') as HTMLStyleElement | null;
|
|
2018
|
+
if (!styleEl) {
|
|
2019
|
+
styleEl = document.createElement('style');
|
|
2020
|
+
styleEl.id = 'vendorsys-word-style-preview-css';
|
|
2021
|
+
styleEl.setAttribute('data-vendorsys-generated', 'true');
|
|
2022
|
+
document.head.appendChild(styleEl);
|
|
2023
|
+
}
|
|
2024
|
+
|
|
2025
|
+
// 汇总所有要生成 CSS 的样式:文件里的 + 预设的(按 styleId 去重)
|
|
2026
|
+
const styleMapById = new Map<string, Style>();
|
|
2027
|
+
documentStylesFromFile.forEach(s => { if (!styleMapById.has(s.styleId)) styleMapById.set(s.styleId, s); });
|
|
2028
|
+
PRESET_STYLES.forEach(s => { if (!styleMapById.has(s.styleId)) styleMapById.set(s.styleId, s); });
|
|
2029
|
+
|
|
2030
|
+
const rules: string[] = [];
|
|
2031
|
+
styleMapById.forEach((style, styleId) => {
|
|
2032
|
+
// 每个 style 可能有两种 className 钩子:
|
|
2033
|
+
// - 如果是文件里来的(上面 fileStyleOptions 里写的):.style-preview-file-${styleId}
|
|
2034
|
+
// - 如果是预设的(上面 PRESET_STYLES 补的):.style-preview-${styleId}
|
|
2035
|
+
// 但因为我们不知道每个 styleId 究竟是哪个来源(合并时的去重策略),
|
|
2036
|
+
// 干脆对每个 styleId 同时挂两个 class 的规则——都加上,都 !important,
|
|
2037
|
+
// 命中哪个算哪个,不会互相冲突。
|
|
2038
|
+
const selectors = [
|
|
2039
|
+
`.style-preview-file-${styleId} .vendorsys-word-style-option-label`,
|
|
2040
|
+
`.style-preview-${styleId} .vendorsys-word-style-option-label`,
|
|
2041
|
+
`.style-preview-file-${styleId}`,
|
|
2042
|
+
`.style-preview-${styleId}`,
|
|
2043
|
+
].join(', ');
|
|
2044
|
+
|
|
2045
|
+
const preview = styleToPreviewCSSProperties(style);
|
|
2046
|
+
const declarations: string[] = [];
|
|
2047
|
+
// —— 修复 fontSize 序列化 bug:
|
|
2048
|
+
// preview.fontSize 本身就已经是字符串 "20px"(由 styleToPreviewCSSProperties 内部生成的)
|
|
2049
|
+
// 前面错误地写成了 `${preview.fontSize}px !important` → 变成 "20pxpx !important"
|
|
2050
|
+
// → 浏览器解析失败,全部被当作无效规则丢弃。现在改对了。
|
|
2051
|
+
if (preview.fontSize) {
|
|
2052
|
+
declarations.push(`font-size: ${preview.fontSize} !important;`);
|
|
2053
|
+
}
|
|
2054
|
+
if (preview.fontWeight !== undefined && preview.fontWeight !== null) {
|
|
2055
|
+
declarations.push(`font-weight: ${preview.fontWeight} !important;`);
|
|
2056
|
+
}
|
|
2057
|
+
if (preview.fontStyle) {
|
|
2058
|
+
declarations.push(`font-style: ${preview.fontStyle} !important;`);
|
|
2059
|
+
}
|
|
2060
|
+
if (preview.fontFamily) {
|
|
2061
|
+
declarations.push(`font-family: ${preview.fontFamily} !important;`);
|
|
2062
|
+
}
|
|
2063
|
+
if (preview.lineHeight !== undefined && preview.lineHeight !== null) {
|
|
2064
|
+
declarations.push(`line-height: ${preview.lineHeight} !important;`);
|
|
2065
|
+
}
|
|
2066
|
+
if (preview.color) {
|
|
2067
|
+
declarations.push(`color: ${preview.color} !important;`);
|
|
2068
|
+
}
|
|
2069
|
+
if (preview.textDecoration) {
|
|
2070
|
+
declarations.push(`text-decoration: ${preview.textDecoration} !important;`);
|
|
2071
|
+
}
|
|
2072
|
+
// textDecorationColor 等少见属性忽略,纯 CSS 兜底不需要面面俱到。
|
|
2073
|
+
|
|
2074
|
+
if (declarations.length > 0) {
|
|
2075
|
+
rules.push(`${selectors} { ${declarations.join(' ')} }`);
|
|
2076
|
+
}
|
|
2077
|
+
});
|
|
2078
|
+
|
|
2079
|
+
// 写入 style 标签
|
|
2080
|
+
styleEl.textContent = rules.join('\n');
|
|
2081
|
+
|
|
2082
|
+
// 卸载 / 下一次 effect 执行前:如果下次 editor 关闭就移除 style 标签(避免残留)
|
|
2083
|
+
return () => {
|
|
2084
|
+
if (styleEl && styleEl.parentNode) {
|
|
2085
|
+
// 这里不直接 remove,因为下一次 effect 会重写 textContent;
|
|
2086
|
+
// remove 放在外层 editor.close 时统一处理。
|
|
2087
|
+
}
|
|
2088
|
+
};
|
|
2089
|
+
}, [visible, documentStylesFromFile, styleToPreviewCSSProperties]);
|
|
2090
|
+
|
|
2091
|
+
// editor 关闭(visible 变为 false)时清理动态生成的 style 标签,
|
|
2092
|
+
// 避免下次打开时样式残留。单独写一个 useEffect 对应 visible=false。
|
|
2093
|
+
useEffect(() => {
|
|
2094
|
+
if (visible) return;
|
|
2095
|
+
const styleEl = document.getElementById('vendorsys-word-style-preview-css');
|
|
2096
|
+
if (styleEl && styleEl.parentNode) {
|
|
2097
|
+
styleEl.parentNode.removeChild(styleEl);
|
|
2098
|
+
}
|
|
2099
|
+
}, [visible]);
|
|
2100
|
+
|
|
2101
|
+
// 暴露 ref 给父组件
|
|
2102
|
+
useImperativeHandle(ref, () => editorRef.current as DocxEditorRef);
|
|
2103
|
+
|
|
2104
|
+
// ============================================================================
|
|
2105
|
+
// Docx Part 快照 + 镜像回写工具(修复"保存后页脚签章/水印图损坏为空占位图")
|
|
2106
|
+
//
|
|
2107
|
+
// 背景:@eigenpal/docx-editor-core 的 save() serializer 对 header/footer 等
|
|
2108
|
+
// 非正文 part 的 drawing/vml 序列化存在 bug(会丢 r:embed / 破坏命名空间),
|
|
2109
|
+
// 导致 save 输出的 docx 中 footer1.xml 里的 drawing 结构损坏 → 重新打开
|
|
2110
|
+
// parseDocx 时 drawing 的 blip 无法匹配 media rel → fallback 成一个
|
|
2111
|
+
// <img src="" width=192 height=192 style=position:absolute> 空占位图。
|
|
2112
|
+
//
|
|
2113
|
+
// 解决思路(完全绕开 lib serializer):
|
|
2114
|
+
// 1) 打开文件时把 docx 里所有 part 的字节(path → Uint8Array)存到
|
|
2115
|
+
// rawDocxPartsRef.current(snapshot)。
|
|
2116
|
+
// 2) 每次保存:在 mergePresetStylesIntoDocx 输出最终 blob 之后,再做一次
|
|
2117
|
+
// JSZip.load → 对"绝不会被正文编辑修改的 part 路径",用 snapshot 里的
|
|
2118
|
+
// 原始字节回写覆盖 → generateAsync 得到最终 blob。
|
|
2119
|
+
// 这样即使 lib serializer 把 footer drawing 序列化错了,我们也能在最后
|
|
2120
|
+
// 把正确字节抄回来,字节级完全一致 → 重新打开 footer drawing 绝对完好。
|
|
2121
|
+
// ============================================================================
|
|
2122
|
+
|
|
2123
|
+
/** 打开文件时:解出 docx 内所有 part 的 bytes,存到 Map<string, Uint8Array>。 */
|
|
2124
|
+
const snapshotDocxParts = useCallback(async (blob: Blob): Promise<Map<string, Uint8Array>> => {
|
|
2125
|
+
const map = new Map<string, Uint8Array>();
|
|
2126
|
+
try {
|
|
2127
|
+
const zip = await JSZip.loadAsync(blob);
|
|
2128
|
+
const tasks: Array<Promise<void>> = [];
|
|
2129
|
+
zip.forEach((relativePath, zipEntry) => {
|
|
2130
|
+
if (zipEntry.dir) return;
|
|
2131
|
+
tasks.push(
|
|
2132
|
+
zipEntry.async('uint8array').then((bytes) => {
|
|
2133
|
+
map.set(relativePath, bytes);
|
|
2134
|
+
})
|
|
2135
|
+
);
|
|
2136
|
+
});
|
|
2137
|
+
await Promise.all(tasks);
|
|
2138
|
+
} catch (err) {
|
|
2139
|
+
console.warn('[WordEditor] docx 快照失败(不影响打开,镜像回写自动跳过):', err);
|
|
2140
|
+
}
|
|
2141
|
+
return map;
|
|
2142
|
+
}, []);
|
|
2143
|
+
|
|
2144
|
+
/** 保存前:把 blob 里所有不可变 part path 的字节用 snapshot 抄回来。 */
|
|
2145
|
+
const restoreImmutableDocxParts = useCallback(
|
|
2146
|
+
async (blob: Blob, snapshot: Map<string, Uint8Array> | undefined | null): Promise<Blob> => {
|
|
2147
|
+
if (!snapshot || snapshot.size === 0) return blob; // 新建空白文档无快照 → 直接返回
|
|
2148
|
+
try {
|
|
2149
|
+
const zip = await JSZip.loadAsync(blob);
|
|
2150
|
+
let patched = false;
|
|
2151
|
+
const tasks: Array<Promise<void>> = [];
|
|
2152
|
+
snapshot.forEach((origBytes, path) => {
|
|
2153
|
+
if (!isImmutablePart(path)) return;
|
|
2154
|
+
const entry = zip.file(path);
|
|
2155
|
+
if (!entry) return;
|
|
2156
|
+
tasks.push(
|
|
2157
|
+
entry.async('uint8array').then((curBytes) => {
|
|
2158
|
+
// 字节级完全一致就跳过,避免重写 zip entry metadata
|
|
2159
|
+
if (curBytes.length !== origBytes.length) {
|
|
2160
|
+
zip.file(path, origBytes);
|
|
2161
|
+
patched = true;
|
|
2162
|
+
return;
|
|
2163
|
+
}
|
|
2164
|
+
for (let i = 0; i < curBytes.length; i++) {
|
|
2165
|
+
if (curBytes[i] !== origBytes[i]) {
|
|
2166
|
+
zip.file(path, origBytes);
|
|
2167
|
+
patched = true;
|
|
2168
|
+
return;
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
})
|
|
2172
|
+
);
|
|
2173
|
+
});
|
|
2174
|
+
await Promise.all(tasks);
|
|
2175
|
+
if (!patched) return blob;
|
|
2176
|
+
// 必须显式指定 DEFLATE:JSZip 的 generateAsync 默认 compression 是 STORE(不压缩),
|
|
2177
|
+
// 不指定会导致整个 docx 未压缩写出,文件体积比 WPS 保存的大 5~10 倍。
|
|
2178
|
+
return await zip.generateAsync({
|
|
2179
|
+
type: 'blob',
|
|
2180
|
+
mimeType: DOCX_MIME_TYPE,
|
|
2181
|
+
compression: 'DEFLATE',
|
|
2182
|
+
compressionOptions: { level: 9 },
|
|
2183
|
+
});
|
|
2184
|
+
} catch (err) {
|
|
2185
|
+
console.warn('[WordEditor] 不可变 part 镜像回写失败(将使用原始保存输出,不影响正文内容):', err);
|
|
2186
|
+
return blob;
|
|
2187
|
+
}
|
|
2188
|
+
},
|
|
2189
|
+
[]
|
|
2190
|
+
);
|
|
2191
|
+
|
|
2192
|
+
const loadDocumentBuffer = useCallback(async (arrayBuffer: ArrayBuffer, defaultToFinalPreview: boolean) => {
|
|
2193
|
+
const sourceBuffer = arrayBuffer.slice(0);
|
|
2194
|
+
|
|
2195
|
+
// —— 新增:打开文件时立即保存全 docx part 快照,用于保存时镜像回写
|
|
2196
|
+
// 修复页脚签章/水印图在保存后重进变成空占位图的 bug。
|
|
2197
|
+
snapshotDocxParts(new Blob([sourceBuffer], { type: DOCX_MIME_TYPE })).then((snap) => {
|
|
2198
|
+
rawDocxPartsRef.current = snap;
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
// 修复 TOC 字段显示问题:将 TOC 字段内的 hyperlink unwrap,使标题文本能被解析器收集
|
|
2202
|
+
let fixedBuffer = sourceBuffer;
|
|
2203
|
+
try {
|
|
2204
|
+
const fixedBlob = await fixTocFieldsInDocx(new Blob([sourceBuffer], { type: DOCX_MIME_TYPE }));
|
|
2205
|
+
fixedBuffer = await fixedBlob.arrayBuffer();
|
|
2206
|
+
} catch (error) {
|
|
2207
|
+
console.warn('修复 TOC 字段失败,使用原始文件:', error);
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
// 将预设样式(含段间距)合并进 styles.xml,必须在 parseDocx 之前完成
|
|
2211
|
+
try {
|
|
2212
|
+
const mergedBlob = await mergePresetStylesIntoDocx(new Blob([fixedBuffer], { type: DOCX_MIME_TYPE }));
|
|
2213
|
+
fixedBuffer = await mergedBlob.arrayBuffer();
|
|
2214
|
+
} catch (error) {
|
|
2215
|
+
console.warn('合并预设样式失败,使用原始文件:', error);
|
|
2216
|
+
}
|
|
2217
|
+
|
|
2218
|
+
// 解析文档并应用预设样式,确保自定义样式始终可用
|
|
2219
|
+
try {
|
|
2220
|
+
const doc = await parseDocx(fixedBuffer);
|
|
2221
|
+
applyPresetStylesToDocument(doc);
|
|
2222
|
+
// 应用文档网格(w:docGrid)整数格行距:编辑器布局引擎不消费 docGrid,
|
|
2223
|
+
// 不注入会导致中文公文行距明显小于 WPS(与只读预览渲染器补丁同一套规则)。
|
|
2224
|
+
applyDocGridLineSpacing(doc);
|
|
2225
|
+
// 修正中文字体:编辑器只认 ascii/hAnsi、忽略 w:eastAsia,导致在线编辑
|
|
2226
|
+
// 显示系统无衬线字体,与只读预览(宋体/仿宋等)不一致。
|
|
2227
|
+
applyCjkFontFallbackToDocument(doc);
|
|
2228
|
+
|
|
2229
|
+
// 打开文件时立刻把 docx 自带的段落样式灌进下拉框数据源,
|
|
2230
|
+
// 不再等 onSelectionChange / StyleResolver 回填(否则用户看到的下拉框只有预设样式)。
|
|
2231
|
+
setDocumentStylesFromFile(extractParagraphStyles(doc));
|
|
2232
|
+
|
|
2233
|
+
if (!defaultToFinalPreview) {
|
|
2234
|
+
revisionPreviewSourceBufferRef.current = null;
|
|
2235
|
+
setInitialDocument(doc);
|
|
2236
|
+
setInitialBuffer(null);
|
|
2237
|
+
setShowFinalRevisionPreview(false);
|
|
2238
|
+
setEditorKey((prev) => prev + 1);
|
|
2239
|
+
return;
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
try {
|
|
2243
|
+
const sourceBlob = new Blob([fixedBuffer], { type: DOCX_MIME_TYPE });
|
|
2244
|
+
const finalPreviewBlob = await createFinalRevisionPreviewBlob(sourceBlob);
|
|
2245
|
+
const finalPreviewBuffer = await finalPreviewBlob.arrayBuffer();
|
|
2246
|
+
|
|
2247
|
+
revisionPreviewSourceBufferRef.current = sourceBuffer;
|
|
2248
|
+
setInitialBuffer(finalPreviewBuffer);
|
|
2249
|
+
setInitialDocument(doc);
|
|
2250
|
+
setShowFinalRevisionPreview(true);
|
|
2251
|
+
} catch (error) {
|
|
2252
|
+
console.warn('生成默认最终版预览失败,将打开原始修订视图:', error);
|
|
2253
|
+
revisionPreviewSourceBufferRef.current = null;
|
|
2254
|
+
setInitialBuffer(fixedBuffer);
|
|
2255
|
+
setInitialDocument(doc);
|
|
2256
|
+
setShowFinalRevisionPreview(false);
|
|
2257
|
+
}
|
|
2258
|
+
} catch (error) {
|
|
2259
|
+
console.warn('解析文档或应用预设样式失败,回退到原始加载方式:', error);
|
|
2260
|
+
|
|
2261
|
+
if (!defaultToFinalPreview) {
|
|
2262
|
+
revisionPreviewSourceBufferRef.current = null;
|
|
2263
|
+
setInitialBuffer(fixedBuffer);
|
|
2264
|
+
setInitialDocument(null);
|
|
2265
|
+
setShowFinalRevisionPreview(false);
|
|
2266
|
+
setEditorKey((prev) => prev + 1);
|
|
2267
|
+
return;
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
try {
|
|
2271
|
+
const sourceBlob = new Blob([fixedBuffer], { type: DOCX_MIME_TYPE });
|
|
2272
|
+
const finalPreviewBlob = await createFinalRevisionPreviewBlob(sourceBlob);
|
|
2273
|
+
const finalPreviewBuffer = await finalPreviewBlob.arrayBuffer();
|
|
2274
|
+
|
|
2275
|
+
revisionPreviewSourceBufferRef.current = sourceBuffer;
|
|
2276
|
+
setInitialBuffer(finalPreviewBuffer);
|
|
2277
|
+
setInitialDocument(null);
|
|
2278
|
+
setShowFinalRevisionPreview(true);
|
|
2279
|
+
} catch (fallbackError) {
|
|
2280
|
+
console.warn('生成默认最终版预览失败,将打开原始修订视图:', fallbackError);
|
|
2281
|
+
revisionPreviewSourceBufferRef.current = null;
|
|
2282
|
+
setInitialBuffer(fixedBuffer);
|
|
2283
|
+
setInitialDocument(null);
|
|
2284
|
+
setShowFinalRevisionPreview(false);
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
setEditorKey((prev) => prev + 1);
|
|
2289
|
+
// 编辑器 remount 后需要重新标记为"未就绪",抑制初始化期间的 onChange
|
|
2290
|
+
isEditorReadyRef.current = false;
|
|
2291
|
+
}, [snapshotDocxParts]);
|
|
2292
|
+
|
|
2293
|
+
// 禁用 Google Fonts,使用系统字体替代
|
|
2294
|
+
useEffect(() => {
|
|
2295
|
+
setGoogleFontsEnabled(false);
|
|
2296
|
+
}, []);
|
|
2297
|
+
|
|
2298
|
+
// 获取当前登录用户
|
|
2299
|
+
useEffect(() => {
|
|
2300
|
+
if (visible) {
|
|
2301
|
+
const getUserId = async () => {
|
|
2302
|
+
try {
|
|
2303
|
+
const userInfo = CacheUserInfo.currentLocalUser();
|
|
2304
|
+
if (userInfo?.id) {
|
|
2305
|
+
setUserId(userInfo.id);
|
|
2306
|
+
} else {
|
|
2307
|
+
setUserId(null);
|
|
2308
|
+
}
|
|
2309
|
+
} catch (error) {
|
|
2310
|
+
console.error('获取用户信息失败:', error);
|
|
2311
|
+
setUserId(null);
|
|
2312
|
+
}
|
|
2313
|
+
};
|
|
2314
|
+
getUserId();
|
|
2315
|
+
}
|
|
2316
|
+
}, [visible]);
|
|
2317
|
+
|
|
2318
|
+
// 加载初始文件
|
|
2319
|
+
useEffect(() => {
|
|
2320
|
+
// initialBlob 变化时(如重新打开文件),重置差分基准
|
|
2321
|
+
baseBlobRef.current = initialBlob;
|
|
2322
|
+
|
|
2323
|
+
const loadInitialFile = async () => {
|
|
2324
|
+
if (visible && userId) {
|
|
2325
|
+
// 如果有传入的 initialBlob,优先加载它
|
|
2326
|
+
if (initialBlob) {
|
|
2327
|
+
try {
|
|
2328
|
+
setLoadingInitialBlob(true);
|
|
2329
|
+
|
|
2330
|
+
// 需求3:彻底移除旧缓存检查。既然有了 initialBlob(来自 localFiles 或服务器),直接加载
|
|
2331
|
+
const arrayBuffer = await initialBlob.arrayBuffer();
|
|
2332
|
+
await loadDocumentBuffer(arrayBuffer, Boolean(readOnly));
|
|
2333
|
+
setHasLocalCache(false);
|
|
2334
|
+
message.info('已加载文件');
|
|
2335
|
+
} catch (error) {
|
|
2336
|
+
console.error('加载文件失败:', error);
|
|
2337
|
+
message.error('加载文件失败');
|
|
2338
|
+
setInitialBuffer(null);
|
|
2339
|
+
setInitialDocument(null);
|
|
2340
|
+
setHasLocalCache(false);
|
|
2341
|
+
} finally {
|
|
2342
|
+
setLoadingInitialBlob(false);
|
|
2343
|
+
}
|
|
2344
|
+
} else if (isCreate && fileName && userId) {
|
|
2345
|
+
// 新建模式:不再自动检查/加载旧的本地缓存(需求3)
|
|
2346
|
+
// 新建的本地文件会通过 initialBlob 从 localFiles store 传入内容
|
|
2347
|
+
// 如果没有 initialBlob(兜底),直接创建空白文档
|
|
2348
|
+
try {
|
|
2349
|
+
// 直接创建带预设样式的新文档,不做缓存检查
|
|
2350
|
+
const newDoc = createDocumentWithStyles();
|
|
2351
|
+
setInitialDocument(newDoc);
|
|
2352
|
+
setDocumentStylesFromFile(extractParagraphStyles(newDoc));
|
|
2353
|
+
setInitialBuffer(null);
|
|
2354
|
+
revisionPreviewSourceBufferRef.current = null;
|
|
2355
|
+
setShowFinalRevisionPreview(false);
|
|
2356
|
+
setEditorKey((prev) => prev + 1);
|
|
2357
|
+
setHasLocalCache(false);
|
|
2358
|
+
message.info('已创建新文档');
|
|
2359
|
+
} catch (error) {
|
|
2360
|
+
console.error('创建新文档失败:', error);
|
|
2361
|
+
try {
|
|
2362
|
+
const newDoc = createDocumentWithStyles();
|
|
2363
|
+
setInitialDocument(newDoc);
|
|
2364
|
+
setDocumentStylesFromFile(extractParagraphStyles(newDoc));
|
|
2365
|
+
setInitialBuffer(null);
|
|
2366
|
+
revisionPreviewSourceBufferRef.current = null;
|
|
2367
|
+
setShowFinalRevisionPreview(false);
|
|
2368
|
+
setEditorKey((prev) => prev + 1);
|
|
2369
|
+
} catch (e) {
|
|
2370
|
+
console.error('创建空文档失败:', e);
|
|
2371
|
+
setInitialDocument(null);
|
|
2372
|
+
setInitialBuffer(null);
|
|
2373
|
+
}
|
|
2374
|
+
setHasLocalCache(false);
|
|
2375
|
+
}
|
|
2376
|
+
} else {
|
|
2377
|
+
// 没有用户ID,重置状态
|
|
2378
|
+
setInitialBuffer(null);
|
|
2379
|
+
setInitialDocument(null);
|
|
2380
|
+
revisionPreviewSourceBufferRef.current = null;
|
|
2381
|
+
setShowFinalRevisionPreview(false);
|
|
2382
|
+
setHasLocalCache(false);
|
|
2383
|
+
}
|
|
2384
|
+
}
|
|
2385
|
+
};
|
|
2386
|
+
|
|
2387
|
+
loadInitialFile();
|
|
2388
|
+
}, [visible, fileName, userId, initialBlob, isCreate, readOnly, loadDocumentBuffer]);
|
|
2389
|
+
|
|
2390
|
+
// 文档加载完成后,自动刷新大纲以解决 TOC 初始化问题
|
|
2391
|
+
useEffect(() => {
|
|
2392
|
+
if (visible && !loadingInitialBlob && (initialBuffer || initialDocument)) {
|
|
2393
|
+
// 延迟 1000ms 后触发大纲刷新,确保文档完全解析
|
|
2394
|
+
const refreshTimer = setTimeout(() => {
|
|
2395
|
+
// 使用 PluginHost 的 refreshPluginStates 方法刷新所有插件状态
|
|
2396
|
+
// 这会触发大纲面板的重新渲染
|
|
2397
|
+
if (pluginHostRef.current) {
|
|
2398
|
+
pluginHostRef.current.refreshPluginStates();
|
|
2399
|
+
}
|
|
2400
|
+
}, 5000);
|
|
2401
|
+
|
|
2402
|
+
return () => clearTimeout(refreshTimer);
|
|
2403
|
+
}
|
|
2404
|
+
}, [visible, loadingInitialBlob, initialBuffer, initialDocument]);
|
|
2405
|
+
|
|
2406
|
+
// 导出为 Blob,并清理 docx 中未被引用的媒体资源
|
|
2407
|
+
const exportToBlob = useCallback(async (): Promise<Blob | null> => {
|
|
2408
|
+
try {
|
|
2409
|
+
if (!editorRef.current) {
|
|
2410
|
+
message.error('编辑器未初始化');
|
|
2411
|
+
return null;
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
suppressEditorSaveRef.current = true;
|
|
2415
|
+
suppressChangeRef.current = true;
|
|
2416
|
+
const arrayBuffer = await editorRef.current.save({ selective: false });
|
|
2417
|
+
if (!arrayBuffer) return null;
|
|
2418
|
+
|
|
2419
|
+
const rawBlob = new Blob([arrayBuffer], { type: DOCX_MIME_TYPE });
|
|
2420
|
+
|
|
2421
|
+
// 全量保存(selective: false)会写入 styles.xml,但仍需合并预设样式确保段间距等格式一致。
|
|
2422
|
+
// 之后再做「不可变 part 镜像回写」:把 header/footer/media 等部分的字节直接
|
|
2423
|
+
// 覆盖成打开时的原始快照,绕开 lib serializer 损坏页脚 drawing → 空图 bug。
|
|
2424
|
+
const mergedBlob = await mergePresetStylesIntoDocx(rawBlob);
|
|
2425
|
+
const patchedBlob = await restoreImmutableDocxParts(mergedBlob, rawDocxPartsRef.current);
|
|
2426
|
+
return await optimizeDocxBlob(patchedBlob);
|
|
2427
|
+
} catch (error) {
|
|
2428
|
+
console.error('导出 Word 失败:', error);
|
|
2429
|
+
return null;
|
|
2430
|
+
} finally {
|
|
2431
|
+
suppressEditorSaveRef.current = false;
|
|
2432
|
+
suppressChangeRef.current = false;
|
|
2433
|
+
}
|
|
2434
|
+
}, [restoreImmutableDocxParts]);
|
|
2435
|
+
|
|
2436
|
+
// 接管 docx-editor-react 内置菜单“文件 -> 保存”,避免下载未优化的 docx
|
|
2437
|
+
const handleEditorMenuSave = useCallback((arrayBuffer: ArrayBuffer) => {
|
|
2438
|
+
if (suppressEditorSaveRef.current) return;
|
|
2439
|
+
|
|
2440
|
+
if (showFinalRevisionPreview && revisionPreviewSourceBufferRef.current) {
|
|
2441
|
+
const sourceBlob = new Blob([revisionPreviewSourceBufferRef.current], { type: DOCX_MIME_TYPE });
|
|
2442
|
+
pendingEditorDownloadRef.current = Promise.resolve(sourceBlob);
|
|
2443
|
+
message.info('最终版仅用于预览,已保存带修订的原文件');
|
|
2444
|
+
return;
|
|
2445
|
+
}
|
|
2446
|
+
|
|
2447
|
+
const rawBlob = new Blob([arrayBuffer], { type: DOCX_MIME_TYPE });
|
|
2448
|
+
// 内置菜单保存也走与 exportToBlob 完全一致的管线:
|
|
2449
|
+
// a) mergePresetStylesIntoDocx(合并预设 styles.xml + 注入内联 spacing/pBdr)
|
|
2450
|
+
// b) restoreImmutableDocxParts(镜像回写 header/footer/media 原始字节,防空图)
|
|
2451
|
+
// c) optimizeDocxBlob(压缩/修订清理等通用优化)
|
|
2452
|
+
pendingEditorDownloadRef.current = mergePresetStylesIntoDocx(rawBlob)
|
|
2453
|
+
.then((merged) => restoreImmutableDocxParts(merged, rawDocxPartsRef.current))
|
|
2454
|
+
.then((patched) => optimizeDocxBlob(patched))
|
|
2455
|
+
.catch((error) => {
|
|
2456
|
+
console.error('内置菜单保存优化失败:', error);
|
|
2457
|
+
message.error('保存优化失败,将使用原始文件');
|
|
2458
|
+
return rawBlob;
|
|
2459
|
+
});
|
|
2460
|
+
}, [showFinalRevisionPreview, restoreImmutableDocxParts]);
|
|
2461
|
+
|
|
2462
|
+
// 内置菜单保存会在 onSave 后继续下载原始 blob,这里拦截并替换为优化后的 blob
|
|
2463
|
+
useEffect(() => {
|
|
2464
|
+
if (!visible) return;
|
|
2465
|
+
|
|
2466
|
+
const originalClick = HTMLAnchorElement.prototype.click;
|
|
2467
|
+
HTMLAnchorElement.prototype.click = function patchedClick() {
|
|
2468
|
+
const pendingDownload = pendingEditorDownloadRef.current;
|
|
2469
|
+
const isDocxDownload = this.href.startsWith('blob:') && /\.docx$/i.test(this.download || '');
|
|
2470
|
+
|
|
2471
|
+
if (pendingDownload && isDocxDownload && this.dataset.optimizedDocxDownload !== 'true') {
|
|
2472
|
+
const originalBlobUrl = this.href;
|
|
2473
|
+
pendingEditorDownloadRef.current = null;
|
|
2474
|
+
|
|
2475
|
+
pendingDownload.then((optimizedBlob) => {
|
|
2476
|
+
downloadBlob(optimizedBlob, fileName);
|
|
2477
|
+
message.success('已保存优化后的文件');
|
|
2478
|
+
}).catch((error) => {
|
|
2479
|
+
console.error('内置菜单保存失败:', error);
|
|
2480
|
+
message.error('保存失败');
|
|
2481
|
+
}).finally(() => {
|
|
2482
|
+
setTimeout(() => URL.revokeObjectURL(originalBlobUrl), 0);
|
|
2483
|
+
});
|
|
2484
|
+
|
|
2485
|
+
return;
|
|
2486
|
+
}
|
|
2487
|
+
|
|
2488
|
+
return originalClick.call(this);
|
|
2489
|
+
};
|
|
2490
|
+
|
|
2491
|
+
return () => {
|
|
2492
|
+
HTMLAnchorElement.prototype.click = originalClick;
|
|
2493
|
+
pendingEditorDownloadRef.current = null;
|
|
2494
|
+
};
|
|
2495
|
+
}, [fileName, visible]);
|
|
2496
|
+
|
|
2497
|
+
// 自动保存到 IndexedDB(防抖)+ 同步 localFiles store,使"有未同步修改"立即生效
|
|
2498
|
+
const autoSaveToIndexedDB = useCallback(async () => {
|
|
2499
|
+
if (showFinalRevisionPreview || !fileName || !userId || !editorRef.current) return;
|
|
2500
|
+
|
|
2501
|
+
// 清除之前的定时器
|
|
2502
|
+
if (saveTimerRef.current) {
|
|
2503
|
+
clearTimeout(saveTimerRef.current);
|
|
2504
|
+
}
|
|
2505
|
+
|
|
2506
|
+
setHasLocalCache(true);
|
|
2507
|
+
|
|
2508
|
+
// 设置新的定时器,10秒后保存
|
|
2509
|
+
saveTimerRef.current = setTimeout(async () => {
|
|
2510
|
+
try {
|
|
2511
|
+
const blob = await exportToBlob();
|
|
2512
|
+
if (blob) {
|
|
2513
|
+
await saveFile(userId, fileName, 'docx', await blob.arrayBuffer());
|
|
2514
|
+
setHasLocalCache(false);
|
|
2515
|
+
|
|
2516
|
+
// 同步更新 localFiles store:
|
|
2517
|
+
// 上传成功后 localId 被清空,这里调用 onSaveLocalBlob 时 handleSaveLocalBlob
|
|
2518
|
+
// 会自动在 localFiles 中新建一条记录,并刷新 files/editingFile/previewHasUnsyncedChanges 等状态。
|
|
2519
|
+
if (onSaveLocalBlobRef.current) {
|
|
2520
|
+
try {
|
|
2521
|
+
await onSaveLocalBlobRef.current(blob, fileName, false);
|
|
2522
|
+
} catch (err) {
|
|
2523
|
+
console.warn('自动保存时同步 localFiles store 失败(不影响本地保存):', err);
|
|
2524
|
+
}
|
|
2525
|
+
}
|
|
2526
|
+
}
|
|
2527
|
+
} catch (error) {
|
|
2528
|
+
console.error('自动保存到 IndexedDB 失败:', error);
|
|
2529
|
+
}
|
|
2530
|
+
}, 10000);
|
|
2531
|
+
}, [exportToBlob, fileName, showFinalRevisionPreview, userId]);
|
|
2532
|
+
|
|
2533
|
+
// 保存到 IndexedDB
|
|
2534
|
+
const handleSave = useCallback(async () => {
|
|
2535
|
+
try {
|
|
2536
|
+
if (!editorRef.current) {
|
|
2537
|
+
message.error('编辑器未初始化');
|
|
2538
|
+
return;
|
|
2539
|
+
}
|
|
2540
|
+
if (!userId) {
|
|
2541
|
+
message.error('无法获取用户信息');
|
|
2542
|
+
return;
|
|
2543
|
+
}
|
|
2544
|
+
|
|
2545
|
+
const blob = await exportToBlob();
|
|
2546
|
+
if (blob) {
|
|
2547
|
+
await saveFile(userId, fileName, 'docx', await blob.arrayBuffer());
|
|
2548
|
+
setHasLocalCache(false);
|
|
2549
|
+
|
|
2550
|
+
// 通知父组件更新 localFiles store(用于显示未同步标记)
|
|
2551
|
+
if (onSaveLocalBlobRef.current) {
|
|
2552
|
+
try {
|
|
2553
|
+
await onSaveLocalBlobRef.current(blob, fileName);
|
|
2554
|
+
} catch (err) {
|
|
2555
|
+
console.warn('同步本地文件内容到 localFiles store 失败(不影响本地保存):', err);
|
|
2556
|
+
}
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
message.success('已保存到本地缓存');
|
|
2560
|
+
}
|
|
2561
|
+
} catch (error) {
|
|
2562
|
+
console.error('保存到本地缓存失败:', error);
|
|
2563
|
+
message.error('保存失败');
|
|
2564
|
+
}
|
|
2565
|
+
}, [exportToBlob, fileName, userId]);
|
|
2566
|
+
|
|
2567
|
+
// ============================================================================
|
|
2568
|
+
// 导出 PDF:
|
|
2569
|
+
// eigenpal 编辑器本身没有打印/PDF 能力,这里先 save() 取含最新修改的 docx blob,
|
|
2570
|
+
// 再离屏挂载 file-viewer 按 Word 原生分页渲染,SVG foreignObject 逐页光栅化直接下载 PDF。
|
|
2571
|
+
// ============================================================================
|
|
2572
|
+
const [exportingPdf, setExportingPdf] = useState(false);
|
|
2573
|
+
const handleExportPdf = useCallback(async () => {
|
|
2574
|
+
setExportingPdf(true);
|
|
2575
|
+
message.loading({ key: 'word-export-pdf', content: '正在保存文档内容…', duration: 0 });
|
|
2576
|
+
try {
|
|
2577
|
+
const blob = await exportToBlob();
|
|
2578
|
+
if (!blob) {
|
|
2579
|
+
message.error('导出失败:无法获取文档内容');
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2582
|
+
await exportWordPdf({
|
|
2583
|
+
file: blob,
|
|
2584
|
+
fileName,
|
|
2585
|
+
onProgress: (page, total) => {
|
|
2586
|
+
message.loading({ key: 'word-export-pdf', content: `正在导出第 ${page} / ${total} 页…`, duration: 0 });
|
|
2587
|
+
},
|
|
2588
|
+
});
|
|
2589
|
+
} catch (error) {
|
|
2590
|
+
console.error('Word 导出 PDF 失败:', error);
|
|
2591
|
+
message.error(`导出 PDF 失败:${(error as Error)?.message || '未知错误'}`);
|
|
2592
|
+
} finally {
|
|
2593
|
+
message.destroy('word-export-pdf');
|
|
2594
|
+
setExportingPdf(false);
|
|
2595
|
+
}
|
|
2596
|
+
}, [exportToBlob, fileName]);
|
|
2597
|
+
|
|
2598
|
+
// ============================================================================
|
|
2599
|
+
// 导出 Markdown:同样先 save() 取含最新修改的 docx blob,
|
|
2600
|
+
// 再用 mammoth + turndown 转成 Markdown 直接下载(浏览器端等价实现 markitdown)。
|
|
2601
|
+
// ============================================================================
|
|
2602
|
+
const [exportingMarkdown, setExportingMarkdown] = useState(false);
|
|
2603
|
+
const handleExportMarkdown = useCallback(async () => {
|
|
2604
|
+
setExportingMarkdown(true);
|
|
2605
|
+
message.loading({ key: 'word-export-md', content: '正在保存文档内容…', duration: 0 });
|
|
2606
|
+
try {
|
|
2607
|
+
const blob = await exportToBlob();
|
|
2608
|
+
if (!blob) {
|
|
2609
|
+
message.error('导出失败:无法获取文档内容');
|
|
2610
|
+
return;
|
|
2611
|
+
}
|
|
2612
|
+
message.loading({ key: 'word-export-md', content: '正在转换为 Markdown…', duration: 0 });
|
|
2613
|
+
await exportWordMarkdown({ file: blob, fileName });
|
|
2614
|
+
} catch (error) {
|
|
2615
|
+
console.error('Word 导出 Markdown 失败:', error);
|
|
2616
|
+
message.error(`导出 Markdown 失败:${(error as Error)?.message || '未知错误'}`);
|
|
2617
|
+
} finally {
|
|
2618
|
+
message.destroy('word-export-md');
|
|
2619
|
+
setExportingMarkdown(false);
|
|
2620
|
+
}
|
|
2621
|
+
}, [exportToBlob, fileName]);
|
|
2622
|
+
|
|
2623
|
+
// 「导出」下拉里额外挂的菜单项:导出 PDF 与下载由 ExportDropdown 自己生成,
|
|
2624
|
+
// 这里只补 Word 独有的「导出Markdown」。
|
|
2625
|
+
const exportExtraItems = useMemo(
|
|
2626
|
+
() => [
|
|
2627
|
+
{
|
|
2628
|
+
key: 'markdown',
|
|
2629
|
+
icon: <FileMarkdownOutlined />,
|
|
2630
|
+
label: '导出Markdown',
|
|
2631
|
+
disabled: exportingMarkdown,
|
|
2632
|
+
onClick: () => {
|
|
2633
|
+
void handleExportMarkdown();
|
|
2634
|
+
},
|
|
2635
|
+
},
|
|
2636
|
+
],
|
|
2637
|
+
[exportingMarkdown, handleExportMarkdown],
|
|
2638
|
+
);
|
|
2639
|
+
|
|
2640
|
+
// 下载原始文件:直接下载上传时的原始 docx(initialBlob),不是编辑后的内容
|
|
2641
|
+
const handleDownloadOriginal = useCallback(() => {
|
|
2642
|
+
if (!initialBlob) return;
|
|
2643
|
+
downloadBlob(initialBlob, fileName);
|
|
2644
|
+
}, [initialBlob, fileName]);
|
|
2645
|
+
const onDownload = !isCreate && initialBlob ? handleDownloadOriginal : undefined;
|
|
2646
|
+
|
|
2647
|
+
// 上传到服务器
|
|
2648
|
+
const handleUpload = useCallback(async () => {
|
|
2649
|
+
if (!onUpload) {
|
|
2650
|
+
message.error('未提供上传功能');
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
try {
|
|
2654
|
+
const blob = await exportToBlob();
|
|
2655
|
+
if (!blob) return;
|
|
2656
|
+
|
|
2657
|
+
// 使用 baseBlobRef 作为差分基准,而非 initialBlob。
|
|
2658
|
+
// 这样在多次上传场景下,基准始终是服务器上最新的文件内容。
|
|
2659
|
+
const baseBlob = baseBlobRef.current;
|
|
2660
|
+
await onUpload(baseBlob, blob, fileName);
|
|
2661
|
+
|
|
2662
|
+
// 上传成功后,更新差分基准为本次上传的 blob,确保下次差分基准与服务器一致
|
|
2663
|
+
baseBlobRef.current = blob;
|
|
2664
|
+
|
|
2665
|
+
// 上传成功后,清空本地缓存
|
|
2666
|
+
if (userId) {
|
|
2667
|
+
await deleteFile(userId, fileName, 'docx');
|
|
2668
|
+
setHasLocalCache(false);
|
|
2669
|
+
setHasUploaded(true);
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
// 上传成功后重置"立即标记未同步"触发器,使后续编辑可再次立即触发
|
|
2673
|
+
markUnsyncedTriggeredRef.current = false;
|
|
2674
|
+
} catch (error) {
|
|
2675
|
+
console.error('上传失败:', error);
|
|
2676
|
+
message.error('上传失败');
|
|
2677
|
+
}
|
|
2678
|
+
}, [fileName, onUpload, exportToBlob, userId]);
|
|
2679
|
+
|
|
2680
|
+
// 处理编辑器变化
|
|
2681
|
+
const handleChange = useCallback(() => {
|
|
2682
|
+
if (showFinalRevisionPreview) return;
|
|
2683
|
+
// 抑制编辑器初始化期间触发的 onChange,避免误标"有修改未同步"
|
|
2684
|
+
if (!isEditorReadyRef.current) return;
|
|
2685
|
+
// 抑制 exportToBlob()/save() 期间触发的 onChange,避免状态更新循环(移动端闪烁)
|
|
2686
|
+
if (suppressChangeRef.current) return;
|
|
2687
|
+
|
|
2688
|
+
// —— 新增:文档内容变化时同步自定义样式选择器的 value ——
|
|
2689
|
+
// 应用新样式、删除字符、合并段落等操作都会触发 onChange,
|
|
2690
|
+
// 在这里读取一次真实的 styleId,确保 Select 永远和文档对齐。
|
|
2691
|
+
// (比只靠 onSelectionChange 更全面:Ctrl+A 全选后不触发 selection change 但会有 document change)
|
|
2692
|
+
try {
|
|
2693
|
+
const styleId = readCurrentParagraphStyleId();
|
|
2694
|
+
// —— 修复 dropdown 闪现(出现立即关闭):只有 styleId 真的变化时才 setState
|
|
2695
|
+
// antd Select 的 open/close 是保存在组件内部 state 里的,
|
|
2696
|
+
// 当父组件 onChange/onSelectionChange 高频调用 setCurrentStyleId(同值) 时,
|
|
2697
|
+
// React 18 在 StrictMode 或并发模式下可能会让 Select 子组件经历一次
|
|
2698
|
+
// "unmount → remount"(或者至少内部 effect 重跑),导致 open state 丢失
|
|
2699
|
+
// → 用户看到 popup 刚渲染出来就立刻被关掉,产生"闪现"错觉。
|
|
2700
|
+
// 加一层同值短路,完全杜绝无意义的 state 更新。
|
|
2701
|
+
setCurrentStyleId((prev) => (prev === styleId ? prev : styleId));
|
|
2702
|
+
} catch {
|
|
2703
|
+
/* 忽略读取异常,避免影响 onChange 原有逻辑 */
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
setHasUploaded(false);
|
|
2707
|
+
// 自动保存到 IndexedDB
|
|
2708
|
+
autoSaveToIndexedDB();
|
|
2709
|
+
|
|
2710
|
+
// 首次编辑(且 hasUnsyncedChanges 目前为 false)时,立即标记未同步。
|
|
2711
|
+
// 上传成功后 hasUnsyncedChanges 被清除,用户继续编辑后应立即重新显示"有未同步修改"。
|
|
2712
|
+
if (!markUnsyncedTriggeredRef.current && !hasUnsyncedChangesRef.current && onSaveLocalBlobRef.current && userId && editorRef.current) {
|
|
2713
|
+
// 提前设置标记,防止 exportToBlob() 期间 onChange 再次进入此分支导致并发 save()
|
|
2714
|
+
markUnsyncedTriggeredRef.current = true;
|
|
2715
|
+
const fn = onSaveLocalBlobRef.current;
|
|
2716
|
+
(async () => {
|
|
2717
|
+
try {
|
|
2718
|
+
const blob = await exportToBlob();
|
|
2719
|
+
if (blob) {
|
|
2720
|
+
await fn(blob, fileName);
|
|
2721
|
+
} else {
|
|
2722
|
+
// exportToBlob 返回 null,重置标记允许下次重试
|
|
2723
|
+
markUnsyncedTriggeredRef.current = false;
|
|
2724
|
+
}
|
|
2725
|
+
} catch (err) {
|
|
2726
|
+
// 失败时重置标记,允许下次重试
|
|
2727
|
+
markUnsyncedTriggeredRef.current = false;
|
|
2728
|
+
console.warn('编辑时立即标记未同步失败(不影响编辑,等待自动/手动保存即可):', err);
|
|
2729
|
+
}
|
|
2730
|
+
})();
|
|
2731
|
+
}
|
|
2732
|
+
}, [autoSaveToIndexedDB, showFinalRevisionPreview, userId, exportToBlob, fileName, readCurrentParagraphStyleId]);
|
|
2733
|
+
|
|
2734
|
+
// 编辑器 remount 后,延迟标记为"就绪",让初始化期间的 onChange 事件先过去
|
|
2735
|
+
useEffect(() => {
|
|
2736
|
+
if (!visible || loadingInitialBlob) return;
|
|
2737
|
+
const timer = setTimeout(() => {
|
|
2738
|
+
isEditorReadyRef.current = true;
|
|
2739
|
+
}, 2000);
|
|
2740
|
+
return () => clearTimeout(timer);
|
|
2741
|
+
}, [editorKey, visible, loadingInitialBlob]);
|
|
2742
|
+
|
|
2743
|
+
const handleToggleFinalRevisionPreview = useCallback(async () => {
|
|
2744
|
+
try {
|
|
2745
|
+
if (showFinalRevisionPreview) {
|
|
2746
|
+
const sourceBuffer = revisionPreviewSourceBufferRef.current;
|
|
2747
|
+
if (!sourceBuffer) {
|
|
2748
|
+
message.warning('未找到原始修订内容,请重新打开文件');
|
|
2749
|
+
return;
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
// 修复 TOC 字段显示问题
|
|
2753
|
+
let fixedBuffer = sourceBuffer.slice(0);
|
|
2754
|
+
try {
|
|
2755
|
+
const fixedBlob = await fixTocFieldsInDocx(new Blob([sourceBuffer], { type: DOCX_MIME_TYPE }));
|
|
2756
|
+
fixedBuffer = await fixedBlob.arrayBuffer();
|
|
2757
|
+
} catch (error) {
|
|
2758
|
+
console.warn('修复 TOC 字段失败,使用原始文件:', error);
|
|
2759
|
+
}
|
|
2760
|
+
|
|
2761
|
+
// 将预设样式合并进 styles.xml
|
|
2762
|
+
try {
|
|
2763
|
+
const mergedBlob = await mergePresetStylesIntoDocx(new Blob([fixedBuffer], { type: DOCX_MIME_TYPE }));
|
|
2764
|
+
fixedBuffer = await mergedBlob.arrayBuffer();
|
|
2765
|
+
} catch (error) {
|
|
2766
|
+
console.warn('合并预设样式失败,使用原始文件:', error);
|
|
2767
|
+
}
|
|
2768
|
+
|
|
2769
|
+
setInitialBuffer(fixedBuffer);
|
|
2770
|
+
setInitialDocument(null);
|
|
2771
|
+
setShowFinalRevisionPreview(false);
|
|
2772
|
+
setEditorKey((prev) => prev + 1);
|
|
2773
|
+
message.info('已切换到修订视图');
|
|
2774
|
+
return;
|
|
2775
|
+
}
|
|
2776
|
+
|
|
2777
|
+
if (!initialBuffer) {
|
|
2778
|
+
message.warning('当前文档暂不支持最终版预览');
|
|
2779
|
+
return;
|
|
2780
|
+
}
|
|
2781
|
+
|
|
2782
|
+
const sourceBuffer = initialBuffer.slice(0);
|
|
2783
|
+
const sourceBlob = new Blob([sourceBuffer], { type: DOCX_MIME_TYPE });
|
|
2784
|
+
const finalPreviewBlob = await createFinalRevisionPreviewBlob(sourceBlob);
|
|
2785
|
+
const finalPreviewBuffer = await finalPreviewBlob.arrayBuffer();
|
|
2786
|
+
|
|
2787
|
+
revisionPreviewSourceBufferRef.current = sourceBuffer;
|
|
2788
|
+
setInitialBuffer(finalPreviewBuffer);
|
|
2789
|
+
setInitialDocument(null);
|
|
2790
|
+
setShowFinalRevisionPreview(true);
|
|
2791
|
+
setEditorKey((prev) => prev + 1);
|
|
2792
|
+
message.success('已切换到最终版预览');
|
|
2793
|
+
} catch (error) {
|
|
2794
|
+
console.error('切换最终版预览失败:', error);
|
|
2795
|
+
message.error('切换最终版预览失败');
|
|
2796
|
+
}
|
|
2797
|
+
}, [initialBuffer, showFinalRevisionPreview]);
|
|
2798
|
+
|
|
2799
|
+
// Ctrl+S / Cmd+S 保存到本地缓存
|
|
2800
|
+
useEffect(() => {
|
|
2801
|
+
if (!visible) return;
|
|
2802
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
2803
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 's') {
|
|
2804
|
+
e.preventDefault();
|
|
2805
|
+
handleSave();
|
|
2806
|
+
}
|
|
2807
|
+
};
|
|
2808
|
+
window.addEventListener('keydown', handleKeyDown);
|
|
2809
|
+
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
2810
|
+
}, [visible, handleSave]);
|
|
2811
|
+
|
|
2812
|
+
// ============================================================================
|
|
2813
|
+
// 页脚/页眉布局强制兜底 —— 通过 JS 直接写 DOM 节点的 style 属性
|
|
2814
|
+
//
|
|
2815
|
+
// 问题背景:
|
|
2816
|
+
// 全局 * { box-sizing: border-box; margin: 0; padding: 0 } 和 Tailwind preflight
|
|
2817
|
+
// 会与 docx-editor-core/layout-painter 的盒模型/定位计算发生冲突。虽然我们已经
|
|
2818
|
+
// 在 word-editor.css 里写了高优先级 CSS 规则,但由于选择器前缀、样式加载顺序、
|
|
2819
|
+
// Vite HMR 热更新等因素,CSS 可能没有精准命中。
|
|
2820
|
+
//
|
|
2821
|
+
// layout-painter 本身已经会写入内联 style(position:absolute、top、left、width),
|
|
2822
|
+
// 但在某些全局样式覆盖(带 !important)或计算异常场景下,这些值可能会失效,
|
|
2823
|
+
// 导致页脚出现在正文段落右侧(与"乙方责任与义务"同一水平线),而不是页面底部。
|
|
2824
|
+
//
|
|
2825
|
+
// 兜底策略:
|
|
2826
|
+
// 1) 在 #vendorsys-word-editor-root 容器内,对所有 .layout-page 强制设置
|
|
2827
|
+
// position = 'relative' —— 作为每页页眉/页脚 absolute 定位的锚点(最关键)
|
|
2828
|
+
// 2) 所有 .layout-page-header/.layout-page-footer 强制设置
|
|
2829
|
+
// position = 'absolute' + display = 'block' + boxSizing = 'content-box'
|
|
2830
|
+
// 3) 使用 MutationObserver + setInterval 双保险:
|
|
2831
|
+
// - layout-painter 可能异步/增量渲染页面,MutationObserver 监听新增节点
|
|
2832
|
+
// - 同时用 setInterval 在 0/100/300/600/1000ms 时重复扫描,确保分页、缩放、
|
|
2833
|
+
// 修订切换等导致 DOM 重建时,兜底样式能重新应用
|
|
2834
|
+
// ============================================================================
|
|
2835
|
+
useEffect(() => {
|
|
2836
|
+
if (!visible) return;
|
|
2837
|
+
|
|
2838
|
+
const rootSelector = '#vendorsys-word-editor-root';
|
|
2839
|
+
const applyLayoutAnchors = () => {
|
|
2840
|
+
const root = document.querySelector(rootSelector);
|
|
2841
|
+
if (!root) return false;
|
|
2842
|
+
|
|
2843
|
+
// 1) .layout-page:position: relative(每页页眉页脚的定位锚点)
|
|
2844
|
+
const pages = root.querySelectorAll<HTMLElement>('.layout-page');
|
|
2845
|
+
pages.forEach((page) => {
|
|
2846
|
+
page.style.position = 'relative';
|
|
2847
|
+
page.style.boxSizing = 'content-box';
|
|
2848
|
+
// layout-painter 自己会写 overflow:hidden,不要覆盖它
|
|
2849
|
+
});
|
|
2850
|
+
|
|
2851
|
+
// 2) .layout-page-header:强制 absolute + block
|
|
2852
|
+
const headers = root.querySelectorAll<HTMLElement>('.layout-page-header');
|
|
2853
|
+
headers.forEach((h) => {
|
|
2854
|
+
h.style.position = 'absolute';
|
|
2855
|
+
h.style.display = 'block';
|
|
2856
|
+
h.style.boxSizing = 'content-box';
|
|
2857
|
+
h.style.setProperty('float', 'none', 'important');
|
|
2858
|
+
h.style.clear = 'both';
|
|
2859
|
+
|
|
2860
|
+
// —— 关键新增:清零页眉内部包装层的负偏移 ——
|
|
2861
|
+
// layout-painter 可能在页眉内容外包一层 div,写入
|
|
2862
|
+
// style="position: relative; top: -XXXpx" 把内容拉出页眉矩形。
|
|
2863
|
+
// 强制把所有直接子 Element 的定位打回 static + top/left=0,
|
|
2864
|
+
// 让孙节点(.layout-textbox 等)的 absolute 锚点直接落到 header 自身。
|
|
2865
|
+
Array.from(h.children).forEach((child) => {
|
|
2866
|
+
if (child instanceof HTMLElement) {
|
|
2867
|
+
child.style.position = 'static';
|
|
2868
|
+
child.style.top = '0px';
|
|
2869
|
+
child.style.left = '0px';
|
|
2870
|
+
child.style.right = 'auto';
|
|
2871
|
+
child.style.bottom = 'auto';
|
|
2872
|
+
child.style.transform = 'none';
|
|
2873
|
+
child.style.margin = '0';
|
|
2874
|
+
}
|
|
2875
|
+
});
|
|
2876
|
+
|
|
2877
|
+
// —— 新增:页眉内 .layout-textbox 收缩为内容宽度 + translate 水平居中 ——
|
|
2878
|
+
// 原问题(对照 WPS):layout-painter 给页眉内 textbox 写死 width:192px,
|
|
2879
|
+
// 但实际内容可能只有几个字(如页码"第 X 页"),加上内部 text-align:left,
|
|
2880
|
+
// 文字视觉上在 192px 大盒里靠左 → 整体在页眉里偏左,与 WPS 不一致。
|
|
2881
|
+
// 修复策略(通用且不猜对齐):
|
|
2882
|
+
// textbox 盒子宽度 = 内容宽度,再把整个盒子水平居中,
|
|
2883
|
+
// 那么无论内部段落是 left/center/right,文字视觉都在页眉正中心,
|
|
2884
|
+
// 与 WPS 渲染完全一致。
|
|
2885
|
+
const headerTextBoxes = h.querySelectorAll<HTMLElement>('.layout-textbox');
|
|
2886
|
+
headerTextBoxes.forEach((tb) => {
|
|
2887
|
+
tb.style.width = 'auto';
|
|
2888
|
+
tb.style.height = 'auto';
|
|
2889
|
+
tb.style.minWidth = '0';
|
|
2890
|
+
tb.style.minHeight = '0';
|
|
2891
|
+
tb.style.left = '50%';
|
|
2892
|
+
tb.style.transform = 'translateX(-50%)';
|
|
2893
|
+
tb.style.top = '0';
|
|
2894
|
+
tb.style.bottom = 'auto';
|
|
2895
|
+
tb.style.right = 'auto';
|
|
2896
|
+
tb.style.padding = '0';
|
|
2897
|
+
const paras = tb.querySelectorAll<HTMLElement>('.layout-paragraph, .layout-line');
|
|
2898
|
+
paras.forEach((p) => {
|
|
2899
|
+
p.style.display = 'inline-block';
|
|
2900
|
+
p.style.width = 'auto';
|
|
2901
|
+
});
|
|
2902
|
+
});
|
|
2903
|
+
});
|
|
2904
|
+
|
|
2905
|
+
// 3) .layout-page-footer:强制 absolute + block(核心修复)
|
|
2906
|
+
// 只兜底关键字段,top/left/width 的精确数值由 layout-painter 内联样式决定
|
|
2907
|
+
const footers = root.querySelectorAll<HTMLElement>('.layout-page-footer');
|
|
2908
|
+
let foundAnyFooter = false;
|
|
2909
|
+
footers.forEach((f) => {
|
|
2910
|
+
foundAnyFooter = true;
|
|
2911
|
+
f.style.position = 'absolute';
|
|
2912
|
+
f.style.display = 'block';
|
|
2913
|
+
f.style.boxSizing = 'content-box';
|
|
2914
|
+
f.style.setProperty('float', 'none', 'important');
|
|
2915
|
+
f.style.clear = 'both';
|
|
2916
|
+
|
|
2917
|
+
// —— 关键新增:清零页脚内部包装层的负偏移 ——
|
|
2918
|
+
// 这就是用户反馈的"第 4 页"漂到正文右边同一水平线的根因:
|
|
2919
|
+
// footer.top = 1033 (页面底部,正确)
|
|
2920
|
+
// 但 footer 的直接子 div 写了 style="position: relative; top: -168px"
|
|
2921
|
+
// 导致 Y 变成 1033 - 168 = 865(与正文段落"乙方责任与义务"对齐)
|
|
2922
|
+
// 这里强制把包装层 position 设为 static,并清零所有偏移。
|
|
2923
|
+
// 孙节点 .layout-textbox(position:absolute) 的锚点就会直接跳到
|
|
2924
|
+
// footer 自身,textbox 的 top/left 成为相对 footer 的偏移,
|
|
2925
|
+
// 与 WPS 渲染一致,内容正确地留在页脚矩形内。
|
|
2926
|
+
Array.from(f.children).forEach((child) => {
|
|
2927
|
+
if (child instanceof HTMLElement) {
|
|
2928
|
+
child.style.position = 'static';
|
|
2929
|
+
child.style.top = '0px';
|
|
2930
|
+
child.style.left = '0px';
|
|
2931
|
+
child.style.right = 'auto';
|
|
2932
|
+
child.style.bottom = 'auto';
|
|
2933
|
+
child.style.transform = 'none';
|
|
2934
|
+
child.style.margin = '0';
|
|
2935
|
+
}
|
|
2936
|
+
});
|
|
2937
|
+
|
|
2938
|
+
// —— 新增:页脚内 .layout-textbox 收缩为内容宽度 + translate 水平居中 ——
|
|
2939
|
+
// 原问题(对照 WPS):
|
|
2940
|
+
// .layout-page-footer 宽 554px(正确,页面内容宽度)
|
|
2941
|
+
// .layout-textbox 宽 192px,left:181 → 盒子本身在页脚中居中
|
|
2942
|
+
// 但 textbox 内部段落 text-align:left → "第 4 页"(~40px)在 192px
|
|
2943
|
+
// 大盒里靠左,文字中心点比页脚真正中心偏左约 76px,观感不符 WPS
|
|
2944
|
+
// 修复策略(同页眉):textbox 收缩至内容宽度,再水平居中整个盒子。
|
|
2945
|
+
// 此方案不会"猜测"docx 原文的段落对齐属性(不硬改 text-align),
|
|
2946
|
+
// 同时天然兼容左/中/右对齐内容的视觉居中效果。
|
|
2947
|
+
const footerTextBoxes = f.querySelectorAll<HTMLElement>('.layout-textbox');
|
|
2948
|
+
footerTextBoxes.forEach((tb) => {
|
|
2949
|
+
tb.style.width = 'auto';
|
|
2950
|
+
tb.style.height = 'auto';
|
|
2951
|
+
tb.style.minWidth = '0';
|
|
2952
|
+
tb.style.minHeight = '0';
|
|
2953
|
+
tb.style.left = '50%';
|
|
2954
|
+
tb.style.transform = 'translateX(-50%)';
|
|
2955
|
+
tb.style.top = '0';
|
|
2956
|
+
tb.style.bottom = 'auto';
|
|
2957
|
+
tb.style.right = 'auto';
|
|
2958
|
+
tb.style.padding = '0';
|
|
2959
|
+
const paras = tb.querySelectorAll<HTMLElement>('.layout-paragraph, .layout-line');
|
|
2960
|
+
paras.forEach((p) => {
|
|
2961
|
+
p.style.display = 'inline-block';
|
|
2962
|
+
p.style.width = 'auto';
|
|
2963
|
+
});
|
|
2964
|
+
});
|
|
2965
|
+
});
|
|
2966
|
+
|
|
2967
|
+
// 4) .layout-page-content:block + content-box
|
|
2968
|
+
const contents = root.querySelectorAll<HTMLElement>('.layout-page-content');
|
|
2969
|
+
contents.forEach((c) => {
|
|
2970
|
+
c.style.boxSizing = 'content-box';
|
|
2971
|
+
c.style.display = 'block';
|
|
2972
|
+
});
|
|
2973
|
+
|
|
2974
|
+
return pages.length > 0 || foundAnyFooter;
|
|
2975
|
+
};
|
|
2976
|
+
|
|
2977
|
+
// ========================================================================
|
|
2978
|
+
// 新增 1:同步自定义样式选择器的 value(编辑器首次渲染、DOM 重建时兜底)
|
|
2979
|
+
// onSelectionChange 只能在用户操作选区时触发,这里额外兜底:
|
|
2980
|
+
// 文档初次渲染完、editorKey 变化重挂载、修订切换等场景下,主动刷新一次。
|
|
2981
|
+
// ========================================================================
|
|
2982
|
+
const syncStyleSelectValue = () => {
|
|
2983
|
+
try {
|
|
2984
|
+
const styleId = readCurrentParagraphStyleId();
|
|
2985
|
+
// —— 修复 dropdown 闪现:同值不 setState(理由见 onChange 里的注释)
|
|
2986
|
+
setCurrentStyleId((prev) => (prev === styleId ? prev : styleId));
|
|
2987
|
+
} catch {
|
|
2988
|
+
/* 忽略读取异常,编辑器可能还没挂载 */
|
|
2989
|
+
}
|
|
2990
|
+
};
|
|
2991
|
+
|
|
2992
|
+
// ========================================================================
|
|
2993
|
+
// 新增 2:标记「红头分隔线」段落 → DOM [data-vendorsys-style-id=HongTouSeparator]
|
|
2994
|
+
//
|
|
2995
|
+
// 背景:@eigenpal/docx-editor-core 在渲染 paragraph.attrs.borders.bottom 时有两处 bug:
|
|
2996
|
+
// a) 其他三边如果 undefined,会 copy bottom 的样式 → 画出四边框(用户截图反馈)
|
|
2997
|
+
// b) border 画在 line-box 而非 paragraph 容器上 → 短文字时红线紧贴文字宽度(不横贯整段)
|
|
2998
|
+
// 修复策略(不影响保存、只改浏览器渲染):
|
|
2999
|
+
// 1) 预设样式里 top/left/right 显式写 style:none,破 fallback(见 style.ts)
|
|
3000
|
+
// 2) PM state 遍历 styleId=HongTouSeparator 的段落,给其 .layout-paragraph DOM
|
|
3001
|
+
// 加一个自定义 data-attr,然后 CSS 里:
|
|
3002
|
+
// - 清掉 lib 写的任何错误 border
|
|
3003
|
+
// - 用 ::after 伪元素绝对定位,画一条横贯整段宽的红色 2px 实线
|
|
3004
|
+
// serializer 仍会把 paragraph.attrs.borders.bottom 正确写入 <w:pBdr>/<w:bottom>,
|
|
3005
|
+
// Word/WPS 打开完全正确。
|
|
3006
|
+
// ========================================================================
|
|
3007
|
+
const HONGTOU_SEP_STYLE_ID = 'HongTouSeparator';
|
|
3008
|
+
const VENDOR_ATTR = 'data-vendorsys-style-id';
|
|
3009
|
+
|
|
3010
|
+
const markHongTouSeparatorParagraphs = () => {
|
|
3011
|
+
try {
|
|
3012
|
+
// —— 0) 守卫:editor 必须已挂载 & ProseMirror view 可拿
|
|
3013
|
+
const editorApi = editorRef.current;
|
|
3014
|
+
if (!editorApi) return;
|
|
3015
|
+
const pmView = editorApi.getEditorRef?.()?.getView?.();
|
|
3016
|
+
const root = document.querySelector('#vendorsys-word-editor-root');
|
|
3017
|
+
if (!root) return;
|
|
3018
|
+
|
|
3019
|
+
// —— 1) 先清理旧标记(避免用户把段落样式改成「正文」后,红线还挂着)
|
|
3020
|
+
let removedCount = 0;
|
|
3021
|
+
root.querySelectorAll<HTMLElement>(`[${VENDOR_ATTR}="${HONGTOU_SEP_STYLE_ID}"]`).forEach((el) => {
|
|
3022
|
+
el.removeAttribute(VENDOR_ATTR);
|
|
3023
|
+
el.classList.remove('vendorsys-pstyle-hongtouseparator');
|
|
3024
|
+
removedCount++;
|
|
3025
|
+
});
|
|
3026
|
+
|
|
3027
|
+
let markedByPm = 0;
|
|
3028
|
+
let markedByDom = 0;
|
|
3029
|
+
|
|
3030
|
+
// —— 2) 第一路径:遍历 ProseMirror doc,按 paragraph.attrs.styleId 命中
|
|
3031
|
+
// 兼容两种情况:
|
|
3032
|
+
// a) attrs.styleId 直接等于 'HongTouSeparator'(保存后重进通常是这种)
|
|
3033
|
+
// b) attrs.styleId 是数字(编辑时刚应用样式,lib 把自定义预设转成了 style index)
|
|
3034
|
+
// → 我们先把数字记下来 debug 日志里输出,让用户确认,同时 PM 路径没命中也没关系,
|
|
3035
|
+
// 因为下面还有独立的 DOM 特征扫描兜底。
|
|
3036
|
+
if (pmView && pmView.domAtPos && pmView.state?.doc) {
|
|
3037
|
+
const doc = pmView.state.doc;
|
|
3038
|
+
const styleIdDebug: Array<{ pos: number; styleId: any; type: string }> = [];
|
|
3039
|
+
doc.descendants((node, pos) => {
|
|
3040
|
+
if (!node || node.type?.name !== 'paragraph') return true; // 继续向下
|
|
3041
|
+
const sid = node.attrs?.styleId;
|
|
3042
|
+
if (sid !== undefined && sid !== null) styleIdDebug.push({ pos, styleId: sid, type: typeof sid });
|
|
3043
|
+
|
|
3044
|
+
// 严格等于字符串 'HongTouSeparator'(场景 a)才标记
|
|
3045
|
+
// (场景 b 的数字 styleId 交给下面 DOM 特征扫描 100% 覆盖)
|
|
3046
|
+
if (sid !== HONGTOU_SEP_STYLE_ID) return true;
|
|
3047
|
+
|
|
3048
|
+
try {
|
|
3049
|
+
const result = pmView.domAtPos(pos);
|
|
3050
|
+
if (!result || !result.node) return true;
|
|
3051
|
+
let target: any = result.node;
|
|
3052
|
+
let paraEl: HTMLElement | null = null;
|
|
3053
|
+
const maxDepth = 12;
|
|
3054
|
+
let depth = 0;
|
|
3055
|
+
while (target && depth < maxDepth) {
|
|
3056
|
+
if (target instanceof HTMLElement) {
|
|
3057
|
+
if (target.classList?.contains('layout-paragraph')) {
|
|
3058
|
+
paraEl = target;
|
|
3059
|
+
break;
|
|
3060
|
+
}
|
|
3061
|
+
const bbs = target.style?.borderBottom || window.getComputedStyle(target).borderBottom;
|
|
3062
|
+
if (bbs && /(255,0,0|#ff0000|#F{2}0{2,4})/i.test(bbs.replace(/\s+/g, ''))) {
|
|
3063
|
+
paraEl = target;
|
|
3064
|
+
break;
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
target = target.parentNode;
|
|
3068
|
+
depth++;
|
|
3069
|
+
}
|
|
3070
|
+
if (!paraEl && result.node instanceof HTMLElement) {
|
|
3071
|
+
paraEl = result.node;
|
|
3072
|
+
} else if (!paraEl && result.node instanceof Text && result.node.parentElement instanceof HTMLElement) {
|
|
3073
|
+
paraEl = result.node.parentElement;
|
|
3074
|
+
}
|
|
3075
|
+
if (!paraEl) return true;
|
|
3076
|
+
|
|
3077
|
+
if (paraEl.getAttribute(VENDOR_ATTR) !== HONGTOU_SEP_STYLE_ID) {
|
|
3078
|
+
paraEl.setAttribute(VENDOR_ATTR, HONGTOU_SEP_STYLE_ID);
|
|
3079
|
+
paraEl.classList.add('vendorsys-pstyle-hongtouseparator');
|
|
3080
|
+
markedByPm++;
|
|
3081
|
+
}
|
|
3082
|
+
} catch {
|
|
3083
|
+
/* 单个段落失败不影响其他 */
|
|
3084
|
+
}
|
|
3085
|
+
return true; // 继续遍历
|
|
3086
|
+
});
|
|
3087
|
+
|
|
3088
|
+
// 把实际遍历到的 paragraph 的 styleId 类型/值打出来,方便下一次精准定位
|
|
3089
|
+
if (styleIdDebug.length > 0) {
|
|
3090
|
+
const numericSids = styleIdDebug.filter((x) => x.type !== 'string');
|
|
3091
|
+
if (numericSids.length > 0) {
|
|
3092
|
+
console.debug(
|
|
3093
|
+
`[WordEditor] 红头分隔线 PM 扫描:发现 ${numericSids.length} 个 paragraph 的 styleId 是非字符串(likely 数字索引):`,
|
|
3094
|
+
numericSids.slice(0, 5),
|
|
3095
|
+
'→ 这部分会由下面的 DOM 特征扫描命中打标,不影响显示。'
|
|
3096
|
+
);
|
|
3097
|
+
}
|
|
3098
|
+
}
|
|
3099
|
+
}
|
|
3100
|
+
|
|
3101
|
+
// —— 3) 第二路径(新增 + 核心修复):无 styleId 依赖的 DOM 特征扫描
|
|
3102
|
+
//
|
|
3103
|
+
// 用户反馈"编辑时选了红头分隔线样式还是看不到红线,保存重进后才有",根因就是:
|
|
3104
|
+
// - 编辑时刚应用样式,lib 把 paragraph.attrs.styleId 存成数字 index(不是字符串
|
|
3105
|
+
// "HongTouSeparator"),导致上面路径 2 全部跳过。
|
|
3106
|
+
// - 但是!lib 已经正确把 paragraph.attrs.borders.bottom { style:single, color:FF0000, size:12 }
|
|
3107
|
+
// 渲染到了 DOM 上(通过 computedStyle 能看到 border-bottom: 1.5px solid #ff0000 或
|
|
3108
|
+
// rgb(255,0,0) 之类),而且其他三边 border(top/left/right)为了规避 lib bug,
|
|
3109
|
+
// 我们在预设样式里已经显式写了 style:none → 它们不会是 solid/colored。
|
|
3110
|
+
//
|
|
3111
|
+
// 所以:不管 paragraph.styleId 是什么数字/字符串,**只要 DOM 上 computedStyle
|
|
3112
|
+
// 显示它"只有红色底边框、其他三边无边框/红色"**,就 100% 是红头分隔线,
|
|
3113
|
+
// 直接打标 → CSS ::after 就能正确匹配。
|
|
3114
|
+
// 这条路径独立于 ProseMirror,100% 可靠,能彻底消灭"保存重进才看得见"问题。
|
|
3115
|
+
const RED_REGEX = /(255,\s*0,\s*0|#ff0000|#F{2}0{2,4}|rgb\(255,\s*0,\s*0\))/i;
|
|
3116
|
+
const SOLID_REGEX = /solid/i;
|
|
3117
|
+
const NO_BORDER_REGEX = /^(none|0px|initial|inherit|transparent|unset|)\s*$/i;
|
|
3118
|
+
|
|
3119
|
+
const isRedBorder = (borderStr: string): boolean => {
|
|
3120
|
+
if (!borderStr) return false;
|
|
3121
|
+
const s = borderStr.replace(/\s+/g, '');
|
|
3122
|
+
return RED_REGEX.test(s) && SOLID_REGEX.test(s);
|
|
3123
|
+
};
|
|
3124
|
+
const isNoBorderOrNonRed = (borderStr: string): boolean => {
|
|
3125
|
+
if (!borderStr) return true;
|
|
3126
|
+
const s = borderStr.replace(/\s+/g, '');
|
|
3127
|
+
if (NO_BORDER_REGEX.test(s)) return true;
|
|
3128
|
+
// 如果是 solid 但颜色不是红色(比如用户自己通过边框按钮加的黑色底边框)→ 不要标成红头分隔线
|
|
3129
|
+
if (SOLID_REGEX.test(s) && RED_REGEX.test(s)) return false;
|
|
3130
|
+
// dashed/dotted/outset 等不是"红头分隔线"的形态 → 也不算红头分隔线
|
|
3131
|
+
return true;
|
|
3132
|
+
};
|
|
3133
|
+
|
|
3134
|
+
// 收集候选节点:所有带 layout-paragraph / layout-line 类名的元素,
|
|
3135
|
+
// 以及 editor root 下所有 div/p/section 这类块级元素(避免 lib 改类名漏掉)
|
|
3136
|
+
const candidates = new Set<HTMLElement>();
|
|
3137
|
+
root.querySelectorAll<HTMLElement>(
|
|
3138
|
+
'.layout-paragraph, .layout-line, div, p, section, article'
|
|
3139
|
+
).forEach((el) => {
|
|
3140
|
+
if (!el || !el.isConnected) return;
|
|
3141
|
+
// 只纳入"明显属于内容区域"的节点(排除 toolbar / scrollbar wrapper 等)
|
|
3142
|
+
if (
|
|
3143
|
+
el.closest('[role="toolbar"]') ||
|
|
3144
|
+
el.closest('[data-vendorsys-hidden-style-picker]') ||
|
|
3145
|
+
el.classList?.contains('ant-select-dropdown')
|
|
3146
|
+
) return;
|
|
3147
|
+
candidates.add(el);
|
|
3148
|
+
});
|
|
3149
|
+
|
|
3150
|
+
candidates.forEach((el) => {
|
|
3151
|
+
if (!el) return;
|
|
3152
|
+
// 如果已经被路径 2 标记过([data-vendorsys-style-id=HongTouSeparator]),跳过
|
|
3153
|
+
if (el.getAttribute(VENDOR_ATTR) === HONGTOU_SEP_STYLE_ID) return;
|
|
3154
|
+
|
|
3155
|
+
try {
|
|
3156
|
+
const cs = window.getComputedStyle(el);
|
|
3157
|
+
const bb = cs.borderBottom || '';
|
|
3158
|
+
const bt = cs.borderTop || '';
|
|
3159
|
+
const bl = cs.borderLeft || '';
|
|
3160
|
+
const br = cs.borderRight || '';
|
|
3161
|
+
|
|
3162
|
+
// 红头分隔线判定:底边框是【红色单实线】 + 上/左/右边框【不是红色单实线】
|
|
3163
|
+
// (lib 以前的 bug 是把其他三边也画成红色,但我们预设样式已经写
|
|
3164
|
+
// pPr.borders.top/left/right.style=none 破了 fallback,所以这三边应为 none/0px,
|
|
3165
|
+
// 万一 lib 哪天又抽风把其他三边也画出红色,这里仍然允许"全四边红"通过,
|
|
3166
|
+
// 因为"有一整条红色底边框"的段在公文里也只有红头分隔线这一种)
|
|
3167
|
+
const hasRedBottom = isRedBorder(bb);
|
|
3168
|
+
const topOk = isNoBorderOrNonRed(bt);
|
|
3169
|
+
const leftOk = isNoBorderOrNonRed(bl);
|
|
3170
|
+
const rightOk = isNoBorderOrNonRed(br);
|
|
3171
|
+
|
|
3172
|
+
if (hasRedBottom && topOk && leftOk && rightOk) {
|
|
3173
|
+
el.setAttribute(VENDOR_ATTR, HONGTOU_SEP_STYLE_ID);
|
|
3174
|
+
el.classList.add('vendorsys-pstyle-hongtouseparator');
|
|
3175
|
+
markedByDom++;
|
|
3176
|
+
}
|
|
3177
|
+
} catch {
|
|
3178
|
+
/* getComputedStyle 偶发异常,跳过 */
|
|
3179
|
+
}
|
|
3180
|
+
});
|
|
3181
|
+
|
|
3182
|
+
if (removedCount > 0 || markedByPm > 0 || markedByDom > 0) {
|
|
3183
|
+
console.debug(
|
|
3184
|
+
`[WordEditor] 红头分隔线打标:清旧 ${removedCount} 条 + PM 命中 ${markedByPm} 条 + DOM 特征命中 ${markedByDom} 条`
|
|
3185
|
+
);
|
|
3186
|
+
}
|
|
3187
|
+
} catch (e) {
|
|
3188
|
+
console.debug('[WordEditor] 标记红头分隔线段落失败(不影响使用):', e);
|
|
3189
|
+
}
|
|
3190
|
+
};
|
|
3191
|
+
|
|
3192
|
+
// ========================================================================
|
|
3193
|
+
// 新增 3:隐藏 DocxEditor 自带的段落样式选择器(最精准版本,aria-label 直接命中)
|
|
3194
|
+
//
|
|
3195
|
+
// 根据浏览器实际 DOM 取证结果(2026-08-14):
|
|
3196
|
+
// DocxEditor 工具栏里的段落样式选择器有一个独一无二的属性:
|
|
3197
|
+
// aria-label="选择段落样式"
|
|
3198
|
+
// (字体选择器是 aria-label="选择字体",缩放选择器是 aria-label="缩放: 75%")
|
|
3199
|
+
//
|
|
3200
|
+
// 所以我们完全不需要猜顺序/猜文本内容,直接用这个 aria-label 属性精准锁定!
|
|
3201
|
+
// 这是绝对不会误伤缩放/字体/字号选择器的终极方案。
|
|
3202
|
+
// ========================================================================
|
|
3203
|
+
const hideBuiltinStylePicker = () => {
|
|
3204
|
+
try {
|
|
3205
|
+
// 直接用 aria-label 精准命中:段落样式选择器(其他任何 combobox 都不会有这个 label)
|
|
3206
|
+
const targetTrigger = document.querySelector(
|
|
3207
|
+
'[role="combobox"][aria-label="选择段落样式"]'
|
|
3208
|
+
);
|
|
3209
|
+
if (!targetTrigger) return;
|
|
3210
|
+
|
|
3211
|
+
// 找 toolbar item 容器(往上 1~5 层),找到第一个"像 toolbar item"的父元素就隐藏
|
|
3212
|
+
let target: HTMLElement | null = targetTrigger as HTMLElement;
|
|
3213
|
+
let depth = 0;
|
|
3214
|
+
while (target && depth < 6) {
|
|
3215
|
+
target = target.parentElement;
|
|
3216
|
+
if (!target) break;
|
|
3217
|
+
depth++;
|
|
3218
|
+
const cls = (target.className as string | undefined) ?? '';
|
|
3219
|
+
if (typeof cls === 'string' && /toolbar|item|group|segment|wrapper|cell|flex items-center/i.test(cls)) break;
|
|
3220
|
+
}
|
|
3221
|
+
const toHide = target ?? (targetTrigger as HTMLElement);
|
|
3222
|
+
toHide.style.setProperty('display', 'none', 'important');
|
|
3223
|
+
// 加 data 属性,配合 word-editor.css 里的 rule 再加固一次,防止 React re-render 清掉
|
|
3224
|
+
toHide.setAttribute('data-vendorsys-hidden-style-picker', 'true');
|
|
3225
|
+
} catch (err) {
|
|
3226
|
+
console.debug('[WordEditor] 隐藏自带段落样式选择器失败(不影响使用):', err);
|
|
3227
|
+
}
|
|
3228
|
+
};
|
|
3229
|
+
|
|
3230
|
+
// 把四个操作打包,便于在多个触发点复用
|
|
3231
|
+
const applyAllFixes = () => {
|
|
3232
|
+
const applied = applyLayoutAnchors();
|
|
3233
|
+
syncStyleSelectValue();
|
|
3234
|
+
markHongTouSeparatorParagraphs();
|
|
3235
|
+
hideBuiltinStylePicker();
|
|
3236
|
+
return applied;
|
|
3237
|
+
};
|
|
3238
|
+
|
|
3239
|
+
// —— 关键:把 applyAllFixes 函数引用暴露给组件级 ref
|
|
3240
|
+
// 这样外部 handleStyleSelectChange (样式选择器 onChange)、快捷键应用样式后、
|
|
3241
|
+
// 以及 setInterval 轮询都能调用同一套函数,不用把 markHongTouSeparatorParagraphs
|
|
3242
|
+
// 搬离 useEffect 内部的闭包作用域。
|
|
3243
|
+
applyAllFixesRef.current = applyAllFixes;
|
|
3244
|
+
|
|
3245
|
+
// 立即执行一次(如果 DOM 已经就绪)
|
|
3246
|
+
applyAllFixes();
|
|
3247
|
+
|
|
3248
|
+
// 定时重试:layout-painter 是异步的,可能需要几轮才把分页渲染完
|
|
3249
|
+
const timers: number[] = [];
|
|
3250
|
+
[100, 300, 600, 1000, 2000].forEach((delay) => {
|
|
3251
|
+
timers.push(window.setTimeout(applyAllFixes, delay));
|
|
3252
|
+
});
|
|
3253
|
+
|
|
3254
|
+
// —— 新增:500ms 轮询兜底
|
|
3255
|
+
// 背景:用户在编辑器打开 2s 之后才应用样式(比如 5 分钟后选红头分隔线),
|
|
3256
|
+
// 此时上面的 setTimeout 多点(最大 2s)已过期;而且应用 paragraph.styleId
|
|
3257
|
+
// 只是 PM state 变化,paragraph DOM 不会 childList 增删(只是 style/class
|
|
3258
|
+
// 属性变化),导致 MutationObserver 原始配置(仅 childList)也不会触发,
|
|
3259
|
+
// 于是 markHongTouSeparatorParagraphs 不会跑,用户看不到红线(保存重进才出现)。
|
|
3260
|
+
// 用 500ms 轻量轮询 100% 覆盖这种"过期 + 无 childList 变化"的场景。
|
|
3261
|
+
// applyAllFixes 内的四个函数都是严格幂等的(查 DOM/写 data-attr/读 styleId/隐藏 toolbar),
|
|
3262
|
+
// 500ms 一次对性能影响可以忽略,不会造成任何视觉抖动或 React 重渲染。
|
|
3263
|
+
const intervalId = window.setInterval(() => {
|
|
3264
|
+
try {
|
|
3265
|
+
applyAllFixesRef.current?.();
|
|
3266
|
+
} catch {
|
|
3267
|
+
/* 忽略偶发异常,下一轮继续 */
|
|
3268
|
+
}
|
|
3269
|
+
}, 500);
|
|
3270
|
+
|
|
3271
|
+
// MutationObserver:监听 #vendorsys-word-editor-root 及其子树
|
|
3272
|
+
// —— 关键扩展:从 { childList, subtree } → { childList, subtree, attributes, attributeFilter }
|
|
3273
|
+
// attributes 监听 style/class 变化,使得应用 paragraph 样式(lib 更新 DOM 的
|
|
3274
|
+
// font-size/color/border-bottom 等 inline style)时也能触发 applyAllFixes,
|
|
3275
|
+
// 大部分场景 0~50ms 内就能把红头分隔线打上标记,不用等 500ms 轮询兜底。
|
|
3276
|
+
// attributeFilter 只监听 style 和 class,避免监听 data-* / id 等无关属性造成过多触发。
|
|
3277
|
+
// 但 attributes 变化仍要排除 antd-select-dropdown 相关节点(防止下拉框闪现问题)。
|
|
3278
|
+
let observer: MutationObserver | null = null;
|
|
3279
|
+
try {
|
|
3280
|
+
const root = document.querySelector(rootSelector);
|
|
3281
|
+
if (root) {
|
|
3282
|
+
observer = new MutationObserver((mutations) => {
|
|
3283
|
+
let needsApply = false;
|
|
3284
|
+
mutations.forEach((m) => {
|
|
3285
|
+
// —— 修复 dropdown 闪现:先判断本次 mutation 相关节点是否属于
|
|
3286
|
+
// antd-select-dropdown。如果是,则完全忽略本次 mutation(无论
|
|
3287
|
+
// childList 还是 attributes 变化都不触发 applyAllFixes)。
|
|
3288
|
+
// 注:用 any[] 不用 Node[],避免 TS 把 Node 解析成 ProseMirror.Node
|
|
3289
|
+
// (当前文件同时 import 了 prosemirror-model 的 Node,同名冲突)。
|
|
3290
|
+
const anyRelatedNode: any[] = [];
|
|
3291
|
+
if (m.target instanceof HTMLElement) anyRelatedNode.push(m.target);
|
|
3292
|
+
if (m.addedNodes) m.addedNodes.forEach((n) => anyRelatedNode.push(n));
|
|
3293
|
+
if (m.removedNodes) m.removedNodes.forEach((n) => anyRelatedNode.push(n));
|
|
3294
|
+
let anyInDropdown = false;
|
|
3295
|
+
for (const n of anyRelatedNode) {
|
|
3296
|
+
if (!(n instanceof HTMLElement)) continue;
|
|
3297
|
+
if (
|
|
3298
|
+
n.classList?.contains('ant-select-dropdown') ||
|
|
3299
|
+
n.querySelector?.('.ant-select-dropdown') ||
|
|
3300
|
+
n.closest?.('.ant-select-dropdown')
|
|
3301
|
+
) {
|
|
3302
|
+
anyInDropdown = true;
|
|
3303
|
+
break;
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
if (anyInDropdown) return; // 下拉框相关的 DOM/属性变化,一律忽略
|
|
3307
|
+
|
|
3308
|
+
// —— 分支 A:childList 新增/删除节点
|
|
3309
|
+
if (m.type === 'childList' && m.addedNodes && m.addedNodes.length > 0) {
|
|
3310
|
+
// (childList 的 antd-dropdown 过滤逻辑在上面已经 return 掉了,
|
|
3311
|
+
// 到这里说明 addedNodes 确实不是 dropdown)
|
|
3312
|
+
needsApply = true;
|
|
3313
|
+
return;
|
|
3314
|
+
}
|
|
3315
|
+
|
|
3316
|
+
// —— 分支 B:attributes(style/class)变化
|
|
3317
|
+
// 应用 paragraph 样式时,lib 会更新 .layout-paragraph 的 inline style
|
|
3318
|
+
// (font-size、border-bottom 等),此时 m.target 就是 paragraph DOM。
|
|
3319
|
+
// attributes 变化不需要再检查 addedNodes(因为没有),直接判定需要 apply。
|
|
3320
|
+
if (m.type === 'attributes') {
|
|
3321
|
+
const tgt = m.target;
|
|
3322
|
+
// 额外过滤:只对"页面内容区域内"的元素 attributes 变化触发,
|
|
3323
|
+
// 防止 toolbar 按钮 hover 等高频 style 变化导致无谓重跑。
|
|
3324
|
+
// (其实 applyAllFixes 极轻,就算不筛也没影响,但保险起见)
|
|
3325
|
+
if (
|
|
3326
|
+
tgt instanceof HTMLElement &&
|
|
3327
|
+
(tgt.classList?.contains('layout-paragraph') ||
|
|
3328
|
+
tgt.classList?.contains('layout-line') ||
|
|
3329
|
+
tgt.closest?.('.layout-page-content, .layout-paragraph, .layout-line'))
|
|
3330
|
+
) {
|
|
3331
|
+
needsApply = true;
|
|
3332
|
+
return;
|
|
3333
|
+
}
|
|
3334
|
+
}
|
|
3335
|
+
});
|
|
3336
|
+
|
|
3337
|
+
if (needsApply) {
|
|
3338
|
+
// 用 rAF 把多次改动合并成一次扫描,避免抖动
|
|
3339
|
+
requestAnimationFrame(applyAllFixes);
|
|
3340
|
+
}
|
|
3341
|
+
});
|
|
3342
|
+
observer.observe(root, {
|
|
3343
|
+
childList: true,
|
|
3344
|
+
subtree: true,
|
|
3345
|
+
attributes: true,
|
|
3346
|
+
attributeFilter: ['style', 'class'],
|
|
3347
|
+
});
|
|
3348
|
+
}
|
|
3349
|
+
} catch (e) {
|
|
3350
|
+
// MutationObserver 在某些场景可能不可用,忽略即可,定时器 + 轮询已覆盖兜底
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
return () => {
|
|
3354
|
+
timers.forEach((t) => window.clearTimeout(t));
|
|
3355
|
+
window.clearInterval(intervalId);
|
|
3356
|
+
if (observer) observer.disconnect();
|
|
3357
|
+
// 清理函数引用:编辑器不可见后 ref 不应再指向已失效的闭包
|
|
3358
|
+
applyAllFixesRef.current = () => {};
|
|
3359
|
+
};
|
|
3360
|
+
}, [visible, editorKey, loadingInitialBlob, showFinalRevisionPreview, readCurrentParagraphStyleId]);
|
|
3361
|
+
|
|
3362
|
+
// 重置状态和清理定时器(当编辑器打开/关闭时)
|
|
3363
|
+
useEffect(() => {
|
|
3364
|
+
if (visible) {
|
|
3365
|
+
setHasUploaded(!isCreate);
|
|
3366
|
+
// 重置"立即标记未同步"触发器
|
|
3367
|
+
markUnsyncedTriggeredRef.current = false;
|
|
3368
|
+
revisionPreviewSourceBufferRef.current = null;
|
|
3369
|
+
setShowFinalRevisionPreview(false);
|
|
3370
|
+
// 清理之前的定时器
|
|
3371
|
+
if (saveTimerRef.current) {
|
|
3372
|
+
clearTimeout(saveTimerRef.current);
|
|
3373
|
+
saveTimerRef.current = null;
|
|
3374
|
+
}
|
|
3375
|
+
}
|
|
3376
|
+
|
|
3377
|
+
// 组件卸载时清理定时器
|
|
3378
|
+
return () => {
|
|
3379
|
+
if (saveTimerRef.current) {
|
|
3380
|
+
clearTimeout(saveTimerRef.current);
|
|
3381
|
+
saveTimerRef.current = null;
|
|
3382
|
+
}
|
|
3383
|
+
};
|
|
3384
|
+
}, [visible, isCreate]);
|
|
3385
|
+
|
|
3386
|
+
return (
|
|
3387
|
+
<Modal
|
|
3388
|
+
open={visible}
|
|
3389
|
+
onCancel={onClose}
|
|
3390
|
+
keyboard={false}
|
|
3391
|
+
closeIcon={null}
|
|
3392
|
+
footer={null}
|
|
3393
|
+
width="100%"
|
|
3394
|
+
zIndex={100}
|
|
3395
|
+
style={{ top: 0, maxWidth: '100vw' }}
|
|
3396
|
+
styles={{
|
|
3397
|
+
body: {
|
|
3398
|
+
height: '100dvh',
|
|
3399
|
+
padding: 0,
|
|
3400
|
+
// overflow: 'visible',
|
|
3401
|
+
},
|
|
3402
|
+
wrapper: {
|
|
3403
|
+
// overflow: 'visible',
|
|
3404
|
+
},
|
|
3405
|
+
}}
|
|
3406
|
+
className="ant-modal-nopadding ant-modal-fullscreen"
|
|
3407
|
+
getContainer={false}
|
|
3408
|
+
transitionName='none'
|
|
3409
|
+
>
|
|
3410
|
+
<style>
|
|
3411
|
+
{`
|
|
3412
|
+
.bg-doc-surface[data-testid=title-bar] {
|
|
3413
|
+
padding-top: 0px !important;
|
|
3414
|
+
padding-bottom: 0px !important;
|
|
3415
|
+
}
|
|
3416
|
+
.bg-doc-surface[data-testid=title-bar] > div {
|
|
3417
|
+
padding-top: 0px !important;
|
|
3418
|
+
padding-bottom: 0px !important;
|
|
3419
|
+
}
|
|
3420
|
+
.bg-doc-surface[data-testid=title-bar] > div:first-of-type svg {
|
|
3421
|
+
display: none;
|
|
3422
|
+
}
|
|
3423
|
+
`}
|
|
3424
|
+
</style>
|
|
3425
|
+
<div className="h-full flex flex-col bg-white p-0 m-0">
|
|
3426
|
+
{/* 工具栏 */}
|
|
3427
|
+
<div className={`flex items-center justify-between px-4 py-2 pr-0 border-b flex-shrink-0 ${isOffline ? 'bg-orange-50 border-orange-200' : 'bg-gray-50 border-gray-200'}`}>
|
|
3428
|
+
{/* 只读模式下直接关闭,不需要提示保存 */}
|
|
3429
|
+
<span> </span>
|
|
3430
|
+
{!readOnly && <span> </span>}
|
|
3431
|
+
<div className="flex items-center gap-2 flex-wrap min-w-[40%]">
|
|
3432
|
+
{!isOffline && isLocalOnly && (
|
|
3433
|
+
<Tag icon={<CloudOutlined />} color="orange" style={{ margin: 0 }}>
|
|
3434
|
+
本地文件
|
|
3435
|
+
</Tag>
|
|
3436
|
+
)}
|
|
3437
|
+
{!isOffline && !isLocalOnly && hasUnsyncedChanges && (
|
|
3438
|
+
<Tag icon={<CloudOutlined />} color="warning" style={{ margin: 0 }}>
|
|
3439
|
+
有未同步修改
|
|
3440
|
+
</Tag>
|
|
3441
|
+
)}
|
|
3442
|
+
{!isOffline && !isLocalOnly && !hasUnsyncedChanges && !onUpload ? (
|
|
3443
|
+
<Tag icon={<CloudServerOutlined />} color="default" style={{ margin: 0 }}>
|
|
3444
|
+
同步完成
|
|
3445
|
+
</Tag>
|
|
3446
|
+
) : null}
|
|
3447
|
+
<FileWordFilled className="text-blue-600" />
|
|
3448
|
+
<span className="text-sm font-medium">{hasLocalCache ? '*' : ''}{fileName}</span>
|
|
3449
|
+
</div>
|
|
3450
|
+
{!readOnly && !hiddenClose && (
|
|
3451
|
+
<Space wrap={true} align="center" direction="horizontal">
|
|
3452
|
+
<ExportDropdown
|
|
3453
|
+
onExportPdf={handleExportPdf}
|
|
3454
|
+
onDownload={onDownload}
|
|
3455
|
+
exportingPdf={exportingPdf}
|
|
3456
|
+
extraItems={exportExtraItems}
|
|
3457
|
+
buttonProps={{ style: { paddingLeft: 4, paddingRight: 4 } }}
|
|
3458
|
+
/>
|
|
3459
|
+
{!showFinalRevisionPreview && onUpload && (
|
|
3460
|
+
<Button
|
|
3461
|
+
type="text" style={{ paddingLeft: 4, paddingRight: 4 }}
|
|
3462
|
+
title="每次上传将保留一个历史版本,最多保留10个版本"
|
|
3463
|
+
icon={<UploadOutlined />}
|
|
3464
|
+
onClick={handleUpload}
|
|
3465
|
+
disabled={uploadPercent !== undefined && uploadPercent < 1}
|
|
3466
|
+
>
|
|
3467
|
+
{uploadPercent !== undefined && uploadPercent < 1 ? `上传中 ${Math.round(uploadPercent * 100)}%` : '上传'}
|
|
3468
|
+
</Button>
|
|
3469
|
+
)}
|
|
3470
|
+
{!showFinalRevisionPreview && (
|
|
3471
|
+
<Button
|
|
3472
|
+
type="primary"
|
|
3473
|
+
icon={<SaveOutlined />}
|
|
3474
|
+
onClick={handleSave}
|
|
3475
|
+
>
|
|
3476
|
+
保存
|
|
3477
|
+
</Button>
|
|
3478
|
+
)}
|
|
3479
|
+
{(!readOnly && (hasLocalCache || !hasUploaded)) ?
|
|
3480
|
+
(<Popconfirm
|
|
3481
|
+
title="确定要退出吗?"
|
|
3482
|
+
description={hasLocalCache?"修改未保存,确定要退出吗?": "修改未上传,确定要退出吗?"}
|
|
3483
|
+
onConfirm={onClose}
|
|
3484
|
+
okText="确定"
|
|
3485
|
+
cancelText="取消"
|
|
3486
|
+
>
|
|
3487
|
+
<Button type="text" className="ml-3 mr-2" icon={<CloseOutlined className="w-6 h-6" />} />
|
|
3488
|
+
</Popconfirm>) : (<CloseOutlined onClick={onClose} className='ml-3 mr-2 w-6 h-6'/>)}
|
|
3489
|
+
</Space>
|
|
3490
|
+
)}
|
|
3491
|
+
{readOnly && !hiddenClose && (
|
|
3492
|
+
<Space>
|
|
3493
|
+
<ExportDropdown
|
|
3494
|
+
onExportPdf={handleExportPdf}
|
|
3495
|
+
onDownload={onDownload}
|
|
3496
|
+
exportingPdf={exportingPdf}
|
|
3497
|
+
extraItems={exportExtraItems}
|
|
3498
|
+
buttonProps={{ style: { paddingLeft: 4, paddingRight: 4 } }}
|
|
3499
|
+
/>
|
|
3500
|
+
<CloseOutlined onClick={onClose} className='ml-3 mr-2 w-6 h-6' />
|
|
3501
|
+
</Space>)
|
|
3502
|
+
}
|
|
3503
|
+
{hiddenClose && (
|
|
3504
|
+
<ExportDropdown
|
|
3505
|
+
onExportPdf={handleExportPdf}
|
|
3506
|
+
onDownload={onDownload}
|
|
3507
|
+
exportingPdf={exportingPdf}
|
|
3508
|
+
extraItems={exportExtraItems}
|
|
3509
|
+
buttonProps={{ style: { paddingLeft: 4, paddingRight: 4 } }}
|
|
3510
|
+
/>
|
|
3511
|
+
)}
|
|
3512
|
+
</div>
|
|
3513
|
+
|
|
3514
|
+
{/* Word 编辑器容器 */}
|
|
3515
|
+
<div
|
|
3516
|
+
key={editorKey}
|
|
3517
|
+
id="vendorsys-word-editor-root"
|
|
3518
|
+
className="vendorsys-word-editor-root flex-1 overflow-auto"
|
|
3519
|
+
style={{ height: 'calc(100% - 50px)', position: 'relative' }}
|
|
3520
|
+
>
|
|
3521
|
+
{visible && !loadingInitialBlob && (
|
|
3522
|
+
<PluginHost ref={pluginHostRef} plugins={wordEditorPlugins}>
|
|
3523
|
+
<DocxEditor
|
|
3524
|
+
ref={editorRef}
|
|
3525
|
+
initialZoom={isMobile ?0.75 :1}
|
|
3526
|
+
// documentName={fileName}
|
|
3527
|
+
author={userId||'tj'}
|
|
3528
|
+
document={initialDocument ?? undefined}
|
|
3529
|
+
documentBuffer={initialDocument ? undefined : (initialBuffer ?? undefined)}
|
|
3530
|
+
onSave={handleEditorMenuSave}
|
|
3531
|
+
onChange={handleChange}
|
|
3532
|
+
// =====================================================================
|
|
3533
|
+
// onSelectionChange:ProseMirror 选区变化时,
|
|
3534
|
+
// 1) 同步当前段落的真实 styleId(解决"全选文本显示正文但实际不是"的 bug)
|
|
3535
|
+
// 2) 顺便从 StyleResolver 里拉取文件内的全部段落样式,填充到
|
|
3536
|
+
// documentStylesFromFile state,让下拉 options 包含文件本身自带的
|
|
3537
|
+
// footer/header/Normal(Web)/自定义模板样式等(不仅是我们的预设)。
|
|
3538
|
+
// =====================================================================
|
|
3539
|
+
onSelectionChange={() => {
|
|
3540
|
+
try {
|
|
3541
|
+
// 先拿 state(拿不到就不处理,等下一次)
|
|
3542
|
+
const state = editorRef.current?.getEditorRef()?.getState();
|
|
3543
|
+
if (state) {
|
|
3544
|
+
// 2) 先同步文件里的全部段落样式(避免下拉框缺文件自带样式)
|
|
3545
|
+
syncDocumentStylesFromFile(state);
|
|
3546
|
+
}
|
|
3547
|
+
|
|
3548
|
+
const styleId = readCurrentParagraphStyleId();
|
|
3549
|
+
// —— 修复 dropdown 闪现:同值不 setState(理由见 onChange 里的注释)
|
|
3550
|
+
// 点击 Select trigger 打开 dropdown 时,editor 会短暂失去选区焦点,
|
|
3551
|
+
// onSelectionChange 会被立即触发一次(但 styleId 值其实没变)。
|
|
3552
|
+
// 如果此时无脑 setState → 父组件重渲染 → antd Select 子组件内部
|
|
3553
|
+
// effect 重跑 → open state 被重置为 false → 闪现。
|
|
3554
|
+
setCurrentStyleId((prev) => (prev === styleId ? prev : styleId));
|
|
3555
|
+
} catch {
|
|
3556
|
+
/* 忽略异常,避免打断编辑器内部的 selection 处理 */
|
|
3557
|
+
}
|
|
3558
|
+
}}
|
|
3559
|
+
// readOnly={readOnly}
|
|
3560
|
+
mode={showFinalRevisionPreview || readOnly ? 'viewing' : 'editing'}
|
|
3561
|
+
showToolbar={true}
|
|
3562
|
+
showFileOpen={true}
|
|
3563
|
+
showHelpMenu={false}
|
|
3564
|
+
showMarginGuides={false}
|
|
3565
|
+
toolbarExtra={
|
|
3566
|
+
<Space size={4}>
|
|
3567
|
+
{/* ================================================================= */}
|
|
3568
|
+
{/* 自定义受控段落样式选择器(用 PRESET_STYLES 做 options) */}
|
|
3569
|
+
{/* */}
|
|
3570
|
+
{/* 策略调整:不再尝试用复杂 JS 语义识别去隐藏 DocxEditor 自带的 */}
|
|
3571
|
+
{/* 样式选择器(之前反复误伤字号/字体选择器,导致工具消失或显示错值)*/}
|
|
3572
|
+
{/* 改为:在我们的选择器前加明确的"样式:" Label,并给外层加独立的 */}
|
|
3573
|
+
{/* CSS 重置层,让用户一眼就能区分"哪个是我们修复过的(双向同步正确)*/}
|
|
3574
|
+
{/* 的样式框,哪个是 DocxEditor 自带的(仍有同步 bug,不建议用)。*/}
|
|
3575
|
+
{/* ================================================================= */}
|
|
3576
|
+
{!showFinalRevisionPreview && !readOnly && (
|
|
3577
|
+
<div
|
|
3578
|
+
className="vendorsys-custom-style-picker"
|
|
3579
|
+
data-vendorsys-custom="true"
|
|
3580
|
+
style={{
|
|
3581
|
+
display: 'inline-flex',
|
|
3582
|
+
alignItems: 'center',
|
|
3583
|
+
gap: 4,
|
|
3584
|
+
marginRight: 4,
|
|
3585
|
+
padding: '0 6px',
|
|
3586
|
+
background: 'rgba(255,255,255,0.04)',
|
|
3587
|
+
borderRadius: 4,
|
|
3588
|
+
height: 28,
|
|
3589
|
+
boxSizing: 'border-box',
|
|
3590
|
+
}}
|
|
3591
|
+
// —— 终极方案双保险:在 Select 外层容器的 mousedown 就阻止默认行为 + 冒泡
|
|
3592
|
+
// 这样 DocxEditor 的 document 级 mousedown 监听不会抢在我们前面执行,
|
|
3593
|
+
// 也不会让内部 input 的默认 focus 行为把编辑器的 focus 抢走。
|
|
3594
|
+
// (为什么选 mouseDown 而不是 click:mousedown 是整个交互链的起点,
|
|
3595
|
+
// 绝大部分"focus/blur 抢夺"都发生在 mousedown 阶段)
|
|
3596
|
+
onMouseDown={(e) => {
|
|
3597
|
+
// 阻止默认 focus 行为(不让 Select 里的 input 自动抢 focus,避免 editor blur)
|
|
3598
|
+
e.preventDefault();
|
|
3599
|
+
// 不再需要 stopPropagation(我们的 document mouseup 监听用 capture 模式了)
|
|
3600
|
+
}}
|
|
3601
|
+
>
|
|
3602
|
+
<span
|
|
3603
|
+
className="vendorsys-custom-style-picker__label"
|
|
3604
|
+
style={{
|
|
3605
|
+
fontSize: 12,
|
|
3606
|
+
color: '#8c8c8c',
|
|
3607
|
+
whiteSpace: 'nowrap',
|
|
3608
|
+
}}
|
|
3609
|
+
>
|
|
3610
|
+
样式:
|
|
3611
|
+
</span>
|
|
3612
|
+
<Select
|
|
3613
|
+
size="small"
|
|
3614
|
+
style={{ width: 150 }}
|
|
3615
|
+
// —— 二级去重后的 displayValue:
|
|
3616
|
+
// currentStyleId 可能是 DocxEditor 内部存的数字字符串(如 "1"/"11" 等),
|
|
3617
|
+
// 它在上面「规范化 name 去重」里被合并成了预设 styleId(如 "Heading1")。
|
|
3618
|
+
// 所以先把 getStyleSelectOptions() 跑一遍,再在 options 里查找:
|
|
3619
|
+
// A) value 精确等于 currentStyleId → 直接用 currentStyleId(最常见)
|
|
3620
|
+
// B) 找不到时 → 按规范化 name 等价找匹配项:
|
|
3621
|
+
// 对每个 option 做 normalizeStyleName(option.label) 与
|
|
3622
|
+
// normalizeStyleName(找到 style 对应 name 或 "样式 currentStyleId") 相等
|
|
3623
|
+
// → 用该 option.value 作为展示 value 传给 Select。
|
|
3624
|
+
// 这样 Select 的 value 永远在 options.value 集合里,antd 一定
|
|
3625
|
+
// 能找到正确的 label(中文)显示,不会出现裸数字/空白。
|
|
3626
|
+
value={(() => {
|
|
3627
|
+
const allOpts = getStyleSelectOptions();
|
|
3628
|
+
if (allOpts.some(o => !o.disabled && o.value === currentStyleId)) {
|
|
3629
|
+
return currentStyleId;
|
|
3630
|
+
}
|
|
3631
|
+
// 找 currentStyleId 对应的「显示 name」:
|
|
3632
|
+
// 优先在 styleMap 里找到 Style 拿 name;否则 fallback 到 "样式 X"
|
|
3633
|
+
const currentNorm = (() => {
|
|
3634
|
+
// 先尝试在当前文件 style 集合里找对应 Style
|
|
3635
|
+
const fromFile = documentStylesFromFileRef.current?.find(s => s.styleId === currentStyleId);
|
|
3636
|
+
const fromPreset = PRESET_STYLES.find(s => s.styleId === currentStyleId);
|
|
3637
|
+
const name = fromFile?.name || fromPreset?.name || `样式 ${currentStyleId}`;
|
|
3638
|
+
return normalizeStyleName(name);
|
|
3639
|
+
})();
|
|
3640
|
+
if (!currentNorm) return currentStyleId;
|
|
3641
|
+
const matchedByNorm = allOpts.find(o => !o.disabled && normalizeStyleName(o.label) === currentNorm);
|
|
3642
|
+
if (matchedByNorm) return matchedByNorm.value;
|
|
3643
|
+
return currentStyleId;
|
|
3644
|
+
})()}
|
|
3645
|
+
placeholder="请选择样式"
|
|
3646
|
+
// —— 2026/08/14 关键修复:onSelect + onChange 双通道
|
|
3647
|
+
// 为什么必须双挂:
|
|
3648
|
+
// antd Select 受控模式下,onChange 只有在"新 value !== 旧 value"
|
|
3649
|
+
// 时才触发。这就造成了用户反馈的精准现象:
|
|
3650
|
+
// "Ctrl+A 全选后点击『正文』无效,但其他样式有效"。
|
|
3651
|
+
// 因为 Ctrl+A 全选多段混合样式时,我们的 readCurrentParagraphStyleId
|
|
3652
|
+
// 官方 API 返回 null → 兜底 'Normal' → currentStyleId='正文'。
|
|
3653
|
+
// 此时用户在下拉框里再点一遍 value=Normal 的『正文』,因为 value
|
|
3654
|
+
// 没变,onChange 完全不触发,用户以为"点击没反应"。
|
|
3655
|
+
// 但其他样式(标题1/标题2等)因为 currentStyleId !== 新 value,
|
|
3656
|
+
// onChange 正常触发,所以看起来只有『正文』没生效。
|
|
3657
|
+
//
|
|
3658
|
+
// 挂 onSelect 后,用户每次点击 option(无论新旧值是否相同)都会
|
|
3659
|
+
// 强制走 handleStyleSelectChange,完美符合用户"每次点击都重新应用
|
|
3660
|
+
// 样式一遍"的需求;onChange 保留用于键盘选择、程序化改值、
|
|
3661
|
+
// 非点击触发的 value 变更等场景。
|
|
3662
|
+
//
|
|
3663
|
+
// 两者同时触发的风险(连续调用两次应用)已在 handleStyleSelectChange
|
|
3664
|
+
// 内用 50ms 时间窗 + styleId 精确去重处理,最多执行一次。
|
|
3665
|
+
onSelect={handleStyleSelectChange}
|
|
3666
|
+
onChange={handleStyleSelectChange}
|
|
3667
|
+
popupMatchSelectWidth={false}
|
|
3668
|
+
showSearch={false}
|
|
3669
|
+
allowClear={false}
|
|
3670
|
+
// —— 终极方案受控 open:完全由我们自己控制 Select 的打开/关闭
|
|
3671
|
+
// 完全不信任 antd 默认的 blur / 点击外部关闭逻辑(会被 DocxEditor
|
|
3672
|
+
// 的全局 blur/mousedown 检测误触发)
|
|
3673
|
+
open={styleDropdownOpen}
|
|
3674
|
+
// 只允许"打开请求"通过;antd 自己想关(因为 blur/外点检测)一律忽略
|
|
3675
|
+
onOpenChange={(open) => {
|
|
3676
|
+
if (open) setStyleDropdownOpen(true);
|
|
3677
|
+
// open=false 时什么都不做!由我们自己的 document mouseup 监听决定何时关
|
|
3678
|
+
}}
|
|
3679
|
+
// —— 修复 dropdown 出现立即消失(闪现):不再挂到 body
|
|
3680
|
+
// 之前挂到 body 下,会被 DocxEditor 挂在 document 上的全局点击/外部检测
|
|
3681
|
+
// 监听器判定为"editor 外部元素" → 触发 blur / 重渲染循环
|
|
3682
|
+
// → antd Select 内部 open state 在重渲染中丢失 → popup 刚渲染完立马 close
|
|
3683
|
+
// 解决方案:挂到 DocxEditor 自己的 DOM 根节点 .ep-root 下,让 DocxEditor
|
|
3684
|
+
// 把 popup 当作"editor 内部元素",不再触发外部点击检测。
|
|
3685
|
+
getPopupContainer={(triggerNode) => {
|
|
3686
|
+
const ownerDoc = triggerNode?.ownerDocument ?? document;
|
|
3687
|
+
// 优先级 1:DocxEditor 自己的 DOM 根节点(最稳妥,一定是 editor 内部)
|
|
3688
|
+
const epRoot = ownerDoc.querySelector('.ep-root');
|
|
3689
|
+
if (epRoot) return epRoot as HTMLElement;
|
|
3690
|
+
// 优先级 2:我们自定义的外层容器
|
|
3691
|
+
const customRoot = ownerDoc.querySelector('#vendorsys-word-editor-root');
|
|
3692
|
+
if (customRoot) return customRoot as HTMLElement;
|
|
3693
|
+
// 兜底:body
|
|
3694
|
+
return ownerDoc.body;
|
|
3695
|
+
}}
|
|
3696
|
+
// —— 修复 popup 被工具栏/页眉盖住:给专属 class,在 CSS 里把 z-index 提到 10000
|
|
3697
|
+
// (antd 默认 z-index 只有 150,会被 DocxEditor 的深色主题工具栏盖住)
|
|
3698
|
+
classNames={{ popup: { root: 'vendorsys-word-style-dropdown' } }}
|
|
3699
|
+
// 因为 fallback option disabled 为 true,用户不能点它,
|
|
3700
|
+
// 所以 dropdown 打开后用户只能选下面的预设样式(正文 / 标题 1 / ...)
|
|
3701
|
+
options={getStyleSelectOptions()}
|
|
3702
|
+
/>
|
|
3703
|
+
</div>
|
|
3704
|
+
)}
|
|
3705
|
+
{!showFinalRevisionPreview && !readOnly && (
|
|
3706
|
+
<Button
|
|
3707
|
+
size="small"
|
|
3708
|
+
type={formatPainter.active ? 'primary' : 'default'}
|
|
3709
|
+
title={formatPainter.active ? '格式刷已开启:选中文本即可应用,再次点击或按 Esc 取消' : '格式刷:复制当前选区格式并应用到其他文本'}
|
|
3710
|
+
icon={<FormatPainterIcon />}
|
|
3711
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
3712
|
+
onClick={() => formatPainter.toggle()}
|
|
3713
|
+
/>
|
|
3714
|
+
)}
|
|
3715
|
+
{!showFinalRevisionPreview && !readOnly && (
|
|
3716
|
+
<Button
|
|
3717
|
+
size="small"
|
|
3718
|
+
icon={<AlignLeftOutlined />}
|
|
3719
|
+
title="段落设置:缩进、段前段后、行距、对齐方式"
|
|
3720
|
+
onClick={openParagraphDialog}
|
|
3721
|
+
>
|
|
3722
|
+
段落
|
|
3723
|
+
</Button>
|
|
3724
|
+
)}
|
|
3725
|
+
<Button size="small" type={showFinalRevisionPreview ? 'primary' : 'default'} onClick={handleToggleFinalRevisionPreview}>
|
|
3726
|
+
{showFinalRevisionPreview ? '查看修订' : '最终状态'}
|
|
3727
|
+
</Button>
|
|
3728
|
+
</Space>
|
|
3729
|
+
}
|
|
3730
|
+
// showOutline={true}
|
|
3731
|
+
i18n={zhCN}
|
|
3732
|
+
fontFamilies={CUSTOM_FONT_FAMILIES}
|
|
3733
|
+
// 标尺:与 Word 一样显示横向标尺,便于直接看出页边距与段落缩进
|
|
3734
|
+
showRuler={!readOnly && !showFinalRevisionPreview}
|
|
3735
|
+
rulerUnit="cm"
|
|
3736
|
+
style={{ height: '100%', width: '100%' }}
|
|
3737
|
+
/>
|
|
3738
|
+
</PluginHost>
|
|
3739
|
+
)}
|
|
3740
|
+
|
|
3741
|
+
{/* 粘贴选项:粘贴完成后在插入位置下方出现的浮动按钮(WPS 风格)。
|
|
3742
|
+
通过 Portal 渲染到 body,用 fixed 定位,避免被编辑器内部层叠上下文盖住。 */}
|
|
3743
|
+
{pasteSmartTag && !readOnly && createPortal(
|
|
3744
|
+
<div
|
|
3745
|
+
ref={pasteSmartTagRef}
|
|
3746
|
+
className="vendorsys-paste-smart-tag"
|
|
3747
|
+
style={{ left: pasteSmartTag.x, top: pasteSmartTag.y }}
|
|
3748
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
3749
|
+
>
|
|
3750
|
+
{pasteSmartTag.expanded ? (
|
|
3751
|
+
<div className="vendorsys-paste-smart-tag-menu">
|
|
3752
|
+
<button type="button" onClick={() => applyPasteMode('formatted')}>
|
|
3753
|
+
<span className="label">带格式粘贴</span>
|
|
3754
|
+
<span className="key">(W)</span>
|
|
3755
|
+
</button>
|
|
3756
|
+
<button type="button" onClick={() => applyPasteMode('match')}>
|
|
3757
|
+
<span className="label">匹配当前格式</span>
|
|
3758
|
+
<span className="key">(M)</span>
|
|
3759
|
+
</button>
|
|
3760
|
+
<button type="button" onClick={() => applyPasteMode('text')}>
|
|
3761
|
+
<span className="label">只粘贴文本</span>
|
|
3762
|
+
<span className="key">(T)</span>
|
|
3763
|
+
</button>
|
|
3764
|
+
</div>
|
|
3765
|
+
) : (
|
|
3766
|
+
<button
|
|
3767
|
+
type="button"
|
|
3768
|
+
className="vendorsys-paste-smart-tag-trigger"
|
|
3769
|
+
title="粘贴选项"
|
|
3770
|
+
onClick={() => setPasteSmartTag({ ...pasteSmartTag, expanded: true })}
|
|
3771
|
+
>
|
|
3772
|
+
<SnippetsOutlined />
|
|
3773
|
+
<DownOutlined className="arrow" />
|
|
3774
|
+
</button>
|
|
3775
|
+
)}
|
|
3776
|
+
</div>,
|
|
3777
|
+
document.body
|
|
3778
|
+
)}
|
|
3779
|
+
{loadingInitialBlob && (
|
|
3780
|
+
<div className="flex items-center justify-center h-full">
|
|
3781
|
+
<span>加载中...</span>
|
|
3782
|
+
</div>
|
|
3783
|
+
)}
|
|
3784
|
+
|
|
3785
|
+
{/* 段落设置:DocxEditor 自带工具栏没有段落信息修改入口,这里补上 */}
|
|
3786
|
+
<Modal
|
|
3787
|
+
open={paragraphDialogOpen}
|
|
3788
|
+
title="段落设置"
|
|
3789
|
+
okText="应用"
|
|
3790
|
+
cancelText="取消"
|
|
3791
|
+
width={460}
|
|
3792
|
+
maskClosable={false}
|
|
3793
|
+
// 外层 Word 弹窗 zIndex=100、编辑器内部下拉的 z-index 到 10000,
|
|
3794
|
+
// 这里必须高于它们,否则对话框会被文档层挡住、表现为「点了没反应」
|
|
3795
|
+
zIndex={11000}
|
|
3796
|
+
getContainer={() => document.body}
|
|
3797
|
+
onOk={applyParagraphSettings}
|
|
3798
|
+
onCancel={() => setParagraphDialogOpen(false)}
|
|
3799
|
+
>
|
|
3800
|
+
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, paddingTop: 8 }}>
|
|
3801
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
3802
|
+
<span style={{ width: 76, color: '#8c8c8c', fontSize: 13 }}>对齐方式</span>
|
|
3803
|
+
<Select<ParagraphAlignmentValue>
|
|
3804
|
+
style={{ width: 160 }}
|
|
3805
|
+
value={paragraphForm.alignment}
|
|
3806
|
+
onChange={(value) => setParagraphForm((prev) => ({ ...prev, alignment: value }))}
|
|
3807
|
+
options={[
|
|
3808
|
+
{ label: '左对齐', value: 'left' },
|
|
3809
|
+
{ label: '居中', value: 'center' },
|
|
3810
|
+
{ label: '右对齐', value: 'right' },
|
|
3811
|
+
{ label: '两端对齐', value: 'both' },
|
|
3812
|
+
]}
|
|
3813
|
+
/>
|
|
3814
|
+
</div>
|
|
3815
|
+
|
|
3816
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
3817
|
+
<span style={{ width: 76, color: '#8c8c8c', fontSize: 13 }}>左缩进</span>
|
|
3818
|
+
<InputNumber
|
|
3819
|
+
min={0}
|
|
3820
|
+
max={20}
|
|
3821
|
+
step={0.1}
|
|
3822
|
+
precision={2}
|
|
3823
|
+
style={{ width: 120 }}
|
|
3824
|
+
value={paragraphForm.indentLeftCm}
|
|
3825
|
+
onChange={(value) => setParagraphForm((prev) => ({ ...prev, indentLeftCm: Number(value) || 0 }))}
|
|
3826
|
+
addonAfter="厘米"
|
|
3827
|
+
/>
|
|
3828
|
+
<span style={{ color: '#8c8c8c', fontSize: 13 }}>右缩进</span>
|
|
3829
|
+
<InputNumber
|
|
3830
|
+
min={0}
|
|
3831
|
+
max={20}
|
|
3832
|
+
step={0.1}
|
|
3833
|
+
precision={2}
|
|
3834
|
+
style={{ width: 120 }}
|
|
3835
|
+
value={paragraphForm.indentRightCm}
|
|
3836
|
+
onChange={(value) => setParagraphForm((prev) => ({ ...prev, indentRightCm: Number(value) || 0 }))}
|
|
3837
|
+
addonAfter="厘米"
|
|
3838
|
+
/>
|
|
3839
|
+
</div>
|
|
3840
|
+
|
|
3841
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
3842
|
+
<span style={{ width: 76, color: '#8c8c8c', fontSize: 13 }}>特殊格式</span>
|
|
3843
|
+
<Select<'none' | 'firstLine' | 'hanging'>
|
|
3844
|
+
style={{ width: 160 }}
|
|
3845
|
+
value={paragraphForm.indentSpecial}
|
|
3846
|
+
onChange={(value) =>
|
|
3847
|
+
setParagraphForm((prev) => ({
|
|
3848
|
+
...prev,
|
|
3849
|
+
indentSpecial: value,
|
|
3850
|
+
indentSpecialCm: value === 'none' ? 0 : prev.indentSpecialCm || 0.74,
|
|
3851
|
+
}))
|
|
3852
|
+
}
|
|
3853
|
+
options={[
|
|
3854
|
+
{ label: '无', value: 'none' },
|
|
3855
|
+
{ label: '首行缩进', value: 'firstLine' },
|
|
3856
|
+
{ label: '悬挂缩进', value: 'hanging' },
|
|
3857
|
+
]}
|
|
3858
|
+
/>
|
|
3859
|
+
<InputNumber
|
|
3860
|
+
min={0}
|
|
3861
|
+
max={20}
|
|
3862
|
+
step={0.1}
|
|
3863
|
+
precision={2}
|
|
3864
|
+
style={{ width: 120 }}
|
|
3865
|
+
disabled={paragraphForm.indentSpecial === 'none'}
|
|
3866
|
+
value={paragraphForm.indentSpecialCm}
|
|
3867
|
+
onChange={(value) => setParagraphForm((prev) => ({ ...prev, indentSpecialCm: Number(value) || 0 }))}
|
|
3868
|
+
addonAfter="厘米"
|
|
3869
|
+
/>
|
|
3870
|
+
</div>
|
|
3871
|
+
|
|
3872
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
3873
|
+
<span style={{ width: 76, color: '#8c8c8c', fontSize: 13 }}>段前</span>
|
|
3874
|
+
<InputNumber
|
|
3875
|
+
min={0}
|
|
3876
|
+
max={200}
|
|
3877
|
+
step={1}
|
|
3878
|
+
style={{ width: 120 }}
|
|
3879
|
+
value={paragraphForm.spaceBeforePt}
|
|
3880
|
+
onChange={(value) => setParagraphForm((prev) => ({ ...prev, spaceBeforePt: Number(value) || 0 }))}
|
|
3881
|
+
addonAfter="磅"
|
|
3882
|
+
/>
|
|
3883
|
+
<span style={{ color: '#8c8c8c', fontSize: 13 }}>段后</span>
|
|
3884
|
+
<InputNumber
|
|
3885
|
+
min={0}
|
|
3886
|
+
max={200}
|
|
3887
|
+
step={1}
|
|
3888
|
+
style={{ width: 120 }}
|
|
3889
|
+
value={paragraphForm.spaceAfterPt}
|
|
3890
|
+
onChange={(value) => setParagraphForm((prev) => ({ ...prev, spaceAfterPt: Number(value) || 0 }))}
|
|
3891
|
+
addonAfter="磅"
|
|
3892
|
+
/>
|
|
3893
|
+
</div>
|
|
3894
|
+
|
|
3895
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
3896
|
+
<span style={{ width: 76, color: '#8c8c8c', fontSize: 13 }}>行距</span>
|
|
3897
|
+
<Select<LineSpacingRuleValue>
|
|
3898
|
+
style={{ width: 160 }}
|
|
3899
|
+
value={paragraphForm.lineRule}
|
|
3900
|
+
onChange={(value) =>
|
|
3901
|
+
setParagraphForm((prev) => ({
|
|
3902
|
+
...prev,
|
|
3903
|
+
lineRule: value,
|
|
3904
|
+
// 倍数与磅值量纲不同,切换时给一个有意义的默认值
|
|
3905
|
+
lineValue: value === 'auto' ? (prev.lineRule === 'auto' ? prev.lineValue : 1) : (prev.lineRule === 'auto' ? 18 : prev.lineValue),
|
|
3906
|
+
}))
|
|
3907
|
+
}
|
|
3908
|
+
options={[
|
|
3909
|
+
{ label: '倍数', value: 'auto' },
|
|
3910
|
+
{ label: '固定值', value: 'exact' },
|
|
3911
|
+
{ label: '最小值', value: 'atLeast' },
|
|
3912
|
+
]}
|
|
3913
|
+
/>
|
|
3914
|
+
<InputNumber
|
|
3915
|
+
min={0.5}
|
|
3916
|
+
max={200}
|
|
3917
|
+
step={paragraphForm.lineRule === 'auto' ? 0.05 : 1}
|
|
3918
|
+
precision={2}
|
|
3919
|
+
style={{ width: 120 }}
|
|
3920
|
+
value={paragraphForm.lineValue}
|
|
3921
|
+
onChange={(value) => setParagraphForm((prev) => ({ ...prev, lineValue: Number(value) || 1 }))}
|
|
3922
|
+
addonAfter={paragraphForm.lineRule === 'auto' ? '倍' : '磅'}
|
|
3923
|
+
/>
|
|
3924
|
+
</div>
|
|
3925
|
+
</div>
|
|
3926
|
+
</Modal>
|
|
3927
|
+
</div>
|
|
3928
|
+
</div>
|
|
3929
|
+
</Modal>
|
|
3930
|
+
);
|
|
3931
|
+
});
|
|
3932
|
+
|
|
3933
|
+
export default WordEditor;
|