omni-viewer-core 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/THIRD_PARTY_NOTICES.md +49 -0
- package/dist/assets/audio-engine/audio_engine.mjs +2 -0
- package/dist/assets/audio-engine/audio_engine.wasm +0 -0
- package/dist/assets/pdfjs/pdf.worker.min.mjs +21 -0
- package/dist/host/index.d.ts +133 -0
- package/dist/host/index.js +10 -0
- package/dist/i18n/catalog.en.d.ts +1 -0
- package/dist/i18n/catalog.en.js +411 -0
- package/dist/i18n/catalog.ko.d.ts +1 -0
- package/dist/i18n/catalog.ko.js +68 -0
- package/dist/i18n/index.d.ts +18 -0
- package/dist/i18n/index.js +41 -0
- package/dist/licenses/emscripten.txt +101 -0
- package/dist/licenses/kissfft.txt +28 -0
- package/dist/licenses/pdfjs-dist.txt +177 -0
- package/dist/parsers/a2l/index.d.ts +1 -0
- package/dist/parsers/a2l/index.js +1 -0
- package/dist/parsers/archive/index.d.ts +22 -0
- package/dist/parsers/archive/index.js +46 -0
- package/dist/parsers/archive/model.d.ts +29 -0
- package/dist/parsers/archive/model.js +1 -0
- package/dist/parsers/archive/worker.d.ts +21 -0
- package/dist/parsers/archive/worker.js +1 -0
- package/dist/parsers/arxml/index.d.ts +1 -0
- package/dist/parsers/arxml/index.js +1 -0
- package/dist/parsers/asc/index.d.ts +1 -0
- package/dist/parsers/asc/index.js +1 -0
- package/dist/parsers/audio/index.d.ts +9 -0
- package/dist/parsers/audio/index.js +46 -0
- package/dist/parsers/automotive/index.d.ts +33 -0
- package/dist/parsers/automotive/index.js +973 -0
- package/dist/parsers/blf/index.d.ts +1 -0
- package/dist/parsers/blf/index.js +1 -0
- package/dist/parsers/csv/delimiter.d.ts +31 -0
- package/dist/parsers/csv/delimiter.js +151 -0
- package/dist/parsers/csv/index.d.ts +30 -0
- package/dist/parsers/csv/index.js +108 -0
- package/dist/parsers/csv/parseText.d.ts +20 -0
- package/dist/parsers/csv/parseText.js +108 -0
- package/dist/parsers/csv/sort.d.ts +26 -0
- package/dist/parsers/csv/sort.js +224 -0
- package/dist/parsers/csv/statistics.d.ts +49 -0
- package/dist/parsers/csv/statistics.js +150 -0
- package/dist/parsers/dbc/index.d.ts +56 -0
- package/dist/parsers/dbc/index.js +94 -0
- package/dist/parsers/doc-binary/index.d.ts +35 -0
- package/dist/parsers/doc-binary/index.js +267 -0
- package/dist/parsers/doc-binary/vscode-parser.d.ts +142 -0
- package/dist/parsers/doc-binary/vscode-parser.js +3747 -0
- package/dist/parsers/excel/index.d.ts +17 -0
- package/dist/parsers/excel/index.js +406 -0
- package/dist/parsers/excel/model.d.ts +80 -0
- package/dist/parsers/excel/model.js +48 -0
- package/dist/parsers/excel/serialize.d.ts +3 -0
- package/dist/parsers/excel/serialize.js +78 -0
- package/dist/parsers/hdf5/index.d.ts +75 -0
- package/dist/parsers/hdf5/index.js +658 -0
- package/dist/parsers/hdf5/node.d.ts +21 -0
- package/dist/parsers/hdf5/node.js +95 -0
- package/dist/parsers/json/index.d.ts +15 -0
- package/dist/parsers/json/index.js +102 -0
- package/dist/parsers/json/model.d.ts +42 -0
- package/dist/parsers/json/model.js +140 -0
- package/dist/parsers/json/parse.d.ts +22 -0
- package/dist/parsers/json/parse.js +279 -0
- package/dist/parsers/json/tokenizer.d.ts +17 -0
- package/dist/parsers/json/tokenizer.js +175 -0
- package/dist/parsers/jsonl/index.d.ts +14 -0
- package/dist/parsers/jsonl/index.js +18 -0
- package/dist/parsers/jsonl/model.d.ts +23 -0
- package/dist/parsers/jsonl/model.js +1 -0
- package/dist/parsers/jsonl/parse.d.ts +7 -0
- package/dist/parsers/jsonl/parse.js +34 -0
- package/dist/parsers/markdown/index.d.ts +37 -0
- package/dist/parsers/markdown/index.js +59 -0
- package/dist/parsers/mat/index.d.ts +31 -0
- package/dist/parsers/mat/index.js +426 -0
- package/dist/parsers/mermaid/index.d.ts +5 -0
- package/dist/parsers/mermaid/index.js +3 -0
- package/dist/parsers/mf4/index.d.ts +1 -0
- package/dist/parsers/mf4/index.js +1 -0
- package/dist/parsers/parquet/index.d.ts +21 -0
- package/dist/parsers/parquet/index.js +68 -0
- package/dist/parsers/pcap/index.d.ts +1 -0
- package/dist/parsers/pcap/index.js +1 -0
- package/dist/parsers/pcapng/index.d.ts +1 -0
- package/dist/parsers/pcapng/index.js +1 -0
- package/dist/parsers/plantuml/index.d.ts +5 -0
- package/dist/parsers/plantuml/index.js +4 -0
- package/dist/parsers/ppt-binary/index.d.ts +5 -0
- package/dist/parsers/ppt-binary/index.js +62 -0
- package/dist/parsers/ppt-binary/pptBinaryBuildContext.d.ts +12 -0
- package/dist/parsers/ppt-binary/pptBinaryBuildContext.js +40 -0
- package/dist/parsers/ppt-binary/pptBinaryContainer.d.ts +4 -0
- package/dist/parsers/ppt-binary/pptBinaryContainer.js +174 -0
- package/dist/parsers/ppt-binary/pptBinaryTypes.d.ts +125 -0
- package/dist/parsers/ppt-binary/pptBinaryTypes.js +1 -0
- package/dist/parsers/ppt-binary/pptSlideLayouts.d.ts +9 -0
- package/dist/parsers/ppt-binary/pptSlideLayouts.js +510 -0
- package/dist/parsers/ppt-binary/vscode-parser.d.ts +116 -0
- package/dist/parsers/ppt-binary/vscode-parser.js +2853 -0
- package/dist/parsers/ppt-binary/vscode.d.ts +4 -0
- package/dist/parsers/ppt-binary/vscode.js +12 -0
- package/dist/parsers/pptx/index.d.ts +15 -0
- package/dist/parsers/pptx/index.js +88 -0
- package/dist/parsers/pptx/vscode-parser.d.ts +167 -0
- package/dist/parsers/pptx/vscode-parser.js +1503 -0
- package/dist/parsers/pptx/vscode.d.ts +4 -0
- package/dist/parsers/pptx/vscode.js +18 -0
- package/dist/parsers/pptx/zip-reader.d.ts +3 -0
- package/dist/parsers/pptx/zip-reader.js +20 -0
- package/dist/parsers/proto/index.d.ts +90 -0
- package/dist/parsers/proto/index.js +197 -0
- package/dist/parsers/psd/index.d.ts +16 -0
- package/dist/parsers/psd/index.js +10 -0
- package/dist/parsers/shapefile/dbf.d.ts +23 -0
- package/dist/parsers/shapefile/dbf.js +112 -0
- package/dist/parsers/shapefile/index.d.ts +44 -0
- package/dist/parsers/shapefile/index.js +119 -0
- package/dist/parsers/shapefile/reproject.d.ts +10 -0
- package/dist/parsers/shapefile/reproject.js +41 -0
- package/dist/parsers/slide-model.d.ts +106 -0
- package/dist/parsers/slide-model.js +1 -0
- package/dist/parsers/toml/index.d.ts +15 -0
- package/dist/parsers/toml/index.js +18 -0
- package/dist/parsers/toml/model.d.ts +15 -0
- package/dist/parsers/toml/model.js +1 -0
- package/dist/parsers/toml/parse.d.ts +16 -0
- package/dist/parsers/toml/parse.js +266 -0
- package/dist/parsers/types.d.ts +103 -0
- package/dist/parsers/types.js +106 -0
- package/dist/parsers/video/index.d.ts +6 -0
- package/dist/parsers/video/index.js +2 -0
- package/dist/parsers/yaml/index.d.ts +16 -0
- package/dist/parsers/yaml/index.js +58 -0
- package/dist/parsers/yaml/model.d.ts +22 -0
- package/dist/parsers/yaml/model.js +1 -0
- package/dist/parsers/yaml/self-loading.d.ts +3 -0
- package/dist/parsers/yaml/self-loading.js +19 -0
- package/dist/parsers/zip-scan.d.ts +8 -0
- package/dist/parsers/zip-scan.js +80 -0
- package/dist/registry/container.d.ts +17 -0
- package/dist/registry/container.js +49 -0
- package/dist/registry/index.d.ts +133 -0
- package/dist/registry/index.js +348 -0
- package/dist/registry/probe.d.ts +3 -0
- package/dist/registry/probe.js +177 -0
- package/dist/registry/sniff.d.ts +17 -0
- package/dist/registry/sniff.js +57 -0
- package/dist/styles/archive.css +10 -0
- package/dist/styles/audio.css +66 -0
- package/dist/styles/automotive.css +1 -0
- package/dist/styles/csv.css +262 -0
- package/dist/styles/excel.css +212 -0
- package/dist/styles/fallback.css +48 -0
- package/dist/styles/hdf5.css +3 -0
- package/dist/styles/hwp.css +12 -0
- package/dist/styles/image.css +115 -0
- package/dist/styles/json.css +342 -0
- package/dist/styles/jsonl.css +1 -0
- package/dist/styles/markdown.css +10 -0
- package/dist/styles/mat.css +2 -0
- package/dist/styles/mermaid.css +1 -0
- package/dist/styles/parquet.css +2 -0
- package/dist/styles/pdf.css +551 -0
- package/dist/styles/plantuml.css +1 -0
- package/dist/styles/ppt.css +1 -0
- package/dist/styles/proto.css +2 -0
- package/dist/styles/psd.css +1 -0
- package/dist/styles/shapefile.css +25 -0
- package/dist/styles/toml.css +1 -0
- package/dist/styles/video.css +74 -0
- package/dist/styles/word.css +685 -0
- package/dist/styles/yaml.css +1 -0
- package/dist/viewers/a2l/index.d.ts +15 -0
- package/dist/viewers/a2l/index.js +5 -0
- package/dist/viewers/archive/archive-preview-decoder.d.ts +4 -0
- package/dist/viewers/archive/archive-preview-decoder.js +223 -0
- package/dist/viewers/archive/controller.d.ts +24 -0
- package/dist/viewers/archive/controller.js +18 -0
- package/dist/viewers/archive/index.d.ts +25 -0
- package/dist/viewers/archive/index.js +230 -0
- package/dist/viewers/archive/preview.d.ts +2 -0
- package/dist/viewers/archive/preview.js +2 -0
- package/dist/viewers/archive/styles.d.ts +1 -0
- package/dist/viewers/archive/styles.js +11 -0
- package/dist/viewers/arxml/index.d.ts +15 -0
- package/dist/viewers/arxml/index.js +5 -0
- package/dist/viewers/asc/index.d.ts +15 -0
- package/dist/viewers/asc/index.js +5 -0
- package/dist/viewers/audio/controller.d.ts +33 -0
- package/dist/viewers/audio/controller.js +49 -0
- package/dist/viewers/audio/engine.d.ts +44 -0
- package/dist/viewers/audio/engine.js +108 -0
- package/dist/viewers/audio/index.d.ts +92 -0
- package/dist/viewers/audio/index.js +448 -0
- package/dist/viewers/audio/self-loading.d.ts +7 -0
- package/dist/viewers/audio/self-loading.js +42 -0
- package/dist/viewers/audio/styles.d.ts +1 -0
- package/dist/viewers/audio/styles.js +73 -0
- package/dist/viewers/automotive/index.d.ts +8 -0
- package/dist/viewers/automotive/index.js +152 -0
- package/dist/viewers/automotive/styles.d.ts +1 -0
- package/dist/viewers/automotive/styles.js +1 -0
- package/dist/viewers/avro/index.d.ts +15 -0
- package/dist/viewers/avro/index.js +5 -0
- package/dist/viewers/bag/index.d.ts +15 -0
- package/dist/viewers/bag/index.js +5 -0
- package/dist/viewers/blf/index.d.ts +15 -0
- package/dist/viewers/blf/index.js +5 -0
- package/dist/viewers/csv/controller.d.ts +126 -0
- package/dist/viewers/csv/controller.js +598 -0
- package/dist/viewers/csv/index.d.ts +48 -0
- package/dist/viewers/csv/index.js +846 -0
- package/dist/viewers/csv/styles.d.ts +1 -0
- package/dist/viewers/csv/styles.js +267 -0
- package/dist/viewers/db3/index.d.ts +15 -0
- package/dist/viewers/db3/index.js +5 -0
- package/dist/viewers/dbc/index.d.ts +15 -0
- package/dist/viewers/dbc/index.js +5 -0
- package/dist/viewers/diagram.d.ts +14 -0
- package/dist/viewers/diagram.js +95 -0
- package/dist/viewers/excel/controller.d.ts +108 -0
- package/dist/viewers/excel/controller.js +548 -0
- package/dist/viewers/excel/grid.d.ts +28 -0
- package/dist/viewers/excel/grid.js +99 -0
- package/dist/viewers/excel/index.d.ts +37 -0
- package/dist/viewers/excel/index.js +620 -0
- package/dist/viewers/excel/self-loading.d.ts +6 -0
- package/dist/viewers/excel/self-loading.js +13 -0
- package/dist/viewers/excel/styles.d.ts +1 -0
- package/dist/viewers/excel/styles.js +216 -0
- package/dist/viewers/fallback/index.d.ts +17 -0
- package/dist/viewers/fallback/index.js +130 -0
- package/dist/viewers/fallback/styles.d.ts +1 -0
- package/dist/viewers/fallback/styles.js +49 -0
- package/dist/viewers/hdf5/index.d.ts +19 -0
- package/dist/viewers/hdf5/index.js +170 -0
- package/dist/viewers/hdf5/styles.d.ts +1 -0
- package/dist/viewers/hdf5/styles.js +4 -0
- package/dist/viewers/hwp/controller.d.ts +19 -0
- package/dist/viewers/hwp/controller.js +22 -0
- package/dist/viewers/hwp/index.d.ts +39 -0
- package/dist/viewers/hwp/index.js +271 -0
- package/dist/viewers/hwp/self-loading.d.ts +2 -0
- package/dist/viewers/hwp/self-loading.js +6 -0
- package/dist/viewers/hwp/styles.d.ts +1 -0
- package/dist/viewers/hwp/styles.js +13 -0
- package/dist/viewers/image/controller.d.ts +210 -0
- package/dist/viewers/image/controller.js +336 -0
- package/dist/viewers/image/decode.d.ts +54 -0
- package/dist/viewers/image/decode.js +150 -0
- package/dist/viewers/image/index.d.ts +38 -0
- package/dist/viewers/image/index.js +749 -0
- package/dist/viewers/image/limits.d.ts +18 -0
- package/dist/viewers/image/limits.js +20 -0
- package/dist/viewers/image/styles.d.ts +1 -0
- package/dist/viewers/image/styles.js +119 -0
- package/dist/viewers/json/controller.d.ts +89 -0
- package/dist/viewers/json/controller.js +411 -0
- package/dist/viewers/json/index.d.ts +25 -0
- package/dist/viewers/json/index.js +503 -0
- package/dist/viewers/json/styles.d.ts +1 -0
- package/dist/viewers/json/styles.js +346 -0
- package/dist/viewers/json/transforms.d.ts +27 -0
- package/dist/viewers/json/transforms.js +253 -0
- package/dist/viewers/jsonl/controller.d.ts +48 -0
- package/dist/viewers/jsonl/controller.js +136 -0
- package/dist/viewers/jsonl/index.d.ts +22 -0
- package/dist/viewers/jsonl/index.js +184 -0
- package/dist/viewers/jsonl/styles.d.ts +1 -0
- package/dist/viewers/jsonl/styles.js +1 -0
- package/dist/viewers/markdown/controller.d.ts +50 -0
- package/dist/viewers/markdown/controller.js +62 -0
- package/dist/viewers/markdown/index.d.ts +64 -0
- package/dist/viewers/markdown/index.js +425 -0
- package/dist/viewers/markdown/math.d.ts +21 -0
- package/dist/viewers/markdown/math.js +100 -0
- package/dist/viewers/markdown/self-loading.d.ts +5 -0
- package/dist/viewers/markdown/self-loading.js +48 -0
- package/dist/viewers/markdown/styles.d.ts +1 -0
- package/dist/viewers/markdown/styles.js +11 -0
- package/dist/viewers/mat/index.d.ts +19 -0
- package/dist/viewers/mat/index.js +113 -0
- package/dist/viewers/mat/styles.d.ts +1 -0
- package/dist/viewers/mat/styles.js +3 -0
- package/dist/viewers/media.d.ts +9 -0
- package/dist/viewers/media.js +77 -0
- package/dist/viewers/mermaid/index.d.ts +17 -0
- package/dist/viewers/mermaid/index.js +6 -0
- package/dist/viewers/mermaid/self-loading.d.ts +2 -0
- package/dist/viewers/mermaid/self-loading.js +2 -0
- package/dist/viewers/mf4/index.d.ts +15 -0
- package/dist/viewers/mf4/index.js +5 -0
- package/dist/viewers/parquet/controller.d.ts +37 -0
- package/dist/viewers/parquet/controller.js +26 -0
- package/dist/viewers/parquet/index.d.ts +23 -0
- package/dist/viewers/parquet/index.js +134 -0
- package/dist/viewers/parquet/styles.d.ts +1 -0
- package/dist/viewers/parquet/styles.js +3 -0
- package/dist/viewers/pcap/index.d.ts +15 -0
- package/dist/viewers/pcap/index.js +5 -0
- package/dist/viewers/pcapng/index.d.ts +15 -0
- package/dist/viewers/pcapng/index.js +5 -0
- package/dist/viewers/pdf/controller.d.ts +120 -0
- package/dist/viewers/pdf/controller.js +191 -0
- package/dist/viewers/pdf/editing.d.ts +26 -0
- package/dist/viewers/pdf/editing.js +265 -0
- package/dist/viewers/pdf/index.d.ts +88 -0
- package/dist/viewers/pdf/index.js +1400 -0
- package/dist/viewers/pdf/self-loading.d.ts +6 -0
- package/dist/viewers/pdf/self-loading.js +14 -0
- package/dist/viewers/pdf/styles.d.ts +1 -0
- package/dist/viewers/pdf/styles.js +555 -0
- package/dist/viewers/plantuml/index.d.ts +17 -0
- package/dist/viewers/plantuml/index.js +6 -0
- package/dist/viewers/plantuml/self-loading.d.ts +2 -0
- package/dist/viewers/plantuml/self-loading.js +2 -0
- package/dist/viewers/ppt/chart.d.ts +8 -0
- package/dist/viewers/ppt/chart.js +83 -0
- package/dist/viewers/ppt/controller.d.ts +34 -0
- package/dist/viewers/ppt/controller.js +27 -0
- package/dist/viewers/ppt/index.d.ts +33 -0
- package/dist/viewers/ppt/index.js +101 -0
- package/dist/viewers/ppt/render.d.ts +5 -0
- package/dist/viewers/ppt/render.js +144 -0
- package/dist/viewers/ppt/self-loading.d.ts +9 -0
- package/dist/viewers/ppt/self-loading.js +4 -0
- package/dist/viewers/ppt/styles.d.ts +1 -0
- package/dist/viewers/ppt/styles.js +1 -0
- package/dist/viewers/proto/index.d.ts +17 -0
- package/dist/viewers/proto/index.js +170 -0
- package/dist/viewers/proto/styles.d.ts +1 -0
- package/dist/viewers/proto/styles.js +2 -0
- package/dist/viewers/psd/index.d.ts +27 -0
- package/dist/viewers/psd/index.js +39 -0
- package/dist/viewers/psd/self-loading.d.ts +10 -0
- package/dist/viewers/psd/self-loading.js +20 -0
- package/dist/viewers/psd/styles.d.ts +1 -0
- package/dist/viewers/psd/styles.js +1 -0
- package/dist/viewers/reqif/index.d.ts +15 -0
- package/dist/viewers/reqif/index.js +5 -0
- package/dist/viewers/shapefile/index.d.ts +35 -0
- package/dist/viewers/shapefile/index.js +336 -0
- package/dist/viewers/shapefile/self-loading.d.ts +7 -0
- package/dist/viewers/shapefile/self-loading.js +32 -0
- package/dist/viewers/shapefile/styles.d.ts +1 -0
- package/dist/viewers/shapefile/styles.js +28 -0
- package/dist/viewers/stp/index.d.ts +15 -0
- package/dist/viewers/stp/index.js +5 -0
- package/dist/viewers/structured-styles.d.ts +1 -0
- package/dist/viewers/structured-styles.js +1 -0
- package/dist/viewers/structured.d.ts +36 -0
- package/dist/viewers/structured.js +138 -0
- package/dist/viewers/toml/controller.d.ts +38 -0
- package/dist/viewers/toml/controller.js +61 -0
- package/dist/viewers/toml/index.d.ts +18 -0
- package/dist/viewers/toml/index.js +34 -0
- package/dist/viewers/types.d.ts +36 -0
- package/dist/viewers/types.js +9 -0
- package/dist/viewers/video/controller.d.ts +29 -0
- package/dist/viewers/video/controller.js +54 -0
- package/dist/viewers/video/index.d.ts +18 -0
- package/dist/viewers/video/index.js +305 -0
- package/dist/viewers/video/styles.d.ts +1 -0
- package/dist/viewers/video/styles.js +79 -0
- package/dist/viewers/word/controller.d.ts +19 -0
- package/dist/viewers/word/controller.js +22 -0
- package/dist/viewers/word/docx-preprocess.d.ts +63 -0
- package/dist/viewers/word/docx-preprocess.js +136 -0
- package/dist/viewers/word/index.d.ts +32 -0
- package/dist/viewers/word/index.js +187 -0
- package/dist/viewers/word/normalize-docx.d.ts +2 -0
- package/dist/viewers/word/normalize-docx.js +50 -0
- package/dist/viewers/word/paginate.d.ts +1 -0
- package/dist/viewers/word/paginate.js +1035 -0
- package/dist/viewers/word/self-loading.d.ts +2 -0
- package/dist/viewers/word/self-loading.js +7 -0
- package/dist/viewers/word/styles.d.ts +1 -0
- package/dist/viewers/word/styles.js +686 -0
- package/dist/viewers/yaml/controller.d.ts +42 -0
- package/dist/viewers/yaml/controller.js +41 -0
- package/dist/viewers/yaml/index.d.ts +21 -0
- package/dist/viewers/yaml/index.js +20 -0
- package/package.json +375 -0
|
@@ -0,0 +1,1400 @@
|
|
|
1
|
+
// PDF viewer — DOM renderer over pdf.js (DESIGN.md §3-②, docs/viewers/pdf.md).
|
|
2
|
+
//
|
|
3
|
+
// Base entry rule (ADR 14): no external imports here. pdf.js and pdf-lib are
|
|
4
|
+
// injected through `PdfViewerDeps`; `viewers/pdf/self-loading` supplies the
|
|
5
|
+
// dynamic-import loaders for platforms whose bundler can resolve the optional
|
|
6
|
+
// peers. Without `loadPdfLib` the editing features render disabled with a
|
|
7
|
+
// reason (degraded mode, ADR 24) — reading always works.
|
|
8
|
+
//
|
|
9
|
+
// CSP rules: no eval, no inline handlers, no innerHTML. No window.prompt /
|
|
10
|
+
// alert / confirm — those are blocked inside the VS Code webview, so text
|
|
11
|
+
// annotations use an inline input overlay instead.
|
|
12
|
+
import { MountAbortedError, VIEWER_ROOT_CLASS } from '../types.js';
|
|
13
|
+
import { createPdfController, mergeHighlightRects, PDF_ZOOM_LEVELS } from './controller.js';
|
|
14
|
+
import { buildSavedPdf, mergePdfBytes, parseLayer, savedPdfName, SIDECAR_LAYER_NAME, SIDECAR_ORIGINAL_NAME } from './editing.js';
|
|
15
|
+
import { pdfViewerCss } from './styles.js';
|
|
16
|
+
export { createPdfController, PDF_ZOOM_LEVELS } from './controller.js';
|
|
17
|
+
export { pdfViewerCss } from './styles.js';
|
|
18
|
+
export { buildEditedPdf, mergePdfBytes, savedPdfName } from './editing.js';
|
|
19
|
+
/** Viewer metadata — single source for the registry codegen (DESIGN.md §7). */
|
|
20
|
+
export const PDF_VIEWER_META = {
|
|
21
|
+
id: 'pdf',
|
|
22
|
+
displayNameKey: 'pdf.title',
|
|
23
|
+
extensions: ['pdf'],
|
|
24
|
+
priority: 20,
|
|
25
|
+
requiredServices: [],
|
|
26
|
+
optionalServices: ['save', 'writeback', 'filePick'],
|
|
27
|
+
inputOwnership: 'borrows'
|
|
28
|
+
};
|
|
29
|
+
const THUMB_FALLBACK_RENDER_COUNT = 10;
|
|
30
|
+
function isPasswordError(error) {
|
|
31
|
+
return (typeof error === 'object' &&
|
|
32
|
+
error !== null &&
|
|
33
|
+
error.name === 'PasswordException');
|
|
34
|
+
}
|
|
35
|
+
export async function mountPdfViewer(input, container, ctx, deps, options = {}) {
|
|
36
|
+
if (options.signal?.aborted)
|
|
37
|
+
throw new MountAbortedError();
|
|
38
|
+
const t = ctx.i18n.t.bind(ctx.i18n);
|
|
39
|
+
// ------------------------------------------------------------- root/frame
|
|
40
|
+
const isolation = options.styleIsolation ?? 'shadow';
|
|
41
|
+
let root;
|
|
42
|
+
if (isolation === 'shadow' && typeof container.attachShadow === 'function') {
|
|
43
|
+
root = container.shadowRoot ?? container.attachShadow({ mode: 'open' });
|
|
44
|
+
root.replaceChildren();
|
|
45
|
+
const style = document.createElement('style');
|
|
46
|
+
style.textContent = pdfViewerCss;
|
|
47
|
+
root.appendChild(style);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
container.classList.add(VIEWER_ROOT_CLASS, 'omni-viewer--pdf');
|
|
51
|
+
root = container;
|
|
52
|
+
}
|
|
53
|
+
const el = (tag, className, text) => {
|
|
54
|
+
const node = document.createElement(tag);
|
|
55
|
+
if (className)
|
|
56
|
+
node.className = className;
|
|
57
|
+
if (text !== undefined)
|
|
58
|
+
node.textContent = text;
|
|
59
|
+
return node;
|
|
60
|
+
};
|
|
61
|
+
const frame = el('div', 'omni-pdf');
|
|
62
|
+
const header = el('div', 'omni-pdf__header');
|
|
63
|
+
const title = el('div', 'omni-pdf__title', input.fileName);
|
|
64
|
+
const toolbar = el('div', 'omni-pdf__toolbar');
|
|
65
|
+
const pageInfo = el('span', 'omni-pdf__page-info');
|
|
66
|
+
const pageInput = el('input', 'omni-pdf__page-input');
|
|
67
|
+
pageInput.type = 'number';
|
|
68
|
+
pageInput.min = '1';
|
|
69
|
+
pageInput.step = '1';
|
|
70
|
+
pageInput.value = '1';
|
|
71
|
+
pageInput.setAttribute('aria-label', t('pdf.goToPage'));
|
|
72
|
+
const pageTotal = el('span', 'omni-pdf__page-total', '/ 0');
|
|
73
|
+
pageInfo.append(pageInput, pageTotal);
|
|
74
|
+
header.append(title, toolbar);
|
|
75
|
+
const zoomBar = el('div', 'omni-pdf__zoom');
|
|
76
|
+
const body = el('div', 'omni-pdf__body');
|
|
77
|
+
const thumbs = el('div', 'omni-pdf__thumbs');
|
|
78
|
+
const pages = el('div', 'omni-pdf__pages');
|
|
79
|
+
body.append(thumbs, pages);
|
|
80
|
+
const status = el('div', 'omni-pdf__status', t('pdf.loading'));
|
|
81
|
+
status.setAttribute('aria-live', 'polite');
|
|
82
|
+
frame.append(header, zoomBar, body, status);
|
|
83
|
+
root.appendChild(frame);
|
|
84
|
+
// ------------------------------------------------------------- lifecycle
|
|
85
|
+
let disposed = false;
|
|
86
|
+
let doc;
|
|
87
|
+
let loadingTask;
|
|
88
|
+
let pageObserver;
|
|
89
|
+
let thumbObserver;
|
|
90
|
+
let records = [];
|
|
91
|
+
let renderEpoch = 0;
|
|
92
|
+
let currentPage = 1;
|
|
93
|
+
let tool = 'view';
|
|
94
|
+
let highlightColor = '#ffeb3b';
|
|
95
|
+
let underlineColor = '#000000';
|
|
96
|
+
let strikeoutColor = '#000000';
|
|
97
|
+
let markupKind = 'highlight';
|
|
98
|
+
let pageLayout = 'single';
|
|
99
|
+
let matchThemeColors = false;
|
|
100
|
+
let sourceBytes = input.data;
|
|
101
|
+
/** A merge changed the document without going through the edit stack. */
|
|
102
|
+
let mergedSinceSave = false;
|
|
103
|
+
let pdfLib;
|
|
104
|
+
let unsubscribe;
|
|
105
|
+
let controller = createPdfController(0);
|
|
106
|
+
/** Layout inputs of the last rebuild — annotation-only changes skip it. */
|
|
107
|
+
let layoutKey = '';
|
|
108
|
+
const abortListener = () => {
|
|
109
|
+
loadingTask?.destroy();
|
|
110
|
+
};
|
|
111
|
+
options.signal?.addEventListener('abort', abortListener, { once: true });
|
|
112
|
+
function throwIfAborted() {
|
|
113
|
+
if (options.signal?.aborted) {
|
|
114
|
+
teardown();
|
|
115
|
+
throw new MountAbortedError();
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function teardown() {
|
|
119
|
+
disposed = true;
|
|
120
|
+
document.removeEventListener('keydown', keyHandler);
|
|
121
|
+
document.removeEventListener('selectionchange', syncMarkupAvailability);
|
|
122
|
+
options.signal?.removeEventListener('abort', abortListener);
|
|
123
|
+
unsubscribe?.();
|
|
124
|
+
pageObserver?.disconnect();
|
|
125
|
+
thumbObserver?.disconnect();
|
|
126
|
+
for (const record of records) {
|
|
127
|
+
record.task?.cancel();
|
|
128
|
+
record.textLayer?.cancel();
|
|
129
|
+
}
|
|
130
|
+
records = [];
|
|
131
|
+
void doc?.destroy();
|
|
132
|
+
doc = undefined;
|
|
133
|
+
root.replaceChildren();
|
|
134
|
+
if (root === container) {
|
|
135
|
+
container.classList.remove(VIEWER_ROOT_CLASS, 'omni-viewer--pdf');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const keyHandler = (keyEvent) => {
|
|
139
|
+
const target = keyEvent.target;
|
|
140
|
+
if ((keyEvent.key === 'Delete' || keyEvent.key === 'Backspace')
|
|
141
|
+
&& !(target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)) {
|
|
142
|
+
const selectedId = controller.state.selectedAnnotationId;
|
|
143
|
+
const selected = controller.state.annotations.find((annotation) => annotation.id === selectedId);
|
|
144
|
+
if (selected) {
|
|
145
|
+
keyEvent.preventDefault();
|
|
146
|
+
controller.dispatch({ type: 'remove-annotation', id: selected.id });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if ((!keyEvent.metaKey && !keyEvent.ctrlKey) || keyEvent.altKey || keyEvent.key.toLowerCase() !== 'z')
|
|
151
|
+
return;
|
|
152
|
+
if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement)
|
|
153
|
+
return;
|
|
154
|
+
keyEvent.preventDefault();
|
|
155
|
+
controller.dispatch({ type: keyEvent.shiftKey ? 'redo' : 'undo' });
|
|
156
|
+
};
|
|
157
|
+
document.addEventListener('keydown', keyHandler);
|
|
158
|
+
// -------------------------------------------------------------- pdf.js
|
|
159
|
+
const pdfjs = await deps.loadPdfjs();
|
|
160
|
+
throwIfAborted();
|
|
161
|
+
pdfjs.GlobalWorkerOptions.workerSrc = await ctx.assets.resolveAssetUrl('assets/pdfjs/pdf.worker.min.mjs');
|
|
162
|
+
throwIfAborted();
|
|
163
|
+
async function loadDocument(bytes, password) {
|
|
164
|
+
const request = {
|
|
165
|
+
// pdf.js transfers the buffer to its worker; keep ours intact.
|
|
166
|
+
data: bytes.slice()
|
|
167
|
+
};
|
|
168
|
+
if (password !== undefined)
|
|
169
|
+
request.password = password;
|
|
170
|
+
loadingTask = pdfjs.getDocument(request);
|
|
171
|
+
try {
|
|
172
|
+
return await loadingTask.promise;
|
|
173
|
+
}
|
|
174
|
+
finally {
|
|
175
|
+
loadingTask = undefined;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Read the hybrid sidecar (pristine source + layer JSON) from a document's
|
|
180
|
+
* attachments. Returns null unless BOTH parts are present and the layer
|
|
181
|
+
* parses — a lone layer over already-flattened pages would double up.
|
|
182
|
+
*/
|
|
183
|
+
async function readSidecar(d) {
|
|
184
|
+
try {
|
|
185
|
+
const attachments = await d.getAttachments?.();
|
|
186
|
+
if (!attachments)
|
|
187
|
+
return null;
|
|
188
|
+
let pristine;
|
|
189
|
+
let layerText;
|
|
190
|
+
for (const entry of Object.values(attachments)) {
|
|
191
|
+
if (entry.filename === SIDECAR_ORIGINAL_NAME)
|
|
192
|
+
pristine = entry.content;
|
|
193
|
+
else if (entry.filename === SIDECAR_LAYER_NAME) {
|
|
194
|
+
layerText = new TextDecoder().decode(entry.content);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
if (!pristine || layerText === undefined)
|
|
198
|
+
return null;
|
|
199
|
+
const layer = parseLayer(layerText);
|
|
200
|
+
return layer ? { pristine, layer } : null;
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// ------------------------------------------------------- password prompt
|
|
207
|
+
function askPassword(retry) {
|
|
208
|
+
return new Promise((resolve) => {
|
|
209
|
+
const overlay = el('div', 'omni-pdf__modal is-open');
|
|
210
|
+
const panel = el('div', 'omni-pdf__panel');
|
|
211
|
+
panel.appendChild(el('div', undefined, t('pdf.passwordRequired')));
|
|
212
|
+
if (retry) {
|
|
213
|
+
panel.appendChild(el('div', 'omni-pdf__error', t('pdf.passwordIncorrect')));
|
|
214
|
+
}
|
|
215
|
+
const inputEl = el('input');
|
|
216
|
+
inputEl.type = 'password';
|
|
217
|
+
inputEl.setAttribute('aria-label', t('pdf.password'));
|
|
218
|
+
const actions = el('div', 'omni-pdf__panel-actions');
|
|
219
|
+
const cancel = el('button', undefined, t('pdf.cancel'));
|
|
220
|
+
cancel.type = 'button';
|
|
221
|
+
const submit = el('button', undefined, t('pdf.passwordSubmit'));
|
|
222
|
+
submit.type = 'button';
|
|
223
|
+
actions.append(cancel, submit);
|
|
224
|
+
panel.append(inputEl, actions);
|
|
225
|
+
overlay.appendChild(panel);
|
|
226
|
+
frame.appendChild(overlay);
|
|
227
|
+
const finish = (value) => {
|
|
228
|
+
overlay.remove();
|
|
229
|
+
resolve(value);
|
|
230
|
+
};
|
|
231
|
+
submit.addEventListener('click', () => finish(inputEl.value));
|
|
232
|
+
cancel.addEventListener('click', () => finish(null));
|
|
233
|
+
inputEl.addEventListener('keydown', (e) => {
|
|
234
|
+
if (e.key === 'Enter')
|
|
235
|
+
finish(inputEl.value);
|
|
236
|
+
if (e.key === 'Escape')
|
|
237
|
+
finish(null);
|
|
238
|
+
});
|
|
239
|
+
inputEl.focus();
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
// --------------------------------------------------------------- toolbar
|
|
243
|
+
const editingAvailable = typeof deps.loadPdfLib === 'function';
|
|
244
|
+
function toolButton(labelKey, onClick, parent = toolbar) {
|
|
245
|
+
const b = el('button', 'omni-pdf__tool', t(labelKey));
|
|
246
|
+
b.type = 'button';
|
|
247
|
+
b.addEventListener('click', onClick);
|
|
248
|
+
parent.appendChild(b);
|
|
249
|
+
return b;
|
|
250
|
+
}
|
|
251
|
+
/** Degraded mode (ADR 24): disabled with a reason, never hidden. */
|
|
252
|
+
function degrade(button, reasonKey) {
|
|
253
|
+
button.disabled = true;
|
|
254
|
+
button.title = t(reasonKey);
|
|
255
|
+
}
|
|
256
|
+
toolbar.appendChild(pageInfo);
|
|
257
|
+
const textBtn = toolButton('pdf.annotationText', () => {
|
|
258
|
+
tool = tool === 'text' ? 'view' : 'text';
|
|
259
|
+
syncToolButtons();
|
|
260
|
+
status.textContent = tool === 'text' ? t('pdf.textPlace') : '';
|
|
261
|
+
});
|
|
262
|
+
const signatureBtn = toolButton('pdf.signature', () => {
|
|
263
|
+
tool = tool === 'signature' ? 'view' : 'signature';
|
|
264
|
+
syncToolButtons();
|
|
265
|
+
status.textContent = tool === 'signature' ? t('pdf.signaturePlace') : '';
|
|
266
|
+
});
|
|
267
|
+
const markupWrap = el('div', 'omni-pdf__markup-wrap');
|
|
268
|
+
toolbar.appendChild(markupWrap);
|
|
269
|
+
const highlightBtn = toolButton('✎', () => {
|
|
270
|
+
if (captureMarkup(markupKind))
|
|
271
|
+
status.textContent = '';
|
|
272
|
+
}, markupWrap);
|
|
273
|
+
// Avoid moving focus to the toolbar before its click handler reads the
|
|
274
|
+
// browser selection (some hosts otherwise clear it on mousedown).
|
|
275
|
+
highlightBtn.addEventListener('mousedown', (event) => event.preventDefault());
|
|
276
|
+
const markupMenuBtn = el('button', 'omni-pdf__markup-menu-btn', '⌄');
|
|
277
|
+
markupMenuBtn.type = 'button';
|
|
278
|
+
markupMenuBtn.setAttribute('aria-label', t('pdf.markupOptions'));
|
|
279
|
+
markupMenuBtn.setAttribute('aria-expanded', 'false');
|
|
280
|
+
const markupMenu = el('div', 'omni-pdf__markup-menu');
|
|
281
|
+
markupMenu.setAttribute('role', 'menu');
|
|
282
|
+
const colorInput = (labelKey, initial, onChange) => {
|
|
283
|
+
const input = el('input', 'omni-pdf__highlight-color');
|
|
284
|
+
input.type = 'color';
|
|
285
|
+
input.value = initial;
|
|
286
|
+
input.setAttribute('aria-label', t(labelKey));
|
|
287
|
+
input.title = t(labelKey);
|
|
288
|
+
input.addEventListener('input', () => onChange(input.value));
|
|
289
|
+
return input;
|
|
290
|
+
};
|
|
291
|
+
const highlightColorInput = colorInput('pdf.highlightColor', highlightColor, (value) => {
|
|
292
|
+
highlightColor = value;
|
|
293
|
+
});
|
|
294
|
+
const underlineColorInput = colorInput('pdf.underlineColor', underlineColor, (value) => {
|
|
295
|
+
underlineColor = value;
|
|
296
|
+
});
|
|
297
|
+
const strikeoutColorInput = colorInput('pdf.strikeoutColor', strikeoutColor, (value) => {
|
|
298
|
+
strikeoutColor = value;
|
|
299
|
+
});
|
|
300
|
+
const markupChoice = (kind, labelKey, input) => {
|
|
301
|
+
const row = el('div', 'omni-pdf__markup-choice');
|
|
302
|
+
const button = el('button', undefined, t(labelKey));
|
|
303
|
+
button.type = 'button';
|
|
304
|
+
button.setAttribute('role', 'menuitemradio');
|
|
305
|
+
button.addEventListener('mousedown', (event) => event.preventDefault());
|
|
306
|
+
button.addEventListener('click', () => {
|
|
307
|
+
markupKind = kind;
|
|
308
|
+
syncMarkupKind();
|
|
309
|
+
markupMenu.classList.remove('is-open');
|
|
310
|
+
markupMenuBtn.setAttribute('aria-expanded', 'false');
|
|
311
|
+
});
|
|
312
|
+
row.append(button, input);
|
|
313
|
+
markupMenu.appendChild(row);
|
|
314
|
+
return button;
|
|
315
|
+
};
|
|
316
|
+
const highlightChoiceBtn = markupChoice('highlight', 'pdf.highlight', highlightColorInput);
|
|
317
|
+
const underlineBtn = markupChoice('underline', 'pdf.underline', underlineColorInput);
|
|
318
|
+
const strikeoutBtn = markupChoice('strikeout', 'pdf.strikeout', strikeoutColorInput);
|
|
319
|
+
function syncMarkupKind() {
|
|
320
|
+
const current = markupKind === 'highlight' ? '✎' : markupKind === 'underline' ? 'U̲' : 'S̶';
|
|
321
|
+
const labelKey = markupKind === 'highlight'
|
|
322
|
+
? 'pdf.highlight'
|
|
323
|
+
: markupKind === 'underline' ? 'pdf.underline' : 'pdf.strikeout';
|
|
324
|
+
highlightBtn.textContent = current;
|
|
325
|
+
highlightBtn.setAttribute('aria-label', t(labelKey));
|
|
326
|
+
highlightBtn.title = t(labelKey);
|
|
327
|
+
for (const [kind, button] of [
|
|
328
|
+
['highlight', highlightChoiceBtn],
|
|
329
|
+
['underline', underlineBtn],
|
|
330
|
+
['strikeout', strikeoutBtn]
|
|
331
|
+
])
|
|
332
|
+
button.setAttribute('aria-checked', String(kind === markupKind));
|
|
333
|
+
}
|
|
334
|
+
syncMarkupKind();
|
|
335
|
+
markupWrap.append(highlightBtn, markupMenuBtn, markupMenu);
|
|
336
|
+
markupMenuBtn.addEventListener('click', () => {
|
|
337
|
+
const isOpen = markupMenu.classList.toggle('is-open');
|
|
338
|
+
markupMenuBtn.setAttribute('aria-expanded', String(isOpen));
|
|
339
|
+
});
|
|
340
|
+
const resetBtn = toolButton('pdf.resetPages', () => controller.dispatch({ type: 'reset-pages' }));
|
|
341
|
+
const saveBtn = toolButton('pdf.save', () => void save());
|
|
342
|
+
const saveAsBtn = toolButton('pdf.saveAs', () => void saveAs());
|
|
343
|
+
const mergeBtn = toolButton('pdf.merge', () => void merge());
|
|
344
|
+
function syncToolButtons() {
|
|
345
|
+
textBtn.setAttribute('aria-pressed', String(tool === 'text'));
|
|
346
|
+
signatureBtn.setAttribute('aria-pressed', String(tool === 'signature'));
|
|
347
|
+
// Placement tools need raw canvas clicks; view mode keeps text selectable.
|
|
348
|
+
pages.classList.toggle('omni-pdf__pages--placing', tool === 'text' || tool === 'signature');
|
|
349
|
+
}
|
|
350
|
+
syncToolButtons();
|
|
351
|
+
if (!editingAvailable) {
|
|
352
|
+
for (const b of [textBtn, signatureBtn, highlightBtn, markupMenuBtn, highlightChoiceBtn, underlineBtn, strikeoutBtn, resetBtn, saveBtn, saveAsBtn, mergeBtn]) {
|
|
353
|
+
degrade(b, 'pdf.editingUnavailable');
|
|
354
|
+
}
|
|
355
|
+
highlightColorInput.disabled = true;
|
|
356
|
+
underlineColorInput.disabled = true;
|
|
357
|
+
strikeoutColorInput.disabled = true;
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
if (!ctx.writeback)
|
|
361
|
+
degrade(saveBtn, 'common.noWriteback');
|
|
362
|
+
if (!ctx.save)
|
|
363
|
+
degrade(saveAsBtn, 'common.noFileSave');
|
|
364
|
+
if (!ctx.filePick)
|
|
365
|
+
degrade(mergeBtn, 'pdf.noFilePick');
|
|
366
|
+
}
|
|
367
|
+
function syncMarkupAvailability() {
|
|
368
|
+
if (!editingAvailable)
|
|
369
|
+
return;
|
|
370
|
+
const enabled = activeSelection() !== null;
|
|
371
|
+
highlightBtn.disabled = !enabled;
|
|
372
|
+
markupMenuBtn.disabled = !enabled;
|
|
373
|
+
highlightChoiceBtn.disabled = !enabled;
|
|
374
|
+
underlineBtn.disabled = !enabled;
|
|
375
|
+
strikeoutBtn.disabled = !enabled;
|
|
376
|
+
if (!enabled) {
|
|
377
|
+
markupMenu.classList.remove('is-open');
|
|
378
|
+
markupMenuBtn.setAttribute('aria-expanded', 'false');
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
document.addEventListener('selectionchange', syncMarkupAvailability);
|
|
382
|
+
pages.addEventListener('mouseup', () => requestAnimationFrame(syncMarkupAvailability));
|
|
383
|
+
syncMarkupAvailability();
|
|
384
|
+
const zoomOutBtn = el('button', 'omni-pdf__zoom-btn', '−');
|
|
385
|
+
zoomOutBtn.type = 'button';
|
|
386
|
+
zoomOutBtn.setAttribute('aria-label', t('pdf.zoomOut'));
|
|
387
|
+
zoomOutBtn.addEventListener('click', () => controller.dispatch({ type: 'zoom-out' }));
|
|
388
|
+
const zoomLevel = el('span', 'omni-pdf__zoom-level');
|
|
389
|
+
zoomLevel.setAttribute('aria-live', 'polite');
|
|
390
|
+
const zoomInBtn = el('button', 'omni-pdf__zoom-btn', '+');
|
|
391
|
+
zoomInBtn.type = 'button';
|
|
392
|
+
zoomInBtn.setAttribute('aria-label', t('pdf.zoomIn'));
|
|
393
|
+
zoomInBtn.addEventListener('click', () => controller.dispatch({ type: 'zoom-in' }));
|
|
394
|
+
const viewMenuWrap = el('div', 'omni-pdf__view-menu-wrap');
|
|
395
|
+
const viewMenuBtn = el('button', 'omni-pdf__view-menu-btn', '⌄');
|
|
396
|
+
viewMenuBtn.type = 'button';
|
|
397
|
+
viewMenuBtn.setAttribute('aria-label', t('pdf.viewOptions'));
|
|
398
|
+
viewMenuBtn.setAttribute('aria-expanded', 'false');
|
|
399
|
+
const viewMenu = el('div', 'omni-pdf__view-menu');
|
|
400
|
+
viewMenu.setAttribute('role', 'menu');
|
|
401
|
+
const fitWidthBtn = el('button', undefined, t('pdf.fitWidth'));
|
|
402
|
+
const fitHeightBtn = el('button', undefined, t('pdf.fitHeight'));
|
|
403
|
+
const singlePageBtn = el('button', undefined, t('pdf.singlePage'));
|
|
404
|
+
const spreadOddBtn = el('button', undefined, t('pdf.twoPagesOdd'));
|
|
405
|
+
const spreadEvenBtn = el('button', undefined, t('pdf.twoPagesEven'));
|
|
406
|
+
const themeBtn = el('button', undefined, t('pdf.matchTheme'));
|
|
407
|
+
const separator = document.createElement('hr');
|
|
408
|
+
const separator2 = document.createElement('hr');
|
|
409
|
+
for (const button of [fitWidthBtn, fitHeightBtn, singlePageBtn, spreadOddBtn, spreadEvenBtn, themeBtn]) {
|
|
410
|
+
button.type = 'button';
|
|
411
|
+
button.setAttribute('role', 'menuitemcheckbox');
|
|
412
|
+
button.setAttribute('aria-checked', 'false');
|
|
413
|
+
}
|
|
414
|
+
viewMenu.append(fitWidthBtn, fitHeightBtn, separator, singlePageBtn, spreadOddBtn, spreadEvenBtn, separator2, themeBtn);
|
|
415
|
+
viewMenuWrap.append(viewMenuBtn, viewMenu);
|
|
416
|
+
zoomBar.append(zoomOutBtn, zoomLevel, zoomInBtn, viewMenuWrap);
|
|
417
|
+
const closeViewMenu = () => {
|
|
418
|
+
viewMenu.classList.remove('is-open');
|
|
419
|
+
viewMenuBtn.setAttribute('aria-expanded', 'false');
|
|
420
|
+
};
|
|
421
|
+
viewMenuBtn.addEventListener('click', () => {
|
|
422
|
+
const isOpen = viewMenu.classList.toggle('is-open');
|
|
423
|
+
viewMenuBtn.setAttribute('aria-expanded', String(isOpen));
|
|
424
|
+
});
|
|
425
|
+
viewMenu.addEventListener('keydown', (event) => {
|
|
426
|
+
if (event.key === 'Escape') {
|
|
427
|
+
closeViewMenu();
|
|
428
|
+
viewMenuBtn.focus();
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
function fitPage(axis) {
|
|
432
|
+
const current = records.find((record) => record.pageNumber === currentPage) ?? records[0];
|
|
433
|
+
if (!current)
|
|
434
|
+
return;
|
|
435
|
+
const viewport = current.page.getViewport({ scale: 1 });
|
|
436
|
+
const available = axis === 'width'
|
|
437
|
+
? pages.clientWidth - 40
|
|
438
|
+
: pages.clientHeight - 40;
|
|
439
|
+
const dimension = axis === 'width' ? viewport.width : viewport.height;
|
|
440
|
+
if (available > 0 && dimension > 0) {
|
|
441
|
+
controller.dispatch({ type: 'set-zoom', zoom: available * 100 / dimension });
|
|
442
|
+
}
|
|
443
|
+
closeViewMenu();
|
|
444
|
+
}
|
|
445
|
+
function setPageLayout(next) {
|
|
446
|
+
pageLayout = next;
|
|
447
|
+
singlePageBtn.setAttribute('aria-checked', String(next === 'single'));
|
|
448
|
+
spreadOddBtn.setAttribute('aria-checked', String(next === 'spread-odd'));
|
|
449
|
+
spreadEvenBtn.setAttribute('aria-checked', String(next === 'spread-even'));
|
|
450
|
+
pages.classList.toggle('omni-pdf__pages--spread', next !== 'single');
|
|
451
|
+
void rebuildLayout();
|
|
452
|
+
closeViewMenu();
|
|
453
|
+
}
|
|
454
|
+
fitWidthBtn.addEventListener('click', () => fitPage('width'));
|
|
455
|
+
fitHeightBtn.addEventListener('click', () => fitPage('height'));
|
|
456
|
+
singlePageBtn.addEventListener('click', () => setPageLayout('single'));
|
|
457
|
+
spreadOddBtn.addEventListener('click', () => setPageLayout('spread-odd'));
|
|
458
|
+
spreadEvenBtn.addEventListener('click', () => setPageLayout('spread-even'));
|
|
459
|
+
themeBtn.addEventListener('click', () => {
|
|
460
|
+
matchThemeColors = !matchThemeColors;
|
|
461
|
+
pages.classList.toggle('omni-pdf__pages--theme', matchThemeColors);
|
|
462
|
+
themeBtn.setAttribute('aria-checked', String(matchThemeColors));
|
|
463
|
+
closeViewMenu();
|
|
464
|
+
});
|
|
465
|
+
setPageLayout('single');
|
|
466
|
+
// ------------------------------------------------------ signature modal
|
|
467
|
+
const signatureModal = el('div', 'omni-pdf__modal');
|
|
468
|
+
const signaturePanel = el('div', 'omni-pdf__panel');
|
|
469
|
+
const signatureLabel = el('label', 'omni-pdf__field', t('pdf.signatureColor'));
|
|
470
|
+
const signatureColorInput = el('input');
|
|
471
|
+
signatureColorInput.type = 'color';
|
|
472
|
+
signatureColorInput.value = '#000000';
|
|
473
|
+
signatureColorInput.setAttribute('aria-label', t('pdf.signatureColor'));
|
|
474
|
+
signatureLabel.appendChild(signatureColorInput);
|
|
475
|
+
const signatureCanvas = el('canvas');
|
|
476
|
+
signatureCanvas.width = 480;
|
|
477
|
+
signatureCanvas.height = 180;
|
|
478
|
+
signatureCanvas.className = 'omni-pdf__signature-pad';
|
|
479
|
+
const signatureActions = el('div', 'omni-pdf__panel-actions');
|
|
480
|
+
const clearSignatureBtn = el('button', undefined, t('pdf.signatureClear'));
|
|
481
|
+
clearSignatureBtn.type = 'button';
|
|
482
|
+
const cancelSignatureBtn = el('button', undefined, t('pdf.cancel'));
|
|
483
|
+
cancelSignatureBtn.type = 'button';
|
|
484
|
+
const useSignatureBtn = el('button', undefined, t('pdf.signatureUse'));
|
|
485
|
+
useSignatureBtn.type = 'button';
|
|
486
|
+
signatureActions.append(clearSignatureBtn, cancelSignatureBtn, useSignatureBtn);
|
|
487
|
+
signaturePanel.append(signatureLabel, signatureCanvas, signatureActions);
|
|
488
|
+
signatureModal.appendChild(signaturePanel);
|
|
489
|
+
frame.appendChild(signatureModal);
|
|
490
|
+
const signatureContext = signatureCanvas.getContext('2d');
|
|
491
|
+
let signing = false;
|
|
492
|
+
let pendingSignaturePosition;
|
|
493
|
+
const clearPad = () => {
|
|
494
|
+
signatureContext?.clearRect(0, 0, signatureCanvas.width, signatureCanvas.height);
|
|
495
|
+
};
|
|
496
|
+
const padPoint = (event) => {
|
|
497
|
+
const box = signatureCanvas.getBoundingClientRect();
|
|
498
|
+
return {
|
|
499
|
+
x: ((event.clientX - box.left) * signatureCanvas.width) / (box.width || 1),
|
|
500
|
+
y: ((event.clientY - box.top) * signatureCanvas.height) / (box.height || 1)
|
|
501
|
+
};
|
|
502
|
+
};
|
|
503
|
+
signatureCanvas.addEventListener('pointerdown', (event) => {
|
|
504
|
+
if (!signatureContext)
|
|
505
|
+
return;
|
|
506
|
+
signing = true;
|
|
507
|
+
signatureCanvas.setPointerCapture?.(event.pointerId);
|
|
508
|
+
const p = padPoint(event);
|
|
509
|
+
signatureContext.beginPath();
|
|
510
|
+
signatureContext.moveTo(p.x, p.y);
|
|
511
|
+
});
|
|
512
|
+
signatureCanvas.addEventListener('pointermove', (event) => {
|
|
513
|
+
if (!signing || !signatureContext)
|
|
514
|
+
return;
|
|
515
|
+
const p = padPoint(event);
|
|
516
|
+
signatureContext.lineTo(p.x, p.y);
|
|
517
|
+
signatureContext.strokeStyle = signatureColorInput.value;
|
|
518
|
+
signatureContext.lineWidth = 2.5;
|
|
519
|
+
signatureContext.lineCap = 'round';
|
|
520
|
+
signatureContext.stroke();
|
|
521
|
+
});
|
|
522
|
+
signatureCanvas.addEventListener('pointerup', () => {
|
|
523
|
+
signing = false;
|
|
524
|
+
});
|
|
525
|
+
clearSignatureBtn.addEventListener('click', clearPad);
|
|
526
|
+
cancelSignatureBtn.addEventListener('click', () => {
|
|
527
|
+
signatureModal.classList.remove('is-open');
|
|
528
|
+
tool = 'view';
|
|
529
|
+
syncToolButtons();
|
|
530
|
+
});
|
|
531
|
+
useSignatureBtn.addEventListener('click', () => {
|
|
532
|
+
const image = trimmedSignature();
|
|
533
|
+
const position = pendingSignaturePosition;
|
|
534
|
+
if (!image || !position)
|
|
535
|
+
return;
|
|
536
|
+
pendingSignaturePosition = undefined;
|
|
537
|
+
signatureModal.classList.remove('is-open');
|
|
538
|
+
tool = 'view';
|
|
539
|
+
syncToolButtons();
|
|
540
|
+
controller.dispatch({
|
|
541
|
+
type: 'add-annotation',
|
|
542
|
+
annotation: {
|
|
543
|
+
kind: 'signature',
|
|
544
|
+
page: position.page,
|
|
545
|
+
x: position.x,
|
|
546
|
+
y: position.y,
|
|
547
|
+
width: 120,
|
|
548
|
+
height: 60,
|
|
549
|
+
dataUrl: image
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
});
|
|
553
|
+
function trimmedSignature() {
|
|
554
|
+
const pixels = signatureContext?.getImageData(0, 0, signatureCanvas.width, signatureCanvas.height);
|
|
555
|
+
if (!pixels)
|
|
556
|
+
return undefined;
|
|
557
|
+
let minX = signatureCanvas.width;
|
|
558
|
+
let minY = signatureCanvas.height;
|
|
559
|
+
let maxX = -1;
|
|
560
|
+
let maxY = -1;
|
|
561
|
+
for (let y = 0; y < signatureCanvas.height; y++) {
|
|
562
|
+
for (let x = 0; x < signatureCanvas.width; x++) {
|
|
563
|
+
if ((pixels.data[(y * signatureCanvas.width + x) * 4 + 3] ?? 0) > 0) {
|
|
564
|
+
minX = Math.min(minX, x);
|
|
565
|
+
minY = Math.min(minY, y);
|
|
566
|
+
maxX = Math.max(maxX, x);
|
|
567
|
+
maxY = Math.max(maxY, y);
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
if (maxX < minX)
|
|
572
|
+
return undefined;
|
|
573
|
+
const pad = 8;
|
|
574
|
+
const crop = document.createElement('canvas');
|
|
575
|
+
crop.width = maxX - minX + 1 + pad * 2;
|
|
576
|
+
crop.height = maxY - minY + 1 + pad * 2;
|
|
577
|
+
crop.getContext('2d')?.drawImage(signatureCanvas, minX, minY, maxX - minX + 1, maxY - minY + 1, pad, pad, maxX - minX + 1, maxY - minY + 1);
|
|
578
|
+
return crop.toDataURL('image/png');
|
|
579
|
+
}
|
|
580
|
+
/** Rasterize user-added text for portable PDF flattening. The sidecar
|
|
581
|
+
* keeps the original string, size and color, so Omni can still edit it. */
|
|
582
|
+
function rasterizeText(text, size, color) {
|
|
583
|
+
const padding = 2;
|
|
584
|
+
const scale = Math.max(2, globalThis.devicePixelRatio || 1);
|
|
585
|
+
const measure = document.createElement('canvas').getContext('2d');
|
|
586
|
+
if (!measure) {
|
|
587
|
+
return { rasterDataUrl: '', rasterWidth: 0, rasterHeight: 0 };
|
|
588
|
+
}
|
|
589
|
+
measure.font = `${size}px sans-serif`;
|
|
590
|
+
const width = Math.ceil(measure.measureText(text).width + padding * 2);
|
|
591
|
+
const height = Math.ceil(size * 1.3 + padding * 2);
|
|
592
|
+
const canvas = document.createElement('canvas');
|
|
593
|
+
canvas.width = Math.ceil(width * scale);
|
|
594
|
+
canvas.height = Math.ceil(height * scale);
|
|
595
|
+
const context = canvas.getContext('2d');
|
|
596
|
+
if (!context)
|
|
597
|
+
return { rasterDataUrl: '', rasterWidth: 0, rasterHeight: 0 };
|
|
598
|
+
context.scale(scale, scale);
|
|
599
|
+
context.font = `${size}px sans-serif`;
|
|
600
|
+
context.textBaseline = 'top';
|
|
601
|
+
context.fillStyle = color;
|
|
602
|
+
context.fillText(text, padding, padding);
|
|
603
|
+
return { rasterDataUrl: canvas.toDataURL('image/png'), rasterWidth: width, rasterHeight: height };
|
|
604
|
+
}
|
|
605
|
+
// -------------------------------------------------------- text overlay
|
|
606
|
+
/** Inline text-annotation controls (window.prompt is blocked in webviews). */
|
|
607
|
+
function openTextInput(wrapper, pageNumber, x, y) {
|
|
608
|
+
const scale = controller.state.zoom / 100;
|
|
609
|
+
const editor = el('div', 'omni-pdf__text-editor');
|
|
610
|
+
const inputEl = el('input', 'omni-pdf__text-input');
|
|
611
|
+
inputEl.type = 'text';
|
|
612
|
+
inputEl.placeholder = t('pdf.annotationText');
|
|
613
|
+
inputEl.setAttribute('aria-label', t('pdf.annotationText'));
|
|
614
|
+
const sizeInput = el('input');
|
|
615
|
+
sizeInput.type = 'number';
|
|
616
|
+
sizeInput.min = '8';
|
|
617
|
+
sizeInput.max = '96';
|
|
618
|
+
sizeInput.step = '1';
|
|
619
|
+
sizeInput.value = '16';
|
|
620
|
+
sizeInput.className = 'omni-pdf__text-size-input';
|
|
621
|
+
sizeInput.setAttribute('aria-label', t('pdf.annotationSize'));
|
|
622
|
+
const colorInput = el('input');
|
|
623
|
+
colorInput.type = 'color';
|
|
624
|
+
colorInput.value = '#000000';
|
|
625
|
+
colorInput.className = 'omni-pdf__color-input';
|
|
626
|
+
colorInput.setAttribute('aria-label', t('pdf.annotationColor'));
|
|
627
|
+
editor.style.left = `${x * scale}px`;
|
|
628
|
+
editor.style.top = `${y * scale}px`;
|
|
629
|
+
editor.append(inputEl, sizeInput, colorInput);
|
|
630
|
+
let done = false;
|
|
631
|
+
const finish = (commit) => {
|
|
632
|
+
if (done)
|
|
633
|
+
return;
|
|
634
|
+
done = true;
|
|
635
|
+
const text = inputEl.value.trim();
|
|
636
|
+
editor.remove();
|
|
637
|
+
tool = 'view';
|
|
638
|
+
syncToolButtons();
|
|
639
|
+
if (commit && text) {
|
|
640
|
+
const size = Math.min(96, Math.max(8, Number(sizeInput.value) || 16));
|
|
641
|
+
const raster = rasterizeText(text, size, colorInput.value);
|
|
642
|
+
controller.dispatch({
|
|
643
|
+
type: 'add-annotation',
|
|
644
|
+
annotation: { kind: 'text', page: pageNumber, x, y, text, size, color: colorInput.value, ...raster }
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
};
|
|
648
|
+
inputEl.addEventListener('keydown', (e) => {
|
|
649
|
+
e.stopPropagation();
|
|
650
|
+
if (e.key === 'Enter')
|
|
651
|
+
finish(true);
|
|
652
|
+
if (e.key === 'Escape')
|
|
653
|
+
finish(false);
|
|
654
|
+
});
|
|
655
|
+
inputEl.addEventListener('blur', (event) => {
|
|
656
|
+
const next = event.relatedTarget;
|
|
657
|
+
if (next instanceof Node && editor.contains(next))
|
|
658
|
+
return;
|
|
659
|
+
finish(true);
|
|
660
|
+
});
|
|
661
|
+
wrapper.appendChild(editor);
|
|
662
|
+
inputEl.focus();
|
|
663
|
+
}
|
|
664
|
+
// ------------------------------------------------------- highlight tool
|
|
665
|
+
/** In shadow isolation the text lives in the shadow root, so prefer its
|
|
666
|
+
* selection API (Chromium) and fall back to the document selection.
|
|
667
|
+
* Chromium may return an empty Selection object for one of these APIs,
|
|
668
|
+
* so do not let that mask the other API's real range. */
|
|
669
|
+
function activeSelection() {
|
|
670
|
+
const shadow = root;
|
|
671
|
+
const shadowSelection = shadow.getSelection?.();
|
|
672
|
+
const documentSelection = document.getSelection();
|
|
673
|
+
return [shadowSelection, documentSelection].find((selection) => !!selection && !selection.isCollapsed && selection.rangeCount > 0) ?? null;
|
|
674
|
+
}
|
|
675
|
+
/** Turn the current selection into a portable text markup annotation. */
|
|
676
|
+
function captureMarkup(kind) {
|
|
677
|
+
const color = kind === 'highlight'
|
|
678
|
+
? highlightColor
|
|
679
|
+
: kind === 'underline' ? underlineColor : strikeoutColor;
|
|
680
|
+
const selection = activeSelection();
|
|
681
|
+
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
|
682
|
+
ctx.logger.log('info', '[pdf highlight] no active text selection');
|
|
683
|
+
return false;
|
|
684
|
+
}
|
|
685
|
+
ctx.logger.log('info', `[pdf ${kind}] selection ranges=${selection.rangeCount} textLength=${selection.toString().length}`);
|
|
686
|
+
const scale = controller.state.zoom / 100;
|
|
687
|
+
const byPage = new Map();
|
|
688
|
+
for (let i = 0; i < selection.rangeCount; i++) {
|
|
689
|
+
for (const rect of Array.from(selection.getRangeAt(i).getClientRects())) {
|
|
690
|
+
if (rect.width < 1 || rect.height < 1)
|
|
691
|
+
continue;
|
|
692
|
+
const cx = rect.left + rect.width / 2;
|
|
693
|
+
const cy = rect.top + rect.height / 2;
|
|
694
|
+
const record = records.find((r) => {
|
|
695
|
+
const b = r.wrapper.getBoundingClientRect();
|
|
696
|
+
return cx >= b.left && cx <= b.right && cy >= b.top && cy <= b.bottom;
|
|
697
|
+
});
|
|
698
|
+
if (!record)
|
|
699
|
+
continue;
|
|
700
|
+
const b = record.wrapper.getBoundingClientRect();
|
|
701
|
+
const list = byPage.get(record.pageNumber) ?? [];
|
|
702
|
+
list.push({
|
|
703
|
+
x: (rect.left - b.left) / scale,
|
|
704
|
+
y: (rect.top - b.top) / scale,
|
|
705
|
+
width: rect.width / scale,
|
|
706
|
+
height: rect.height / scale
|
|
707
|
+
});
|
|
708
|
+
byPage.set(record.pageNumber, list);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if (byPage.size === 0) {
|
|
712
|
+
ctx.logger.log('info', '[pdf highlight] selection has no PDF page rectangles');
|
|
713
|
+
return false;
|
|
714
|
+
}
|
|
715
|
+
for (const [page, rects] of byPage) {
|
|
716
|
+
controller.dispatch({
|
|
717
|
+
type: 'add-annotation',
|
|
718
|
+
annotation: { kind, page, rects: mergeHighlightRects(rects), color }
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
selection.removeAllRanges();
|
|
722
|
+
ctx.logger.log('info', `[pdf ${kind}] added pages=${byPage.size}`);
|
|
723
|
+
return true;
|
|
724
|
+
}
|
|
725
|
+
function captureHighlight() {
|
|
726
|
+
return captureMarkup('highlight');
|
|
727
|
+
}
|
|
728
|
+
// ------------------------------------------------------- annotations UI
|
|
729
|
+
function renderOverlays(record) {
|
|
730
|
+
for (const node of record.wrapper.querySelectorAll('.omni-pdf__annotation')) {
|
|
731
|
+
node.remove();
|
|
732
|
+
}
|
|
733
|
+
const scale = controller.state.zoom / 100;
|
|
734
|
+
for (const annotation of controller.state.annotations) {
|
|
735
|
+
if (annotation.page !== record.pageNumber)
|
|
736
|
+
continue;
|
|
737
|
+
if (annotation.kind === 'highlight' || annotation.kind === 'underline' || annotation.kind === 'strikeout') {
|
|
738
|
+
const rects = mergeHighlightRects(annotation.rects);
|
|
739
|
+
const deleteRect = rects.reduce((best, rect) => rect.y < best.y || (rect.y === best.y && rect.x + rect.width > best.x + best.width)
|
|
740
|
+
? rect
|
|
741
|
+
: best);
|
|
742
|
+
const setGroupHover = (hovered) => {
|
|
743
|
+
for (const node of record.wrapper.querySelectorAll('.omni-pdf__markup')) {
|
|
744
|
+
if (node.dataset.annotationId === annotation.id) {
|
|
745
|
+
node.classList.toggle('is-hovered', hovered);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
};
|
|
749
|
+
for (const rect of rects) {
|
|
750
|
+
const box = el('div', `omni-pdf__annotation omni-pdf__markup omni-pdf__${annotation.kind}`);
|
|
751
|
+
box.dataset.annotationId = annotation.id;
|
|
752
|
+
box.tabIndex = 0;
|
|
753
|
+
box.setAttribute('role', 'button');
|
|
754
|
+
box.setAttribute('aria-label', t(annotation.kind === 'highlight'
|
|
755
|
+
? 'pdf.highlight'
|
|
756
|
+
: annotation.kind === 'underline' ? 'pdf.underline' : 'pdf.strikeout'));
|
|
757
|
+
if (controller.state.selectedAnnotationId === annotation.id) {
|
|
758
|
+
box.classList.add('is-selected');
|
|
759
|
+
}
|
|
760
|
+
box.style.left = `${rect.x * scale}px`;
|
|
761
|
+
box.style.top = `${rect.y * scale}px`;
|
|
762
|
+
box.style.width = `${rect.width * scale}px`;
|
|
763
|
+
box.style.height = `${rect.height * scale}px`;
|
|
764
|
+
box.style.setProperty('--omni-hl-color', annotation.color);
|
|
765
|
+
box.title = t(annotation.kind === 'highlight'
|
|
766
|
+
? 'pdf.highlight'
|
|
767
|
+
: annotation.kind === 'underline' ? 'pdf.underline' : 'pdf.strikeout');
|
|
768
|
+
box.addEventListener('click', (event) => {
|
|
769
|
+
event.preventDefault();
|
|
770
|
+
event.stopPropagation();
|
|
771
|
+
controller.dispatch({ type: 'select-annotation', id: annotation.id });
|
|
772
|
+
});
|
|
773
|
+
box.addEventListener('pointerenter', () => setGroupHover(true));
|
|
774
|
+
box.addEventListener('pointerleave', (event) => {
|
|
775
|
+
const next = event.relatedTarget;
|
|
776
|
+
const nextHighlight = next instanceof Element
|
|
777
|
+
? next.closest('.omni-pdf__markup')
|
|
778
|
+
: null;
|
|
779
|
+
if (nextHighlight?.dataset.annotationId !== annotation.id) {
|
|
780
|
+
setGroupHover(false);
|
|
781
|
+
}
|
|
782
|
+
});
|
|
783
|
+
if (rect === deleteRect) {
|
|
784
|
+
const deleteButton = el('button', 'omni-pdf__annotation-delete', '×');
|
|
785
|
+
deleteButton.type = 'button';
|
|
786
|
+
deleteButton.setAttribute('aria-label', t('pdf.deleteAnnotation'));
|
|
787
|
+
deleteButton.addEventListener('pointerdown', (event) => {
|
|
788
|
+
event.preventDefault();
|
|
789
|
+
event.stopPropagation();
|
|
790
|
+
});
|
|
791
|
+
deleteButton.addEventListener('click', (event) => {
|
|
792
|
+
event.preventDefault();
|
|
793
|
+
event.stopPropagation();
|
|
794
|
+
controller.dispatch({ type: 'remove-annotation', id: annotation.id });
|
|
795
|
+
});
|
|
796
|
+
box.appendChild(deleteButton);
|
|
797
|
+
}
|
|
798
|
+
record.wrapper.appendChild(box);
|
|
799
|
+
}
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
const overlay = el('div', 'omni-pdf__annotation');
|
|
803
|
+
overlay.tabIndex = 0;
|
|
804
|
+
overlay.setAttribute('role', 'button');
|
|
805
|
+
overlay.setAttribute('aria-label', annotation.kind === 'text' ? annotation.text : t('pdf.signature'));
|
|
806
|
+
if (controller.state.selectedAnnotationId === annotation.id) {
|
|
807
|
+
overlay.classList.add('is-selected');
|
|
808
|
+
}
|
|
809
|
+
overlay.style.left = `${annotation.x * scale}px`;
|
|
810
|
+
overlay.style.top = `${annotation.y * scale}px`;
|
|
811
|
+
if (annotation.kind === 'text') {
|
|
812
|
+
overlay.textContent = annotation.text;
|
|
813
|
+
overlay.style.fontSize = `${annotation.size * scale}px`;
|
|
814
|
+
overlay.style.color = annotation.color;
|
|
815
|
+
}
|
|
816
|
+
else {
|
|
817
|
+
const image = el('img');
|
|
818
|
+
image.src = annotation.dataUrl;
|
|
819
|
+
image.alt = t('pdf.signature');
|
|
820
|
+
image.style.width = `${annotation.width * scale}px`;
|
|
821
|
+
image.style.height = `${annotation.height * scale}px`;
|
|
822
|
+
overlay.appendChild(image);
|
|
823
|
+
}
|
|
824
|
+
let dragStart;
|
|
825
|
+
overlay.addEventListener('pointerdown', (event) => {
|
|
826
|
+
if (event.target.closest('.omni-pdf__annotation-delete'))
|
|
827
|
+
return;
|
|
828
|
+
event.preventDefault();
|
|
829
|
+
event.stopPropagation();
|
|
830
|
+
dragStart = {
|
|
831
|
+
x: annotation.x,
|
|
832
|
+
y: annotation.y,
|
|
833
|
+
clientX: event.clientX,
|
|
834
|
+
clientY: event.clientY
|
|
835
|
+
};
|
|
836
|
+
overlay.classList.add('is-selected');
|
|
837
|
+
overlay.setPointerCapture?.(event.pointerId);
|
|
838
|
+
});
|
|
839
|
+
overlay.addEventListener('pointermove', (event) => {
|
|
840
|
+
if (!dragStart)
|
|
841
|
+
return;
|
|
842
|
+
const x = dragStart.x + (event.clientX - dragStart.clientX) / scale;
|
|
843
|
+
const y = dragStart.y + (event.clientY - dragStart.clientY) / scale;
|
|
844
|
+
overlay.style.left = `${x * scale}px`;
|
|
845
|
+
overlay.style.top = `${y * scale}px`;
|
|
846
|
+
});
|
|
847
|
+
overlay.addEventListener('pointerup', (event) => {
|
|
848
|
+
if (!dragStart)
|
|
849
|
+
return;
|
|
850
|
+
const x = dragStart.x + (event.clientX - dragStart.clientX) / scale;
|
|
851
|
+
const y = dragStart.y + (event.clientY - dragStart.clientY) / scale;
|
|
852
|
+
dragStart = undefined;
|
|
853
|
+
overlay.focus({ preventScroll: true });
|
|
854
|
+
controller.dispatch({ type: 'move-annotation', id: annotation.id, x, y });
|
|
855
|
+
controller.dispatch({ type: 'select-annotation', id: annotation.id });
|
|
856
|
+
});
|
|
857
|
+
const deleteButton = el('button', 'omni-pdf__annotation-delete', '×');
|
|
858
|
+
deleteButton.type = 'button';
|
|
859
|
+
deleteButton.setAttribute('aria-label', t('pdf.deleteAnnotation'));
|
|
860
|
+
deleteButton.addEventListener('pointerdown', (event) => {
|
|
861
|
+
event.preventDefault();
|
|
862
|
+
event.stopPropagation();
|
|
863
|
+
});
|
|
864
|
+
deleteButton.addEventListener('click', (event) => {
|
|
865
|
+
event.preventDefault();
|
|
866
|
+
event.stopPropagation();
|
|
867
|
+
controller.dispatch({ type: 'remove-annotation', id: annotation.id });
|
|
868
|
+
});
|
|
869
|
+
overlay.appendChild(deleteButton);
|
|
870
|
+
record.wrapper.appendChild(overlay);
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
// -------------------------------------------------------------- render
|
|
874
|
+
async function renderPage(record, epoch) {
|
|
875
|
+
if (disposed || epoch !== renderEpoch || record.rendered || record.rendering)
|
|
876
|
+
return;
|
|
877
|
+
record.rendering = true;
|
|
878
|
+
const viewport = record.page.getViewport({ scale: controller.state.zoom / 100 });
|
|
879
|
+
const canvas = document.createElement('canvas');
|
|
880
|
+
canvas.width = Math.floor(viewport.width);
|
|
881
|
+
canvas.height = Math.floor(viewport.height);
|
|
882
|
+
canvas.setAttribute('role', 'img');
|
|
883
|
+
canvas.setAttribute('aria-label', t('common.page', { page: record.pageNumber, pages: controller.state.pageOrder.length }));
|
|
884
|
+
const canvasContext = canvas.getContext('2d');
|
|
885
|
+
if (!canvasContext) {
|
|
886
|
+
record.rendering = false;
|
|
887
|
+
return;
|
|
888
|
+
}
|
|
889
|
+
record.wrapper.replaceChildren(canvas);
|
|
890
|
+
const task = record.page.render({ canvasContext, viewport });
|
|
891
|
+
record.task = task;
|
|
892
|
+
try {
|
|
893
|
+
await task.promise;
|
|
894
|
+
if (!disposed && epoch === renderEpoch) {
|
|
895
|
+
record.rendered = true;
|
|
896
|
+
await renderTextLayer(record, viewport, epoch);
|
|
897
|
+
renderOverlays(record);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
catch {
|
|
901
|
+
// Cancellation during zoom / reload is expected.
|
|
902
|
+
}
|
|
903
|
+
finally {
|
|
904
|
+
record.rendering = false;
|
|
905
|
+
record.task = undefined;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
/** Overlay pdf.js's selectable text layer on the rendered canvas so users
|
|
909
|
+
* can select/copy text and (later) highlight it. */
|
|
910
|
+
async function renderTextLayer(record, viewport, epoch) {
|
|
911
|
+
try {
|
|
912
|
+
const textContentSource = await record.page.getTextContent();
|
|
913
|
+
if (disposed || epoch !== renderEpoch)
|
|
914
|
+
return;
|
|
915
|
+
// Keep pdf.js's conventional class as well as our scoped class:
|
|
916
|
+
// integrations may provide the upstream text-layer rules, while
|
|
917
|
+
// the scoped rules below cover shadow-root rendering.
|
|
918
|
+
const container = el('div', 'omni-pdf__text-layer textLayer');
|
|
919
|
+
container.style.setProperty('--scale-factor', String(controller.state.zoom / 100));
|
|
920
|
+
container.style.width = `${Math.floor(viewport.width)}px`;
|
|
921
|
+
container.style.height = `${Math.floor(viewport.height)}px`;
|
|
922
|
+
const textLayer = new pdfjs.TextLayer({ textContentSource, container, viewport });
|
|
923
|
+
record.textLayer?.cancel();
|
|
924
|
+
record.textLayer = textLayer;
|
|
925
|
+
await textLayer.render();
|
|
926
|
+
if (disposed || epoch !== renderEpoch) {
|
|
927
|
+
textLayer.cancel();
|
|
928
|
+
return;
|
|
929
|
+
}
|
|
930
|
+
// Insert under annotation overlays (which renderOverlays appends next).
|
|
931
|
+
record.wrapper.appendChild(container);
|
|
932
|
+
ctx.logger.log('info', `[pdf text-layer] page=${record.pageNumber} rendered children=${container.childElementCount}`);
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
935
|
+
// Cancellation during zoom/reload is normal, but log other cases
|
|
936
|
+
// so a host integration can diagnose missing selectable text.
|
|
937
|
+
if (!disposed && epoch === renderEpoch) {
|
|
938
|
+
ctx.logger.log('info', `[pdf text-layer] page=${record.pageNumber} failed: ${String(error)}`);
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
function setCurrentPage(pageNumber) {
|
|
943
|
+
currentPage = pageNumber;
|
|
944
|
+
pageInput.value = String(currentPage);
|
|
945
|
+
pageTotal.textContent = `/ ${records.length}`;
|
|
946
|
+
records.forEach((record, index) => {
|
|
947
|
+
thumbs.children[index]?.toggleAttribute('aria-current', record.pageNumber === currentPage);
|
|
948
|
+
});
|
|
949
|
+
}
|
|
950
|
+
function goToPageInput() {
|
|
951
|
+
const pageNumber = Number(pageInput.value);
|
|
952
|
+
const record = records.find((item) => item.pageNumber === pageNumber);
|
|
953
|
+
if (!record) {
|
|
954
|
+
pageInput.value = String(currentPage);
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
scrollToPage(record);
|
|
958
|
+
setCurrentPage(pageNumber);
|
|
959
|
+
}
|
|
960
|
+
pageInput.addEventListener('keydown', (event) => {
|
|
961
|
+
if (event.key !== 'Enter')
|
|
962
|
+
return;
|
|
963
|
+
event.preventDefault();
|
|
964
|
+
goToPageInput();
|
|
965
|
+
pageInput.blur();
|
|
966
|
+
});
|
|
967
|
+
pageInput.addEventListener('change', goToPageInput);
|
|
968
|
+
function scrollToPage(record) {
|
|
969
|
+
const before = pages.scrollTop;
|
|
970
|
+
const viewport = pages.getBoundingClientRect();
|
|
971
|
+
const page = record.wrapper.getBoundingClientRect();
|
|
972
|
+
ctx.logger.log('info', `[pdf navigation] request page=${record.pageNumber} scrollTop=${before} ` +
|
|
973
|
+
`viewport=${viewport.top},${viewport.height} page=${page.top},${page.height}`);
|
|
974
|
+
// The page panel has its own fixed-height scrollport, so a single
|
|
975
|
+
// coordinate-based movement is enough. Combining this with
|
|
976
|
+
// scrollIntoView applied the same offset twice.
|
|
977
|
+
pages.scrollTop = Math.max(0, pages.scrollTop + page.top - viewport.top);
|
|
978
|
+
requestAnimationFrame(() => {
|
|
979
|
+
ctx.logger.log('info', `[pdf navigation] result page=${record.pageNumber} scrollTop=${pages.scrollTop} ` +
|
|
980
|
+
`delta=${pages.scrollTop - before}`);
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
function syncCurrentPageFromViewport() {
|
|
984
|
+
const viewport = pages.getBoundingClientRect();
|
|
985
|
+
const visible = records.filter((record) => {
|
|
986
|
+
const box = record.wrapper.getBoundingClientRect();
|
|
987
|
+
return box.bottom > viewport.top && box.top < viewport.bottom;
|
|
988
|
+
});
|
|
989
|
+
if (visible.length === 0)
|
|
990
|
+
return;
|
|
991
|
+
const current = visible.find((record) => {
|
|
992
|
+
const box = record.wrapper.getBoundingClientRect();
|
|
993
|
+
return box.top <= viewport.top && box.bottom > viewport.top;
|
|
994
|
+
}) ?? visible.sort((a, b) => {
|
|
995
|
+
const aDistance = Math.abs(a.wrapper.getBoundingClientRect().top - viewport.top);
|
|
996
|
+
const bDistance = Math.abs(b.wrapper.getBoundingClientRect().top - viewport.top);
|
|
997
|
+
return aDistance - bDistance;
|
|
998
|
+
})[0];
|
|
999
|
+
if (current)
|
|
1000
|
+
setCurrentPage(current.pageNumber);
|
|
1001
|
+
}
|
|
1002
|
+
pages.addEventListener('scroll', syncCurrentPageFromViewport, { passive: true });
|
|
1003
|
+
async function rebuildLayout() {
|
|
1004
|
+
if (!doc || disposed)
|
|
1005
|
+
return;
|
|
1006
|
+
// Preserve the source page the reader is actually viewing. `currentPage`
|
|
1007
|
+
// alone is not sufficient here: the lazy-render observer deliberately
|
|
1008
|
+
// observes pages outside the viewport as well.
|
|
1009
|
+
const previousViewport = pages.getBoundingClientRect();
|
|
1010
|
+
const visibleBeforeRebuild = records
|
|
1011
|
+
.filter((record) => {
|
|
1012
|
+
const box = record.wrapper.getBoundingClientRect();
|
|
1013
|
+
return box.bottom > previousViewport.top && box.top < previousViewport.bottom;
|
|
1014
|
+
})
|
|
1015
|
+
.sort((a, b) => {
|
|
1016
|
+
const aDistance = Math.abs(a.wrapper.getBoundingClientRect().top - previousViewport.top);
|
|
1017
|
+
const bDistance = Math.abs(b.wrapper.getBoundingClientRect().top - previousViewport.top);
|
|
1018
|
+
return aDistance - bDistance;
|
|
1019
|
+
})[0];
|
|
1020
|
+
const pageToRestore = visibleBeforeRebuild?.pageNumber ?? currentPage;
|
|
1021
|
+
// Retain where the reader was within the page, rather than only its
|
|
1022
|
+
// page number. This keeps zoom from jumping to a page boundary.
|
|
1023
|
+
const pageOffsetRatio = visibleBeforeRebuild
|
|
1024
|
+
? Math.max(0, Math.min(1, (previousViewport.top - visibleBeforeRebuild.wrapper.getBoundingClientRect().top) / Math.max(1, visibleBeforeRebuild.wrapper.getBoundingClientRect().height)))
|
|
1025
|
+
: 0;
|
|
1026
|
+
const epoch = ++renderEpoch;
|
|
1027
|
+
pageObserver?.disconnect();
|
|
1028
|
+
thumbObserver?.disconnect();
|
|
1029
|
+
for (const record of records) {
|
|
1030
|
+
record.task?.cancel();
|
|
1031
|
+
record.textLayer?.cancel();
|
|
1032
|
+
}
|
|
1033
|
+
records = [];
|
|
1034
|
+
pages.replaceChildren();
|
|
1035
|
+
thumbs.replaceChildren();
|
|
1036
|
+
const scale = controller.state.zoom / 100;
|
|
1037
|
+
const pageTotal = controller.state.pageOrder.length;
|
|
1038
|
+
const thumbRenderers = [];
|
|
1039
|
+
for (const pageNumber of controller.state.pageOrder) {
|
|
1040
|
+
const page = await doc.getPage(pageNumber);
|
|
1041
|
+
if (disposed || epoch !== renderEpoch)
|
|
1042
|
+
return;
|
|
1043
|
+
const viewport = page.getViewport({ scale });
|
|
1044
|
+
const wrapper = el('div', 'omni-pdf__page');
|
|
1045
|
+
wrapper.dataset.pageNumber = String(pageNumber);
|
|
1046
|
+
wrapper.style.width = `${viewport.width}px`;
|
|
1047
|
+
wrapper.style.height = `${viewport.height}px`;
|
|
1048
|
+
wrapper.appendChild(el('div', 'omni-pdf__placeholder', t('common.page', { page: pageNumber, pages: pageTotal })));
|
|
1049
|
+
wrapper.addEventListener('click', (event) => {
|
|
1050
|
+
const target = event.target;
|
|
1051
|
+
if (target instanceof Element && target.closest('.omni-pdf__annotation'))
|
|
1052
|
+
return;
|
|
1053
|
+
if (tool !== 'text' && tool !== 'signature') {
|
|
1054
|
+
if (controller.state.selectedAnnotationId) {
|
|
1055
|
+
controller.dispatch({ type: 'select-annotation', id: null });
|
|
1056
|
+
}
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (target !== wrapper && !(target instanceof HTMLCanvasElement))
|
|
1060
|
+
return;
|
|
1061
|
+
const bounds = wrapper.getBoundingClientRect();
|
|
1062
|
+
const currentScale = controller.state.zoom / 100;
|
|
1063
|
+
const x = (event.clientX - bounds.left) / currentScale;
|
|
1064
|
+
const y = (event.clientY - bounds.top) / currentScale;
|
|
1065
|
+
if (tool === 'text') {
|
|
1066
|
+
openTextInput(wrapper, pageNumber, x, y);
|
|
1067
|
+
}
|
|
1068
|
+
else {
|
|
1069
|
+
pendingSignaturePosition = { page: pageNumber, x, y };
|
|
1070
|
+
clearPad();
|
|
1071
|
+
signatureModal.classList.add('is-open');
|
|
1072
|
+
}
|
|
1073
|
+
});
|
|
1074
|
+
if (pageLayout === 'spread-even' && records.length === 0) {
|
|
1075
|
+
const blank = el('div', 'omni-pdf__spread-blank');
|
|
1076
|
+
blank.style.width = `${viewport.width}px`;
|
|
1077
|
+
blank.style.height = `${viewport.height}px`;
|
|
1078
|
+
pages.appendChild(blank);
|
|
1079
|
+
}
|
|
1080
|
+
pages.appendChild(wrapper);
|
|
1081
|
+
const record = {
|
|
1082
|
+
pageNumber,
|
|
1083
|
+
page,
|
|
1084
|
+
wrapper,
|
|
1085
|
+
rendered: false,
|
|
1086
|
+
rendering: false,
|
|
1087
|
+
task: undefined,
|
|
1088
|
+
textLayer: undefined
|
|
1089
|
+
};
|
|
1090
|
+
records.push(record);
|
|
1091
|
+
// --- thumbnail (lazy-rendered — docs §5: never all at once) ---
|
|
1092
|
+
const thumb = el('button', 'omni-pdf__thumb');
|
|
1093
|
+
thumb.type = 'button';
|
|
1094
|
+
thumb.setAttribute('aria-label', t('common.page', { page: pageNumber, pages: pageTotal }));
|
|
1095
|
+
const thumbCanvas = el('canvas');
|
|
1096
|
+
const thumbViewport = page.getViewport({
|
|
1097
|
+
scale: Math.min(0.18, 90 / (viewport.width / scale || 1))
|
|
1098
|
+
});
|
|
1099
|
+
thumbCanvas.width = Math.floor(thumbViewport.width);
|
|
1100
|
+
thumbCanvas.height = Math.floor(thumbViewport.height);
|
|
1101
|
+
let thumbRendered = false;
|
|
1102
|
+
const renderThumb = () => {
|
|
1103
|
+
if (thumbRendered || disposed || epoch !== renderEpoch)
|
|
1104
|
+
return;
|
|
1105
|
+
thumbRendered = true;
|
|
1106
|
+
const thumbContext = thumbCanvas.getContext('2d');
|
|
1107
|
+
if (!thumbContext)
|
|
1108
|
+
return;
|
|
1109
|
+
void page
|
|
1110
|
+
.render({ canvasContext: thumbContext, viewport: thumbViewport })
|
|
1111
|
+
.promise.catch(() => undefined);
|
|
1112
|
+
};
|
|
1113
|
+
thumbRenderers.push({ element: thumb, render: renderThumb });
|
|
1114
|
+
const label = el('span', 'omni-pdf__thumb-label', String(pageNumber));
|
|
1115
|
+
thumb.append(thumbCanvas, label);
|
|
1116
|
+
thumb.addEventListener('click', () => {
|
|
1117
|
+
// Keep thumbnail navigation inside the PDF scroll container.
|
|
1118
|
+
scrollToPage(record);
|
|
1119
|
+
setCurrentPage(pageNumber);
|
|
1120
|
+
});
|
|
1121
|
+
thumb.draggable = true;
|
|
1122
|
+
thumb.addEventListener('dragstart', (event) => {
|
|
1123
|
+
event.dataTransfer?.setData('text/plain', String(pageNumber));
|
|
1124
|
+
thumb.classList.add('is-dragging');
|
|
1125
|
+
});
|
|
1126
|
+
thumb.addEventListener('dragend', () => {
|
|
1127
|
+
for (const node of [...thumbs.children]) {
|
|
1128
|
+
node.classList.remove('is-dragging', 'drop-before', 'drop-after');
|
|
1129
|
+
}
|
|
1130
|
+
});
|
|
1131
|
+
thumb.addEventListener('dragover', (event) => {
|
|
1132
|
+
event.preventDefault();
|
|
1133
|
+
for (const node of [...thumbs.children]) {
|
|
1134
|
+
node.classList.remove('drop-before', 'drop-after');
|
|
1135
|
+
}
|
|
1136
|
+
const rect = thumb.getBoundingClientRect();
|
|
1137
|
+
thumb.classList.add(event.clientY < rect.top + rect.height / 2 ? 'drop-before' : 'drop-after');
|
|
1138
|
+
});
|
|
1139
|
+
thumb.addEventListener('dragleave', () => thumb.classList.remove('drop-before', 'drop-after'));
|
|
1140
|
+
thumb.addEventListener('drop', (event) => {
|
|
1141
|
+
event.preventDefault();
|
|
1142
|
+
const order = controller.state.pageOrder;
|
|
1143
|
+
const from = order.indexOf(Number(event.dataTransfer?.getData('text/plain')));
|
|
1144
|
+
let to = order.indexOf(pageNumber);
|
|
1145
|
+
if (thumb.classList.contains('drop-after'))
|
|
1146
|
+
to += 1;
|
|
1147
|
+
if (from >= 0 && to >= 0) {
|
|
1148
|
+
if (from < to)
|
|
1149
|
+
to--;
|
|
1150
|
+
controller.dispatch({
|
|
1151
|
+
type: 'reorder-pages',
|
|
1152
|
+
from,
|
|
1153
|
+
to: Math.min(to, order.length - 1)
|
|
1154
|
+
});
|
|
1155
|
+
}
|
|
1156
|
+
});
|
|
1157
|
+
if (editingAvailable) {
|
|
1158
|
+
const remove = el('button', 'omni-pdf__thumb-remove', '×');
|
|
1159
|
+
remove.type = 'button';
|
|
1160
|
+
remove.setAttribute('aria-label', t('pdf.deletePage'));
|
|
1161
|
+
remove.addEventListener('click', (event) => {
|
|
1162
|
+
event.stopPropagation();
|
|
1163
|
+
controller.dispatch({ type: 'delete-page', page: pageNumber });
|
|
1164
|
+
});
|
|
1165
|
+
thumb.appendChild(remove);
|
|
1166
|
+
}
|
|
1167
|
+
thumbs.appendChild(thumb);
|
|
1168
|
+
}
|
|
1169
|
+
if (typeof IntersectionObserver === 'function') {
|
|
1170
|
+
pageObserver = new IntersectionObserver((entries) => {
|
|
1171
|
+
for (const entry of entries) {
|
|
1172
|
+
if (!entry.isIntersecting)
|
|
1173
|
+
continue;
|
|
1174
|
+
const record = records.find((item) => item.wrapper === entry.target);
|
|
1175
|
+
if (!record)
|
|
1176
|
+
continue;
|
|
1177
|
+
void renderPage(record, epoch);
|
|
1178
|
+
}
|
|
1179
|
+
}, { root: pages, rootMargin: '150% 0px 150% 0px', threshold: 0 });
|
|
1180
|
+
for (const record of records)
|
|
1181
|
+
pageObserver.observe(record.wrapper);
|
|
1182
|
+
thumbObserver = new IntersectionObserver((entries) => {
|
|
1183
|
+
for (const entry of entries) {
|
|
1184
|
+
if (!entry.isIntersecting)
|
|
1185
|
+
continue;
|
|
1186
|
+
thumbRenderers.find((item) => item.element === entry.target)?.render();
|
|
1187
|
+
}
|
|
1188
|
+
}, { root: thumbs, rootMargin: '100% 0px 100% 0px', threshold: 0 });
|
|
1189
|
+
for (const item of thumbRenderers)
|
|
1190
|
+
thumbObserver.observe(item.element);
|
|
1191
|
+
}
|
|
1192
|
+
else {
|
|
1193
|
+
// No observer support: render the current page and nearby thumbs.
|
|
1194
|
+
for (const item of thumbRenderers.slice(0, THUMB_FALLBACK_RENDER_COUNT)) {
|
|
1195
|
+
item.render();
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const current = records.find((record) => record.pageNumber === pageToRestore) ?? records[0];
|
|
1199
|
+
if (current) {
|
|
1200
|
+
setCurrentPage(current.pageNumber);
|
|
1201
|
+
void renderPage(current, epoch);
|
|
1202
|
+
// Do not use scrollIntoView here: it may scroll the host document
|
|
1203
|
+
// as well as the PDF list. Restrict restoration to the PDF list.
|
|
1204
|
+
if (visibleBeforeRebuild) {
|
|
1205
|
+
pages.scrollTop = Math.max(0, current.wrapper.offsetTop + current.wrapper.offsetHeight * pageOffsetRatio);
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
syncCurrentPageFromViewport();
|
|
1209
|
+
status.textContent = `${records.length} ${t('pdf.pages')}`;
|
|
1210
|
+
}
|
|
1211
|
+
function refreshChrome() {
|
|
1212
|
+
const state = controller.state;
|
|
1213
|
+
zoomLevel.textContent = `${state.zoom}%`;
|
|
1214
|
+
zoomOutBtn.disabled = state.zoom === PDF_ZOOM_LEVELS[0];
|
|
1215
|
+
zoomInBtn.disabled = state.zoom === PDF_ZOOM_LEVELS[PDF_ZOOM_LEVELS.length - 1];
|
|
1216
|
+
if (editingAvailable && ctx.writeback) {
|
|
1217
|
+
saveBtn.disabled = !(state.dirty || mergedSinceSave);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
/** Full rebuild only when zoom / page structure changed; annotation-only
|
|
1221
|
+
* changes just refresh the overlays (no canvas re-render, no scroll loss). */
|
|
1222
|
+
function onStateChange() {
|
|
1223
|
+
const state = controller.state;
|
|
1224
|
+
const nextKey = JSON.stringify({ zoom: state.zoom, pageOrder: state.pageOrder });
|
|
1225
|
+
refreshChrome();
|
|
1226
|
+
if (nextKey !== layoutKey) {
|
|
1227
|
+
layoutKey = nextKey;
|
|
1228
|
+
void rebuildLayout();
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
for (const record of records) {
|
|
1232
|
+
if (record.rendered)
|
|
1233
|
+
renderOverlays(record);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
function adoptController(next) {
|
|
1237
|
+
unsubscribe?.();
|
|
1238
|
+
controller = next;
|
|
1239
|
+
layoutKey = JSON.stringify({
|
|
1240
|
+
zoom: next.state.zoom,
|
|
1241
|
+
pageOrder: next.state.pageOrder
|
|
1242
|
+
});
|
|
1243
|
+
unsubscribe = next.subscribe(onStateChange);
|
|
1244
|
+
}
|
|
1245
|
+
// ------------------------------------------------------- editing actions
|
|
1246
|
+
async function ensurePdfLib() {
|
|
1247
|
+
if (!deps.loadPdfLib)
|
|
1248
|
+
throw new Error('pdf-lib unavailable');
|
|
1249
|
+
pdfLib ??= await deps.loadPdfLib();
|
|
1250
|
+
return pdfLib;
|
|
1251
|
+
}
|
|
1252
|
+
function stateForSave() {
|
|
1253
|
+
const state = controller.state;
|
|
1254
|
+
return {
|
|
1255
|
+
...state,
|
|
1256
|
+
annotations: state.annotations.map((annotation) => {
|
|
1257
|
+
if (annotation.kind !== 'text'
|
|
1258
|
+
|| (annotation.rasterDataUrl && annotation.rasterWidth && annotation.rasterHeight))
|
|
1259
|
+
return annotation;
|
|
1260
|
+
return { ...annotation, ...rasterizeText(annotation.text, annotation.size, annotation.color) };
|
|
1261
|
+
})
|
|
1262
|
+
};
|
|
1263
|
+
}
|
|
1264
|
+
async function save() {
|
|
1265
|
+
if (!ctx.writeback || saveBtn.disabled)
|
|
1266
|
+
return;
|
|
1267
|
+
try {
|
|
1268
|
+
const lib = await ensurePdfLib();
|
|
1269
|
+
// Flatten from the pristine base (not the on-disk flattened copy),
|
|
1270
|
+
// embedding the sidecar so overlays stay removable on reopen. The
|
|
1271
|
+
// in-memory session keeps `sourceBytes` pristine + the live layer;
|
|
1272
|
+
// we only rebaseline the dirty marker.
|
|
1273
|
+
const data = await buildSavedPdf(lib, sourceBytes, stateForSave());
|
|
1274
|
+
await ctx.writeback.write(data);
|
|
1275
|
+
mergedSinceSave = false;
|
|
1276
|
+
controller.dispatch({ type: 'mark-saved' });
|
|
1277
|
+
refreshChrome();
|
|
1278
|
+
status.textContent = t('common.savedToOriginal');
|
|
1279
|
+
}
|
|
1280
|
+
catch (error) {
|
|
1281
|
+
ctx.logger.log('error', `pdf save failed: ${String(error)}`);
|
|
1282
|
+
status.textContent = t('common.saveFailed');
|
|
1283
|
+
}
|
|
1284
|
+
}
|
|
1285
|
+
async function saveAs() {
|
|
1286
|
+
if (!ctx.save || saveAsBtn.disabled)
|
|
1287
|
+
return;
|
|
1288
|
+
try {
|
|
1289
|
+
const lib = await ensurePdfLib();
|
|
1290
|
+
// Keep the same hybrid sidecar as Save so a Save As document can
|
|
1291
|
+
// be reopened in Omni Viewer with all overlays still editable.
|
|
1292
|
+
// Other PDF readers continue to see the flattened result.
|
|
1293
|
+
const data = await buildSavedPdf(lib, sourceBytes, stateForSave());
|
|
1294
|
+
await ctx.save.saveFile(savedPdfName(input.fileName), data, 'application/pdf');
|
|
1295
|
+
status.textContent = t('common.saved', { name: savedPdfName(input.fileName) });
|
|
1296
|
+
}
|
|
1297
|
+
catch (error) {
|
|
1298
|
+
ctx.logger.log('error', `pdf save-as failed: ${String(error)}`);
|
|
1299
|
+
status.textContent = t('common.saveFailed');
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
async function merge() {
|
|
1303
|
+
if (!ctx.filePick || mergeBtn.disabled)
|
|
1304
|
+
return;
|
|
1305
|
+
const picked = await ctx.filePick.pickFile({ accept: ['application/pdf', '.pdf'] });
|
|
1306
|
+
if (!picked || disposed)
|
|
1307
|
+
return;
|
|
1308
|
+
try {
|
|
1309
|
+
status.textContent = t('pdf.merging');
|
|
1310
|
+
const lib = await ensurePdfLib();
|
|
1311
|
+
// `sourceBytes` is the pristine base while the controller holds
|
|
1312
|
+
// the live reorder/delete/annotation layer. Keep that layer and
|
|
1313
|
+
// append the new source pages to its current visible order.
|
|
1314
|
+
const previousState = controller.state;
|
|
1315
|
+
const previousSourcePageCount = doc?.numPages ?? sourceBytes.length;
|
|
1316
|
+
const merged = await mergePdfBytes(lib, sourceBytes, picked.data);
|
|
1317
|
+
await doc?.destroy();
|
|
1318
|
+
doc = await loadDocument(merged);
|
|
1319
|
+
if (disposed) {
|
|
1320
|
+
void doc.destroy();
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
sourceBytes = merged;
|
|
1324
|
+
currentPage = 1;
|
|
1325
|
+
// The merged pages exist only in memory until the user saves.
|
|
1326
|
+
mergedSinceSave = true;
|
|
1327
|
+
const appendedPages = Array.from({ length: Math.max(0, doc.numPages - previousSourcePageCount) }, (_, index) => previousSourcePageCount + index + 1);
|
|
1328
|
+
adoptController(createPdfController(doc.numPages, {
|
|
1329
|
+
pageOrder: [...previousState.pageOrder, ...appendedPages],
|
|
1330
|
+
annotations: previousState.annotations
|
|
1331
|
+
}));
|
|
1332
|
+
refreshChrome();
|
|
1333
|
+
await rebuildLayout();
|
|
1334
|
+
status.textContent = t('pdf.mergeComplete');
|
|
1335
|
+
}
|
|
1336
|
+
catch (error) {
|
|
1337
|
+
ctx.logger.log('error', `pdf merge failed: ${String(error)}`);
|
|
1338
|
+
status.textContent = t('pdf.mergeFailed');
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
// ------------------------------------------------------------- open doc
|
|
1342
|
+
let password;
|
|
1343
|
+
for (;;) {
|
|
1344
|
+
try {
|
|
1345
|
+
doc = await loadDocument(sourceBytes, password);
|
|
1346
|
+
break;
|
|
1347
|
+
}
|
|
1348
|
+
catch (error) {
|
|
1349
|
+
throwIfAborted();
|
|
1350
|
+
if (isPasswordError(error)) {
|
|
1351
|
+
const entered = await askPassword(password !== undefined);
|
|
1352
|
+
throwIfAborted();
|
|
1353
|
+
if (entered !== null) {
|
|
1354
|
+
password = entered;
|
|
1355
|
+
continue;
|
|
1356
|
+
}
|
|
1357
|
+
status.textContent = t('pdf.passwordRequired');
|
|
1358
|
+
}
|
|
1359
|
+
else {
|
|
1360
|
+
ctx.logger.log('error', `pdf load failed: ${String(error)}`);
|
|
1361
|
+
status.textContent = t('pdf.loadFailed');
|
|
1362
|
+
}
|
|
1363
|
+
status.classList.add('omni-pdf__status--error');
|
|
1364
|
+
// Stable failed handle: one empty controller, normal dispose.
|
|
1365
|
+
const failedController = createPdfController(0);
|
|
1366
|
+
return {
|
|
1367
|
+
get controller() {
|
|
1368
|
+
return failedController;
|
|
1369
|
+
},
|
|
1370
|
+
isDirty: () => false,
|
|
1371
|
+
dispose: teardown
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
throwIfAborted();
|
|
1376
|
+
// Hybrid rehydration: if this file was saved by us, its visible pages are
|
|
1377
|
+
// flattened but it carries the pristine source + layer JSON. Render/edit
|
|
1378
|
+
// from the pristine base and restore the overlays as a removable layer.
|
|
1379
|
+
const sidecar = await readSidecar(doc);
|
|
1380
|
+
if (sidecar) {
|
|
1381
|
+
await doc.destroy();
|
|
1382
|
+
sourceBytes = sidecar.pristine;
|
|
1383
|
+
doc = await loadDocument(sourceBytes, password);
|
|
1384
|
+
throwIfAborted();
|
|
1385
|
+
adoptController(createPdfController(doc.numPages, sidecar.layer));
|
|
1386
|
+
}
|
|
1387
|
+
else {
|
|
1388
|
+
adoptController(createPdfController(doc.numPages));
|
|
1389
|
+
}
|
|
1390
|
+
refreshChrome();
|
|
1391
|
+
await rebuildLayout();
|
|
1392
|
+
throwIfAborted();
|
|
1393
|
+
return {
|
|
1394
|
+
get controller() {
|
|
1395
|
+
return controller;
|
|
1396
|
+
},
|
|
1397
|
+
isDirty: () => controller.state.dirty || mergedSinceSave,
|
|
1398
|
+
dispose: teardown
|
|
1399
|
+
};
|
|
1400
|
+
}
|