├── .gitignore ├── .npmignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── eslint.config.js ├── index.html ├── package-lock.json ├── package.json ├── public └── favicon.svg ├── src ├── App.css ├── App.tsx ├── assets │ ├── icon_img_enlarge_default.svg │ ├── icon_img_narrow_default.svg │ ├── icon_img_normal_default.svg │ ├── icon_img_rotate+90_default.svg │ ├── icon_img_rotate-90_default.svg │ ├── outline-left.svg │ ├── outline-right.svg │ └── react.svg ├── components │ ├── FilePreview │ │ ├── ImagesViewer │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── PDFViewer │ │ │ ├── index.module.less │ │ │ ├── index.tsx │ │ │ └── types.ts │ │ ├── Pagination │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── Toolbar │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── index.module.less │ │ ├── index.tsx │ │ └── types.ts │ ├── JsonView │ │ └── index.tsx │ ├── Loading │ │ ├── index.module.less │ │ └── index.tsx │ ├── MarkLayer │ │ ├── SvgRect │ │ │ ├── Text.tsx │ │ │ ├── index.module.less │ │ │ └── index.tsx │ │ ├── helpers.ts │ │ ├── index.module.less │ │ └── index.tsx │ ├── RadioGroup │ │ ├── index.module.less │ │ └── index.tsx │ └── ResultView │ │ ├── Footer.tsx │ │ ├── Header.tsx │ │ ├── KeyValueList.module.less │ │ ├── KeyValueList.tsx │ │ ├── KeyValueTable.module.less │ │ ├── KeyValueTable.tsx │ │ ├── index.module.less │ │ └── index.tsx ├── examples │ ├── ImageExample.tsx │ ├── PDFExample.tsx │ ├── data.ts │ ├── image_example.json │ └── pdf_example.json ├── hooks │ ├── useContentLinkage.ts │ ├── useFrameSetState.ts │ ├── useLoadPDFLib.ts │ ├── useMarkTool.ts │ ├── usePDFMarkLayer.ts │ └── usePreviewTool.tsx ├── index.css ├── index.ts ├── main.tsx ├── types │ ├── common.ts │ ├── global.d.ts │ └── keyVal.ts ├── utils │ ├── browser.ts │ ├── debounce.ts │ ├── dom.ts │ ├── object.ts │ ├── throttle.ts │ ├── transformers.ts │ └── uuid.ts └── vite-env.d.ts ├── tsconfig.app.json ├── tsconfig.json ├── tsconfig.node.json ├── tsconfig.tsbuildinfo └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # 开发工具配置 2 | .git/ 3 | .gitignore 4 | .eslintrc* 5 | .prettierrc* 6 | .editorconfig 7 | .vscode/ 8 | .idea/ 9 | *.log 10 | 11 | # 开发依赖 12 | node_modules/ 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # 构建工具配置 18 | vite.config.ts 19 | tsconfig.json 20 | tsconfig.*.json 21 | *.config.js 22 | *.config.ts 23 | 24 | # 源代码和开发文件 25 | src/ 26 | public/ 27 | index.html 28 | *.test.* 29 | *.spec.* 30 | __tests__/ 31 | test/ 32 | tests/ 33 | coverage/ 34 | 35 | # 文档和贡献指南 36 | CONTRIBUTING.md 37 | CHANGELOG.md 38 | *.md 39 | !README.md 40 | 41 | # 其他 42 | .DS_Store 43 | .env* 44 | *.local 45 | .qodo/ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | 2 | # 贡献指南 3 | 4 | 感谢你对本项目感兴趣并希望贡献代码!为了确保贡献的顺利进行,请遵循以下流程和指南。 5 | 6 | ## 如何贡献 7 | 8 | ### 报告问题 9 | 10 | 1. 确保问题尚未被报告或解决。 11 | 2. 提供尽可能详细的信息,包括复现步骤、期望行为和实际行为。 12 | 3. 如果适用,附上相关截图或错误日志。 13 | 14 | ### 提交代码 15 | 16 | 1. **Fork 本仓库** 17 | 在 GitHub 上点击 `Fork` 按钮,将此仓库复制到你的 GitHub 帐户中。 18 | 19 | 2. **创建特性分支** 20 | 在你的 fork 仓库中,创建一个新的分支用于开发。分支命名建议遵循以下格式: 21 | 22 | ```bash 23 | git checkout -b feature/描述你的功能 24 | git checkout -b fix/描述你的修复 25 | ``` 26 | 27 | 3. **编写代码和测试** 28 | - 编写清晰且符合项目风格的代码。 29 | - 尽量编写测试代码,以确保代码的正确性和稳定性。 30 | 31 | 4. **提交代码** 32 | 使用简洁且有描述性的提交信息提交代码。推荐的提交信息格式: 33 | 34 | ``` 35 | [类型] 简要描述 36 | 37 | - 详细描述(可选) 38 | ``` 39 | 40 | 常见的类型包括: 41 | - `feat`: 新功能 42 | - `fix`: 修复问题 43 | - `docs`: 修改文档 44 | - `style`: 代码风格改进(不影响代码运行) 45 | - `refactor`: 代码重构 46 | - `test`: 添加或修改测试 47 | - `chore`: 其他非代码修改(构建工具、依赖更新等) 48 | 49 | 5. **同步主分支** 50 | 确保你的分支与上游仓库的 `main`(或 `master`)分支保持同步: 51 | 52 | ```bash 53 | git checkout main 54 | git pull upstream main 55 | git checkout feature/你的分支 56 | git rebase main 57 | ``` 58 | 59 | 6. **提交 Pull Request** 60 | - 在 GitHub 上提交 Pull Request,选择合适的标题并简要描述你的修改。 61 | - 如果你的代码修改解决了某个 issue,请在 Pull Request 中引用该 issue,例如:`Closes #123`。 62 | - 耐心等待维护者的反馈,并根据需要进行进一步修改。 63 | 64 | ## 代码风格 65 | 66 | - 使用 [ESLint](https://eslint.org/) 进行代码检查,遵循项目的 ESLint 规则。 67 | - 确保所有 TypeScript 文件通过 [TSLint](https://palantir.github.io/tslint/) 检查。 68 | 69 | ## 分支管理 70 | 71 | - `main`(或 `master`)分支为生产分支,请勿直接在此分支提交代码。 72 | - 所有新的功能或修复应在独立的分支上开发,并通过 Pull Request 合并到 `main`(或 `master`)分支。 73 | 74 | ## 开发环境 75 | 76 | 确保你的开发环境满足以下要求: 77 | 78 | - Node.js 版本 >= 18.x 79 | - Yarn 或 npm 最新版本 80 | - TypeScript 版本 >= 4.x 81 | 82 | ## 联系我们 83 | 84 | 如果你有任何疑问或建议,请通过 GitHub Issues 或邮件联系我们。 85 | 86 | 感谢你的贡献! 87 | 88 | ### 使用说明 89 | 90 | - 根据项目的实际情况调整 `分支命名`、`提交信息格式` 等部分。 91 | - 如果有具体的代码风格指南或开发环境要求,可以详细说明。 92 | - 请确保贡献者了解如何在本地测试他们的修改。 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | CC-NC License 2 | 3 | Copyright (c) 2024 Intsig 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, and sublicense the Software, 9 | subject to the following conditions: 10 | 11 | 1. The above copyright notice and this permission notice shall be included in all 12 | copies or substantial portions of the Software. 13 | 14 | 2. The Software may not be used for commercial purposes. Commercial purposes include, 15 | but are not limited to, selling copies of the Software, incorporating the Software 16 | into a product or service that is sold, or using the Software to generate revenue. 17 | 18 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 19 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 20 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 21 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 22 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 23 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 24 | SOFTWARE. 25 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Textin OCR Frontend 2 | 3 | 一个用于展示 Textin 识别结果的 React 组件库,支持文件预览、坐标回显和结果展示。 4 | 5 | 目前已支持票据类解析结果(key-value)的展示,具体对应 Textin[票据文字识别](https://www.textin.com/product/textin_bill)如[国内通用票据识别](https://www.textin.com/market/detail/bill_recognize_v2)、[银行回单识别](https://www.textin.com/market/detail/bank_receipts)、[电子承兑汇票识别](https://www.textin.com/market/detail/electr_acceptance_bill)等相关产品识别结果的展示。 6 | 7 | 组件库使用的数据结构是规范后的前端数据结构,使用时需要将 OCR 识别 API 返回的数据转换为前端使用的结构,具体参见本项目[examples](https://github.com/intsig-textin/textin-ocr-frontend/blob/main/src/examples/data.ts),原始数据结构参见 Textin 官网[API 文档](https://www.textin.com/document/bill_recognize_v2)。 8 | 9 | ## 特性 10 | 11 | - 📄 支持图片和 PDF 文件预览 12 | - 🎯 支持文本区域坐标回显和高亮 13 | - 🔄 预览区域和识别结果双向联动 14 | - 📊 支持 JSON 格式结果展示 15 | - 🎨 TODO:可自定义样式和主题 16 | 17 | ## 安装 18 | 19 | 拉取项目 20 | 21 | ```bash 22 | git clone https://github.com/intsig-textin/textin-ocr-frontend.git 23 | ``` 24 | 25 | ```bash 26 | npm install textin-ocr-frontend 27 | # 或 28 | yarn add textin-ocr-frontend 29 | ``` 30 | 31 | ## 快速开始 32 | 33 | ```tsx 34 | import { FilePreview, ResultView, JsonView } from "textin-ocr-frontend"; 35 | 36 | function App() { 37 | return ( 38 |
39 |
40 | 41 |
42 |
43 | 44 |
45 |
46 | ); 47 | } 48 | ``` 49 | 50 | ## 组件说明 51 | 52 | ### 1. FilePreview 文件预览组件 53 | 54 | 文件预览组件,支持 PDF 和图片预览,支持缩放、旋转、分页等功能。 55 | 56 | #### Props 57 | 58 | | 参数 | 说明 | 类型 | 默认值 | 59 | | ---------------- | --------------------------- | ---------------------------------------------------------------------------------------------- | ------ | 60 | | className | 自定义类名 | string | - | 61 | | style | 自定义样式 | [React.CSSProperties](https://react.dev/reference/react-dom/components/common#cssproperties) | - | 62 | | src | 文件源,支持 PDF 和图片数组 | [PDFSrc](#pdfsrc) \| string[] | - | 63 | | rects | 标注框数据 | [IRectItem](#irectitem)[][] | - | 64 | | pages | 页面数据 | [IPageItem](#ipageitem)[] | - | 65 | | getContainerRef | 获取容器引用 | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | - | 66 | | activeContentId | 当前选中的内容 ID | string | - | 67 | | showMark | 是否显示标注 | boolean | - | 68 | | hasPagination | 是否显示分页 | boolean | - | 69 | | hasToolbar | 是否显示工具栏 | boolean | - | 70 | | toolbarOptions | 工具栏配置 | [ToolbarOptions](#toolbaroptions) | - | 71 | | toolbarStyle | 工具栏样式 | [React.CSSProperties](https://react.dev/reference/react-dom/components/common#cssproperties) | - | 72 | | loading | 加载中状态 | boolean | - | 73 | | loadingComponent | 自定义加载组件 | ReactNode | - | 74 | 75 | ### 2. ResultView 结果展示组件 76 | 77 | 结果展示组件,支持表格和列表两种展示方式。 78 | 79 | #### Props 80 | 81 | | 参数 | 说明 | 类型 | 默认值 | 82 | | --------------------- | --------------------------- | ---------------------------------------------------------------------------------------------- | ------ | 83 | | className | 自定义类名 | string | - | 84 | | style | 自定义样式 | [React.CSSProperties](https://react.dev/reference/react-dom/components/common#cssproperties) | - | 85 | | resultList | 结果列表 | [IResultListItem](#iresultlistitem)[] | - | 86 | | getContainerRef | 获取容器引用 | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | - | 87 | | activeContentId | 当前选中的内容 ID | string | - | 88 | | activeParentContentId | 当前选中的内容所属的上级 ID | string | - | 89 | | loading | 加载中状态 | boolean | - | 90 | | loadingComponent | 自定义加载组件 | ReactNode | - | 91 | 92 | ### 3. MarkLayer 标注层组件 93 | 94 | 标注层组件,用于在图片显示标注框。 95 | 96 | #### Props 97 | 98 | | 参数 | 说明 | 类型 | 默认值 | 99 | | --------------- | ----------------------------- | -------------------------------------------------------------------------------------------- | ------ | 100 | | className | 自定义类名 | string | - | 101 | | style | 自定义样式 | [React.CSSProperties](https://react.dev/reference/react-dom/components/common#cssproperties) | - | 102 | | rects | 标注框数据 | [IRectItem](#irectitem)[] | - | 103 | | rate | 渲染比例(渲染宽度/原始宽度) | number | - | 104 | | activeContentId | 当前选中的内容 ID | string | - | 105 | | getContainer | 获取容器元素 | () => HTMLElement \| null \| undefined | - | 106 | | svgAttr | SVG 属性 | [SVGProps](https://react.dev/reference/react-dom/components/common#svg-attributes) | - | 107 | | onMouseDown | 鼠标按下事件 | (e: any) => void | - | 108 | 109 | ### 4. JsonView JSON 展示组件 110 | 111 | JSON 数据展示组件,用于格式化展示 JSON 数据。 112 | 本项目 JSON 数据采用`react-json-view`库渲染,API 保持一致,详细属性可参考其官方文档。 113 | 114 | #### Props 115 | 116 | | 参数 | 说明 | 类型 | 默认值 | 117 | | ------- | ------------------------- | ---------------------------------------------------------------------------------------------- | ------ | 118 | | style | 自定义样式 | [React.CSSProperties](https://react.dev/reference/react-dom/components/common#cssproperties) | - | 119 | | src | JSON 数据 | any | - | 120 | | ...rest | 其他 react-json-view 属性 | [ReactJsonViewProps](https://github.com/mac-s-g/react-json-view/blob/master/src/js/index.d.ts) | - | 121 | 122 | ## API Interface 定义 123 | 124 | ### PDFSrc 125 | 126 | PDF 文件源配置 127 | 128 | ```typescript 129 | interface DocumentInitParameters { 130 | [key: string]: any; 131 | url?: string | URL; 132 | data?: TypedArray | ArrayBuffer | Array | string; 133 | httpHeaders?: Object; 134 | withCredentials?: boolean; 135 | password?: string; 136 | length?: boolean; 137 | } 138 | 139 | type PDFSrc = DocumentInitParameters; 140 | ``` 141 | 142 | ### IRectItem 143 | 144 | 标注框数据 145 | 146 | ```typescript 147 | interface IRectItem { 148 | [key: string]: any; 149 | key?: string; 150 | type?: string; 151 | rect_type?: string; 152 | uid: string; 153 | parent_uid?: string; 154 | content_id: string; 155 | parent_id?: string; 156 | position: number[]; 157 | angle?: number; 158 | render_text?: string; 159 | } 160 | ``` 161 | 162 | ### IPageItem 163 | 164 | 页面数据 165 | 166 | ```typescript 167 | interface IPageItem { 168 | page_number: number; 169 | duration: number; 170 | ppi: number; 171 | width: number; 172 | height: number; 173 | angle?: number; 174 | } 175 | ``` 176 | 177 | ### IResultListItem 178 | 179 | 结果列表项 180 | 181 | ```typescript 182 | interface IResultListItem extends IRectItem { 183 | type: string; 184 | description: string; 185 | no: number; 186 | list: IFieldItem[]; 187 | flightList: IFieldItem[][]; 188 | page_id?: number; 189 | } 190 | ``` 191 | 192 | ### IFieldItem 193 | 194 | 字段项 195 | 196 | ```typescript 197 | interface IFieldItem extends IOriginFieldItem { 198 | uid: string; 199 | parent_uid?: string; 200 | } 201 | 202 | interface IOriginFieldItem { 203 | key: string; 204 | type?: string; 205 | value: string; 206 | description: string; 207 | position: number[]; 208 | } 209 | ``` 210 | 211 | ### ToolbarOptions 212 | 213 | 工具栏配置 214 | 215 | ```typescript 216 | interface ToolbarOptions { 217 | tools: PreviewToolItem[]; 218 | } 219 | 220 | interface PreviewToolItem { 221 | Icon: React.ComponentType; 222 | onClick: () => void; 223 | type: string; 224 | disabled?: boolean; 225 | } 226 | ``` 227 | 228 | ### PreviewToolItem 229 | 230 | 工具栏配置项 231 | 232 | ```typescript 233 | interface PreviewToolItem { 234 | Icon: React.ComponentType; // 工具栏图标组件 235 | onClick: () => void; // 点击事件处理函数 236 | type: string; // 工具类型 237 | disabled?: boolean; // 是否禁用 238 | } 239 | ``` 240 | 241 | ## Hooks 242 | 243 | ### useContentLinkage 244 | 245 | 用于实现预览区域和识别结果的双向联动。 246 | 247 | ```tsx 248 | const { activeContentId, activeParentContentId, registerLinkage } = 249 | useContentLinkage({ 250 | viewContainerRef, 251 | resultContainerRef, 252 | }); 253 | ``` 254 | 255 | #### 参数 256 | 257 | | 参数 | 类型 | 必填 | 说明 | 258 | | ------------------ | ------------------------------------------------------------------------------------------------------ | ---- | ------------ | 259 | | viewContainerRef | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | 是 | 预览容器引用 | 260 | | resultContainerRef | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | 是 | 结果容器引用 | 261 | 262 | #### 返回值 263 | 264 | | 属性 | 类型 | 说明 | 265 | | --------------------- | ---------- | --------------------------- | 266 | | activeContentId | string | 当前选中的内容 ID | 267 | | activeParentContentId | string | 当前选中的内容所属的父级 ID | 268 | | registerLinkage | () => void | 注册联动事件 | 269 | 270 | ### usePDFMarkLayer 271 | 272 | 用于在 PDF 文档上实现标注层功能。 273 | 274 | ```tsx 275 | const { run } = usePDFMarkLayer({ 276 | containerRef, 277 | pdfViewerRef, 278 | rects, 279 | pages, 280 | dpi, 281 | activeContentId, 282 | showMark, 283 | }); 284 | ``` 285 | 286 | #### 参数 287 | 288 | | 参数 | 类型 | 必填 | 说明 | 289 | | --------------- | -------------------------------------------------------------------------------------- | ---- | ----------------- | 290 | | containerRef | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | 是 | PDF 容器引用 | 291 | | pdfViewerRef | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | 否 | PDF 查看器引用 | 292 | | rects | [IRectItem](#irectitem)[][] | 否 | 标注框数据 | 293 | | pages | [IPageItem](#ipageitem)[] | 是 | 页面数据 | 294 | | dpi | number | 否 | 分辨率 | 295 | | activeContentId | string | 否 | 当前选中的内容 ID | 296 | | showMark | boolean | 否 | 是否显示标注 | 297 | 298 | #### 返回值 299 | 300 | | 属性 | 类型 | 说明 | 301 | | ---- | ---------- | ---------- | 302 | | run | () => void | 运行标注层 | 303 | 304 | ### usePreviewTool 305 | 306 | 用于实现预览工具栏功能,包括缩放、旋转和 1:1 还原。 307 | 308 | ```tsx 309 | const { tools, scale, rotate, position, onMouseDown, onWheel, resizeScale } = 310 | usePreviewTool({ 311 | viewContainerRef, 312 | viewRef, 313 | toolbarOptions, 314 | }); 315 | ``` 316 | 317 | #### 参数 318 | 319 | | 参数 | 类型 | 必填 | 说明 | 320 | | ---------------- | ---------------------------------------------------------------------------------------------- | ---- | ------------ | 321 | | viewContainerRef | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | 是 | 预览容器引用 | 322 | | viewRef | [React.RefObject](https://react.dev/reference/react/useRef#typing-the-ref-object) | 是 | 预览内容引用 | 323 | | toolbarOptions | [ToolbarOptions](#toolbaroptions) | 否 | 工具栏配置 | 324 | 325 | #### 返回值 326 | 327 | | 属性 | 类型 | 说明 | 328 | | ----------- | ------------------------------------- | -------------------- | 329 | | tools | [PreviewToolItem](#previewtoolitem)[] | 工具栏配置项 | 330 | | scale | number | 当前缩放比例 | 331 | | rotate | number | 当前旋转角度 | 332 | | position | { x: number; y: number } | 当前位移位置 | 333 | | onMouseDown | (event: any) => void | 鼠标按下事件处理函数 | 334 | | onWheel | (event: WheelEvent) => void | 滚轮事件处理函数 | 335 | | resizeScale | () => void | 重置缩放比例函数 | 336 | 337 | ## 示例 338 | 339 | #### [图片示例](https://github.com/intsig-textin/textin-ocr-frontend/tree/main/src/examples/ImageExample.tsx) 340 | 341 | #### [PDF示例](https://github.com/intsig-textin/textin-ocr-frontend/tree/main/src/examples/PDFExample.tsx) 342 | 343 | ## 未来规划 344 | 345 | - 组件支持更多自定义配置、样式覆盖等特性 346 | - 支持可编辑、复制、导出结果 347 | - 支持更多复杂类型如通用文档解析识别结果展示 348 | 349 | ## 二次开发 350 | 351 | 项目基于 vite 和 react 构建,您可将该项目 fork 到本地自主扩展: 352 | 353 | 拉取项目 354 | 355 | ```bash 356 | git clone https://github.com/intsig-textin/textin-ocr-frontend.git 357 | ``` 358 | 359 | 安装依赖 360 | 361 | ```bash 362 | npm install 363 | ``` 364 | 365 | 启动项目 366 | 367 | ```bash 368 | npm run dev 369 | ``` 370 | 371 | 浏览器访问 http://localhost:5173/ 372 | 373 | ## 贡献 374 | 375 | 欢迎贡献代码!在开始之前,请阅读 [CONTRIBUTING.md](https://github.com/intsig-textin/textin-ocr-frontend/blob/main/CONTRIBUTING.md) 以了解贡献流程和指南。 376 | 377 | ## 许可证 378 | 379 | 本项目采用 [CC-NC License](https://github.com/intsig-textin/textin-ocr-frontend/blob/main/LICENSE)。 380 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js' 2 | import globals from 'globals' 3 | import reactHooks from 'eslint-plugin-react-hooks' 4 | import reactRefresh from 'eslint-plugin-react-refresh' 5 | import tseslint from 'typescript-eslint' 6 | 7 | export default tseslint.config( 8 | { ignores: ['dist'] }, 9 | { 10 | extends: [js.configs.recommended, ...tseslint.configs.recommended], 11 | files: ['**/*.{ts,tsx}'], 12 | languageOptions: { 13 | ecmaVersion: 2020, 14 | globals: globals.browser, 15 | }, 16 | plugins: { 17 | 'react-hooks': reactHooks, 18 | 'react-refresh': reactRefresh, 19 | }, 20 | rules: { 21 | ...reactHooks.configs.recommended.rules, 22 | 'react-refresh/only-export-components': [ 23 | 'warn', 24 | { allowConstantExport: true }, 25 | ], 26 | "@typescript-eslint/no-explicit-any": "warning" 27 | }, 28 | }, 29 | ) 30 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | textin-ocr-frontend 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "textin-ocr-frontend", 3 | "version": "1.0.2", 4 | "private": false, 5 | "type": "module", 6 | "main": "dist/index.js", 7 | "module": "dist/index.mjs", 8 | "types": "dist/index.d.ts", 9 | "repository": { 10 | "type": "git", 11 | "url": "https://github.com/intsig-textin/textin-ocr-frontend.git" 12 | }, 13 | "homepage": "https://github.com/intsig-textin/textin-ocr-frontend#readme", 14 | "bugs": { 15 | "url": "https://github.com/intsig-textin/textin-ocr-frontend/issues" 16 | }, 17 | "files": [ 18 | "dist", 19 | "README.md", 20 | "LICENSE" 21 | ], 22 | "scripts": { 23 | "dev": "vite", 24 | "build": "vite build", 25 | "lint": "eslint .", 26 | "preview": "vite preview" 27 | }, 28 | "peerDependencies": { 29 | "react": ">=16.8.0", 30 | "react-dom": ">=16.8.0" 31 | }, 32 | "dependencies": { 33 | "ahooks": "^3.8.4", 34 | "classnames": "^2.5.1", 35 | "react-json-view": "^1.21.3" 36 | }, 37 | "devDependencies": { 38 | "@eslint/js": "^9.22.0", 39 | "@types/node": "^22.15.16", 40 | "@types/react": "^17.0.3", 41 | "@types/react-dom": "^17.0.3", 42 | "@vitejs/plugin-react": "^4.3.4", 43 | "eslint": "^9.22.0", 44 | "eslint-plugin-react-hooks": "^5.2.0", 45 | "eslint-plugin-react-refresh": "^0.4.19", 46 | "globals": "^16.0.0", 47 | "less": "^4.3.0", 48 | "typescript": "~5.7.2", 49 | "typescript-eslint": "^8.26.1", 50 | "vite": "^6.3.1", 51 | "vite-plugin-dts": "^4.5.3", 52 | "vite-plugin-svgr": "^4.3.0" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 85 | 86 | 87 | 88 | 89 | 90 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | #root { 2 | background-color: white; 3 | color: #000; 4 | } 5 | 6 | .logo { 7 | height: 6em; 8 | padding: 1.5em; 9 | will-change: filter; 10 | transition: filter 300ms; 11 | } 12 | .logo:hover { 13 | filter: drop-shadow(0 0 2em #646cffaa); 14 | } 15 | .logo.react:hover { 16 | filter: drop-shadow(0 0 2em #61dafbaa); 17 | } 18 | 19 | @keyframes logo-spin { 20 | from { 21 | transform: rotate(0deg); 22 | } 23 | to { 24 | transform: rotate(360deg); 25 | } 26 | } 27 | 28 | @media (prefers-reduced-motion: no-preference) { 29 | a:nth-of-type(2) .logo { 30 | animation: logo-spin infinite 20s linear; 31 | } 32 | } 33 | 34 | .card { 35 | padding: 2em; 36 | } 37 | 38 | .read-the-docs { 39 | color: #888; 40 | } 41 | -------------------------------------------------------------------------------- /src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import "./App.css"; 3 | import ImageExample from "./examples/ImageExample"; 4 | import PDFExample from "./examples/PDFExample"; 5 | 6 | const styles = { 7 | container: { 8 | display: "flex", 9 | width: "100vw", 10 | height: "100vh", 11 | overflow: "hidden", 12 | }, 13 | sidebar: { 14 | width: "160px", 15 | backgroundColor: "#f5f5f5", 16 | borderRight: "1px solid #e0e0e0", 17 | padding: "16px", 18 | }, 19 | sidebarTitle: { 20 | fontSize: "14px", 21 | color: "#666", 22 | marginBottom: "8px", 23 | padding: "8px 0", 24 | }, 25 | menuItem: { 26 | padding: "8px 12px", 27 | cursor: "pointer", 28 | borderRadius: "4px", 29 | marginBottom: "4px", 30 | }, 31 | menuItemActive: { 32 | color: "#1a66ff", 33 | }, 34 | menuItemInactive: { 35 | color: "#333", 36 | backgroundColor: "transparent", 37 | }, 38 | content: { 39 | flex: 1, 40 | padding: "16px", 41 | overflow: "auto", 42 | }, 43 | } as const; 44 | 45 | function App() { 46 | const [activeExample, setActiveExample] = useState<"image" | "pdf">("image"); 47 | 48 | const getMenuItemStyle = (isActive: boolean) => ({ 49 | ...styles.menuItem, 50 | ...(isActive ? styles.menuItemActive : styles.menuItemInactive), 51 | }); 52 | 53 | return ( 54 |
55 | {/* 左侧菜单 */} 56 |
57 |
示例列表
58 |
setActiveExample("image")} 60 | style={getMenuItemStyle(activeExample === "image")} 61 | > 62 | 示例一:单张图片 63 |
64 |
setActiveExample("pdf")} 66 | style={getMenuItemStyle(activeExample === "pdf")} 67 | > 68 | 示例二:多页PDF 69 |
70 |
71 | 72 | {/* 右侧内容区 */} 73 |
74 | {activeExample === "image" ? : } 75 |
76 |
77 | ); 78 | } 79 | 80 | export default App; 81 | -------------------------------------------------------------------------------- /src/assets/icon_img_enlarge_default.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | > 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/icon_img_narrow_default.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | > 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/icon_img_normal_default.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | > 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/icon_img_rotate+90_default.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | > 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/assets/icon_img_rotate-90_default.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | > 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/assets/outline-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 1-Arrow/outline-left 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/outline-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /src/assets/react.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/components/FilePreview/ImagesViewer/index.module.less: -------------------------------------------------------------------------------- 1 | .imagesViewer { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | 6 | .imageWrapper { 7 | position: absolute; 8 | left: 0; 9 | right: 0; 10 | top: 0; 11 | bottom: 0; 12 | text-align: center; 13 | display: flex; 14 | justify-content: center; 15 | align-items: center; 16 | .image { 17 | max-width: 100%; 18 | max-height: 100%; 19 | vertical-align: middle; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/components/FilePreview/ImagesViewer/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { ReactNode, useRef } from "react"; 2 | import classNames from "classnames"; 3 | import MarkLayer from "../../MarkLayer"; 4 | import useMarkTool from "../../../hooks/useMarkTool"; 5 | import { IPageItem, IRectItem } from "../../../types/keyVal"; 6 | import styles from "./index.module.less"; 7 | import { BlockLoading } from "../../Loading"; 8 | 9 | export interface IImagesViewerProps { 10 | wrapperClassName?: string; 11 | wrapperStyle?: React.CSSProperties; 12 | pageClassName?: string; 13 | pageStyle?: React.CSSProperties; 14 | srcList: string[]; 15 | rects?: IRectItem[][]; 16 | pages?: IPageItem[]; 17 | showMark?: boolean; 18 | viewContainerRef?: React.RefObject; 19 | viewRef?: React.RefObject; 20 | activeContentId?: string; 21 | scale: number; 22 | rotate: number; 23 | position?: { x: number; y: number }; 24 | loading?: boolean; 25 | onMouseDown?: (e: any) => void; 26 | resizeScale?: () => void; 27 | loadingComponent?: ReactNode; 28 | } 29 | 30 | export default function ImagesViewer({ 31 | wrapperClassName, 32 | wrapperStyle, 33 | pageClassName, 34 | pageStyle, 35 | srcList, 36 | rects, 37 | pages, 38 | showMark, 39 | viewContainerRef, 40 | viewRef, 41 | activeContentId, 42 | scale, 43 | rotate, 44 | position, 45 | loading, 46 | loadingComponent, 47 | onMouseDown, 48 | resizeScale, 49 | }: IImagesViewerProps) { 50 | return ( 51 |
} 55 | > 56 | {srcList.map((src, index) => ( 57 | 73 | ))} 74 | {loading && (loadingComponent || )} 75 |
76 | ); 77 | } 78 | 79 | export interface IImageViewProps { 80 | className?: string; 81 | style?: React.CSSProperties; 82 | src: string; 83 | rects?: IRectItem[]; 84 | page?: IPageItem; 85 | showMark?: boolean; 86 | viewContainerRef?: React.RefObject; 87 | activeContentId?: string; 88 | rotate: number; 89 | fixedRotate?: number; 90 | scale: number; 91 | onMouseDown?: (e: any) => void; 92 | resizeScale?: () => void; 93 | // 需要旋转角补位 94 | angleFix?: boolean; 95 | // 需要旋转的角度 96 | angle?: number; 97 | position?: { x: number; y: number }; 98 | } 99 | 100 | export function ImageView({ 101 | className, 102 | style, 103 | src, 104 | rects, 105 | showMark, 106 | viewContainerRef, 107 | activeContentId, 108 | rotate, 109 | scale, 110 | onMouseDown, 111 | resizeScale, 112 | angleFix, 113 | angle, 114 | position, 115 | }: IImageViewProps) { 116 | const imgRef = useRef(null); 117 | const { renderRate, markStyle, updateMark } = useMarkTool({ 118 | imgRef, 119 | viewContainerRef, 120 | resizeScale, 121 | angleFix, 122 | angle, 123 | }); 124 | const angleBasic = angleFix && angle ? rotate + angle : rotate; 125 | 126 | const handleImageLoad = () => { 127 | updateMark(); 128 | }; 129 | 130 | return ( 131 |
138 | 146 | {showMark && rects && ( 147 | viewContainerRef?.current} 157 | /> 158 | )} 159 |
160 | ); 161 | } 162 | -------------------------------------------------------------------------------- /src/components/FilePreview/PDFViewer/index.module.less: -------------------------------------------------------------------------------- 1 | @fill-alpha: 0; 2 | @fill-active-alpha: 0.15; 3 | @stroke-alpha: 1; 4 | @primary-color: #1a66ff; 5 | 6 | @paragraph-color: @primary-color; 7 | @title-color: @primary-color; 8 | @stamp-color: #ad581a; 9 | @list-color: #5d2281; 10 | @image-color: #bd8d1c; 11 | @formula-color: #d94141; 12 | @catalog-color: #695cff; 13 | @handwriting-color: #9c32d1; 14 | @question_stem-color: #9c32d1; 15 | @question_content-color: #0a91f2; 16 | @watermark-color: #6abe28; 17 | @table-color: #11a35f; 18 | @header-footer-color: #637599; // 页眉页脚 19 | 20 | .pdf-viewer { 21 | position: absolute; 22 | top: 0; 23 | right: 0; 24 | bottom: 0; 25 | left: 0; 26 | box-sizing: content-box; 27 | width: 100%; 28 | height: 100%; 29 | overflow: auto; 30 | 31 | &::-webkit-scrollbar { 32 | width: 8px; 33 | height: 8px; 34 | } 35 | 36 | &::-webkit-scrollbar-thumb { 37 | background-color: #a2a8b2; 38 | border-radius: 8px; 39 | } 40 | 41 | * { 42 | box-sizing: content-box; 43 | 44 | user-select: text; 45 | 46 | ::selection { 47 | color: transparent; 48 | } 49 | } 50 | 51 | :global { 52 | .pdfViewer.removePageBorders .page { 53 | margin-bottom: 12px; 54 | &:last-child { 55 | margin-bottom: 0; 56 | } 57 | } 58 | .page { 59 | .rectLayer { 60 | position: absolute; 61 | top: 0; 62 | right: 0; 63 | bottom: 0; 64 | left: 0; 65 | z-index: 10; 66 | overflow: visible; 67 | transform-origin: left top; 68 | 69 | polygon, 70 | polyline, 71 | path { 72 | vector-effect: non-scaling-stroke; 73 | } 74 | 75 | polygon { 76 | fill: rgba(@primary-color, @fill-alpha); 77 | stroke: rgba(@primary-color, 0.5); 78 | stroke-width: 1px; 79 | 80 | &.active { 81 | fill: rgba(@primary-color, @fill-active-alpha); 82 | stroke: @primary-color; 83 | stroke-width: 2px; 84 | } 85 | 86 | &.paragraph { 87 | fill: rgba(@paragraph-color, @fill-alpha); 88 | stroke: rgba(@paragraph-color, 0.6); 89 | 90 | &.active { 91 | fill: rgba(@paragraph-color, @fill-active-alpha); 92 | stroke: @paragraph-color; 93 | } 94 | } 95 | 96 | &.table { 97 | fill: rgba(@table-color, @fill-alpha); 98 | stroke: rgba(@table-color, @stroke-alpha); 99 | 100 | &.active { 101 | fill: rgba(@table-color, @fill-active-alpha); 102 | stroke: @table-color; 103 | } 104 | } 105 | 106 | &.stamp { 107 | fill: rgba(@stamp-color, @fill-alpha); 108 | stroke: rgba(@stamp-color, @stroke-alpha); 109 | 110 | &.active { 111 | fill: rgba(@stamp-color, @fill-active-alpha); 112 | stroke: @stamp-color; 113 | } 114 | } 115 | 116 | &.image { 117 | fill: rgba(@image-color, @fill-alpha); 118 | stroke: rgba(@image-color, @stroke-alpha); 119 | 120 | &.active { 121 | fill: rgba(@image-color, @fill-active-alpha); 122 | stroke: @image-color; 123 | } 124 | } 125 | 126 | &.formula { 127 | fill: rgba(@formula-color, @fill-alpha); 128 | stroke: rgba(@formula-color, @stroke-alpha); 129 | 130 | &.active { 131 | fill: rgba(@formula-color, @fill-active-alpha); 132 | stroke: @formula-color; 133 | } 134 | } 135 | 136 | &.handwriting { 137 | fill: rgba(@handwriting-color, @fill-alpha); 138 | stroke: rgba(@handwriting-color, @stroke-alpha); 139 | 140 | &.active { 141 | fill: rgba(@handwriting-color, @fill-active-alpha); 142 | stroke: @handwriting-color; 143 | } 144 | } 145 | 146 | &.catalog_text { 147 | fill: rgba(@catalog-color, @fill-alpha); 148 | stroke: rgba(@catalog-color, @stroke-alpha); 149 | 150 | &.active { 151 | fill: rgba(@catalog-color, @fill-active-alpha); 152 | stroke: @catalog-color; 153 | } 154 | } 155 | 156 | &.title { 157 | fill: rgba(@title-color, @fill-alpha); 158 | stroke: rgba(@title-color, 1); 159 | stroke-width: 2px; 160 | 161 | &.active { 162 | fill: rgba(@title-color, @fill-active-alpha); 163 | stroke: @title-color; 164 | } 165 | } 166 | 167 | &.question_stem { 168 | fill: rgba(@question_stem-color, @fill-alpha); 169 | stroke: rgba(@question_stem-color, @stroke-alpha); 170 | 171 | &.active { 172 | fill: rgba(@question_stem-color, @fill-active-alpha); 173 | stroke: @question_stem-color; 174 | } 175 | } 176 | 177 | &.question_content { 178 | fill: rgba(@question_content-color, @fill-alpha); 179 | stroke: rgba(@question_content-color, @stroke-alpha); 180 | 181 | &.active { 182 | fill: rgba(@question_content-color, @fill-active-alpha); 183 | stroke: @question_content-color; 184 | } 185 | } 186 | 187 | &.header_footer { 188 | fill: rgba(@header-footer-color, 0.1); 189 | stroke: rgba(@header-footer-color, @stroke-alpha); 190 | 191 | &.active { 192 | fill: rgba(@header-footer-color, @fill-active-alpha); 193 | stroke: @header-footer-color; 194 | } 195 | } 196 | 197 | &.catalog { 198 | // box-shadow: 2px 2px 4px 0px #e6fffb, -2px -2px 4px 0px #ffffff; 199 | opacity: 0; 200 | // fill: rgba(#00474f, @fill-alpha); 201 | // stroke: #00474f; 202 | 203 | &.active:local { 204 | transform-origin: center; 205 | animation: catalogAnimate 0.5s linear; 206 | transform-box: fill-box; 207 | 208 | @keyframes catalogAnimate { 209 | 0% { 210 | transform: scale(1.08); 211 | } 212 | 213 | 25% { 214 | transform: scale(0.95); 215 | } 216 | 217 | 50% { 218 | transform: scale(1.08); 219 | } 220 | 221 | 75% { 222 | transform: scale(0.95); 223 | } 224 | 225 | 100% { 226 | transform: scale(1); 227 | } 228 | } 229 | } 230 | } 231 | } 232 | 233 | .cell-g-wrapper { 234 | &.cell-g-hidden { 235 | polygon.table { 236 | opacity: 1 !important; 237 | fill: rgba(@table-color, @fill-alpha); 238 | &.active { 239 | fill: rgba(@table-color, @fill-active-alpha); 240 | } 241 | } 242 | path { 243 | opacity: 0; 244 | } 245 | .cell-toggle-show { 246 | display: none; 247 | } 248 | .cell-toggle-hidden { 249 | display: block; 250 | } 251 | } 252 | 253 | &.cell-g-wrapper-hover { 254 | .cell-toggle-hidden, 255 | .cell-toggle-show { 256 | opacity: 1; 257 | } 258 | } 259 | 260 | path { 261 | fill: rgba(@paragraph-color, @fill-alpha); 262 | stroke: rgba(@paragraph-color, @stroke-alpha); 263 | stroke-width: 1px; 264 | 265 | &.table { 266 | fill: rgba(@table-color, @fill-alpha); 267 | stroke: rgba(@table-color, @stroke-alpha); 268 | 269 | &.active { 270 | fill: rgba(@table-color, @fill-active-alpha); 271 | stroke-width: 2px; 272 | } 273 | } 274 | } 275 | 276 | polygon.table { 277 | fill: transparent; 278 | 279 | &:not(:only-child) { 280 | opacity: 0; 281 | } 282 | } 283 | 284 | .cell-toggle-hidden { 285 | display: none; 286 | opacity: 0; 287 | } 288 | .cell-toggle-show { 289 | display: block; 290 | opacity: 0; 291 | } 292 | } 293 | 294 | .section-line { 295 | circle { 296 | fill: rgba(@primary-color, 1); 297 | } 298 | polyline { 299 | fill: none; 300 | stroke: rgba(@primary-color, 1); 301 | stroke-dasharray: 4; 302 | stroke-width: 2px; 303 | } 304 | path { 305 | fill: rgba(@primary-color, 1); 306 | } 307 | 308 | &.section-table { 309 | circle { 310 | fill: rgba(@table-color, 1); 311 | } 312 | polyline { 313 | stroke: rgba(@table-color, 1); 314 | } 315 | path { 316 | fill: rgba(@table-color, 1); 317 | } 318 | } 319 | } 320 | } 321 | .textLayer { 322 | z-index: 9; 323 | } 324 | } 325 | } 326 | } 327 | 328 | .pdf-page { 329 | position: absolute; 330 | right: 8px; 331 | bottom: -24px - 18px; 332 | z-index: 99; 333 | width: auto; 334 | } 335 | -------------------------------------------------------------------------------- /src/components/FilePreview/PDFViewer/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useEffect, useRef, useState } from "react"; 2 | import { useDebounceFn, useExternal, useSize } from "ahooks"; 3 | import classNames from "classnames"; 4 | import { useLoadPDFLib } from "../../../hooks/useLoadPDFLib"; 5 | import { IPageItem, IRectItem } from "../../../types/keyVal"; 6 | import { usePDFMarkLayer } from "../../../hooks/usePDFMarkLayer"; 7 | import { isBoolean } from "../../../utils/object"; 8 | import { BlockLoading } from "../../Loading"; 9 | import Pagination from "../Pagination"; 10 | import { PDFSrc } from "./types"; 11 | import styles from "./index.module.less"; 12 | 13 | const defaultPDFScaleValue = "page-width"; 14 | 15 | export interface IPDFViewerProps { 16 | src: PDFSrc; 17 | password?: string; 18 | rects?: IRectItem[][]; 19 | pages: IPageItem[]; 20 | showMark?: boolean; 21 | activeContentId?: string; 22 | dpi?: number; 23 | scale?: number; 24 | rotate?: number; 25 | loading?: boolean; 26 | loadingComponent?: ReactNode; 27 | onLoad?: (e: any) => void; 28 | onError?: (e: any) => void; 29 | } 30 | 31 | export default function PDFViewer({ 32 | src, 33 | rects, 34 | pages, 35 | showMark = true, 36 | activeContentId, 37 | dpi, 38 | scale, 39 | rotate, 40 | loading: propLoading, 41 | loadingComponent, 42 | onLoad, 43 | onError, 44 | }: IPDFViewerProps) { 45 | const { pdfLibReady, buildDir, cmapsURL } = useLoadPDFLib({ 46 | onError: () => onError?.({}), 47 | }); 48 | const [loading, setLoading] = useState(true); 49 | const [currentPage, setCurrentPage] = useState(1); 50 | const [totalPage, setTotalPage] = useState(1); 51 | 52 | const containerRef = useRef(); 53 | const onContainerRef = (ref: any) => { 54 | containerRef.current = ref; 55 | }; 56 | const viewerRef = useRef(); 57 | const timeoutRef = useRef(); 58 | 59 | const viewerCss = useExternal(`${buildDir}/web/pdf_viewer.css`); 60 | const viewer = useExternal( 61 | pdfLibReady ? `${buildDir}/web/pdf_viewer.js` : undefined 62 | ); 63 | const sandbox = useExternal( 64 | pdfLibReady ? `${buildDir}/build/pdf.sandbox.js` : undefined 65 | ); 66 | 67 | const resize = () => { 68 | if (viewerRef.current) { 69 | viewerRef.current.currentScaleValue = defaultPDFScaleValue; 70 | } 71 | }; 72 | 73 | const { run: debouncedResize } = useDebounceFn(resize, { wait: 300 }); 74 | 75 | const viewContainerSize = useSize(containerRef.current?.parentElement); 76 | 77 | const { run: createMark } = usePDFMarkLayer({ 78 | containerRef: containerRef, 79 | pdfViewerRef: viewerRef, 80 | rects, 81 | pages, 82 | showMark, 83 | activeContentId, 84 | dpi, 85 | }); 86 | 87 | useEffect(() => { 88 | debouncedResize(); 89 | }, [viewContainerSize?.width]); 90 | 91 | useEffect(() => { 92 | if ([viewerCss, viewer, sandbox].includes("error")) { 93 | onError?.({}); 94 | const info = { 95 | name: "pdf.js错误", 96 | keyword: "pdf_viewer加载失败", 97 | message: { 98 | "pdf_viewer.css": viewerCss, 99 | "pdf_viewer.js": viewer, 100 | "pdf.sandbox.js": sandbox, 101 | }, 102 | }; 103 | console.log(info); 104 | } 105 | }, [viewerCss, viewer, sandbox]); 106 | 107 | useEffect(() => { 108 | if ( 109 | window.pdfjsSandbox && 110 | viewerCss === "ready" && 111 | window.pdfjsViewer && 112 | pdfLibReady 113 | ) { 114 | clearTimeout(timeoutRef.current); 115 | onLoad?.({}); 116 | setCurrentPage(1); 117 | setTotalPage(0); 118 | if (viewerRef.current?.setDocument) { 119 | viewerRef.current.setDocument(null); 120 | } 121 | setLoading(true); 122 | handleInit() 123 | .catch((error) => { 124 | if (onError) { 125 | onError?.({}); 126 | } else { 127 | console.log("pdf预览失败", error); 128 | } 129 | const info = { 130 | name: "pdf.js错误", 131 | keyword: "pdf_viewer渲染出错", 132 | message: error, 133 | }; 134 | console.log(info); 135 | }) 136 | .finally(() => { 137 | setLoading(false); 138 | }); 139 | } 140 | }, [viewerCss, viewer, sandbox, pdfLibReady]); 141 | 142 | const handleInit = async () => { 143 | if (!src) { 144 | return; 145 | } 146 | const container = containerRef.current; 147 | const eventBus = new window.pdfjsViewer.EventBus(); 148 | /** 149 | * options 150 | * https://github.com/mozilla/pdf.js/blob/v2.11.338/web/base_viewer.js#L188-L204 151 | */ 152 | const pdfViewer = new window.pdfjsViewer.PDFViewer({ 153 | container, 154 | eventBus, 155 | // annotationMode: 0, // 禁用注释 156 | removePageBorders: true, // 移除页边框 157 | }); 158 | 159 | eventBus.on("pagesinit", function () { 160 | pdfViewer.currentScaleValue = defaultPDFScaleValue; 161 | }); 162 | /** 163 | * options 164 | * https://github.com/mozilla/pdf.js/blob/v2.11.338/src/display/api.js#L320-L328 165 | */ 166 | const loadingTask = window.pdfjsLib.getDocument({ 167 | cmapsURL, 168 | cMapPacked: true, 169 | ...src, 170 | }); 171 | const pdfDocument = await loadingTask.promise; 172 | pdfViewer.setDocument(pdfDocument); 173 | 174 | viewerRef.current = pdfViewer; 175 | 176 | createMark(); 177 | 178 | setTotalPage(viewerRef.current.pdfDocument.numPages); 179 | viewerRef.current.eventBus.on("pagechanging", () => { 180 | setCurrentPage(viewerRef.current.currentPageNumber); 181 | }); 182 | }; 183 | 184 | const setScaleAndRotate = ({ 185 | scale, 186 | rotate, 187 | }: { 188 | scale?: number; 189 | rotate?: number; 190 | }) => { 191 | if (!viewerRef.current) { 192 | return; 193 | } 194 | viewerRef.current.currentScale = scale || 1; 195 | if (viewerRef.current.currentScale === 1) { 196 | viewerRef.current.currentScaleValue = defaultPDFScaleValue; 197 | } 198 | viewerRef.current.pagesRotation = rotate || 0; 199 | }; 200 | 201 | useEffect(() => { 202 | setScaleAndRotate({ scale, rotate }); 203 | }, [scale, rotate]); 204 | 205 | const onPageChange = (page: number) => { 206 | viewerRef.current.currentPageNumber = page; 207 | setCurrentPage(page); 208 | }; 209 | 210 | const pdfLoading = isBoolean(propLoading) ? propLoading : loading; 211 | 212 | return ( 213 |
214 |
219 |
220 | {pdfLoading && (loadingComponent || )} 221 |
222 | 228 |
229 | ); 230 | } 231 | -------------------------------------------------------------------------------- /src/components/FilePreview/PDFViewer/types.ts: -------------------------------------------------------------------------------- 1 | import { TypedArray } from "../../../types/common"; 2 | 3 | export interface DocumentInitParameters { 4 | [key: string]: any; 5 | url?: string | URL; 6 | data?: TypedArray | ArrayBuffer | Array | string; 7 | httpHeaders?: Object; 8 | withCredentials?: boolean; 9 | password?: string; 10 | length?: boolean; 11 | } 12 | 13 | export type PDFSrc = DocumentInitParameters; 14 | -------------------------------------------------------------------------------- /src/components/FilePreview/Pagination/index.module.less: -------------------------------------------------------------------------------- 1 | .pagination { 2 | display: inline-flex; 3 | align-items: center; 4 | gap: 8px; 5 | font-size: 14px; 6 | 7 | .pageChangeIcon { 8 | width: 24px; 9 | height: 24px; 10 | cursor: pointer; 11 | 12 | &.disabled { 13 | g { 14 | fill: #c5c7cf; 15 | } 16 | cursor: not-allowed; 17 | } 18 | } 19 | 20 | .pageIndicator { 21 | height: 24px; 22 | .pageInput { 23 | color: inherit; 24 | box-sizing: border-box; 25 | height: 100%; 26 | margin-right: 8px; 27 | padding: 0 6px; 28 | text-align: center; 29 | background-color: #fff; 30 | border: 1px solid #dcdfe5; 31 | border-radius: 2px; 32 | outline: none; 33 | transition: border-color 0.3s; 34 | 35 | &:disabled { 36 | background-color: #f5f5f5; 37 | cursor: not-allowed; 38 | } 39 | 40 | &:focus { 41 | border-color: #1a66ff; 42 | box-shadow: 0 0 0 2px rgba(26, 102, 255, 0.2); 43 | } 44 | } 45 | 46 | .separator { 47 | margin: 0 10px 0 5px; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/components/FilePreview/Pagination/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from "react"; 2 | import classNames from "classnames"; 3 | import OutlineLeft from "../../../assets/outline-left.svg?react"; 4 | import OutlineRight from "../../../assets/outline-right.svg?react"; 5 | import styles from "./index.module.less"; 6 | 7 | export interface IPaginationProps { 8 | className?: string; 9 | style?: React.CSSProperties; 10 | current: number; 11 | total: number; 12 | onChange?: (page: number) => void; 13 | } 14 | 15 | export default function Pagination({ 16 | className, 17 | style, 18 | current, 19 | total, 20 | onChange, 21 | }: IPaginationProps) { 22 | const [inputPage, setInputPage] = useState(current.toString()); 23 | 24 | useEffect(() => { 25 | if (current) { 26 | setInputPage(current.toString()); 27 | } 28 | }, [current]); 29 | 30 | const handleChange = (page: number) => { 31 | if (page < 1 || page > total || page === current) return; 32 | onChange?.(page); 33 | }; 34 | 35 | const handleJump = (_e: React.KeyboardEvent | React.FocusEvent) => { 36 | const page = parseInt(inputPage); 37 | if (isNaN(page) || page < 1 || page > total) { 38 | return; 39 | } 40 | handleChange(page); 41 | }; 42 | 43 | if (total === 0) return null; 44 | 45 | return ( 46 |
47 | {/* 上一页按钮 */} 48 | handleChange(current - 1)} 53 | aria-label="Previous page" 54 | /> 55 | 56 | {/* 当前页码 */} 57 |
58 | setInputPage(e.target.value.replace(/[^0-9]/g, ""))} 64 | onKeyDown={(e) => e.key === "Enter" && handleJump(e)} 65 | onBlur={handleJump} 66 | disabled={total === 0} 67 | aria-label="跳转页码" 68 | /> 69 | / 70 | {total} 71 |
72 | 73 | {/* 下一页按钮 */} 74 | handleChange(current + 1)} 79 | aria-label="Next page" 80 | /> 81 |
82 | ); 83 | } 84 | -------------------------------------------------------------------------------- /src/components/FilePreview/Toolbar/index.module.less: -------------------------------------------------------------------------------- 1 | .toolbar { 2 | position: relative; 3 | .toolbarList { 4 | display: flex; 5 | align-items: center; 6 | justify-content: center; 7 | width: 100%; 8 | min-height: 60px; 9 | padding: 20px 0; 10 | color: #030a1a; 11 | text-align: center; 12 | list-style: none; 13 | pointer-events: auto; 14 | box-sizing: border-box; 15 | margin: 0; 16 | padding: 0; 17 | .toolItem { 18 | height: 20px; 19 | margin-left: 16px; 20 | line-height: 20px; 21 | cursor: pointer; 22 | } 23 | .toolItemIcon { 24 | width: 20px; 25 | height: 20px; 26 | } 27 | .toolItemDisabled { 28 | pointer-events: none; 29 | color: #c5c7cf; 30 | :global { 31 | svg g { 32 | fill: #c5c7cf; 33 | } 34 | } 35 | } 36 | .toolSplitLine { 37 | width: 1px; 38 | height: 20px; 39 | margin-left: 16px; 40 | overflow: hidden; 41 | font-size: 20px; 42 | line-height: 20px; 43 | background: #c5c7cf; 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /src/components/FilePreview/Toolbar/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import { PreviewToolItem } from "../../../hooks/usePreviewTool"; 3 | import styles from "./index.module.less"; 4 | 5 | export interface IToolbarProps { 6 | className?: string; 7 | style?: React.CSSProperties; 8 | tools: PreviewToolItem[]; 9 | } 10 | 11 | export default function Toolbar({ className, style, tools }: IToolbarProps) { 12 | return ( 13 |
14 |
    15 | {tools.map(({ Icon, onClick, type, disabled, ...props }) => { 16 | return ( 17 |
  • 26 | {type !== "line" && Icon && ( 27 | 28 | )} 29 |
  • 30 | ); 31 | })} 32 |
33 |
34 | ); 35 | } 36 | -------------------------------------------------------------------------------- /src/components/FilePreview/index.module.less: -------------------------------------------------------------------------------- 1 | .filePreview { 2 | max-height: 100%; 3 | max-width: 100%; 4 | width: 100%; 5 | height: 100%; 6 | overflow: hidden; 7 | .filePreviewWrapper { 8 | position: relative; 9 | width: 100%; 10 | height: calc(100% - 60px); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/components/FilePreview/index.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode, useRef } from "react"; 2 | import classNames from "classnames"; 3 | import { ToolbarOptions, usePreviewTool } from "../../hooks/usePreviewTool"; 4 | import { IPageItem, IRectItem } from "../../types/keyVal"; 5 | import { isBoolean } from "../../utils/object"; 6 | import ImagesViewer from "./ImagesViewer"; 7 | import PDFViewer from "./PDFViewer"; 8 | import { PDFSrc } from "./PDFViewer/types"; 9 | import Toolbar from "./Toolbar"; 10 | import styles from "./index.module.less"; 11 | 12 | export const filePreviewContainerId = "filePreviewContainer"; 13 | 14 | export interface IFilePreviewProps { 15 | className?: string; 16 | style?: React.CSSProperties; 17 | src: PDFSrc | string[]; 18 | rects?: IRectItem[][]; 19 | pages: IPageItem[]; 20 | getContainerRef: React.RefObject; 21 | activeContentId?: string; 22 | showMark?: boolean; 23 | hasPagination?: boolean; 24 | hasToolbar?: boolean; 25 | toolbarOptions?: ToolbarOptions; 26 | toolbarStyle?: React.CSSProperties; 27 | loading?: boolean; 28 | loadingComponent?: ReactNode; 29 | } 30 | 31 | export default function FilePreview({ 32 | className, 33 | style, 34 | src, 35 | rects, 36 | pages, 37 | getContainerRef, 38 | activeContentId, 39 | showMark = true, 40 | hasPagination, 41 | hasToolbar = true, 42 | toolbarOptions, 43 | toolbarStyle, 44 | loading, 45 | loadingComponent, 46 | }: IFilePreviewProps) { 47 | const viewContainerRef = getContainerRef; 48 | const viewRef = useRef(null); 49 | const { tools, scale, position, rotate, onMouseDown, resizeScale } = 50 | usePreviewTool({ viewContainerRef, viewRef, toolbarOptions }); 51 | 52 | const isPdf = !Array.isArray(src); 53 | const needPagination = isBoolean(hasPagination) ? hasPagination : isPdf; 54 | 55 | return ( 56 |
} 58 | className={classNames(styles.filePreview, className)} 59 | style={style} 60 | > 61 |
65 | {Array.isArray(src) ? ( 66 | 82 | ) : ( 83 | 94 | )} 95 |
96 | {hasToolbar && ( 97 | 104 | )} 105 |
106 | ); 107 | } 108 | -------------------------------------------------------------------------------- /src/components/FilePreview/types.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/intsig-textin/textin-ocr-frontend/7e08cd54bea12eac5c952a63085f0669a9feef9a/src/components/FilePreview/types.ts -------------------------------------------------------------------------------- /src/components/JsonView/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * Json结果展示组件 3 | * TODO: 接口和使用说明 4 | */ 5 | 6 | import ReactJson, { ReactJsonViewProps } from "react-json-view"; 7 | 8 | export default function JsonView({ style, src, ...rest }: ReactJsonViewProps) { 9 | return ( 10 | 28 | ); 29 | } 30 | -------------------------------------------------------------------------------- /src/components/Loading/index.module.less: -------------------------------------------------------------------------------- 1 | .loading-spinner { 2 | border-radius: 50%; 3 | box-sizing: border-box; 4 | animation: spin infinite 0.75s linear; 5 | } 6 | 7 | @keyframes spin { 8 | 0% { 9 | transform: rotate(0deg); 10 | } 11 | 100% { 12 | transform: rotate(360deg); 13 | } 14 | } 15 | 16 | .block-loading { 17 | position: absolute; 18 | top: 0; 19 | left: 0; 20 | width: 100%; 21 | height: 100%; 22 | display: flex; 23 | justify-content: center; 24 | align-items: center; 25 | background: #f0f2f5; 26 | } 27 | -------------------------------------------------------------------------------- /src/components/Loading/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import classNames from "classnames"; 3 | import styles from "./index.module.less"; 4 | 5 | interface LoadingProps { 6 | size?: number; 7 | color?: string; 8 | className?: string; 9 | } 10 | 11 | const Loading: React.FC = ({ 12 | className, 13 | size = 20, 14 | color = "#1a66ff", 15 | }) => { 16 | const spinnerStyle = { 17 | width: `${size}px`, 18 | height: `${size}px`, 19 | border: `2px solid ${color}`, 20 | borderTopColor: "transparent", 21 | }; 22 | 23 | return ( 24 |
28 | ); 29 | }; 30 | 31 | export default Loading; 32 | 33 | export function BlockLoading(props: LoadingProps) { 34 | return ( 35 |
36 | 37 |
38 | ); 39 | } 40 | -------------------------------------------------------------------------------- /src/components/MarkLayer/SvgRect/Text.tsx: -------------------------------------------------------------------------------- 1 | import { memo } from "react"; 2 | 3 | const textStyle = { 4 | fontSize: 12, 5 | fill: "#ffffff", 6 | }; 7 | 8 | interface ITextProps { 9 | points: number[]; 10 | num: number; 11 | } 12 | 13 | export default memo(({ points, num }) => { 14 | return ( 15 | <> 16 | 17 | 24 | {num} 25 | 26 | 27 | ); 28 | }); 29 | -------------------------------------------------------------------------------- /src/components/MarkLayer/SvgRect/index.module.less: -------------------------------------------------------------------------------- 1 | @fill-alpha: 0; 2 | @fill-active-alpha: 0.15; 3 | @stroke-alpha: 1; 4 | @primary-color: #1a66ff; 5 | 6 | @paragraph-color: @primary-color; 7 | @title-color: @primary-color; 8 | @stamp-color: #ad581a; 9 | @list-color: #5d2281; 10 | @image-color: #bd8d1c; 11 | @formula-color: #d94141; 12 | @catalog-color: #695cff; 13 | @handwriting-color: #9c32d1; 14 | @question_stem-color: #9c32d1; 15 | @question_content-color: #0a91f2; 16 | @watermark-color: #6abe28; 17 | @table-color: #11a35f; 18 | @header-footer-color: #637599; // 页眉页脚 19 | 20 | .svg { 21 | position: absolute; 22 | top: 0; 23 | left: 0; 24 | width: 100%; 25 | height: 100%; 26 | overflow: visible !important; 27 | cursor: default; 28 | 29 | &.over-range { 30 | display: none; 31 | } 32 | 33 | polygon, 34 | polyline, 35 | path { 36 | vector-effect: non-scaling-stroke; 37 | } 38 | 39 | polygon { 40 | position: relative; 41 | fill: rgba(@primary-color, @fill-alpha); 42 | stroke: rgba(@primary-color, 0.5); 43 | stroke-width: 1px; 44 | } 45 | 46 | :global { 47 | .active { 48 | fill: rgba(@primary-color, @fill-active-alpha); 49 | stroke: @primary-color; 50 | stroke-width: 2px; 51 | } 52 | 53 | .paragraph { 54 | fill: rgba(@paragraph-color, @fill-alpha); 55 | stroke: rgba(@paragraph-color, 0.6); 56 | 57 | &.active { 58 | fill: rgba(@paragraph-color, @fill-active-alpha); 59 | stroke: @paragraph-color; 60 | } 61 | } 62 | 63 | .table, 64 | .editable { 65 | fill: rgba(@table-color, @fill-alpha); 66 | stroke: rgba(@table-color, @stroke-alpha); 67 | 68 | &.active { 69 | fill: rgba(@table-color, @fill-active-alpha); 70 | stroke: @table-color; 71 | } 72 | } 73 | 74 | .stamp { 75 | fill: rgba(@stamp-color, @fill-alpha); 76 | stroke: rgba(@stamp-color, @stroke-alpha); 77 | 78 | &.active { 79 | fill: rgba(@stamp-color, @fill-active-alpha); 80 | stroke: @stamp-color; 81 | } 82 | } 83 | 84 | .image { 85 | fill: rgba(@image-color, @fill-alpha); 86 | stroke: rgba(@image-color, @stroke-alpha); 87 | 88 | &.active { 89 | fill: rgba(@image-color, @fill-active-alpha); 90 | stroke: @image-color; 91 | } 92 | } 93 | 94 | .formula { 95 | fill: rgba(@formula-color, @fill-alpha); 96 | stroke: rgba(@formula-color, @stroke-alpha); 97 | 98 | &.active { 99 | fill: rgba(@formula-color, @fill-active-alpha); 100 | stroke: @formula-color; 101 | } 102 | } 103 | 104 | .handwriting { 105 | fill: rgba(@handwriting-color, @fill-alpha); 106 | stroke: rgba(@handwriting-color, @stroke-alpha); 107 | 108 | &.active { 109 | fill: rgba(@handwriting-color, @fill-active-alpha); 110 | stroke: @handwriting-color; 111 | } 112 | } 113 | 114 | .catalog_text { 115 | fill: rgba(@catalog-color, @fill-alpha); 116 | stroke: rgba(@catalog-color, @stroke-alpha); 117 | 118 | &.active { 119 | fill: rgba(@catalog-color, @fill-active-alpha); 120 | stroke: @catalog-color; 121 | } 122 | } 123 | 124 | .title { 125 | fill: rgba(@title-color, @fill-alpha); 126 | stroke: rgba(@title-color, 1); 127 | stroke-width: 2px; 128 | 129 | &.active { 130 | fill: rgba(@title-color, @fill-active-alpha); 131 | stroke: @title-color; 132 | } 133 | } 134 | 135 | .question_stem { 136 | fill: rgba(@question_stem-color, @fill-alpha); 137 | stroke: rgba(@question_stem-color, @stroke-alpha); 138 | 139 | &.active { 140 | fill: rgba(@question_stem-color, @fill-active-alpha); 141 | stroke: @question_stem-color; 142 | } 143 | } 144 | 145 | .question_content { 146 | fill: rgba(@question_content-color, @fill-alpha); 147 | stroke: rgba(@question_content-color, @stroke-alpha); 148 | 149 | &.active { 150 | fill: rgba(@question_content-color, @fill-active-alpha); 151 | stroke: @question_content-color; 152 | } 153 | } 154 | 155 | .header_footer, 156 | .edge { 157 | fill: rgba(@header-footer-color, 0.1); 158 | stroke: rgba(@header-footer-color, @stroke-alpha); 159 | 160 | &.active { 161 | fill: rgba(@header-footer-color, @fill-active-alpha); 162 | stroke: @header-footer-color; 163 | } 164 | } 165 | 166 | .watermark { 167 | fill: rgba(@watermark-color, @fill-alpha); 168 | stroke: rgba(@watermark-color, @stroke-alpha); 169 | 170 | &.active { 171 | fill: rgba(@watermark-color, @fill-active-alpha); 172 | stroke: @watermark-color; 173 | } 174 | } 175 | 176 | .list { 177 | fill: rgba(@list-color, @fill-alpha); 178 | stroke: rgba(@list-color, @stroke-alpha); 179 | 180 | &.active { 181 | fill: rgba(@list-color, @fill-active-alpha); 182 | stroke: @list-color; 183 | } 184 | } 185 | 186 | .catalog { 187 | // box-shadow: 2px 2px 4px 0px #e6fffb, -2px -2px 4px 0px #ffffff; 188 | visibility: hidden; 189 | // fill: rgba(#00474f, 0.05); 190 | // stroke: #00474f; 191 | 192 | &.active:local { 193 | transform-origin: center; 194 | visibility: visible; 195 | animation: catalogAnimate 0.5s linear; 196 | transform-box: fill-box; 197 | 198 | @keyframes catalogAnimate { 199 | 0% { 200 | transform: scale(1.08); 201 | } 202 | 25% { 203 | transform: scale(0.95); 204 | } 205 | 50% { 206 | transform: scale(1.08); 207 | } 208 | 75% { 209 | transform: scale(0.95); 210 | } 211 | 100% { 212 | transform: scale(1); 213 | } 214 | } 215 | } 216 | } 217 | 218 | .cell-g-wrapper { 219 | &.cell-g-hidden { 220 | polygon.table { 221 | opacity: 1 !important; 222 | fill: rgba(@table-color, @fill-alpha); 223 | &.active { 224 | fill: rgba(@table-color, @fill-active-alpha); 225 | } 226 | } 227 | path { 228 | opacity: 0; 229 | } 230 | .cell-toggle-show { 231 | display: none; 232 | } 233 | .cell-toggle-hidden { 234 | display: block; 235 | } 236 | } 237 | 238 | &:hover { 239 | .cell-toggle-hidden, 240 | .cell-toggle-show { 241 | opacity: 1; 242 | } 243 | } 244 | 245 | path { 246 | fill: rgba(@paragraph-color, @fill-alpha); 247 | stroke: rgba(@paragraph-color, @stroke-alpha); 248 | stroke-width: 1px; 249 | 250 | &.table { 251 | fill: rgba(@table-color, @fill-alpha); 252 | stroke: rgba(@table-color, @stroke-alpha); 253 | 254 | &.active { 255 | fill: rgba(@table-color, @fill-active-alpha); 256 | stroke-width: 2px; 257 | } 258 | } 259 | } 260 | 261 | polygon.table { 262 | fill: transparent; 263 | 264 | &:not(:only-child) { 265 | opacity: 0; 266 | } 267 | } 268 | 269 | .cell-toggle-hidden { 270 | display: none; 271 | opacity: 0; 272 | } 273 | .cell-toggle-show { 274 | display: block; 275 | opacity: 0; 276 | } 277 | } 278 | 279 | .section-line { 280 | circle { 281 | fill: rgba(@primary-color, 1); 282 | } 283 | polyline { 284 | fill: none; 285 | stroke: rgba(@primary-color, 1); 286 | stroke-dasharray: 4; 287 | stroke-width: 2px; 288 | } 289 | path { 290 | fill: rgba(@primary-color, 1); 291 | } 292 | 293 | &.section-table { 294 | circle { 295 | fill: rgba(@table-color, 1); 296 | } 297 | polyline { 298 | stroke: rgba(@table-color, 1); 299 | } 300 | path { 301 | fill: rgba(@table-color, 1); 302 | } 303 | } 304 | } 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/components/MarkLayer/SvgRect/index.tsx: -------------------------------------------------------------------------------- 1 | import type { ReactNode, SVGProps } from "react"; 2 | import { useState, memo, useEffect, useRef, useLayoutEffect } from "react"; 3 | import { useSize } from "ahooks"; 4 | import classNames from "classnames"; 5 | import { scrollIntoViewIfNeeded } from "../../../utils/dom"; 6 | import { IRectItem } from "../../../types/keyVal"; 7 | import RectText from "./Text"; 8 | import styles from "./index.module.less"; 9 | 10 | interface ISvgRectProps { 11 | svgAttr?: SVGProps; 12 | rate: number; 13 | rectList: IRectItem[]; 14 | showText?: boolean; 15 | autoLink?: boolean; 16 | className?: string; 17 | pageNumber?: number | string; 18 | hiddenOverRange?: boolean; 19 | viewAngle?: number; 20 | activeContentId?: string; 21 | getContainer: () => HTMLElement | null | undefined; 22 | onClick?: (rect: IRectItem) => void; 23 | } 24 | 25 | export default function SvgRect({ 26 | className, 27 | svgAttr, 28 | rate, 29 | rectList, 30 | showText, 31 | pageNumber = "1", 32 | hiddenOverRange = true, 33 | viewAngle, 34 | activeContentId, 35 | getContainer, 36 | onClick, 37 | }: ISvgRectProps) { 38 | const [isOverRange, setIsOverRange] = useState(false); 39 | const [viewBox, setViewBox] = useState(); 40 | const svgRef = useRef(null); 41 | 42 | const wrapperSize = useSize(svgRef as unknown as HTMLElement); 43 | 44 | useEffect(() => { 45 | // 隐藏超出范围的框 46 | try { 47 | if ( 48 | svgRef.current && 49 | svgRef.current.parentElement && 50 | Array.isArray(rectList) && 51 | rate 52 | ) { 53 | let { clientWidth, clientHeight } = svgRef.current.parentElement; 54 | const viewBox = svgRef.current.getAttribute("viewBox") || ""; 55 | let viewRate = 1; 56 | if (viewBox) { 57 | const [_, __, width, height] = viewBox 58 | .split(" ") 59 | .map((i) => Number(i)); 60 | viewRate = width / clientWidth; 61 | clientWidth = width; 62 | clientHeight = height; 63 | } 64 | setViewBox({ width: clientWidth, height: clientHeight, viewRate }); 65 | const isOver = rectList.some( 66 | ({ position }) => 67 | position[2] * rate - clientWidth > 5 || 68 | position[4] * rate - clientWidth > 5 || 69 | position[5] * rate - clientHeight > 5 || 70 | position[7] * rate - clientHeight > 5 71 | ); 72 | setIsOverRange(isOver); 73 | } 74 | } catch (error) { 75 | console.log("判断isOver", error); 76 | } 77 | }, [rectList, rate, wrapperSize, svgAttr?.viewBox]); 78 | 79 | return ( 80 | 88 | {rectList.map((item, idx) => ( 89 | onClick?.(item)} 95 | renderText={(points) => 96 | (showText || !!item.renderText) && ( 97 | 98 | ) 99 | } 100 | viewBox={viewBox} 101 | viewAngle={viewAngle} 102 | getContainer={getContainer} 103 | /> 104 | ))} 105 | 106 | ); 107 | } 108 | 109 | interface IRectProps extends IRectItem { 110 | rate: number; 111 | active?: boolean; 112 | onClick?: (rect: IRectItem) => void; 113 | renderText: (point: number[]) => ReactNode; 114 | getContainer: () => HTMLElement | null | undefined; 115 | } 116 | 117 | const Rect = memo((rect: IRectProps) => { 118 | const { 119 | position, 120 | rate, 121 | active, 122 | parent_uid, 123 | uid, 124 | onClick, 125 | renderText, 126 | getContainer, 127 | } = rect; 128 | if (!rate || !(Array.isArray(position) && position.length)) return null; 129 | 130 | const rectRef = useRef(null); 131 | 132 | useLayoutEffect(() => { 133 | if (active && rectRef.current) { 134 | const container = getContainer(); 135 | const scrollOptions: ScrollIntoViewOptions = { 136 | block: "nearest", 137 | inline: "nearest", 138 | }; 139 | if (container) { 140 | scrollIntoViewIfNeeded( 141 | rectRef.current, 142 | container, 143 | scrollOptions, 144 | rectRef.current.getBoundingClientRect().height 145 | ); 146 | } else { 147 | rectRef.current.scrollIntoView(scrollOptions); 148 | } 149 | } 150 | }, [active]); 151 | 152 | const list = position.map((val) => Math.round(val * rate)); 153 | const finalPoints = `${list[0]} ${list[1]},${list[2]} ${list[3]},${list[4]} ${list[5]},${list[6]} ${list[7]}`; 154 | const rect_type = rect.rect_type || rect.type || ""; 155 | 156 | return ( 157 | <> 158 | onClick?.(rect)} 169 | /> 170 | {renderText(list)} 171 | 172 | ); 173 | }); 174 | -------------------------------------------------------------------------------- /src/components/MarkLayer/helpers.ts: -------------------------------------------------------------------------------- 1 | export function getImgWidth(result: any, img: any) { 2 | if (result?.width) { 3 | return result?.width; 4 | } else if (result?.rotated_image_width) { 5 | if (!img) return result.rotated_image_width; 6 | // 卡证类,不同服务的之间image_angle/rotated_image_width/rotated_image_height的逻辑不统一,只能通过尽量兼容 7 | const { rotated_image_width: width, rotated_image_height: height } = result; 8 | const { naturalWidth, naturalHeight } = img; 9 | // 图片是否旋转 10 | if (Math.abs(width / height - naturalWidth / naturalHeight) <= 0.02) { 11 | // 宽高比例一致,未旋转 12 | return width; 13 | } 14 | return height; 15 | } 16 | if (!img) return 1; 17 | const { width, height, naturalWidth, naturalHeight } = img; 18 | // 图片是否旋转 19 | if (Math.abs(width / height - naturalWidth / naturalHeight) <= 0.02) { 20 | // 宽高比例一致,未旋转 21 | return img?.naturalWidth; 22 | } 23 | return img?.naturalHeight; 24 | } 25 | -------------------------------------------------------------------------------- /src/components/MarkLayer/index.module.less: -------------------------------------------------------------------------------- 1 | .markWrapper { 2 | position: absolute; 3 | top: auto; 4 | right: auto; 5 | left: auto; 6 | bottom: auto; 7 | z-index: 20; 8 | width: auto; 9 | height: auto; 10 | text-align: center; 11 | } 12 | -------------------------------------------------------------------------------- /src/components/MarkLayer/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { SVGProps } from "react"; 2 | import classNames from "classnames"; 3 | import SvgRect from "./SvgRect"; 4 | import { IRectItem } from "../../types/keyVal"; 5 | import styles from "./index.module.less"; 6 | 7 | export interface IMarkLayerProps { 8 | className?: string; 9 | style?: React.CSSProperties; 10 | rects: IRectItem[]; 11 | rate: number; 12 | activeContentId?: string; 13 | getContainer: () => HTMLElement | null | undefined; 14 | svgAttr?: SVGProps; 15 | onMouseDown?: (e: any) => void; 16 | } 17 | 18 | export default function MarkLayer({ 19 | className, 20 | style, 21 | rects, 22 | rate, 23 | activeContentId, 24 | getContainer, 25 | svgAttr, 26 | onMouseDown, 27 | }: IMarkLayerProps) { 28 | return ( 29 |
34 | 41 |
42 | ); 43 | } 44 | -------------------------------------------------------------------------------- /src/components/RadioGroup/index.module.less: -------------------------------------------------------------------------------- 1 | .radioGroup { 2 | display: flex; 3 | align-items: center; 4 | row-gap: 8px; 5 | } 6 | 7 | .radioButton { 8 | cursor: pointer; 9 | &:hover { 10 | .radioButtonLabel { 11 | color: #1a66ff; 12 | } 13 | } 14 | } 15 | 16 | .radioButtonLabel { 17 | display: flex; 18 | justify-content: center; 19 | padding: 4px 20px; 20 | border: 1px solid #dcdfe5; 21 | background-color: #fff; 22 | color: #030a1a; 23 | cursor: pointer; 24 | } 25 | 26 | .radioButtonLabelChecked { 27 | color: #1a66ff; 28 | border-color: #1a66ff; 29 | } 30 | 31 | .lineTypeRadioGroup { 32 | .radioButtonLabel { 33 | border: none; 34 | border-bottom: 1px solid #dcdfe5; 35 | } 36 | .radioButtonLabelChecked { 37 | border-width: 2px; 38 | border-color: #1a66ff; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/components/RadioGroup/index.tsx: -------------------------------------------------------------------------------- 1 | import classNames from "classnames"; 2 | import React, { useState } from "react"; 3 | import styles from "./index.module.less"; 4 | 5 | interface RadioButtonProps { 6 | style?: React.CSSProperties; 7 | label: string; 8 | value: string; 9 | checked: boolean; 10 | onChange: () => void; 11 | labelProps?: Record; 12 | } 13 | 14 | const RadioButton: React.FC = ({ 15 | style, 16 | label, 17 | value, 18 | checked, 19 | onChange, 20 | labelProps, 21 | }) => { 22 | return ( 23 | 43 | ); 44 | }; 45 | 46 | interface RadioGroupProps { 47 | className?: string; 48 | style?: React.CSSProperties; 49 | optionStyle?: React.CSSProperties; 50 | options: { label: string; value: string; labelProps?: Record }[]; 51 | defaultValue?: string; 52 | value?: string; 53 | onChange?: (value: string) => void; 54 | type?: "line" | "button"; 55 | } 56 | 57 | export const RadioGroup: React.FC = ({ 58 | className, 59 | style, 60 | optionStyle, 61 | options, 62 | defaultValue, 63 | value: propsValue, 64 | onChange, 65 | type = "button", 66 | }) => { 67 | const [_value, _setValue] = useState(defaultValue || options[0].value); 68 | 69 | const value = propsValue || _value; 70 | 71 | const handleChange = (val: string) => { 72 | _setValue(val); 73 | onChange?.(val); 74 | }; 75 | 76 | return ( 77 |
84 | {options.map((option) => ( 85 | handleChange(option.value)} 92 | labelProps={option.labelProps} 93 | /> 94 | ))} 95 |
96 | ); 97 | }; 98 | -------------------------------------------------------------------------------- /src/components/ResultView/Footer.tsx: -------------------------------------------------------------------------------- 1 | export default function Footer() { 2 | return
; 3 | } 4 | -------------------------------------------------------------------------------- /src/components/ResultView/Header.tsx: -------------------------------------------------------------------------------- 1 | interface IHederProps { 2 | width: number | any; 3 | valueWidth: number | any; 4 | } 5 | export default function Header({ width, valueWidth }: IHederProps) { 6 | return ( 7 |
8 |
9 | 字段名 10 |
11 |
15 | 信息内容 16 |
17 |
18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /src/components/ResultView/KeyValueList.module.less: -------------------------------------------------------------------------------- 1 | @text-color: #030a1a; 2 | 3 | .container { 4 | overflow: hidden; 5 | &:not(:last-child) { 6 | margin-bottom: 16px; 7 | } 8 | :global { 9 | .rowWrap { 10 | position: relative; 11 | margin-top: 16px; 12 | .robot-result-item { 13 | &:not(:last-child) { 14 | margin-bottom: 8px; 15 | } 16 | .result-item-key { 17 | padding-left: 10px; 18 | color: #a2a8b2; 19 | font-size: 12px; 20 | } 21 | } 22 | .robot-result-item textarea { 23 | color: @text-color; 24 | font-size: 12px; 25 | } 26 | 27 | &::before { 28 | position: absolute; 29 | top: 0; 30 | bottom: 0; 31 | left: 20px; 32 | width: 2px; 33 | background: #e4e4eb; 34 | border-radius: 1; 35 | content: ""; 36 | } 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/components/ResultView/KeyValueList.tsx: -------------------------------------------------------------------------------- 1 | import type { IFieldItem } from "../../types/keyVal"; 2 | import { Field } from "./KeyValueTable"; 3 | import styles from "./KeyValueList.module.less"; 4 | 5 | interface IKeyValueListProps { 6 | list: IFieldItem[][]; 7 | width?: number; 8 | valueWidth?: number; 9 | disabled?: boolean; 10 | activeContentId?: string; 11 | onClick?: (e: any) => void; 12 | getContainer: () => HTMLElement | null | undefined; 13 | } 14 | export default function KeyValueList({ 15 | list, 16 | width, 17 | valueWidth, 18 | disabled, 19 | onClick, 20 | activeContentId, 21 | getContainer, 22 | }: IKeyValueListProps) { 23 | return ( 24 |
25 | {list.map((rowItem, idx) => { 26 | return ( 27 |
28 | {rowItem.map((row) => ( 29 | onClick?.(row.uid)} 38 | /> 39 | ))} 40 |
41 | ); 42 | })} 43 |
44 | ); 45 | } 46 | -------------------------------------------------------------------------------- /src/components/ResultView/KeyValueTable.module.less: -------------------------------------------------------------------------------- 1 | @text-color: #030a1a; 2 | @text-gray-color: #a2a8b2; 3 | @primary-color: #1a66ff; 4 | 5 | .tableWrapper { 6 | overflow: hidden; 7 | } 8 | :global { 9 | .robot-result-item { 10 | display: flex; 11 | margin-bottom: 12px; 12 | padding-left: 20px; 13 | color: @text-color; 14 | font-size: 14px; 15 | line-height: 18px; 16 | .result-item-key { 17 | color: @text-gray-color; 18 | font-size: 13px; 19 | cursor: text; 20 | .ant-input-disabled { 21 | background-color: #fff; 22 | } 23 | } 24 | .result-item-key { 25 | flex-shrink: 0; 26 | min-width: 80px; 27 | max-width: 50%; 28 | margin-right: 20px; 29 | white-space: pre; 30 | } 31 | .result-item-value { 32 | min-width: 50%; 33 | &.active { 34 | color: @primary-color; 35 | } 36 | } 37 | &.active { 38 | .result-item-value { 39 | color: @primary-color; 40 | } 41 | } 42 | img { 43 | max-width: 100%; 44 | margin: 9px 0; 45 | } 46 | input, 47 | textarea { 48 | min-height: 20px; 49 | padding: 0; 50 | color: @text-color; 51 | border: none; 52 | outline: none; 53 | box-shadow: none !important; 54 | resize: none; 55 | &:focus { 56 | color: @primary-color !important; 57 | } 58 | &[disabled] { 59 | color: @text-color; 60 | background-color: #fff; 61 | cursor: text; 62 | } 63 | } 64 | 65 | &:last-child { 66 | margin-bottom: 0px; 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/components/ResultView/KeyValueTable.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useRef } from "react"; 2 | import classNames from "classnames"; 3 | import { scrollIntoViewIfNeeded } from "../../utils/dom"; 4 | import { IFieldItem } from "../../types/keyVal"; 5 | import styles from "./KeyValueTable.module.less"; 6 | 7 | export interface IKeyValueTableProps { 8 | dataSource: IFieldItem[]; 9 | width?: number; 10 | valueWidth?: number; 11 | activeContentId?: string; 12 | getContainer: () => HTMLElement | null | undefined; 13 | } 14 | 15 | export default function KeyValueTable({ 16 | dataSource, 17 | width, 18 | valueWidth, 19 | activeContentId, 20 | getContainer, 21 | }: IKeyValueTableProps) { 22 | return ( 23 |
24 | {dataSource.map(({ key, ...rowItem }) => ( 25 | 33 | ))} 34 |
35 | ); 36 | } 37 | 38 | interface IFieldProps extends Pick { 39 | parent_uid?: string; 40 | uid: string; 41 | width?: number | any; 42 | valueWidth?: any; 43 | disabled?: boolean; 44 | active?: boolean; 45 | onClick?: (e: any) => void; 46 | getContainer: () => HTMLElement | null | undefined; 47 | } 48 | 49 | export function Field({ 50 | parent_uid, 51 | uid, 52 | value, 53 | description, 54 | width, 55 | valueWidth, 56 | active, 57 | onClick, 58 | getContainer, 59 | }: IFieldProps) { 60 | const rowRef = useRef(null); 61 | 62 | useLayoutEffect(() => { 63 | if (active && rowRef.current) { 64 | const container = getContainer(); 65 | const scrollOptions: ScrollIntoViewOptions = { 66 | block: "nearest", 67 | inline: "nearest", 68 | }; 69 | if (container) { 70 | scrollIntoViewIfNeeded( 71 | rowRef.current, 72 | container, 73 | scrollOptions, 74 | rowRef.current.getBoundingClientRect().height 75 | ); 76 | } else { 77 | rowRef.current.scrollIntoView(scrollOptions); 78 | } 79 | } 80 | }, [active]); 81 | 82 | return ( 83 |
90 |
91 | {description} 92 |
93 |
99 |
{value}
100 |
101 |
102 | ); 103 | } 104 | -------------------------------------------------------------------------------- /src/components/ResultView/index.module.less: -------------------------------------------------------------------------------- 1 | .resultContainer { 2 | display: flex; 3 | flex-direction: column; 4 | height: 100%; 5 | text-align: left; 6 | .radioGroup { 7 | display: flex; 8 | flex-wrap: wrap; 9 | row-gap: 8px; 10 | column-gap: 4px; 11 | white-space: nowrap; 12 | margin-bottom: 8px; 13 | :global { 14 | .radioButton { 15 | flex: 1; 16 | } 17 | } 18 | } 19 | .contentContainer { 20 | position: relative; 21 | display: flex; 22 | flex: 1; 23 | flex-direction: column; 24 | overflow: hidden; 25 | .content { 26 | flex: 1; 27 | overflow-x: hidden; 28 | overflow-y: auto; 29 | } 30 | 31 | [data-page-number]:not(:only-child):not(.hidden-page):not(:empty):has( 32 | > :not(.hidden) 33 | ):after { 34 | display: block; 35 | margin: 8px 0 20px; 36 | color: #858c99; 37 | font-size: 12px; 38 | text-align: center; 39 | border-bottom: 1px solid #eee; 40 | content: "[\7b2c"attr(data-page-number) "\9875]"; 41 | -webkit-user-select: none; 42 | -moz-user-select: none; 43 | -ms-user-select: none; 44 | user-select: none; 45 | } 46 | } 47 | 48 | :global { 49 | .desc { 50 | padding: 9px 0; 51 | padding-left: 20px; 52 | color: rgb(72, 119, 255); 53 | font-size: 14px; 54 | line-height: 22px; 55 | background-color: rgba(72, 119, 255, 0.1); 56 | } 57 | .result-title { 58 | display: flex; 59 | margin-bottom: 12px; 60 | line-height: 36px; 61 | background-color: #f2f5fa; 62 | padding: 0 20px; 63 | .result-title-key { 64 | flex-shrink: 0; 65 | min-width: 3em; 66 | max-width: 50%; 67 | margin-right: 20px; 68 | white-space: pre; 69 | } 70 | .result-title-key, 71 | .result-title-value { 72 | // flex: 1; 73 | color: #a2a8b2; 74 | } 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/components/ResultView/index.tsx: -------------------------------------------------------------------------------- 1 | /** 2 | * 解析结果展示组件 3 | * 4 | */ 5 | 6 | import React, { ReactNode, useEffect, useMemo, useRef, useState } from "react"; 7 | import classNames from "classnames"; 8 | import { RadioGroup } from "../RadioGroup"; 9 | import { IResultListItem } from "../../types/keyVal"; 10 | import { ensureArray } from "../../utils/object"; 11 | import Header from "./Header"; 12 | import KeyValueTable from "./KeyValueTable"; 13 | import KeyValueList from "./KeyValueList"; 14 | import styles from "./index.module.less"; 15 | import { BlockLoading } from "../Loading"; 16 | 17 | export interface IResultViewProps { 18 | className?: string; 19 | style?: React.CSSProperties; 20 | resultList?: IResultListItem[]; 21 | getContainerRef?: React.RefObject; 22 | activeContentId?: string; 23 | activeParentContentId?: string; 24 | loading?: boolean; 25 | loadingComponent?: ReactNode; 26 | } 27 | 28 | export default function ResultView({ 29 | className, 30 | style, 31 | resultList, 32 | getContainerRef, 33 | activeContentId, 34 | activeParentContentId, 35 | loading, 36 | loadingComponent, 37 | }: IResultViewProps) { 38 | const [activeItemId, setActiveItemId] = useState(resultList?.[0]?.uid); 39 | const changeActiveItem = (value: string) => { 40 | setActiveItemId(value); 41 | }; 42 | const activeItemResult = useMemo( 43 | () => 44 | ensureArray(resultList).find( 45 | (item) => item.uid === activeItemId 46 | ), 47 | [activeItemId, resultList] 48 | ); 49 | 50 | useEffect(() => { 51 | if (activeParentContentId && activeParentContentId !== activeItemId) { 52 | changeActiveItem(activeParentContentId); 53 | } 54 | }, [activeParentContentId]); 55 | 56 | const refValue = useRef(); 57 | const [width, setWidth] = useState(); 58 | const [valueWidth, setValueWidth] = useState(); 59 | 60 | useEffect(() => { 61 | const dataSource = [...(activeItemResult?.list || [])]; 62 | if (activeItemResult?.flightList?.length) { 63 | const list = activeItemResult?.flightList.reduce( 64 | (pre, cur) => [...pre, ...cur], 65 | [] 66 | ); 67 | dataSource.push(...list); 68 | } 69 | if (dataSource.length > 0) { 70 | const arr: any[] = []; 71 | dataSource.forEach((item) => { 72 | const count: number = countCharacters(item.description); 73 | arr.push(count); 74 | }); 75 | const maxKey = Math.max(...arr); 76 | setWidth((maxKey + 2) * 6); 77 | const domValueWidth = refValue.current 78 | ? refValue.current?.offsetWidth 79 | : 0; 80 | setValueWidth(domValueWidth); 81 | } 82 | }, [activeItemResult]); 83 | 84 | const countCharacters = (str: string = "") => { 85 | let totalCount = 0; 86 | for (let i = 0; i < str.length; i++) { 87 | const c = str.charCodeAt(i); 88 | if ((c >= 0x0001 && c <= 0x007e) || (0xff60 <= c && c <= 0xff9f)) { 89 | totalCount++; 90 | } else { 91 | totalCount += 2; 92 | } 93 | } 94 | return totalCount; 95 | }; 96 | 97 | const splitIndex = useMemo(() => { 98 | if ( 99 | !activeItemResult || 100 | !( 101 | Array.isArray(activeItemResult.flightList) && 102 | activeItemResult.flightList.length 103 | ) 104 | ) 105 | return -1; 106 | return activeItemResult.flightList[0]?.some((i) => i.value) 107 | ? activeItemResult.list?.findIndex((i) => i.value === "") 108 | : -1; 109 | }, [activeItemResult]); 110 | 111 | return ( 112 |
} 116 | > 117 | ({ 122 | label: `${item.no} ${item.description}`, 123 | value: item.uid, 124 | labelProps: { 125 | "data-content-id": item.uid, 126 | }, 127 | }))} 128 | /> 129 |
130 | {activeItemResult && ( 131 | <> 132 |
133 |
138 | 0 ? splitIndex : undefined 142 | )} 143 | width={width} 144 | valueWidth={valueWidth} 145 | activeContentId={activeContentId} 146 | getContainer={() => getContainerRef?.current} 147 | /> 148 | getContainerRef?.current} 154 | /> 155 | {splitIndex > 0 && ( 156 | getContainerRef?.current} 162 | /> 163 | )} 164 |
165 | 166 | )} 167 |
168 | {loading && (loadingComponent || )} 169 |
170 | ); 171 | } 172 | -------------------------------------------------------------------------------- /src/examples/ImageExample.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useRef, useState } from "react"; 2 | import FilePreview from "../components/FilePreview"; 3 | import { RadioGroup } from "../components/RadioGroup"; 4 | import ResultView from "../components/ResultView"; 5 | import { imageExample } from "./data"; 6 | import JsonView from "../components/JsonView"; 7 | import { useContentLinkage } from "../hooks/useContentLinkage"; 8 | 9 | export default function ImageExample() { 10 | const [resultTab, setResultTab] = useState("text"); 11 | const viewContainerRef = useRef(null); 12 | const resultContainerRef = useRef(null); 13 | 14 | const { activeParentContentId, activeContentId, registerLinkage } = 15 | useContentLinkage({ 16 | viewContainerRef, 17 | resultContainerRef, 18 | }); 19 | 20 | useLayoutEffect(() => { 21 | registerLinkage(); 22 | }, []); 23 | 24 | return ( 25 |
35 |
36 |
预览
37 |
46 | 53 |
54 |
55 |
56 | 67 | {resultTab === "text" && ( 68 | 82 | )} 83 | {resultTab === "json" && ( 84 | 92 | )} 93 |
94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /src/examples/PDFExample.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect, useRef, useState } from "react"; 2 | import FilePreview from "../components/FilePreview"; 3 | import { RadioGroup } from "../components/RadioGroup"; 4 | import ResultView from "../components/ResultView"; 5 | import { pdfExample } from "./data"; 6 | import JsonView from "../components/JsonView"; 7 | import { useContentLinkage } from "../hooks/useContentLinkage"; 8 | 9 | export default function PDFExample() { 10 | const [resultTab, setResultTab] = useState("text"); 11 | const viewContainerRef = useRef(null); 12 | const resultContainerRef = useRef(null); 13 | 14 | const { activeParentContentId, activeContentId, registerLinkage } = 15 | useContentLinkage({ 16 | viewContainerRef, 17 | resultContainerRef, 18 | }); 19 | 20 | useLayoutEffect(() => { 21 | registerLinkage(); 22 | }, []); 23 | 24 | return ( 25 |
35 |
36 |
预览
37 |
46 | 55 |
56 |
57 |
58 | 69 | {resultTab === "text" && ( 70 | 83 | )} 84 | {resultTab === "json" && ( 85 | 93 | )} 94 |
95 |
96 | ); 97 | } 98 | -------------------------------------------------------------------------------- /src/examples/data.ts: -------------------------------------------------------------------------------- 1 | import { IOriginResult } from "../types/keyVal"; 2 | import { transformKeyValApiResultToView } from "../utils/transformers"; 3 | import imageExampleJson from "./image_example.json"; 4 | import pdfExampleJson from "./pdf_example.json"; 5 | 6 | // 图片示例 7 | export const imageExample = { 8 | src: [ 9 | "https://web-api.textin.com/open/image/download?filename=62f87022e581449081b718f9c1cd9296", 10 | ], 11 | json: imageExampleJson, 12 | ...transformKeyValApiResultToView(imageExampleJson as IOriginResult), 13 | }; 14 | 15 | // pdf示例 16 | export const pdfExample = { 17 | src: "https://web-api.textin.com/open/image/download?filename=17c9fe6ace284ea1b99912b58bd674ed", 18 | json: pdfExampleJson, 19 | ...transformKeyValApiResultToView(pdfExampleJson as IOriginResult), 20 | }; 21 | -------------------------------------------------------------------------------- /src/hooks/useContentLinkage.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 预览与解析结果联动逻辑 3 | */ 4 | 5 | import React, { useState } from "react"; 6 | 7 | export const useContentLinkage = ({ 8 | viewContainerRef, 9 | resultContainerRef, 10 | }: { 11 | viewContainerRef: React.RefObject; 12 | resultContainerRef: React.RefObject; 13 | }) => { 14 | const [activeContentId, setActiveContentId] = useState(""); 15 | const [activeParentContentId, setActiveParentContentId] = useState(""); 16 | 17 | const registerLinkage = () => { 18 | if (!viewContainerRef.current || !resultContainerRef.current) { 19 | return; 20 | } 21 | viewContainerRef.current.addEventListener("click", handleClick); 22 | resultContainerRef.current.addEventListener("click", handleClick); 23 | }; 24 | 25 | const handleClick = (e: any) => { 26 | const targetContent = findTargetContentId(e.target, e.currentTarget); 27 | const targetContentId = targetContent?.dataset?.contentId; 28 | const targetParentContentId = 29 | targetContent?.dataset?.parentContentId || targetContentId; 30 | if (targetParentContentId) { 31 | setActiveParentContentId(targetParentContentId); 32 | } 33 | 34 | if (targetContentId) { 35 | setActiveContentId(targetContentId); 36 | } 37 | }; 38 | 39 | const findTargetContentId = ( 40 | target: HTMLElement, 41 | container: HTMLElement 42 | ): HTMLElement | null => { 43 | let currentElement: HTMLElement | null = target; 44 | while (currentElement && currentElement !== container) { 45 | if (currentElement.hasAttribute("data-content-id")) { 46 | return currentElement; 47 | } 48 | currentElement = currentElement.parentElement; 49 | } 50 | return null; 51 | }; 52 | 53 | return { 54 | activeContentId, 55 | setActiveContentId, 56 | activeParentContentId, 57 | setActiveParentContentId, 58 | registerLinkage, 59 | }; 60 | }; 61 | -------------------------------------------------------------------------------- /src/hooks/useFrameSetState.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useState, useEffect } from "react"; 2 | 3 | type SetActionType = Partial | ((state: T) => Partial); 4 | 5 | export default function useFrameSetState(initial: T) { 6 | const frame = useRef(null); 7 | const [state, setState] = useState(initial); 8 | const queue = useRef[]>([]); 9 | 10 | const setFrameState = (newState: SetActionType) => { 11 | queue.current.push(newState); 12 | 13 | if (frame.current === null) { 14 | frame.current = requestAnimationFrame(() => { 15 | setState((prevState) => { 16 | let memoState: any = prevState; 17 | queue.current.forEach((queueState) => { 18 | if (typeof queueState === "function") { 19 | memoState = { ...memoState, ...queueState(memoState) }; 20 | } else { 21 | memoState = { ...memoState, ...queueState }; 22 | } 23 | }); 24 | queue.current = []; // 清空队列 25 | frame.current = null; // 重置帧 26 | return memoState; 27 | }); 28 | }); 29 | } 30 | }; 31 | 32 | useEffect(() => { 33 | return () => { 34 | if (frame.current) { 35 | cancelAnimationFrame(frame.current); 36 | } 37 | }; 38 | }, []); 39 | 40 | return [state, setFrameState] as const; 41 | } 42 | -------------------------------------------------------------------------------- /src/hooks/useLoadPDFLib.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useMemo, useState } from "react"; 2 | import { useExternal } from "ahooks"; 3 | import { isIEBrowser } from "../utils/browser"; 4 | 5 | export const useLoadPDFLib = ({ 6 | onLoad, 7 | onError, 8 | }: { onLoad?: () => void; onError?: () => void } = {}) => { 9 | const [pdfLibReady, setPdfLibReady] = useState( 10 | () => !!(window.pdfjsLib && window.pdfjsWorker) 11 | ); 12 | 13 | const { dir, buildDir, cmapsURL, getParams } = useMemo(() => { 14 | //TODO: 内置到本地 15 | const versionMap = { 16 | new: "https://static.textin.com/deps/pdfjs-dist@2.11.338", 17 | old: "https://static.textin.com/deps/pdfjs-dist@2.0.943", 18 | }; 19 | const isOld = isIEBrowser || !document.body?.attachShadow; 20 | const dir = isOld ? versionMap.old : versionMap.new; 21 | return { 22 | dir, 23 | buildDir: dir + (isOld ? "" : "/legacy"), 24 | getParams: (scale: number) => (isOld ? scale : { scale }), 25 | cmapsURL: `${dir}/cmaps/`, 26 | }; 27 | }, []); 28 | 29 | // 兼容IE 11 的版本 30 | const status1 = useExternal(`${buildDir}/build/pdf.min.js`); 31 | const status2 = useExternal(`${buildDir}/build/pdf.worker.min.js`); 32 | 33 | useEffect(() => { 34 | if (status1 === "error" || status2 === "error") { 35 | onError?.(); 36 | setPdfLibReady(false); 37 | console.error("pdf load error"); 38 | } 39 | }, [status1, status2]); 40 | 41 | useEffect(() => { 42 | (async () => { 43 | if (window.pdfjsLib && window.pdfjsWorker && !pdfLibReady) { 44 | setPdfLibReady(true); 45 | onLoad?.(); 46 | } 47 | })(); 48 | }, [status1, status2]); 49 | 50 | return { pdfLibReady, dir, buildDir, cmapsURL, getParams }; 51 | }; 52 | -------------------------------------------------------------------------------- /src/hooks/useMarkTool.ts: -------------------------------------------------------------------------------- 1 | import type React from "react"; 2 | import { useRef, useState } from "react"; 3 | import { 4 | useSize, 5 | useDebounceFn, 6 | useUpdateEffect, 7 | useEventListener, 8 | } from "ahooks"; 9 | import { getImgWidth } from "../components/MarkLayer/helpers"; 10 | 11 | interface IPositionStyle { 12 | width: number; 13 | height: number; 14 | left: number; 15 | top: number; 16 | } 17 | interface MarkToolOptions { 18 | viewContainerRef?: React.RefObject; 19 | imgRef: React.MutableRefObject; 20 | angle?: number; 21 | angleFix?: boolean; 22 | onSizeChange?: () => void; 23 | clearList?: () => void; 24 | resizeScale?: () => void; 25 | } 26 | export default function useMarkTool({ 27 | viewContainerRef, 28 | imgRef, 29 | angle, 30 | angleFix, 31 | onSizeChange = () => {}, 32 | resizeScale, 33 | clearList = () => {}, 34 | }: MarkToolOptions) { 35 | const containerSize = useSize(viewContainerRef); 36 | const isClear = useRef(false); 37 | 38 | const [renderRate, setRenderRate] = useState(0); // 原图尺寸与渲染尺寸比例 39 | const [markStyle, setMarkStyle] = useState({ 40 | width: 0, 41 | height: 0, 42 | left: 0, 43 | top: 0, 44 | }); 45 | const winSizeFn = useDebounceFn( 46 | () => { 47 | isClear.current = false; 48 | updateMark(); 49 | onSizeChange(); 50 | }, 51 | { wait: 0 } 52 | ); 53 | 54 | // 监听容器尺寸变化,如果改变则清除画框,重置旋转角度 55 | useUpdateEffect(() => { 56 | winSizeFn.run(); 57 | if (!isClear.current) { 58 | resizeScale?.(); 59 | isClear.current = true; 60 | clearList(); 61 | } 62 | }, [containerSize, angle]); 63 | 64 | // 有旋转角度时,监听旋转结束后更新蒙层位置 65 | useEventListener( 66 | "transitionend", 67 | () => { 68 | if (angle === 90 || angle === 270) { 69 | updateMark(); 70 | if (onSizeChange) { 71 | onSizeChange(); 72 | } 73 | } 74 | }, 75 | { target: imgRef.current || undefined } 76 | ); 77 | 78 | function updateMark() { 79 | if (!imgRef.current) return; 80 | updateRenderRate(); 81 | updateMarkStyle(); 82 | } 83 | 84 | const updateRenderRate = () => { 85 | if (!imgRef.current) { 86 | return; 87 | } 88 | const imgNaturalWidth = getImgWidth({}, imgRef.current); 89 | const imgRenderWidth = imgRef.current.offsetWidth; 90 | setRenderRate(imgRenderWidth / imgNaturalWidth); 91 | }; 92 | 93 | const updateMarkStyle = () => { 94 | if (!imgRef.current) { 95 | return; 96 | } 97 | const { offsetLeft: left, offsetTop: top } = imgRef.current; 98 | const { width, height } = imgRef.current.getBoundingClientRect(); 99 | 100 | let currentStyle = { 101 | left, 102 | top, 103 | width, 104 | height, 105 | }; 106 | // 旋转角度 width <=> height 补位 107 | if (angleFix && angle && [90, 270].includes(angle)) { 108 | const diff = (width - height) / 2; 109 | currentStyle = { 110 | width: height, 111 | height: width, 112 | left: left + diff, 113 | top: top - diff, 114 | }; 115 | } 116 | setMarkStyle(currentStyle); 117 | }; 118 | 119 | return { renderRate, markStyle, updateMark }; 120 | } 121 | -------------------------------------------------------------------------------- /src/hooks/usePDFMarkLayer.ts: -------------------------------------------------------------------------------- 1 | import { IPageItem, IRectItem } from "../types/keyVal"; 2 | import { useCallback, useEffect } from "react"; 3 | import { scrollIntoViewIfNeeded } from "../utils/dom"; 4 | 5 | const svgNS = "http://www.w3.org/2000/svg"; 6 | const rectClass = "rectLayer"; 7 | 8 | let observer: MutationObserver; 9 | 10 | interface PDFMarkLayerOptions { 11 | containerRef: React.RefObject; 12 | pdfViewerRef?: React.RefObject; 13 | rects?: IRectItem[][]; 14 | pages: IPageItem[]; 15 | dpi?: number; 16 | activeContentId?: string; 17 | showMark?: boolean; 18 | } 19 | 20 | export const usePDFMarkLayer = ({ 21 | containerRef, 22 | pdfViewerRef, 23 | rects, 24 | pages, 25 | dpi, 26 | activeContentId, 27 | showMark = true, 28 | }: PDFMarkLayerOptions) => { 29 | const pdfViewDpi = 96; 30 | let resultDpi = dpi || 144; 31 | let dpiScale = pdfViewDpi / resultDpi; 32 | 33 | function createPageMark( 34 | pageItem: HTMLDivElement, 35 | { activeId }: { activeId?: string } 36 | ) { 37 | const page = pageItem.dataset.pageNumber; 38 | const { clientWidth, clientHeight } = pageItem; 39 | const scale = pdfViewerRef?.current.currentScale; 40 | const pageIndex = Number(page) - 1; 41 | const curPageRects = rects![pageIndex]; 42 | if (!dpi && Array.isArray(pages)) { 43 | const curPage = pages[pageIndex] || {}; 44 | if (curPage.ppi && typeof curPage.ppi === "number") { 45 | resultDpi = curPage.ppi; 46 | } else if ( 47 | typeof curPage.width === "number" && 48 | typeof curPage.height === "number" 49 | ) { 50 | let { width: resultWidth, height: resultHeight } = curPage; 51 | const { viewHeight, viewWidth } = [90, 270].includes( 52 | pdfViewerRef?.current.pagesRotation 53 | ) 54 | ? { viewHeight: clientWidth, viewWidth: clientHeight } 55 | : { viewHeight: clientHeight, viewWidth: clientWidth }; 56 | // 判断结果中的width/height是否反了 57 | const sizeRate = viewWidth / viewHeight; 58 | if ( 59 | [90, 270].includes(curPage.angle || 0) && 60 | Math.abs(curPage.width / curPage.height - sizeRate) > 0.02 && 61 | Math.abs(curPage.height / curPage.width - sizeRate) <= 0.02 62 | ) { 63 | resultWidth = curPage.height; 64 | resultHeight = curPage.width; 65 | } 66 | resultDpi = Math.round( 67 | pdfViewDpi * (resultWidth / (viewWidth / scale)) 68 | ); 69 | } 70 | dpiScale = pdfViewDpi / resultDpi; 71 | } 72 | if (page && Array.isArray(curPageRects) && curPageRects.length) { 73 | const oldDom = pageItem.querySelector(`.${rectClass}`); 74 | if (oldDom) { 75 | oldDom.remove(); 76 | } 77 | const svgDom = document.createElementNS(svgNS, "svg"); 78 | const pageAngle = curPageRects[0].angle || 0; 79 | const rotate = (pdfViewerRef?.current.pagesRotation + pageAngle) % 360; 80 | let info = { 81 | width: clientWidth, 82 | height: clientHeight, 83 | translateX: 0, 84 | translateY: 0, 85 | }; 86 | if (rotate === 90) { 87 | info = { 88 | width: clientHeight, 89 | height: clientWidth, 90 | translateX: 0, 91 | translateY: -clientWidth, 92 | }; 93 | } else if (rotate === 180) { 94 | info = { 95 | width: clientWidth, 96 | height: clientHeight, 97 | translateX: -clientWidth, 98 | translateY: -clientHeight, 99 | }; 100 | } else if (rotate === 270) { 101 | info = { 102 | width: clientHeight, 103 | height: clientWidth, 104 | translateX: -clientHeight, 105 | translateY: 0, 106 | }; 107 | } 108 | svgDom.setAttribute("data-dpi-scale", `${dpiScale}`); 109 | svgDom.setAttribute("data-angle", `${pageAngle}`); 110 | svgDom.setAttribute("class", `${rectClass}`); 111 | svgDom.setAttribute("width", `${info.width}`); 112 | svgDom.setAttribute("height", `${info.height}`); 113 | svgDom.setAttribute( 114 | "style", 115 | `transform: rotate(${rotate}deg) translate3d(${info.translateX}px, ${info.translateY}px, 0)` 116 | ); 117 | const viewBoxWidth = Number((info.width / scale / dpiScale).toFixed(2)); 118 | const viewBoxHeight = Number((info.height / scale / dpiScale).toFixed(2)); 119 | svgDom.setAttribute("viewBox", `0 0 ${viewBoxWidth} ${viewBoxHeight}`); 120 | let isOver = false; 121 | curPageRects.forEach((rect) => { 122 | if (!rect.position) return; 123 | const polygon = document.createElementNS(svgNS, "polygon"); 124 | polygon.setAttribute( 125 | "points", 126 | rect.position.reduce( 127 | (pre, cur, i) => pre + (i % 2 ? "," : " ") + cur, 128 | "" 129 | ) 130 | ); 131 | polygon.setAttribute("vector-effect", "non-scaling-stroke"); 132 | polygon.setAttribute("data-content-id", `${rect.content_id}`); 133 | if (rect.parent_id) { 134 | polygon.setAttribute("data-parent-content-id", `${rect.parent_id}`); 135 | } 136 | if (activeId && activeId === `${rect.content_id}`) { 137 | polygon.classList.add("active"); 138 | } 139 | const rect_type = rect.rect_type || rect.type; 140 | if (rect_type) { 141 | polygon.classList.add(rect_type); 142 | } 143 | const textScale = 1 / scale / dpiScale; 144 | if (rect.render_text) { 145 | const renderGroup = document.createElementNS(svgNS, "g"); 146 | const translateScale = (1 - textScale) / textScale; 147 | renderGroup.setAttribute( 148 | "style", 149 | `transform: scale(${textScale}) translate(${ 150 | rect.position[0] * translateScale 151 | }px, ${rect.position[1] * translateScale}px)` 152 | ); 153 | const renderRect = document.createElementNS(svgNS, "rect"); 154 | const attr1: any = { 155 | width: "16", 156 | height: "16", 157 | fill: "#4877FF", 158 | x: `${rect.position[0]}`, 159 | y: `${rect.position[1]}`, 160 | }; 161 | for (const attr in attr1) { 162 | renderRect.setAttribute(attr, attr1[attr]); 163 | } 164 | renderGroup.appendChild(renderRect); 165 | const renderText = document.createElementNS(svgNS, "text"); 166 | const attr2: any = { 167 | style: "font-size: 12px; fill: #fff", 168 | x: `${rect.position[0] + 4}`, 169 | y: `${rect.position[1] + 11}`, 170 | }; 171 | for (const attr in attr2) { 172 | renderText.setAttribute(attr, attr2[attr]); 173 | } 174 | renderText.textContent = rect.render_text; 175 | renderGroup.appendChild(renderText); 176 | svgDom.appendChild(renderGroup); 177 | } 178 | svgDom.appendChild(polygon); 179 | 180 | if (!isOver) { 181 | isOver = 182 | rect.position[2] - viewBoxWidth > 5 || 183 | rect.position[4] - viewBoxWidth > 5 || 184 | rect.position[5] - viewBoxHeight > 5 || 185 | rect.position[7] - viewBoxHeight > 5; 186 | // console.log('isOver', { rect, viewBoxHeight, viewBoxWidth }); 187 | } 188 | }); 189 | if (!isOver) { 190 | pageItem.insertBefore( 191 | svgDom, 192 | pageItem.children[1] || 193 | pageItem.children[pageItem.children.length - 1] 194 | ); 195 | } 196 | } 197 | } 198 | 199 | function removeExpiredMark() { 200 | const oldDoms: HTMLDivElement[] = containerRef.current.querySelectorAll( 201 | `.page:not([data-loaded="true"]) .${rectClass}` 202 | ); 203 | oldDoms.forEach((item) => { 204 | item.remove(); 205 | }); 206 | } 207 | 208 | function createMark(list: any[]) { 209 | try { 210 | if (list.forEach) { 211 | list.forEach((pageItem) => { 212 | createPageMark(pageItem, { activeId: activeContentId }); 213 | }); 214 | } 215 | removeExpiredMark(); 216 | } catch (error) { 217 | console.log("MutationObserver callback error", error); 218 | } 219 | } 220 | 221 | function removeOldMark() { 222 | const oldPagesMark: HTMLDivElement[] = 223 | containerRef.current.querySelectorAll(`.${rectClass}`); 224 | oldPagesMark.forEach((item) => { 225 | item.remove(); 226 | }); 227 | } 228 | 229 | const run = useCallback(() => { 230 | if ( 231 | !containerRef.current || 232 | !pdfViewerRef?.current || 233 | !rects || 234 | !MutationObserver 235 | ) { 236 | return; 237 | } 238 | removeOldMark(); 239 | if (!showMark) { 240 | return; 241 | } 242 | const initDoms = containerRef.current.querySelectorAll( 243 | '.page[data-loaded="true"]' 244 | ); 245 | createMark(initDoms); 246 | 247 | // 监听pdf页面加载(data-loaded=true),绘制页面回显 248 | if (observer && observer.disconnect) { 249 | observer.disconnect(); 250 | } 251 | observer = new MutationObserver((pages: MutationRecord[]) => { 252 | const pageList = pages.map ? pages.map((item) => item.target) : []; 253 | createMark(pageList); 254 | }); 255 | const config = { attributeFilter: ["data-loaded"], subtree: true }; 256 | observer.observe(containerRef.current, config); 257 | }, [showMark, rects]); 258 | 259 | useEffect(() => { 260 | run(); 261 | }, [run]); 262 | 263 | const removeActiveClass = () => { 264 | const activeEles = Array.from( 265 | containerRef.current.querySelectorAll("[data-content-id].active") 266 | ); 267 | activeEles.forEach((ele: any) => ele.classList.remove("active")); 268 | }; 269 | 270 | const addActiveClassAndScrollTo = (contentId: string) => { 271 | const targetContent = containerRef.current.querySelector( 272 | `[data-content-id="${contentId}"]` 273 | ); 274 | if (targetContent) { 275 | targetContent.classList.add("active"); 276 | scrollIntoViewIfNeeded( 277 | targetContent, 278 | containerRef.current, 279 | { block: "nearest", inline: "nearest" }, 280 | targetContent.getBoundingClientRect().height 281 | ); 282 | } 283 | }; 284 | 285 | useEffect(() => { 286 | if (activeContentId && containerRef.current) { 287 | removeActiveClass(); 288 | addActiveClassAndScrollTo(activeContentId); 289 | } 290 | }, [activeContentId]); 291 | 292 | return { run }; 293 | }; 294 | -------------------------------------------------------------------------------- /src/hooks/usePreviewTool.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from "react"; 2 | import { useThrottleEffect } from "ahooks"; 3 | import RotateLeft from "../assets/icon_img_rotate-90_default.svg?react"; 4 | import RotateRight from "../assets/icon_img_rotate+90_default.svg?react"; 5 | import ZoomIn from "../assets/icon_img_enlarge_default.svg?react"; 6 | import ZoomOut from "../assets/icon_img_narrow_default.svg?react"; 7 | import Normal from "../assets/icon_img_normal_default.svg?react"; 8 | 9 | export interface PreviewToolItem { 10 | type: string; 11 | Icon?: React.FC>; 12 | disabled?: boolean; 13 | onClick?: () => void; 14 | } 15 | 16 | export interface ToolbarOptions { 17 | zoomStep?: number; 18 | maxScale?: number; 19 | } 20 | 21 | export interface PreviewToolOptions { 22 | viewContainerRef: React.RefObject; 23 | viewRef: React.RefObject; 24 | toolbarOptions?: ToolbarOptions; 25 | } 26 | 27 | export const usePreviewTool = ({ 28 | viewContainerRef, 29 | viewRef, 30 | toolbarOptions, 31 | }: PreviewToolOptions) => { 32 | const initialPosition = { 33 | x: 0, 34 | y: 0, 35 | }; 36 | 37 | const MAX_SCALE_VALUE = toolbarOptions?.maxScale || 4; 38 | const ZOOM_STEP = toolbarOptions?.zoomStep || 0.25; 39 | 40 | const [scale, setScale] = useState(1); 41 | const [rotate, setRotate] = useState(0); 42 | const [position, setPosition] = useState<{ 43 | x: number; 44 | y: number; 45 | }>(initialPosition); 46 | 47 | const originPositionRef = useRef<{ 48 | originX: number; 49 | originY: number; 50 | deltaX: number; 51 | deltaY: number; 52 | }>({ 53 | originX: 0, 54 | originY: 0, 55 | deltaX: 0, 56 | deltaY: 0, 57 | }); 58 | const [isMoving, setMoving] = useState(false); 59 | 60 | // img tools 61 | const onZoomIn = () => { 62 | if (scale < MAX_SCALE_VALUE) { 63 | setScale((value) => value + ZOOM_STEP); 64 | } 65 | setPosition(initialPosition); 66 | }; 67 | 68 | const onZoomOut = () => { 69 | if (scale > 1) { 70 | setScale((value) => value - ZOOM_STEP); 71 | } 72 | setPosition(initialPosition); 73 | }; 74 | 75 | const onRotateNormal = () => { 76 | clear(); 77 | }; 78 | const onRotateRight = () => { 79 | setRotate((value) => value + 90); 80 | }; 81 | 82 | const onRotateLeft = () => { 83 | setRotate((value) => value - 90); 84 | }; 85 | const clear = () => { 86 | setPosition(initialPosition); 87 | resizeScale(); 88 | setMoving(false); 89 | setRotate(0); 90 | }; 91 | const resizeScale = () => setScale(1); 92 | 93 | const tools: PreviewToolItem[] = [ 94 | { 95 | Icon: ZoomOut, 96 | onClick: onZoomOut, 97 | type: "zoomOut", 98 | disabled: scale === 1, 99 | }, 100 | { 101 | Icon: ZoomIn, 102 | onClick: onZoomIn, 103 | type: "zoomIn", 104 | disabled: scale === MAX_SCALE_VALUE, 105 | }, 106 | { 107 | Icon: Normal, 108 | onClick: onRotateNormal, 109 | type: "normal", 110 | disabled: scale === 1, 111 | }, 112 | { 113 | disabled: true, 114 | type: "line", 115 | }, 116 | { 117 | Icon: RotateLeft, 118 | onClick: onRotateLeft, 119 | type: "rotateLeft", 120 | }, 121 | { 122 | Icon: RotateRight, 123 | onClick: onRotateRight, 124 | type: "rotateRight", 125 | }, 126 | ]; 127 | 128 | // 处理旋转后、宽高补位 129 | useEffect(() => { 130 | if (rotate) { 131 | fixPosition(); 132 | } 133 | }, [rotate]); 134 | 135 | function fixPosition() { 136 | if (!viewRef.current) return; 137 | const width = viewRef.current.offsetWidth * scale; 138 | const height = viewRef.current.offsetHeight * scale; 139 | const { left: imgLeft, top: imgTop } = 140 | viewRef.current.getBoundingClientRect(); 141 | const { left: wrapLeft, top: wrapTop } = 142 | viewContainerRef.current?.getBoundingClientRect() || { 143 | left: 0, 144 | top: 0, 145 | }; 146 | 147 | const isRotate = rotate % 180 !== 0; 148 | const fixState = getFixScaleEleTransPosition( 149 | isRotate ? height : width, 150 | isRotate ? width : height, 151 | imgLeft - wrapLeft, 152 | imgTop - wrapTop 153 | ) || { x: 0, y: 0 }; 154 | if (fixState) { 155 | setPosition({ ...fixState }); 156 | } 157 | } 158 | const onMouseUp = () => { 159 | if (isMoving) { 160 | setMoving(false); 161 | // fixPosition(); 162 | } 163 | }; 164 | const onMouseDown = (event: any) => { 165 | event.preventDefault(); 166 | // Without this mask close will abnormal 167 | event.stopPropagation(); 168 | originPositionRef.current.deltaX = event.clientX - position.x; 169 | originPositionRef.current.deltaY = event.clientY - position.y; 170 | originPositionRef.current.originX = position.x; 171 | originPositionRef.current.originY = position.y; 172 | setMoving(true); 173 | }; 174 | const onMouseMove = (event: any) => { 175 | if (isMoving) { 176 | setPosition({ 177 | x: event.clientX - originPositionRef.current.deltaX, 178 | y: event.clientY - originPositionRef.current.deltaY, 179 | }); 180 | } 181 | }; 182 | 183 | /** 184 | * 处理滚动事件 185 | */ 186 | const [wheelNum, setWheelNum] = useState(0); 187 | useThrottleEffect( 188 | () => { 189 | if (!wheelNum) return; 190 | if (wheelNum > 1) { 191 | onZoomIn(); 192 | setWheelNum(0); 193 | return; 194 | } 195 | if (wheelNum < -1) { 196 | onZoomOut(); 197 | setWheelNum(0); 198 | } 199 | }, 200 | [wheelNum], 201 | { 202 | wait: 40, 203 | } 204 | ); 205 | const onWheel = (event: WheelEvent) => { 206 | // event.preventDefault(); 207 | // deltaY < 100 区分笔记本触摸板滑动 208 | if (event.ctrlKey || Math.abs(event.deltaY) < 100) return; 209 | const direct = event.deltaY > 0 ? "down" : "up"; 210 | setWheelNum((num) => { 211 | let curNum = num; 212 | if (direct === "up") { 213 | curNum += 1; 214 | } else { 215 | curNum -= 1; 216 | } 217 | return curNum; 218 | }); 219 | }; 220 | 221 | useEffect(() => { 222 | viewContainerRef.current?.addEventListener("mouseup", onMouseUp, false); 223 | viewContainerRef.current?.addEventListener("mousemove", onMouseMove, false); 224 | 225 | return () => { 226 | viewContainerRef.current?.removeEventListener( 227 | "mouseup", 228 | onMouseUp, 229 | false 230 | ); 231 | 232 | viewContainerRef.current?.removeEventListener( 233 | "mousemove", 234 | onMouseMove, 235 | false 236 | ); 237 | }; 238 | }, [isMoving]); 239 | 240 | return { 241 | viewRef, 242 | viewContainerRef, 243 | tools, 244 | scale, 245 | rotate, 246 | position, 247 | onMouseDown, 248 | onWheel, 249 | resizeScale, 250 | }; 251 | }; 252 | 253 | function fixPoint( 254 | key: "x" | "y", 255 | start: number, 256 | width: number, 257 | clientWidth: number 258 | ) { 259 | const startAddWidth = start + width; 260 | const offsetStart = (width - clientWidth) / 2; 261 | 262 | if (width > clientWidth) { 263 | if (start > 0) { 264 | return { 265 | [key]: offsetStart, 266 | }; 267 | } 268 | if (start < 0 && startAddWidth < clientWidth) { 269 | return { 270 | [key]: -offsetStart, 271 | }; 272 | } 273 | } else if (start < 0 || startAddWidth > clientWidth) { 274 | return { 275 | [key]: start < 0 ? offsetStart : -offsetStart, 276 | }; 277 | } 278 | return {}; 279 | } 280 | 281 | /** 282 | * Fix positon x,y point when 283 | * 284 | * Ele width && height < client 285 | * - Back origin 286 | * 287 | * - Ele width | height > clientWidth | clientHeight 288 | * - left | top > 0 -> Back 0 289 | * - left | top + width | height < clientWidth | clientHeight -> Back left | top + width | height === clientWidth | clientHeight 290 | * 291 | * Regardless of other 292 | */ 293 | export function getFixScaleEleTransPosition( 294 | width: number, 295 | height: number, 296 | left: number, 297 | top: number 298 | ): null | { x: number; y: number } { 299 | // const { width: clientWidth, height: clientHeight } = getClientSize(); 300 | let fixPos: any = null; 301 | if (!document.querySelector("#imgContainer")) { 302 | return fixPos; 303 | } 304 | const { clientWidth, clientHeight } = document.querySelector( 305 | "#imgContainer" 306 | ) as Element; 307 | 308 | if (width <= clientWidth && height <= clientHeight) { 309 | fixPos = { 310 | x: 0, 311 | y: 0, 312 | }; 313 | } else if (width > clientWidth || height > clientHeight) { 314 | fixPos = { 315 | ...fixPoint("x", left, width, clientWidth), 316 | ...fixPoint("y", top, height, clientHeight), 317 | }; 318 | } 319 | 320 | return fixPos; 321 | } 322 | -------------------------------------------------------------------------------- /src/index.css: -------------------------------------------------------------------------------- 1 | :root { 2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif; 3 | line-height: 1.5; 4 | font-weight: 400; 5 | 6 | color-scheme: light dark; 7 | color: rgba(255, 255, 255, 0.87); 8 | background-color: #242424; 9 | 10 | font-synthesis: none; 11 | text-rendering: optimizeLegibility; 12 | -webkit-font-smoothing: antialiased; 13 | -moz-osx-font-smoothing: grayscale; 14 | --current-active-id: none; 15 | } 16 | 17 | a { 18 | font-weight: 500; 19 | color: #646cff; 20 | text-decoration: inherit; 21 | } 22 | a:hover { 23 | color: #535bf2; 24 | } 25 | 26 | body { 27 | margin: 0; 28 | display: flex; 29 | place-items: center; 30 | min-width: 320px; 31 | min-height: 100vh; 32 | } 33 | 34 | h1 { 35 | font-size: 3.2em; 36 | line-height: 1.1; 37 | } 38 | 39 | button { 40 | border-radius: 8px; 41 | border: 1px solid transparent; 42 | padding: 0.6em 1.2em; 43 | font-size: 1em; 44 | font-weight: 500; 45 | font-family: inherit; 46 | background-color: #1a1a1a; 47 | cursor: pointer; 48 | transition: border-color 0.25s; 49 | } 50 | button:hover { 51 | border-color: #646cff; 52 | } 53 | button:focus, 54 | button:focus-visible { 55 | outline: 4px auto -webkit-focus-ring-color; 56 | } 57 | 58 | @media (prefers-color-scheme: light) { 59 | :root { 60 | color: #213547; 61 | background-color: #ffffff; 62 | } 63 | a:hover { 64 | color: #747bff; 65 | } 66 | button { 67 | background-color: #f9f9f9; 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // src/index.ts 2 | export { default as FilePreview } from "./components/FilePreview"; 3 | export { default as ResultView } from "./components/ResultView"; 4 | export { default as MarkLayer } from "./components/MarkLayer"; 5 | export { default as JsonView } from "./components/JsonView"; 6 | 7 | export { useContentLinkage } from "./hooks/useContentLinkage"; 8 | export { usePDFMarkLayer } from "./hooks/usePDFMarkLayer"; 9 | export { usePreviewTool } from "./hooks/usePreviewTool"; 10 | 11 | // 导出类型定义 12 | export type { 13 | IRectItem, 14 | IPageItem, 15 | IResultListItem, 16 | IFieldItem, 17 | } from "./types/keyVal"; 18 | 19 | export type { PDFSrc } from "./components/FilePreview/PDFViewer/types"; 20 | export type { ToolbarOptions } from "./hooks/usePreviewTool"; 21 | export type { PreviewToolItem } from "./hooks/usePreviewTool"; 22 | -------------------------------------------------------------------------------- /src/main.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.css'; 4 | import App from './App'; 5 | 6 | ReactDOM.render( 7 | 8 | 9 | , 10 | document.getElementById('root') 11 | ); 12 | -------------------------------------------------------------------------------- /src/types/common.ts: -------------------------------------------------------------------------------- 1 | export type TypedArray = 2 | | Int8Array 3 | | Uint8Array 4 | | Uint8ClampedArray 5 | | Int16Array 6 | | Uint16Array 7 | | Int32Array 8 | | Uint32Array 9 | | Float32Array 10 | | Float64Array; 11 | -------------------------------------------------------------------------------- /src/types/global.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.css"; 2 | declare module "*.less" { 3 | const classes: { [key: string]: string }; 4 | export default classes; 5 | } 6 | declare module "*.png"; 7 | declare module "*.jpg"; 8 | declare module "*.jpeg"; 9 | declare module "rc-util*"; 10 | declare module "*.xlsx"; 11 | declare module "*.svg" { 12 | export function ReactComponent( 13 | props: React.SVGProps 14 | ): React.ReactElement; 15 | const url: string; 16 | export default url; 17 | } 18 | 19 | declare interface Window { 20 | [key: string]: any; 21 | pdfjsLib?: any; 22 | pdfjsWorker?: any; 23 | } 24 | -------------------------------------------------------------------------------- /src/types/keyVal.ts: -------------------------------------------------------------------------------- 1 | export type IOriginResult = { 2 | pages: IOriginPageItem[]; 3 | }; 4 | 5 | export interface IOriginPageItem { 6 | duration: number; 7 | page_number: number; 8 | ppi: number; 9 | result: { 10 | width: number; 11 | height: number; 12 | object_list?: IOriginResultListItem[]; 13 | rotated_image_width?: number; 14 | }; 15 | } 16 | 17 | export interface IOriginResultListItem { 18 | image_angle: number; 19 | class: string; 20 | item_list?: IOriginFieldItem[]; 21 | flight_data_list?: IOriginFieldItem[][]; 22 | product_list?: IOriginFieldItem[][]; 23 | transport_list?: IOriginFieldItem[][]; 24 | stamp_list?: IOriginFieldItem[][]; 25 | qr_code_list?: IOriginFieldItem[][]; 26 | kind: string; 27 | kind_description: string; 28 | position: number[]; 29 | rotated_image_height: number; 30 | rotated_image_width: number; 31 | type: string; 32 | type_description: string; 33 | page_id?: number; 34 | } 35 | export interface IOriginFieldItem { 36 | key: string; 37 | type?: string; 38 | value: string; 39 | description: string; 40 | position: number[]; 41 | } 42 | 43 | export interface IFieldItem extends IOriginFieldItem { 44 | uid: string; 45 | parent_uid?: string; 46 | } 47 | 48 | export interface IRectItem { 49 | [key: string]: any; 50 | key?: string; 51 | type?: string; 52 | rect_type?: string; 53 | uid: string; 54 | parent_uid?: string; 55 | content_id: string; 56 | parent_id?: string; 57 | position: number[]; 58 | angle?: number; 59 | render_text?: string; 60 | } 61 | 62 | export interface IResultListItem extends IRectItem { 63 | type: string; 64 | description: string; 65 | no: number; 66 | list: IFieldItem[]; 67 | flightList: IFieldItem[][]; 68 | page_id?: number; 69 | } 70 | 71 | export interface IPageItem { 72 | page_number: number; 73 | duration: number; 74 | ppi: number; 75 | width: number; 76 | height: number; 77 | angle?: number; 78 | } 79 | -------------------------------------------------------------------------------- /src/utils/browser.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Browser list 3 | * google chrome: 65 4 | * QQBrowser: 10 5 | * Edge: 44 6 | * 360Browser: se 7 | * IE: 11 8 | */ 9 | 10 | // 查看各个浏览器agent【https://tool.ip138.com/useragent/】 11 | const userAgent = navigator.userAgent || window.navigator.userAgent; 12 | 13 | function getVersion(browserAgent: string, keywords: string) { 14 | const reg = new RegExp(`(${keywords}\/)[0-9]+`); 15 | const version = browserAgent.match(reg); 16 | if (version?.length) { 17 | return version[0]?.replace(new RegExp(`${keywords}\/`), ""); 18 | } 19 | return 100; 20 | } 21 | 22 | const BrowserCompatible = (agent?: string) => { 23 | const browserAgent = agent || userAgent; 24 | if (!browserAgent) return true; 25 | 26 | // 判断是否IE<11浏览器 27 | const isIE = 28 | browserAgent.indexOf("compatible") > -1 && 29 | browserAgent.indexOf("MSIE") > -1; 30 | // 判断是否IE的Edge浏览器 [百科详情 - https://baike.baidu.com/item/Microsoft%20Edge#8] 31 | // 判断是否是旧版内核Edge 32 | const isEdge = 33 | browserAgent.indexOf("Edge") > -1 || browserAgent.indexOf("Edg") > -1; 34 | // 判断是否是QQ浏览器10版本以上 35 | const isQQBrowser = browserAgent.indexOf("QQBrowser") > -1; 36 | // 判断是否是360极速版本 / 360浏览器不区分版本 37 | const is360Browser = browserAgent.indexOf("360SE") > -1; 38 | // google 39 | const isChromeBrowser = 40 | browserAgent.indexOf("Chrome") > -1 && 41 | browserAgent.indexOf("AppleWebKit") > -1; 42 | 43 | if (isIE) { 44 | return false; 45 | } 46 | if (isEdge) { 47 | if (browserAgent.indexOf("Edge") > -1) { 48 | const version = getVersion(browserAgent, "Edge"); 49 | return +version >= 44; 50 | } 51 | if (browserAgent.indexOf("Edg") > -1) { 52 | const version = getVersion(browserAgent, "Edg"); 53 | return +version >= 44; 54 | } 55 | } 56 | if (isChromeBrowser) { 57 | const version = getVersion(browserAgent, "Chrome"); 58 | return +version >= 65; 59 | } 60 | if (isQQBrowser) { 61 | const version = getVersion(browserAgent, "QQBrowser"); 62 | return +version >= 10; 63 | } 64 | if (is360Browser) { 65 | if (browserAgent.indexOf("360SEE") > -1) { 66 | return true; 67 | } 68 | return false; 69 | } 70 | return true; 71 | }; 72 | 73 | export const checkBrowser = () => { 74 | const status = BrowserCompatible(); 75 | const { location } = window; 76 | if (!status && location.pathname !== "/") { 77 | // ie 中没有window.location.origin 78 | window.location.replace( 79 | `${window.location.protocol}//${window.location.host}/` 80 | ); 81 | return true; 82 | } 83 | }; 84 | 85 | /** 86 | * 判断是否Mac OS 87 | */ 88 | export const isMacOS = () => { 89 | if (!userAgent) return false; 90 | return /macintosh|mac os x/i.test(navigator.userAgent); 91 | }; 92 | 93 | /** 94 | * 判断是否IE<11浏览器 95 | */ 96 | export const isLessIE11 = userAgent 97 | ? userAgent.toLowerCase().match(/rv:([\d.]+)\) like gecko/) 98 | : true; 99 | export const isIEBrowser = userAgent 100 | ? userAgent.indexOf("rv:11.0") > -1 || userAgent.indexOf("MSIE") > -1 101 | : false; 102 | 103 | // 判断是否为移动端设备的函数 104 | export const isMobileDevice = (userAgent: string) => { 105 | return /Android|BlackBerry|iPhone|iPad|iPod|Opera Mini|IEMobile|WPDesktop|Mobile/i.test( 106 | userAgent 107 | ); 108 | }; 109 | 110 | export default BrowserCompatible; 111 | -------------------------------------------------------------------------------- /src/utils/debounce.ts: -------------------------------------------------------------------------------- 1 | interface DebounceOptions { 2 | /** 3 | * 是否在延迟开始前调用 4 | * @default false 5 | */ 6 | leading?: boolean; 7 | /** 8 | * 是否在延迟结束后调用 9 | * @default true 10 | */ 11 | trailing?: boolean; 12 | /** 13 | * 最大等待时间 (毫秒) 14 | */ 15 | maxWait?: number; 16 | } 17 | 18 | export interface Cancelable { 19 | /** 20 | * 取消当前延迟调用 21 | */ 22 | cancel: () => void; 23 | /** 24 | * 立即执行并取消后续调用 25 | */ 26 | flush: () => void; 27 | } 28 | 29 | export function debounce any>( 30 | func: T, 31 | wait: number = 0, 32 | options: DebounceOptions = {} 33 | ): T & Cancelable { 34 | type FuncParams = Parameters; 35 | type FuncReturn = ReturnType; 36 | 37 | let lastArgs: FuncParams | null = null; 38 | let lastThis: any; 39 | let result: FuncReturn; 40 | let timerId: ReturnType | null = null; 41 | let lastCallTime = 0; 42 | let lastInvokeTime = 0; 43 | 44 | const { leading = false, trailing = true, maxWait } = options; 45 | const maxing = typeof maxWait === "number"; 46 | 47 | const invokeFunc = (time: number): FuncReturn => { 48 | const args = lastArgs!; 49 | const thisArg = lastThis; 50 | 51 | lastArgs = lastThis = null; 52 | lastInvokeTime = time; 53 | result = func.apply(thisArg, args); 54 | return result; 55 | }; 56 | 57 | const remainingWait = (time: number): number => { 58 | const timeSinceLastCall = time - lastCallTime; 59 | const timeSinceLastInvoke = time - lastInvokeTime; 60 | 61 | const timeWaiting = wait - timeSinceLastCall; 62 | 63 | return maxing 64 | ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) 65 | : timeWaiting; 66 | }; 67 | 68 | const shouldInvoke = (time: number): boolean => { 69 | const timeSinceLastCall = time - lastCallTime; 70 | const timeSinceLastInvoke = time - lastInvokeTime; 71 | 72 | return ( 73 | lastCallTime === 0 || 74 | timeSinceLastCall >= wait || 75 | timeSinceLastCall < 0 || 76 | (maxing && timeSinceLastInvoke >= maxWait) 77 | ); 78 | }; 79 | 80 | const trailingEdge = (time: number): FuncReturn => { 81 | timerId = null; 82 | if (trailing && lastArgs) { 83 | return invokeFunc(time); 84 | } 85 | lastArgs = lastThis = null; 86 | return result; 87 | }; 88 | 89 | const timerExpired = () => { 90 | const now = Date.now(); 91 | if (shouldInvoke(now)) { 92 | return trailingEdge(now); 93 | } 94 | timerId = setTimeout(timerExpired, remainingWait(now)); 95 | }; 96 | 97 | const leadingEdge = (time: number): FuncReturn => { 98 | lastInvokeTime = time; 99 | timerId = setTimeout(timerExpired, wait); 100 | return leading ? invokeFunc(time) : result; 101 | }; 102 | 103 | const debounced = function (this: any, ...args: FuncParams): FuncReturn { 104 | const now = Date.now(); 105 | const isInvoking = shouldInvoke(now); 106 | 107 | lastArgs = args; 108 | lastThis = this; 109 | lastCallTime = now; 110 | 111 | if (isInvoking) { 112 | if (!timerId) { 113 | return leadingEdge(lastCallTime); 114 | } 115 | if (maxing) { 116 | timerId = setTimeout(timerExpired, wait); 117 | return invokeFunc(lastCallTime); 118 | } 119 | } 120 | 121 | if (!timerId) { 122 | timerId = setTimeout(timerExpired, wait); 123 | } 124 | 125 | return result; 126 | } as T & Cancelable; 127 | 128 | debounced.cancel = () => { 129 | if (timerId) { 130 | clearTimeout(timerId); 131 | timerId = null; 132 | } 133 | lastInvokeTime = 0; 134 | lastCallTime = 0; 135 | lastArgs = lastThis = null; 136 | }; 137 | 138 | debounced.flush = () => { 139 | return timerId ? trailingEdge(Date.now()) : result; 140 | }; 141 | 142 | return debounced; 143 | } 144 | -------------------------------------------------------------------------------- /src/utils/dom.ts: -------------------------------------------------------------------------------- 1 | export function isElementInContainerViewport( 2 | el: HTMLElement | SVGPolygonElement, 3 | container: HTMLElement, 4 | offset: number = 0 5 | ): boolean { 6 | const elRect = el.getBoundingClientRect(); 7 | const containerRect = container.getBoundingClientRect(); 8 | 9 | // 计算垂直方向的重叠区域高度 10 | const overlapTop = Math.max(elRect.top, containerRect.top); 11 | const overlapBottom = Math.min(elRect.bottom, containerRect.bottom); 12 | const overlapHeight = overlapBottom - overlapTop; 13 | // 如果重叠高度 ≥ offset,返回 true 14 | return overlapHeight > offset; 15 | } 16 | 17 | export function scrollIntoViewIfNeeded( 18 | el?: HTMLElement | SVGPolygonElement, 19 | container?: HTMLElement, 20 | scrollArgs?: boolean | ScrollIntoViewOptions, 21 | offset = 0 22 | ) { 23 | if (!el || !container) { 24 | return; 25 | } 26 | if (isElementInContainerViewport(el, container, offset || 0)) { 27 | return; 28 | } 29 | el.scrollIntoView(scrollArgs); 30 | } 31 | -------------------------------------------------------------------------------- /src/utils/object.ts: -------------------------------------------------------------------------------- 1 | export function ensureArray(data: any) { 2 | return (Array.isArray(data) ? data : []) as T[]; 3 | } 4 | 5 | export const sortList = (list: any[]) => { 6 | return list 7 | .map((item) => ({ 8 | ...item, 9 | value: [null, undefined].includes(item.value) ? "" : String(item.value), 10 | })) 11 | .sort((a, b) => { 12 | if (a.value && !b.value) { 13 | return -1; 14 | } else if (!a.value && b.value) { 15 | return 1; 16 | } 17 | return 0; 18 | }); 19 | }; 20 | 21 | export const isBoolean = (value: any) => value === true || value === false; 22 | -------------------------------------------------------------------------------- /src/utils/throttle.ts: -------------------------------------------------------------------------------- 1 | import { Cancelable, debounce } from "./debounce"; 2 | 3 | interface ThrottleOptions { 4 | /** 5 | * 是否在节流开始前调用 6 | * @default true 7 | */ 8 | leading?: boolean; 9 | /** 10 | * 是否在节流结束后调用 11 | * @default true 12 | */ 13 | trailing?: boolean; 14 | /** 15 | * 最大等待时间 (毫秒) 16 | */ 17 | maxWait?: number; 18 | } 19 | 20 | export function throttle any>( 21 | func: T, 22 | wait: number = 0, 23 | options: ThrottleOptions = {} 24 | ): T & Cancelable { 25 | type FuncParams = Parameters; 26 | type FuncReturn = ReturnType; 27 | 28 | let lastArgs: FuncParams | null = null; 29 | let lastThis: any; 30 | let result: FuncReturn; 31 | 32 | const { leading = true, trailing = true, maxWait } = options; 33 | const throttled = debounce(func, wait, { 34 | leading, 35 | trailing, 36 | maxWait: typeof maxWait === "number" ? Math.max(maxWait, wait) : undefined, 37 | }) as T & Cancelable; 38 | 39 | // 覆盖 flush 方法类型 40 | const originalFlush = throttled.flush; 41 | throttled.flush = (() => { 42 | const flushed = originalFlush(); 43 | lastArgs = lastThis = null; 44 | return flushed; 45 | }) as typeof originalFlush; 46 | 47 | return throttled; 48 | } 49 | -------------------------------------------------------------------------------- /src/utils/transformers.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IOriginPageItem, 3 | IOriginResult, 4 | IOriginResultListItem, 5 | IOriginFieldItem, 6 | IFieldItem, 7 | IResultListItem, 8 | IRectItem, 9 | } from "../types/keyVal"; 10 | import { ensureArray, sortList } from "./object"; 11 | import { generateUUID } from "./uuid"; 12 | 13 | const convertFieldtem = 14 | (parentUid: string) => 15 | ({ 16 | value, 17 | description, 18 | key, 19 | position, 20 | type, 21 | }: IOriginFieldItem): IFieldItem => ({ 22 | type, 23 | position: ensureArray(position), 24 | value, 25 | description: description || key || "", 26 | key, 27 | uid: generateUUID(), 28 | parent_uid: parentUid, 29 | }); 30 | 31 | export const convertApiDataToResultList = ( 32 | res: IOriginResult 33 | ): IResultListItem[] => { 34 | const objectList: IOriginResultListItem[] = res.pages.reduce( 35 | (pre: IOriginResultListItem[], cur, i: number) => [ 36 | ...pre, 37 | ...(cur.result.object_list || []).map((item) => ({ 38 | ...item, 39 | page_id: i, 40 | })), 41 | ], 42 | [] 43 | ); 44 | 45 | return objectList.map( 46 | ( 47 | { 48 | position, 49 | type, 50 | type_description, 51 | item_list = [], 52 | flight_data_list, 53 | product_list, 54 | transport_list, 55 | stamp_list, 56 | qr_code_list, 57 | page_id = 0, 58 | }, 59 | idx 60 | ) => { 61 | const tableData = 62 | product_list || flight_data_list || transport_list || []; 63 | const otherList = [ 64 | ...ensureArray(stamp_list).map((list) => 65 | ensureArray(list).map((item) => ({ 66 | ...item, 67 | type: "stamp", 68 | })) 69 | ), 70 | ...ensureArray(qr_code_list).map((list) => 71 | ensureArray(list).map((item) => ({ 72 | ...item, 73 | type: "image", 74 | })) 75 | ), 76 | ]; 77 | const typeItem = ensureArray(item_list).find( 78 | (item) => item.key === "invoice_type" 79 | ); 80 | const parentUid = generateUUID(); 81 | return { 82 | uid: parentUid, 83 | position: ensureArray(position).slice(0, 8), 84 | type, 85 | no: idx + 1, 86 | description: typeItem?.value || type_description, 87 | list: sortList( 88 | ensureArray(item_list).map( 89 | convertFieldtem(parentUid) 90 | ) 91 | ), 92 | flightList: [ 93 | ...tableData.map((item) => 94 | sortList(item.map(convertFieldtem(parentUid))) 95 | ), 96 | ...otherList.map((item) => 97 | sortList(item.map(convertFieldtem(parentUid))) 98 | ), 99 | ], 100 | page_id: page_id + 1, 101 | } as IResultListItem; 102 | } 103 | ); 104 | }; 105 | 106 | export const transformKeyValApiResultToView = (result: IOriginResult) => { 107 | const resultList = convertApiDataToResultList(result); 108 | const rects = resultList.reduce((pre: Record[][], item) => { 109 | if (typeof item.page_id === "number") { 110 | const pageIndex = item.page_id - 1; 111 | if (!pre[pageIndex]) { 112 | pre[pageIndex] = []; 113 | } 114 | 115 | if (Array.isArray(item.list)) { 116 | for (const row of item.list) { 117 | pre[pageIndex].push({ 118 | content_id: row.uid, 119 | position: row.position, 120 | parent_id: item.uid, 121 | }); 122 | } 123 | } 124 | let preItem; 125 | if (Array.isArray(item.flightList)) { 126 | for (const rowList of item.flightList) { 127 | for (const row of rowList) { 128 | // 印章多个字段共用一个坐标框 129 | if (row.type === "stamp") { 130 | if ( 131 | preItem?.type === row.type && 132 | preItem.position?.join() === row.position?.join() 133 | ) { 134 | continue; 135 | } 136 | } 137 | preItem = { ...row }; 138 | pre[pageIndex].push({ 139 | content_id: row.uid, 140 | position: row.position, 141 | type: row.type, 142 | parent_id: item.uid, 143 | }); 144 | } 145 | } 146 | } 147 | 148 | pre[pageIndex].push({ 149 | content_id: item.uid, 150 | position: item.position, 151 | type: "category", 152 | render_text: item.no, 153 | }); 154 | } 155 | 156 | return pre; 157 | }, []); 158 | const finalRects = rects.map((rect) => 159 | rect 160 | .map( 161 | (item, i) => 162 | ({ 163 | ...item, 164 | uid: item.content_id, 165 | content_id: item.content_id, 166 | position: item.position, 167 | type: item.type, 168 | rect_type: item.rect_type, 169 | angle: item.angle || 0, 170 | renderText: item.render_text, 171 | sort: ["category"].includes(item.type as string) ? i - 10000 : i, 172 | parent_uid: item.parent_id, 173 | } as IRectItem) 174 | ) 175 | .sort((a, b) => a.sort - b.sort) 176 | ); 177 | const pages = ensureArray(result?.pages).map((item) => ({ 178 | ...item, 179 | width: item.result.width, 180 | height: item.result.height, 181 | })); 182 | return { result: resultList, rects: finalRects, pages }; 183 | }; 184 | -------------------------------------------------------------------------------- /src/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | export const generateUUID = (): string => { 2 | let dateData = new Date().getUTCDate(); 3 | const uuid = "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace( 4 | /[xy]/g, 5 | (c: string) => { 6 | const randomData = (dateData + Math.random() * 16) % 16 | 0; 7 | dateData = Math.floor(dateData / 16); 8 | return (c === "x" ? randomData : (randomData & 0x3) | 0x8).toString(16); 9 | } 10 | ); 11 | return uuid; 12 | }; 13 | -------------------------------------------------------------------------------- /src/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | -------------------------------------------------------------------------------- /tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", 4 | "target": "ES2020", 5 | "useDefineForClassFields": true, 6 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 7 | "module": "ESNext", 8 | "skipLibCheck": true, 9 | "noImplicitAny": true, 10 | 11 | /* Bundler mode */ 12 | "moduleResolution": "bundler", 13 | "allowImportingTsExtensions": true, 14 | "isolatedModules": true, 15 | "moduleDetection": "force", 16 | "noEmit": true, 17 | "jsx": "react-jsx", 18 | 19 | /* Linting */ 20 | "strict": true, 21 | "noUnusedLocals": true, 22 | "noUnusedParameters": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUncheckedSideEffectImports": true, 25 | 26 | "composite": true 27 | }, 28 | "include": ["src"], 29 | } 30 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "useDefineForClassFields": true, 5 | "lib": ["ES2020", "DOM", "DOM.Iterable"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | "moduleResolution": "bundler", 9 | "allowImportingTsExtensions": false, 10 | "resolveJsonModule": true, 11 | "isolatedModules": true, 12 | "jsx": "react-jsx", 13 | "strict": true, 14 | "noUnusedLocals": false, 15 | "noUnusedParameters": true, 16 | "noFallthroughCasesInSwitch": true, 17 | "declaration": true, 18 | "declarationDir": "./dist", 19 | "emitDeclarationOnly": true, 20 | "outDir": "./dist", 21 | "rootDir": "./src" 22 | }, 23 | "include": ["src/**/*"], 24 | "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.test.tsx"], 25 | "references": [{ "path": "./tsconfig.node.json" }] 26 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", 4 | "target": "ES2022", 5 | "lib": ["ES2023"], 6 | "module": "ESNext", 7 | "skipLibCheck": true, 8 | 9 | /* Bundler mode */ 10 | "moduleResolution": "bundler", 11 | "allowImportingTsExtensions": true, 12 | "isolatedModules": true, 13 | "moduleDetection": "force", 14 | "declaration": true, 15 | "declarationDir": "dist", 16 | "emitDeclarationOnly": true, 17 | 18 | /* Linting */ 19 | "strict": true, 20 | "noUnusedLocals": true, 21 | "noUnusedParameters": true, 22 | "noFallthroughCasesInSwitch": true, 23 | "noUncheckedSideEffectImports": true, 24 | 25 | "composite": true 26 | }, 27 | "include": ["vite.config.ts"] 28 | } 29 | -------------------------------------------------------------------------------- /tsconfig.tsbuildinfo: -------------------------------------------------------------------------------- 1 | {"root":["./src/app.tsx","./src/index.ts","./src/main.tsx","./src/vite-env.d.ts","./src/components/filepreview/index.tsx","./src/components/filepreview/types.ts","./src/components/filepreview/imagesviewer/index.tsx","./src/components/filepreview/pdfviewer/index.tsx","./src/components/filepreview/pdfviewer/types.ts","./src/components/filepreview/pagination/index.tsx","./src/components/filepreview/toolbar/index.tsx","./src/components/jsonview/index.tsx","./src/components/loading/index.tsx","./src/components/marklayer/helpers.ts","./src/components/marklayer/index.tsx","./src/components/marklayer/svgrect/text.tsx","./src/components/marklayer/svgrect/index.tsx","./src/components/radiogroup/index.tsx","./src/components/resultview/footer.tsx","./src/components/resultview/header.tsx","./src/components/resultview/keyvaluelist.tsx","./src/components/resultview/keyvaluetable.tsx","./src/components/resultview/index.tsx","./src/examples/imageexample.tsx","./src/examples/pdfexample.tsx","./src/examples/data.ts","./src/hooks/usecontentlinkage.ts","./src/hooks/useframesetstate.ts","./src/hooks/useloadpdflib.ts","./src/hooks/usemarktool.ts","./src/hooks/usepdfmarklayer.ts","./src/hooks/usepreviewtool.tsx","./src/types/common.ts","./src/types/global.d.ts","./src/types/keyval.ts","./src/utils/browser.ts","./src/utils/debounce.ts","./src/utils/dom.ts","./src/utils/object.ts","./src/utils/throttle.ts","./src/utils/transformers.ts","./src/utils/uuid.ts"],"version":"5.7.3"} -------------------------------------------------------------------------------- /vite.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import react from "@vitejs/plugin-react"; 3 | import svgr from "vite-plugin-svgr"; 4 | import { resolve } from 'path'; 5 | import dts from 'vite-plugin-dts'; 6 | 7 | // https://vite.dev/config/ 8 | export default defineConfig({ 9 | plugins: [ 10 | react(), 11 | svgr(), 12 | dts({ 13 | include: ['src/**/*'], 14 | outDir: 'dist', 15 | rollupTypes: true 16 | }) 17 | ], 18 | build: { 19 | lib: { 20 | entry: resolve(__dirname, 'src/index.ts'), 21 | name: 'TextinOcrFrontend', 22 | fileName: (format) => `index.${format === 'es' ? 'mjs' : 'js'}` 23 | }, 24 | rollupOptions: { 25 | external: ['react', 'react-dom'], 26 | output: { 27 | globals: { 28 | react: 'React', 29 | 'react-dom': 'ReactDOM' 30 | } 31 | } 32 | } 33 | } 34 | }); 35 | --------------------------------------------------------------------------------