makersclaw 0.1.0 → 0.3.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 +41 -1
- package/THIRD-PARTY-NOTICES.md +180 -0
- package/package.json +15 -8
- package/r/annotate.json +62 -0
- package/r/approval-request.json +52 -0
- package/r/audio-player.json +74 -0
- package/r/auth-form.json +55 -0
- package/r/board.json +55 -0
- package/r/calendar-view.json +105 -0
- package/r/cart.json +62 -0
- package/r/chart.json +83 -0
- package/r/chat.json +95 -0
- package/r/code-editor.json +94 -0
- package/r/comment-box.json +64 -0
- package/r/contact-section.json +53 -0
- package/r/core.json +341 -0
- package/r/csv-import.json +63 -0
- package/r/data-table.json +69 -0
- package/r/diagram.json +74 -0
- package/r/doc-editor.json +82 -0
- package/r/doc-page.json +61 -0
- package/r/email-render.json +73 -0
- package/r/faq.json +50 -0
- package/r/file-upload.json +54 -0
- package/r/flow-canvas.json +93 -0
- package/r/footer.json +52 -0
- package/r/gantt.json +70 -0
- package/r/job-panel.json +56 -0
- package/r/json-editor.json +66 -0
- package/r/map.json +89 -0
- package/r/menubar.json +52 -0
- package/r/mind-map.json +67 -0
- package/r/navigation-menu.json +52 -0
- package/r/order-detail.json +54 -0
- package/r/org-chart.json +66 -0
- package/r/page-builder.json +105 -0
- package/r/payment-methods.json +51 -0
- package/r/pdf-document.json +79 -0
- package/r/pdf-viewer.json +88 -0
- package/r/product-detail.json +76 -0
- package/r/product-grid.json +65 -0
- package/r/record-form.json +67 -0
- package/r/record-notes.json +55 -0
- package/r/registry.json +3612 -0
- package/r/search-dialog.json +51 -0
- package/r/settings-list.json +66 -0
- package/r/setup-checklist.json +51 -0
- package/r/share-dialog.json +52 -0
- package/r/sidebar.json +69 -0
- package/r/signature-pad.json +72 -0
- package/r/sketch-canvas.json +99 -0
- package/r/spreadsheet.json +90 -0
- package/r/stat-cards.json +66 -0
- package/r/task-list.json +63 -0
- package/r/task-model.json +98 -0
- package/r/testimonials.json +52 -0
- package/r/transaction-list.json +53 -0
- package/r/whiteboard.json +66 -0
- package/registry/blocks/annotate/README.md +179 -0
- package/registry/blocks/annotate/annotate-data.js +250 -0
- package/registry/blocks/annotate/annotate.jsx +646 -0
- package/registry/blocks/annotate/block.json +10 -0
- package/registry/blocks/annotate/demo.jsx +135 -0
- package/registry/blocks/annotate/index.js +15 -0
- package/registry/blocks/approval-request/README.md +116 -0
- package/registry/blocks/approval-request/approval-request.jsx +386 -0
- package/registry/blocks/approval-request/block.json +10 -0
- package/registry/blocks/approval-request/demo.jsx +127 -0
- package/registry/blocks/approval-request/index.js +4 -0
- package/registry/blocks/audio-player/README.md +167 -0
- package/registry/blocks/audio-player/audio-player.jsx +623 -0
- package/registry/blocks/audio-player/block.json +14 -0
- package/registry/blocks/audio-player/colors.js +109 -0
- package/registry/blocks/audio-player/demo.jsx +209 -0
- package/registry/blocks/audio-player/index.js +6 -0
- package/registry/blocks/audio-player/time.js +67 -0
- package/registry/blocks/audio-player/wavesurfer.js +9 -0
- package/registry/blocks/auth-form/README.md +126 -0
- package/registry/blocks/auth-form/auth-form.jsx +568 -0
- package/registry/blocks/auth-form/block.json +10 -0
- package/registry/blocks/auth-form/demo.jsx +131 -0
- package/registry/blocks/auth-form/index.js +3 -0
- package/registry/blocks/board/README.md +64 -13
- package/registry/blocks/board/block.json +2 -2
- package/registry/blocks/board/board.jsx +237 -94
- package/registry/blocks/board/demo.jsx +103 -24
- package/registry/blocks/calendar-view/README.md +180 -0
- package/registry/blocks/calendar-view/agenda.jsx +147 -0
- package/registry/blocks/calendar-view/block.json +76 -0
- package/registry/blocks/calendar-view/calendar-model.js +442 -0
- package/registry/blocks/calendar-view/calendar.jsx +627 -0
- package/registry/blocks/calendar-view/demo.jsx +195 -0
- package/registry/blocks/calendar-view/draft.jsx +49 -0
- package/registry/blocks/calendar-view/index.js +39 -0
- package/registry/blocks/calendar-view/month.jsx +346 -0
- package/registry/blocks/calendar-view/paint.js +69 -0
- package/registry/blocks/calendar-view/time-grid.jsx +527 -0
- package/registry/blocks/calendar-view/tray.jsx +87 -0
- package/registry/blocks/cart/README.md +146 -0
- package/registry/blocks/cart/block.json +10 -0
- package/registry/blocks/cart/cart.jsx +597 -0
- package/registry/blocks/cart/demo.jsx +98 -0
- package/registry/blocks/cart/index.js +4 -0
- package/registry/blocks/cart/money.js +28 -0
- package/registry/blocks/chart/README.md +316 -0
- package/registry/blocks/chart/block.json +10 -0
- package/registry/blocks/chart/cartesian.jsx +642 -0
- package/registry/blocks/chart/core.jsx +262 -0
- package/registry/blocks/chart/demo.jsx +357 -0
- package/registry/blocks/chart/heatmap.jsx +336 -0
- package/registry/blocks/chart/index.js +8 -0
- package/registry/blocks/chart/pie.jsx +245 -0
- package/registry/blocks/chart/sparkline.jsx +88 -0
- package/registry/blocks/chat/README.md +273 -0
- package/registry/blocks/chat/attachment.jsx +95 -0
- package/registry/blocks/chat/block.json +10 -0
- package/registry/blocks/chat/chat.jsx +132 -0
- package/registry/blocks/chat/composer.jsx +364 -0
- package/registry/blocks/chat/demo.jsx +578 -0
- package/registry/blocks/chat/header.jsx +34 -0
- package/registry/blocks/chat/index.js +9 -0
- package/registry/blocks/chat/message.jsx +450 -0
- package/registry/blocks/chat/scroller.jsx +247 -0
- package/registry/blocks/chat/text.jsx +104 -0
- package/registry/blocks/code-editor/README.md +210 -0
- package/registry/blocks/code-editor/block.json +22 -0
- package/registry/blocks/code-editor/code-editor.jsx +534 -0
- package/registry/blocks/code-editor/codemirror.js +38 -0
- package/registry/blocks/code-editor/demo.jsx +205 -0
- package/registry/blocks/code-editor/index.js +8 -0
- package/registry/blocks/code-editor/json.js +317 -0
- package/registry/blocks/code-editor/sql.js +136 -0
- package/registry/blocks/code-editor/text.js +101 -0
- package/registry/blocks/code-editor/theme.js +186 -0
- package/registry/blocks/comment-box/README.md +123 -0
- package/registry/blocks/comment-box/block.json +10 -0
- package/registry/blocks/comment-box/comment-body.jsx +217 -0
- package/registry/blocks/comment-box/comment-box.jsx +198 -0
- package/registry/blocks/comment-box/comment-time.jsx +77 -0
- package/registry/blocks/comment-box/demo.jsx +141 -0
- package/registry/blocks/comment-box/index.js +8 -0
- package/registry/blocks/contact-section/README.md +152 -0
- package/registry/blocks/contact-section/block.json +11 -0
- package/registry/blocks/contact-section/contact-section.jsx +509 -0
- package/registry/blocks/contact-section/demo.jsx +93 -0
- package/registry/blocks/contact-section/index.js +4 -0
- package/registry/blocks/csv-import/README.md +133 -0
- package/registry/blocks/csv-import/block.json +13 -0
- package/registry/blocks/csv-import/csv-import.jsx +479 -0
- package/registry/blocks/csv-import/demo.jsx +106 -0
- package/registry/blocks/csv-import/import.js +200 -0
- package/registry/blocks/csv-import/index.js +7 -0
- package/registry/blocks/data-table/README.md +386 -0
- package/registry/blocks/data-table/block.json +10 -0
- package/registry/blocks/data-table/data-table.jsx +1164 -0
- package/registry/blocks/data-table/demo.jsx +588 -0
- package/registry/blocks/data-table/index.js +5 -0
- package/registry/blocks/data-table/model.js +169 -0
- package/registry/blocks/diagram/README.md +108 -0
- package/registry/blocks/diagram/block.json +13 -0
- package/registry/blocks/diagram/demo.jsx +113 -0
- package/registry/blocks/diagram/diagram.jsx +481 -0
- package/registry/blocks/diagram/index.js +7 -0
- package/registry/blocks/diagram/mermaid.js +52 -0
- package/registry/blocks/diagram/palette.js +313 -0
- package/registry/blocks/diagram/source.js +98 -0
- package/registry/blocks/doc-editor/README.md +215 -0
- package/registry/blocks/doc-editor/block.json +15 -0
- package/registry/blocks/doc-editor/demo.jsx +225 -0
- package/registry/blocks/doc-editor/doc-editor.jsx +291 -0
- package/registry/blocks/doc-editor/doc.js +309 -0
- package/registry/blocks/doc-editor/index.js +7 -0
- package/registry/blocks/doc-editor/schema.jsx +56 -0
- package/registry/blocks/doc-editor/shadcn-classes.js +110 -0
- package/registry/blocks/doc-editor/theme.js +82 -0
- package/registry/blocks/doc-page/README.md +119 -0
- package/registry/blocks/doc-page/autosave.js +104 -0
- package/registry/blocks/doc-page/block.json +10 -0
- package/registry/blocks/doc-page/demo.jsx +137 -0
- package/registry/blocks/doc-page/doc-page.jsx +276 -0
- package/registry/blocks/doc-page/index.js +4 -0
- package/registry/blocks/email-render/README.md +151 -0
- package/registry/blocks/email-render/block.json +14 -0
- package/registry/blocks/email-render/demo.jsx +153 -0
- package/registry/blocks/email-render/doc-email.jsx +210 -0
- package/registry/blocks/email-render/email.jsx +221 -0
- package/registry/blocks/email-render/index.js +10 -0
- package/registry/blocks/email-render/render.js +50 -0
- package/registry/blocks/email-render/theme.js +215 -0
- package/registry/blocks/faq/README.md +101 -0
- package/registry/blocks/faq/block.json +11 -0
- package/registry/blocks/faq/demo.jsx +89 -0
- package/registry/blocks/faq/faq.jsx +220 -0
- package/registry/blocks/faq/index.js +4 -0
- package/registry/blocks/file-upload/README.md +138 -0
- package/registry/blocks/file-upload/block.json +10 -0
- package/registry/blocks/file-upload/demo.jsx +204 -0
- package/registry/blocks/file-upload/file-upload.jsx +635 -0
- package/registry/blocks/file-upload/index.js +4 -0
- package/registry/blocks/flow-canvas/README.md +420 -0
- package/registry/blocks/flow-canvas/block.json +13 -0
- package/registry/blocks/flow-canvas/board.js +819 -0
- package/registry/blocks/flow-canvas/chrome.jsx +649 -0
- package/registry/blocks/flow-canvas/context.jsx +35 -0
- package/registry/blocks/flow-canvas/demo.jsx +249 -0
- package/registry/blocks/flow-canvas/edges.jsx +107 -0
- package/registry/blocks/flow-canvas/flow-canvas.jsx +1329 -0
- package/registry/blocks/flow-canvas/index.js +41 -0
- package/registry/blocks/flow-canvas/nodes.jsx +392 -0
- package/registry/blocks/flow-canvas/theme.js +174 -0
- package/registry/blocks/footer/README.md +133 -0
- package/registry/blocks/footer/block.json +11 -0
- package/registry/blocks/footer/demo.jsx +119 -0
- package/registry/blocks/footer/footer.jsx +406 -0
- package/registry/blocks/footer/index.js +4 -0
- package/registry/blocks/gantt/README.md +220 -0
- package/registry/blocks/gantt/block.json +10 -0
- package/registry/blocks/gantt/demo.jsx +148 -0
- package/registry/blocks/gantt/gantt.jsx +923 -0
- package/registry/blocks/gantt/index.js +7 -0
- package/registry/blocks/gantt/rows.jsx +468 -0
- package/registry/blocks/gantt/schedule.js +278 -0
- package/registry/blocks/job-panel/README.md +86 -2
- package/registry/blocks/job-panel/block.json +3 -3
- package/registry/blocks/job-panel/demo.jsx +230 -3
- package/registry/blocks/job-panel/job-panel.jsx +169 -3
- package/registry/blocks/json-editor/README.md +133 -0
- package/registry/blocks/json-editor/block.json +10 -0
- package/registry/blocks/json-editor/demo.jsx +92 -0
- package/registry/blocks/json-editor/index.js +7 -0
- package/registry/blocks/json-editor/json-editor.jsx +305 -0
- package/registry/blocks/json-editor/json-tree.jsx +393 -0
- package/registry/blocks/json-editor/tree.js +225 -0
- package/registry/blocks/map/README.md +221 -0
- package/registry/blocks/map/block.json +14 -0
- package/registry/blocks/map/demo.jsx +201 -0
- package/registry/blocks/map/geo.js +198 -0
- package/registry/blocks/map/index.js +8 -0
- package/registry/blocks/map/list.jsx +128 -0
- package/registry/blocks/map/map-view.jsx +715 -0
- package/registry/blocks/map/maplibre.js +30 -0
- package/registry/blocks/map/marks.jsx +146 -0
- package/registry/blocks/map/theme.js +142 -0
- package/registry/blocks/menubar/README.md +109 -0
- package/registry/blocks/menubar/block.json +10 -0
- package/registry/blocks/menubar/demo.jsx +128 -0
- package/registry/blocks/menubar/index.js +4 -0
- package/registry/blocks/menubar/menubar.jsx +199 -0
- package/registry/blocks/mind-map/README.md +206 -0
- package/registry/blocks/mind-map/block.json +10 -0
- package/registry/blocks/mind-map/demo.jsx +103 -0
- package/registry/blocks/mind-map/index.js +31 -0
- package/registry/blocks/mind-map/mind-map.jsx +1125 -0
- package/registry/blocks/mind-map/topic.jsx +143 -0
- package/registry/blocks/mind-map/tree.js +645 -0
- package/registry/blocks/navigation-menu/README.md +124 -0
- package/registry/blocks/navigation-menu/block.json +10 -0
- package/registry/blocks/navigation-menu/demo.jsx +84 -0
- package/registry/blocks/navigation-menu/index.js +4 -0
- package/registry/blocks/navigation-menu/navigation-menu.jsx +448 -0
- package/registry/blocks/order-detail/README.md +109 -0
- package/registry/blocks/order-detail/block.json +10 -0
- package/registry/blocks/order-detail/demo.jsx +88 -0
- package/registry/blocks/order-detail/index.js +4 -0
- package/registry/blocks/order-detail/order-detail.jsx +354 -0
- package/registry/blocks/org-chart/README.md +145 -0
- package/registry/blocks/org-chart/block.json +10 -0
- package/registry/blocks/org-chart/demo.jsx +94 -0
- package/registry/blocks/org-chart/index.js +7 -0
- package/registry/blocks/org-chart/org-chart.jsx +631 -0
- package/registry/blocks/org-chart/org.js +245 -0
- package/registry/blocks/org-chart/person.jsx +96 -0
- package/registry/blocks/page-builder/README.md +257 -0
- package/registry/blocks/page-builder/block.json +14 -0
- package/registry/blocks/page-builder/demo.jsx +145 -0
- package/registry/blocks/page-builder/grapes.js +18 -0
- package/registry/blocks/page-builder/index.js +11 -0
- package/registry/blocks/page-builder/page-builder.jsx +704 -0
- package/registry/blocks/page-builder/page-view.jsx +33 -0
- package/registry/blocks/page-builder/page.js +118 -0
- package/registry/blocks/page-builder/panels.jsx +588 -0
- package/registry/blocks/page-builder/sections.js +209 -0
- package/registry/blocks/page-builder/theme.js +87 -0
- package/registry/blocks/page-builder/thumbs.jsx +189 -0
- package/registry/blocks/page-builder/tokens.js +329 -0
- package/registry/blocks/payment-methods/README.md +104 -0
- package/registry/blocks/payment-methods/block.json +10 -0
- package/registry/blocks/payment-methods/demo.jsx +71 -0
- package/registry/blocks/payment-methods/index.js +4 -0
- package/registry/blocks/payment-methods/payment-methods.jsx +302 -0
- package/registry/blocks/pdf-document/README.md +213 -0
- package/registry/blocks/pdf-document/block.json +13 -0
- package/registry/blocks/pdf-document/demo.jsx +136 -0
- package/registry/blocks/pdf-document/index.js +33 -0
- package/registry/blocks/pdf-document/money.js +83 -0
- package/registry/blocks/pdf-document/pdf-document.jsx +144 -0
- package/registry/blocks/pdf-document/primitives.jsx +271 -0
- package/registry/blocks/pdf-document/quote.jsx +179 -0
- package/registry/blocks/pdf-document/theme.js +193 -0
- package/registry/blocks/pdf-viewer/README.md +191 -0
- package/registry/blocks/pdf-viewer/block.json +14 -0
- package/registry/blocks/pdf-viewer/demo.jsx +252 -0
- package/registry/blocks/pdf-viewer/helpers.js +270 -0
- package/registry/blocks/pdf-viewer/index.js +8 -0
- package/registry/blocks/pdf-viewer/notes.jsx +195 -0
- package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1368 -0
- package/registry/blocks/pdf-viewer/pdfjs.js +37 -0
- package/registry/blocks/pdf-viewer/ssr-shim-end.js +6 -0
- package/registry/blocks/pdf-viewer/ssr-shim.js +44 -0
- package/registry/blocks/product-detail/README.md +217 -0
- package/registry/blocks/product-detail/block.json +10 -0
- package/registry/blocks/product-detail/demo.jsx +233 -0
- package/registry/blocks/product-detail/index.js +7 -0
- package/registry/blocks/product-detail/price.jsx +115 -0
- package/registry/blocks/product-detail/product-detail.jsx +348 -0
- package/registry/blocks/product-detail/product-media.jsx +102 -0
- package/registry/blocks/product-detail/product-options.jsx +144 -0
- package/registry/blocks/product-grid/README.md +162 -0
- package/registry/blocks/product-grid/block.json +10 -0
- package/registry/blocks/product-grid/demo.jsx +255 -0
- package/registry/blocks/product-grid/index.js +5 -0
- package/registry/blocks/product-grid/product-card.jsx +232 -0
- package/registry/blocks/product-grid/product-grid.jsx +235 -0
- package/registry/blocks/record-form/README.md +196 -0
- package/registry/blocks/record-form/block.json +10 -0
- package/registry/blocks/record-form/demo.jsx +525 -0
- package/registry/blocks/record-form/index.js +4 -0
- package/registry/blocks/record-form/record-fields.jsx +457 -0
- package/registry/blocks/record-form/record-form.jsx +415 -0
- package/registry/blocks/record-form/rules.js +81 -0
- package/registry/blocks/record-notes/README.md +139 -0
- package/registry/blocks/record-notes/block.json +10 -0
- package/registry/blocks/record-notes/demo.jsx +179 -0
- package/registry/blocks/record-notes/index.js +4 -0
- package/registry/blocks/record-notes/record-notes.jsx +266 -0
- package/registry/blocks/search-dialog/README.md +108 -0
- package/registry/blocks/search-dialog/block.json +10 -0
- package/registry/blocks/search-dialog/demo.jsx +116 -0
- package/registry/blocks/search-dialog/index.js +3 -0
- package/registry/blocks/search-dialog/search-dialog.jsx +235 -0
- package/registry/blocks/settings-list/README.md +171 -0
- package/registry/blocks/settings-list/block.json +10 -0
- package/registry/blocks/settings-list/demo.jsx +333 -0
- package/registry/blocks/settings-list/index.js +4 -0
- package/registry/blocks/settings-list/row-state.jsx +93 -0
- package/registry/blocks/settings-list/settings-list.jsx +349 -0
- package/registry/blocks/settings-list/settings-matrix.jsx +118 -0
- package/registry/blocks/setup-checklist/README.md +142 -0
- package/registry/blocks/setup-checklist/block.json +10 -0
- package/registry/blocks/setup-checklist/demo.jsx +159 -0
- package/registry/blocks/setup-checklist/index.js +4 -0
- package/registry/blocks/setup-checklist/setup-checklist.jsx +446 -0
- package/registry/blocks/share-dialog/README.md +113 -0
- package/registry/blocks/share-dialog/block.json +10 -0
- package/registry/blocks/share-dialog/demo.jsx +140 -0
- package/registry/blocks/share-dialog/index.js +3 -0
- package/registry/blocks/share-dialog/share-dialog.jsx +384 -0
- package/registry/blocks/sidebar/README.md +190 -0
- package/registry/blocks/sidebar/block.json +43 -0
- package/registry/blocks/sidebar/demo.jsx +577 -0
- package/registry/blocks/sidebar/index.js +4 -0
- package/registry/blocks/sidebar/sidebar-panel.jsx +149 -0
- package/registry/blocks/sidebar/sidebar.jsx +864 -0
- package/registry/blocks/signature-pad/README.md +137 -0
- package/registry/blocks/signature-pad/block.json +13 -0
- package/registry/blocks/signature-pad/colors.js +115 -0
- package/registry/blocks/signature-pad/demo.jsx +94 -0
- package/registry/blocks/signature-pad/index.js +6 -0
- package/registry/blocks/signature-pad/pad.js +7 -0
- package/registry/blocks/signature-pad/signature-pad.jsx +434 -0
- package/registry/blocks/signature-pad/signature.js +68 -0
- package/registry/blocks/sketch-canvas/README.md +280 -0
- package/registry/blocks/sketch-canvas/block.json +13 -0
- package/registry/blocks/sketch-canvas/demo.jsx +159 -0
- package/registry/blocks/sketch-canvas/excalidraw.js +40 -0
- package/registry/blocks/sketch-canvas/index.js +9 -0
- package/registry/blocks/sketch-canvas/palette.js +226 -0
- package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1188 -0
- package/registry/blocks/sketch-canvas/sketch.js +179 -0
- package/registry/blocks/sketch-canvas/ssr-shim-end.js +6 -0
- package/registry/blocks/sketch-canvas/ssr-shim.js +45 -0
- package/registry/blocks/sketch-canvas/stamps.js +104 -0
- package/registry/blocks/sketch-canvas/theme.js +131 -0
- package/registry/blocks/spreadsheet/README.md +307 -0
- package/registry/blocks/spreadsheet/block.json +13 -0
- package/registry/blocks/spreadsheet/cells.jsx +455 -0
- package/registry/blocks/spreadsheet/chrome.jsx +325 -0
- package/registry/blocks/spreadsheet/demo.jsx +177 -0
- package/registry/blocks/spreadsheet/grid.js +387 -0
- package/registry/blocks/spreadsheet/index.js +34 -0
- package/registry/blocks/spreadsheet/spreadsheet.jsx +1732 -0
- package/registry/blocks/spreadsheet/types.js +473 -0
- package/registry/blocks/stat-cards/README.md +173 -0
- package/registry/blocks/stat-cards/block.json +10 -0
- package/registry/blocks/stat-cards/demo.jsx +257 -0
- package/registry/blocks/stat-cards/index.js +4 -0
- package/registry/blocks/stat-cards/stat-cards.jsx +307 -0
- package/registry/blocks/stat-cards/usage.jsx +243 -0
- package/registry/blocks/task-list/README.md +176 -0
- package/registry/blocks/task-list/block.json +10 -0
- package/registry/blocks/task-list/demo.jsx +134 -0
- package/registry/blocks/task-list/index.js +5 -0
- package/registry/blocks/task-list/task-list.jsx +907 -0
- package/registry/blocks/task-model/README.md +455 -0
- package/registry/blocks/task-model/block.json +167 -0
- package/registry/blocks/task-model/changes.js +89 -0
- package/registry/blocks/task-model/dates.js +371 -0
- package/registry/blocks/task-model/demo.jsx +215 -0
- package/registry/blocks/task-model/dnd.jsx +240 -0
- package/registry/blocks/task-model/index.js +97 -0
- package/registry/blocks/task-model/model.js +919 -0
- package/registry/blocks/task-model/parts.jsx +449 -0
- package/registry/blocks/task-model/toolbar.jsx +271 -0
- package/registry/blocks/testimonials/README.md +139 -0
- package/registry/blocks/testimonials/block.json +11 -0
- package/registry/blocks/testimonials/demo.jsx +137 -0
- package/registry/blocks/testimonials/index.js +4 -0
- package/registry/blocks/testimonials/testimonials.jsx +430 -0
- package/registry/blocks/transaction-list/README.md +126 -0
- package/registry/blocks/transaction-list/block.json +10 -0
- package/registry/blocks/transaction-list/demo.jsx +79 -0
- package/registry/blocks/transaction-list/index.js +4 -0
- package/registry/blocks/transaction-list/transaction-list.jsx +383 -0
- package/registry/blocks/whiteboard/README.md +179 -0
- package/registry/blocks/whiteboard/block.json +10 -0
- package/registry/blocks/whiteboard/demo.jsx +169 -0
- package/registry/blocks/whiteboard/index.js +6 -0
- package/registry/blocks/whiteboard/templates.js +150 -0
- package/registry/blocks/whiteboard/whiteboard.jsx +721 -0
- package/src/install.mjs +18 -0
- package/src/shadcn.mjs +171 -0
- package/templates/next/files/README.md +145 -11
- package/templates/next/files/app/globals.css +55 -4
- package/templates/next/files/check/run.mjs +3 -3
- package/templates/next/files/components/parts/fixture.jsx +1 -1
- package/templates/next/files/components/parts/sample.js +15 -0
- package/templates/next/files/components/ui/avatar.jsx +141 -0
- package/templates/next/files/components/ui/breadcrumb.jsx +149 -0
- package/templates/next/files/components/ui/button-group.jsx +70 -0
- package/templates/next/files/components/ui/button.jsx +132 -12
- package/templates/next/files/components/ui/calendar.jsx +609 -0
- package/templates/next/files/components/ui/carousel.jsx +281 -0
- package/templates/next/files/components/ui/collapsible.jsx +208 -0
- package/templates/next/files/components/ui/combobox.jsx +608 -0
- package/templates/next/files/components/ui/command.jsx +577 -0
- package/templates/next/files/components/ui/context-menu.jsx +191 -0
- package/templates/next/files/components/ui/date-picker.jsx +360 -0
- package/templates/next/files/components/ui/distribution.jsx +117 -0
- package/templates/next/files/components/ui/feedback.jsx +143 -0
- package/templates/next/files/components/ui/field.jsx +115 -3
- package/templates/next/files/components/ui/file-input.jsx +234 -0
- package/templates/next/files/components/ui/icon.jsx +130 -8
- package/templates/next/files/components/ui/image.jsx +143 -0
- package/templates/next/files/components/ui/index.js +47 -2
- package/templates/next/files/components/ui/input-group.jsx +213 -0
- package/templates/next/files/components/ui/input-otp.jsx +170 -0
- package/templates/next/files/components/ui/item.jsx +206 -0
- package/templates/next/files/components/ui/menu.jsx +454 -82
- package/templates/next/files/components/ui/notice.jsx +126 -14
- package/templates/next/files/components/ui/pagination.jsx +156 -0
- package/templates/next/files/components/ui/popover.jsx +284 -0
- package/templates/next/files/components/ui/radio.jsx +193 -0
- package/templates/next/files/components/ui/rating.jsx +131 -0
- package/templates/next/files/components/ui/resizable.jsx +338 -0
- package/templates/next/files/components/ui/scroll-area.jsx +123 -0
- package/templates/next/files/components/ui/separator.jsx +119 -0
- package/templates/next/files/components/ui/sheet.jsx +96 -11
- package/templates/next/files/components/ui/slider.jsx +174 -0
- package/templates/next/files/components/ui/stat.jsx +574 -0
- package/templates/next/files/components/ui/steps.jsx +338 -0
- package/templates/next/files/components/ui/swatch.jsx +33 -0
- package/templates/next/files/components/ui/table.jsx +130 -0
- package/templates/next/files/components/ui/toast.jsx +440 -0
- package/templates/next/files/components/ui/toggle.jsx +195 -0
- package/templates/next/files/components/ui/tooltip.jsx +270 -0
- package/templates/next/files/lib/sample.js +4 -12
- package/templates/next/template.json +264 -8
|
@@ -0,0 +1,1368 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// PdfViewer — a PDF on screen: pages in one scrolling column, a page
|
|
4
|
+
// rail of thumbnails, zoom (steps, fit width, fit page, Ctrl/⌘ + wheel or
|
|
5
|
+
// pinch), find in the text, and highlights with notes that save through
|
|
6
|
+
// callbacks. pdf.js (via react-pdf, see ./pdfjs.js) draws the pages; this
|
|
7
|
+
// file is the viewer around them, in the kit and the workspace's tokens.
|
|
8
|
+
//
|
|
9
|
+
// Client only, one static bundle. pdf.js needs the DOM and a worker, and
|
|
10
|
+
// the app's check refuses import() (so no next/dynamic): the modules load
|
|
11
|
+
// with the page (the server half through ssr-shim.js), and the viewer is
|
|
12
|
+
// created only after the first effect — the mount guard. Until then, on
|
|
13
|
+
// the server and in the first paint, the same frame shows with skeleton
|
|
14
|
+
// pages, so nothing jumps.
|
|
15
|
+
//
|
|
16
|
+
// Only the pages near the window are drawn (a 300-page contract scrolls
|
|
17
|
+
// like a 3-page quote); the others hold their exact place as blank paper.
|
|
18
|
+
import { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
19
|
+
import { Button, Field, Icon, IconButton, Input, InputGroup, Kbd, Menu, PasswordInput, Sheet, Skeleton, Tooltip, cx, toast } from "../../ui";
|
|
20
|
+
import { Document, Page } from "./pdfjs";
|
|
21
|
+
import { NotesPanel, TONE_FILL, TONE_RING, toneOf } from "./notes";
|
|
22
|
+
import {
|
|
23
|
+
anchorAt,
|
|
24
|
+
clampZoom,
|
|
25
|
+
findMatches,
|
|
26
|
+
fitScale,
|
|
27
|
+
highlightAt,
|
|
28
|
+
itemBox,
|
|
29
|
+
indexMatches,
|
|
30
|
+
layoutPages,
|
|
31
|
+
markItem,
|
|
32
|
+
pageAt,
|
|
33
|
+
rectsForQuote,
|
|
34
|
+
stepZoom,
|
|
35
|
+
toPageRects,
|
|
36
|
+
visiblePages,
|
|
37
|
+
} from "./helpers";
|
|
38
|
+
|
|
39
|
+
const GAP = 16; // px between pages
|
|
40
|
+
const PAD = 16; // px around the column
|
|
41
|
+
const WIDE = 900; // viewer width (px) from which the rails sit beside the pages
|
|
42
|
+
const COMPACT = 640; // under this the toolbar folds its search and zoom steps
|
|
43
|
+
|
|
44
|
+
// The search marks, painted into pdf.js's transparent text layer.
|
|
45
|
+
const MARK = "rounded-[2px] bg-warning/35 text-transparent";
|
|
46
|
+
const MARK_CURRENT = "rounded-[2px] bg-primary/40 text-transparent outline-2 outline-primary";
|
|
47
|
+
|
|
48
|
+
// Handed to pdf.js; copied once per load attempt (react-pdf reloads the
|
|
49
|
+
// document when `options` changes identity, so never per render).
|
|
50
|
+
const OPTIONS = { isEvalSupported: false };
|
|
51
|
+
|
|
52
|
+
const ZOOM_CHOICES = [
|
|
53
|
+
{ value: "fit", label: "Fit width" },
|
|
54
|
+
{ value: "page", label: "Fit page" },
|
|
55
|
+
{ value: "0.5", label: "50%" },
|
|
56
|
+
{ value: "0.75", label: "75%" },
|
|
57
|
+
{ value: "1", label: "100%" },
|
|
58
|
+
{ value: "1.25", label: "125%" },
|
|
59
|
+
{ value: "1.5", label: "150%" },
|
|
60
|
+
{ value: "2", label: "200%" },
|
|
61
|
+
{ value: "3", label: "300%" },
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** A ref for the viewer's handle: `const v = usePdfViewer()`, then
|
|
65
|
+
* `<PdfViewer viewerRef={v} … />` and `v.current?.goToPage(3)`. */
|
|
66
|
+
export function usePdfViewer() {
|
|
67
|
+
return useRef(null);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function PdfViewer(props) {
|
|
71
|
+
const [mounted, setMounted] = useState(false);
|
|
72
|
+
useEffect(() => setMounted(true), []);
|
|
73
|
+
if (!mounted) {
|
|
74
|
+
return (
|
|
75
|
+
<Frame className={props.className} height={props.height} label={props.title}>
|
|
76
|
+
<ToolbarSkeleton />
|
|
77
|
+
<PagesSkeleton />
|
|
78
|
+
</Frame>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
return <LiveViewer {...props} />;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function Frame({ className, height = "min(80vh, 860px)", label, children, rootRef, onKeyDown }) {
|
|
85
|
+
return (
|
|
86
|
+
<section
|
|
87
|
+
ref={rootRef}
|
|
88
|
+
aria-label={label || "PDF document"}
|
|
89
|
+
onKeyDown={onKeyDown}
|
|
90
|
+
style={{ height }}
|
|
91
|
+
className={cx("flex min-h-96 flex-col overflow-hidden rounded-lg border border-border bg-background", className)}
|
|
92
|
+
>
|
|
93
|
+
{children}
|
|
94
|
+
</section>
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// ---- the live viewer --------------------------------------------------------
|
|
99
|
+
|
|
100
|
+
/** What react-pdf is given: a URL as is, a Blob as is, bytes COPIED (pdf.js
|
|
101
|
+
* moves an ArrayBuffer into its worker, which empties the caller's). */
|
|
102
|
+
function toSource(file) {
|
|
103
|
+
if (!file) return null;
|
|
104
|
+
if (typeof file === "string") return file;
|
|
105
|
+
if (typeof ArrayBuffer !== "undefined" && file instanceof ArrayBuffer) return { data: new Uint8Array(file.slice(0)) };
|
|
106
|
+
if (ArrayBuffer.isView?.(file)) return { data: new Uint8Array(file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength)) };
|
|
107
|
+
return file;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function LiveViewer({
|
|
111
|
+
file,
|
|
112
|
+
title,
|
|
113
|
+
fileName,
|
|
114
|
+
highlights,
|
|
115
|
+
onHighlight,
|
|
116
|
+
onHighlightChange,
|
|
117
|
+
onHighlightDelete,
|
|
118
|
+
initialPage = 1,
|
|
119
|
+
onPageChange,
|
|
120
|
+
defaultZoom = "fit",
|
|
121
|
+
thumbnails = true,
|
|
122
|
+
search = true,
|
|
123
|
+
download = true,
|
|
124
|
+
notes,
|
|
125
|
+
viewerRef,
|
|
126
|
+
emptyTitle = "No document yet",
|
|
127
|
+
emptyBody = "When a PDF is attached, it shows here.",
|
|
128
|
+
height,
|
|
129
|
+
loading = false,
|
|
130
|
+
className,
|
|
131
|
+
}) {
|
|
132
|
+
const readOnly = !onHighlight;
|
|
133
|
+
const [attempt, setAttempt] = useState(0);
|
|
134
|
+
const source = useMemo(() => toSource(file), [file, attempt]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
135
|
+
// A retry is a new load: react-pdf caches a document by (file, options).
|
|
136
|
+
const options = useMemo(() => ({ ...OPTIONS }), [attempt]);
|
|
137
|
+
|
|
138
|
+
// The document and its geometry.
|
|
139
|
+
const [pdf, setPdf] = useState(null);
|
|
140
|
+
const [sizes, setSizes] = useState(null); // [{ width, height }] at scale 1
|
|
141
|
+
const [loadError, setLoadError] = useState(null);
|
|
142
|
+
const [password, setPassword] = useState(null); // { submit(pw), wrong }
|
|
143
|
+
const texts = useRef(null); // [[str]] per page, read on the first search
|
|
144
|
+
const geo = useRef(null); // [[{ str, x, y, w, h }]] per page, the same read
|
|
145
|
+
const reading = useRef(null); // the read in progress
|
|
146
|
+
const [textsReady, setTextsReady] = useState(false);
|
|
147
|
+
|
|
148
|
+
// The frame's own width decides the layout, not the window's: the viewer
|
|
149
|
+
// may sit in a narrow column of a wide screen.
|
|
150
|
+
const rootRef = useRef(null);
|
|
151
|
+
const [rootWidth, setRootWidth] = useState(1024);
|
|
152
|
+
const wide = rootWidth >= WIDE;
|
|
153
|
+
const compact = rootWidth < COMPACT;
|
|
154
|
+
|
|
155
|
+
// The scrolling column.
|
|
156
|
+
const scroller = useRef(null);
|
|
157
|
+
const [view, setView] = useState({ top: 0, height: 600, width: 600 });
|
|
158
|
+
const anchor = useRef(null); // { page, frac, offset } kept across a zoom
|
|
159
|
+
|
|
160
|
+
// Zoom: "fit" (width) · "page" · a number.
|
|
161
|
+
const [zoom, setZoom] = useState(defaultZoom);
|
|
162
|
+
const scale = useMemo(() => {
|
|
163
|
+
if (!sizes) return 1;
|
|
164
|
+
if (zoom === "fit") return fitScale(sizes, view.width - PAD * 2);
|
|
165
|
+
if (zoom === "page") return fitScale(sizes, view.width - PAD * 2, view.height - PAD * 2);
|
|
166
|
+
return clampZoom(zoom);
|
|
167
|
+
}, [zoom, sizes, view.width, view.height]);
|
|
168
|
+
const layout = useMemo(() => layoutPages(sizes ?? [], scale, GAP, PAD), [sizes, scale]);
|
|
169
|
+
const pageCount = sizes?.length ?? 0;
|
|
170
|
+
const maxWidth = sizes?.length ? Math.max(...sizes.map((s) => s.width)) * scale : 0;
|
|
171
|
+
const current = pageCount ? pageAt(layout.tops, view.top + Math.min(view.height / 3, 240)) : 1;
|
|
172
|
+
const [first, last] = visiblePages(layout, view.top, view.top + view.height, 1);
|
|
173
|
+
|
|
174
|
+
// Panels.
|
|
175
|
+
const [railOpen, setRailOpen] = useState(thumbnails);
|
|
176
|
+
const [railSheet, setRailSheet] = useState(false);
|
|
177
|
+
const [notesOpen, setNotesOpen] = useState(notes ?? (highlights?.length ?? 0) > 0);
|
|
178
|
+
const [notesSheet, setNotesSheet] = useState(false);
|
|
179
|
+
const showNotes = !readOnly || (highlights?.length ?? 0) > 0;
|
|
180
|
+
|
|
181
|
+
// Search.
|
|
182
|
+
const [searchOpen, setSearchOpen] = useState(false);
|
|
183
|
+
const [query, setQuery] = useState("");
|
|
184
|
+
const [matchIndex, setMatchIndex] = useState(0);
|
|
185
|
+
const searchInput = useRef(null);
|
|
186
|
+
const jump = useRef(false); // scroll to the current match once its page has drawn its text
|
|
187
|
+
|
|
188
|
+
// Highlights: the parent's list, with this viewer's unsaved changes on top.
|
|
189
|
+
const [items, setItems] = useState(() => highlights ?? []);
|
|
190
|
+
useEffect(() => setItems(highlights ?? []), [highlights]);
|
|
191
|
+
const [activeId, setActiveId] = useState(null);
|
|
192
|
+
const [editingId, setEditingId] = useState(null);
|
|
193
|
+
const [selection, setSelection] = useState(null); // { page, rects, text }
|
|
194
|
+
const [composer, setComposer] = useState(false); // the "Add note" field in the selection bar
|
|
195
|
+
const deletes = useRef(new Map()); // id → timer, while its Undo is offered
|
|
196
|
+
|
|
197
|
+
// ---- loading --------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
const onLoadSuccess = useCallback(async (doc) => {
|
|
200
|
+
setLoadError(null);
|
|
201
|
+
setPassword(null);
|
|
202
|
+
texts.current = null;
|
|
203
|
+
geo.current = null;
|
|
204
|
+
reading.current = null;
|
|
205
|
+
setTextsReady(false);
|
|
206
|
+
const out = [];
|
|
207
|
+
for (let i = 1; i <= doc.numPages; i++) {
|
|
208
|
+
const p = await doc.getPage(i);
|
|
209
|
+
const vp = p.getViewport({ scale: 1 });
|
|
210
|
+
out.push({ width: vp.width, height: vp.height });
|
|
211
|
+
}
|
|
212
|
+
setPdf(doc);
|
|
213
|
+
setSizes(out);
|
|
214
|
+
}, []);
|
|
215
|
+
|
|
216
|
+
const onLoadError = useCallback((e) => {
|
|
217
|
+
setPdf(null);
|
|
218
|
+
setSizes(null);
|
|
219
|
+
setLoadError(e ?? new Error("Unknown error"));
|
|
220
|
+
}, []);
|
|
221
|
+
|
|
222
|
+
const onPassword = useCallback((submit, reason) => {
|
|
223
|
+
setPassword({ submit, wrong: reason === 2 });
|
|
224
|
+
}, []);
|
|
225
|
+
|
|
226
|
+
useEffect(() => {
|
|
227
|
+
setPdf(null);
|
|
228
|
+
setSizes(null);
|
|
229
|
+
setLoadError(null);
|
|
230
|
+
setPassword(null);
|
|
231
|
+
setSelection(null);
|
|
232
|
+
setMatchIndex(0);
|
|
233
|
+
}, [source]);
|
|
234
|
+
|
|
235
|
+
// The words of every page, read once, on the first search.
|
|
236
|
+
const ensureTexts = useCallback(() => {
|
|
237
|
+
if (!pdf || texts.current) return;
|
|
238
|
+
if (reading.current?.pdf === pdf) return;
|
|
239
|
+
const job = { pdf };
|
|
240
|
+
reading.current = job;
|
|
241
|
+
(async () => {
|
|
242
|
+
const out = [];
|
|
243
|
+
const boxes = [];
|
|
244
|
+
for (let i = 1; i <= pdf.numPages; i++) {
|
|
245
|
+
const p = await pdf.getPage(i);
|
|
246
|
+
const tc = await p.getTextContent();
|
|
247
|
+
out.push(tc.items.map((it) => it.str ?? ""));
|
|
248
|
+
boxes.push(tc.items.map(itemBox));
|
|
249
|
+
}
|
|
250
|
+
if (reading.current !== job) return;
|
|
251
|
+
texts.current = out;
|
|
252
|
+
geo.current = boxes;
|
|
253
|
+
setTextsReady(true);
|
|
254
|
+
})().catch(() => {
|
|
255
|
+
if (reading.current === job) reading.current = null;
|
|
256
|
+
});
|
|
257
|
+
}, [pdf]);
|
|
258
|
+
|
|
259
|
+
// ---- the column's size and scroll ------------------------------------------
|
|
260
|
+
|
|
261
|
+
useLayoutEffect(() => {
|
|
262
|
+
const root = rootRef.current;
|
|
263
|
+
if (!root) return undefined;
|
|
264
|
+
const ro = new ResizeObserver(() => setRootWidth(root.clientWidth));
|
|
265
|
+
ro.observe(root);
|
|
266
|
+
setRootWidth(root.clientWidth);
|
|
267
|
+
return () => ro.disconnect();
|
|
268
|
+
}, []);
|
|
269
|
+
|
|
270
|
+
const measure = useCallback(() => {
|
|
271
|
+
const el = scroller.current;
|
|
272
|
+
if (!el) return;
|
|
273
|
+
setView((v) => (v.top === el.scrollTop && v.height === el.clientHeight && v.width === el.clientWidth ? v : { top: el.scrollTop, height: el.clientHeight, width: el.clientWidth }));
|
|
274
|
+
}, []);
|
|
275
|
+
|
|
276
|
+
const layoutRef = useRef(layout);
|
|
277
|
+
layoutRef.current = layout;
|
|
278
|
+
useLayoutEffect(() => {
|
|
279
|
+
const el = scroller.current;
|
|
280
|
+
if (!el) return undefined;
|
|
281
|
+
let width = el.clientWidth;
|
|
282
|
+
const ro = new ResizeObserver(() => {
|
|
283
|
+
// A fit zoom changes with the width: keep the same line in view.
|
|
284
|
+
if (el.clientWidth !== width && layoutRef.current.tops.length) anchor.current = { ...anchorAt(layoutRef.current, el.scrollTop), offset: 0 };
|
|
285
|
+
width = el.clientWidth;
|
|
286
|
+
measure();
|
|
287
|
+
});
|
|
288
|
+
ro.observe(el);
|
|
289
|
+
measure();
|
|
290
|
+
return () => ro.disconnect();
|
|
291
|
+
}, [measure, sizes]);
|
|
292
|
+
|
|
293
|
+
const frame = useRef(0);
|
|
294
|
+
const onScroll = () => {
|
|
295
|
+
cancelAnimationFrame(frame.current);
|
|
296
|
+
frame.current = requestAnimationFrame(measure);
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
// After a zoom, put the anchored line back where it was.
|
|
300
|
+
useLayoutEffect(() => {
|
|
301
|
+
const el = scroller.current;
|
|
302
|
+
const a = anchor.current;
|
|
303
|
+
if (!el || !a || !layout.tops.length) return;
|
|
304
|
+
anchor.current = null;
|
|
305
|
+
const top = layout.tops[a.page - 1] + a.frac * layout.heights[a.page - 1] - (a.offset ?? 0);
|
|
306
|
+
el.scrollTop = Math.max(0, top);
|
|
307
|
+
measure();
|
|
308
|
+
}, [layout, measure]);
|
|
309
|
+
|
|
310
|
+
const zoomTo = useCallback(
|
|
311
|
+
(next, offset = 0) => {
|
|
312
|
+
if (next === zoom) return;
|
|
313
|
+
const el = scroller.current;
|
|
314
|
+
if (el && layout.tops.length) anchor.current = { ...anchorAt(layout, el.scrollTop + offset), offset };
|
|
315
|
+
setZoom(next);
|
|
316
|
+
},
|
|
317
|
+
[layout, zoom],
|
|
318
|
+
);
|
|
319
|
+
|
|
320
|
+
// Ctrl/⌘ + wheel (and a trackpad pinch, which arrives as one) zooms
|
|
321
|
+
// around the pointer instead of zooming the whole page.
|
|
322
|
+
useEffect(() => {
|
|
323
|
+
const el = scroller.current;
|
|
324
|
+
if (!el) return undefined;
|
|
325
|
+
const onWheel = (e) => {
|
|
326
|
+
if (!e.ctrlKey && !e.metaKey) return;
|
|
327
|
+
e.preventDefault();
|
|
328
|
+
const offset = e.clientY - el.getBoundingClientRect().top;
|
|
329
|
+
zoomTo(clampZoom(Math.round(scale * Math.exp(-e.deltaY * 0.01) * 100) / 100), offset);
|
|
330
|
+
};
|
|
331
|
+
el.addEventListener("wheel", onWheel, { passive: false });
|
|
332
|
+
return () => el.removeEventListener("wheel", onWheel);
|
|
333
|
+
}, [scale, zoomTo, sizes]);
|
|
334
|
+
|
|
335
|
+
// ---- going places -----------------------------------------------------------
|
|
336
|
+
|
|
337
|
+
const goToPage = useCallback(
|
|
338
|
+
(n, { frac = 0, smooth = false } = {}) => {
|
|
339
|
+
const el = scroller.current;
|
|
340
|
+
if (!el || !pageCount) return;
|
|
341
|
+
const p = Math.min(pageCount, Math.max(1, Math.round(n) || 1));
|
|
342
|
+
const top = layout.tops[p - 1] + frac * layout.heights[p - 1] - (frac ? el.clientHeight / 3 : PAD);
|
|
343
|
+
el.scrollTo({ top: Math.max(0, top), behavior: smooth && !reducedMotion() ? "smooth" : "auto" });
|
|
344
|
+
},
|
|
345
|
+
[layout, pageCount],
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
const goToHighlight = useCallback(
|
|
349
|
+
(h) => {
|
|
350
|
+
if (!h) return;
|
|
351
|
+
setActiveId(h.id);
|
|
352
|
+
if (!h.page) return;
|
|
353
|
+
const y = Math.min(...(h.rects?.length ? h.rects.map((r) => r.y) : [0]));
|
|
354
|
+
goToPage(h.page, { frac: y, smooth: true });
|
|
355
|
+
},
|
|
356
|
+
[goToPage],
|
|
357
|
+
);
|
|
358
|
+
|
|
359
|
+
// The first page to open on, once the geometry is known.
|
|
360
|
+
const opened = useRef(false);
|
|
361
|
+
useEffect(() => {
|
|
362
|
+
if (!sizes || opened.current) return;
|
|
363
|
+
opened.current = true;
|
|
364
|
+
if (initialPage > 1) requestAnimationFrame(() => goToPage(initialPage));
|
|
365
|
+
}, [sizes, initialPage, goToPage]);
|
|
366
|
+
|
|
367
|
+
const lastReported = useRef(null);
|
|
368
|
+
useEffect(() => {
|
|
369
|
+
if (!pageCount || lastReported.current === current) return;
|
|
370
|
+
lastReported.current = current;
|
|
371
|
+
onPageChange?.(current, pageCount);
|
|
372
|
+
}, [current, pageCount, onPageChange]);
|
|
373
|
+
|
|
374
|
+
// A highlight given by its words alone ({ text, page? }, no rects) is
|
|
375
|
+
// placed once the pages' text has been read.
|
|
376
|
+
const unplaced = items.some((h) => !h.rects?.length && h.text);
|
|
377
|
+
useEffect(() => {
|
|
378
|
+
if (unplaced && pdf) ensureTexts();
|
|
379
|
+
}, [unplaced, pdf, ensureTexts]);
|
|
380
|
+
const shown = useMemo(() => {
|
|
381
|
+
if (!unplaced) return items;
|
|
382
|
+
return items.map((h) => {
|
|
383
|
+
if (h.rects?.length || !h.text) return h;
|
|
384
|
+
if (!textsReady || !geo.current || !sizes) return { ...h, rects: [] };
|
|
385
|
+
const pages = h.page ? [h.page] : sizes.map((_, i) => i + 1);
|
|
386
|
+
for (const n of pages) {
|
|
387
|
+
const rects = rectsForQuote(geo.current[n - 1], h.text, sizes[n - 1]);
|
|
388
|
+
if (rects.length) return { ...h, page: n, rects };
|
|
389
|
+
}
|
|
390
|
+
return { ...h, page: h.page ?? null, rects: [] };
|
|
391
|
+
});
|
|
392
|
+
}, [items, unplaced, textsReady, sizes]);
|
|
393
|
+
|
|
394
|
+
// ---- search -----------------------------------------------------------------
|
|
395
|
+
|
|
396
|
+
const matches = useMemo(() => (textsReady && query ? findMatches(texts.current, query) : []), [textsReady, query]);
|
|
397
|
+
const byItem = useMemo(() => indexMatches(matches), [matches]);
|
|
398
|
+
const currentMatch = matches[matchIndex] ?? null;
|
|
399
|
+
|
|
400
|
+
const renderText = useMemo(() => {
|
|
401
|
+
if (!matches.length) return undefined;
|
|
402
|
+
return ({ pageNumber, itemIndex, str }) => {
|
|
403
|
+
const here = byItem.get(`${pageNumber}:${itemIndex}`);
|
|
404
|
+
if (!here) return markItem(str, null);
|
|
405
|
+
const cur = currentMatch && currentMatch.page === pageNumber && currentMatch.item === itemIndex ? currentMatch.start : -1;
|
|
406
|
+
return markItem(str, here, { current: cur, markClass: MARK, currentClass: MARK_CURRENT });
|
|
407
|
+
};
|
|
408
|
+
}, [matches, byItem, currentMatch]);
|
|
409
|
+
|
|
410
|
+
const scrollToMark = useCallback(() => {
|
|
411
|
+
const mark = scroller.current?.querySelector('mark[data-current="true"]');
|
|
412
|
+
if (!mark) return false;
|
|
413
|
+
mark.scrollIntoView({ block: "center", inline: "nearest", behavior: reducedMotion() ? "auto" : "smooth" });
|
|
414
|
+
return true;
|
|
415
|
+
}, []);
|
|
416
|
+
|
|
417
|
+
// A new current match: its page's text layer redraws with the new mark
|
|
418
|
+
// (or the page is drawn, once the column is taken there), and when it
|
|
419
|
+
// has, onTextDrawn centres the mark.
|
|
420
|
+
const drawn = useRef([first, last]);
|
|
421
|
+
drawn.current = [first, last];
|
|
422
|
+
useEffect(() => {
|
|
423
|
+
if (!currentMatch) return;
|
|
424
|
+
jump.current = true;
|
|
425
|
+
const [a, b] = drawn.current;
|
|
426
|
+
if (currentMatch.page < a || currentMatch.page > b) goToPage(currentMatch.page);
|
|
427
|
+
}, [currentMatch, goToPage]);
|
|
428
|
+
|
|
429
|
+
const onTextDrawn = useCallback(() => {
|
|
430
|
+
if (jump.current && scrollToMark()) jump.current = false;
|
|
431
|
+
}, [scrollToMark]);
|
|
432
|
+
|
|
433
|
+
const openSearch = () => {
|
|
434
|
+
setSearchOpen(true);
|
|
435
|
+
ensureTexts();
|
|
436
|
+
requestAnimationFrame(() => {
|
|
437
|
+
searchInput.current?.focus();
|
|
438
|
+
searchInput.current?.select();
|
|
439
|
+
});
|
|
440
|
+
};
|
|
441
|
+
const step = (dir) => {
|
|
442
|
+
if (!matches.length) return;
|
|
443
|
+
setMatchIndex((i) => (i + dir + matches.length) % matches.length);
|
|
444
|
+
};
|
|
445
|
+
const closeSearch = () => {
|
|
446
|
+
setQuery("");
|
|
447
|
+
setMatchIndex(0);
|
|
448
|
+
setSearchOpen(false);
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
// ---- highlights ---------------------------------------------------------------
|
|
452
|
+
|
|
453
|
+
const selectionRef = useRef(null);
|
|
454
|
+
selectionRef.current = selection;
|
|
455
|
+
const composerRef = useRef(false);
|
|
456
|
+
composerRef.current = composer;
|
|
457
|
+
const readSelection = useCallback(() => {
|
|
458
|
+
if (readOnly) return;
|
|
459
|
+
// Typing a note in the selection bar moves the browser's selection into
|
|
460
|
+
// its field: that is not a new selection of the page's text.
|
|
461
|
+
if (document.activeElement?.closest?.("[data-selection-bar]")) return;
|
|
462
|
+
const sel = window.getSelection?.();
|
|
463
|
+
if (!sel || sel.isCollapsed || !sel.rangeCount) {
|
|
464
|
+
if (!composerRef.current) setSelection(null);
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
const range = sel.getRangeAt(0);
|
|
468
|
+
const start = range.startContainer.nodeType === 1 ? range.startContainer : range.startContainer.parentElement;
|
|
469
|
+
if (start?.closest?.("[data-selection-bar]")) return;
|
|
470
|
+
const pageEl = start?.closest?.(".react-pdf__Page");
|
|
471
|
+
if (!pageEl || !scroller.current?.contains(pageEl)) return setSelection(null);
|
|
472
|
+
const page = Number(pageEl.dataset.pageNumber);
|
|
473
|
+
const rects = toPageRects([...range.getClientRects()], pageEl.getBoundingClientRect());
|
|
474
|
+
const text = sel.toString().replace(/\s+/g, " ").trim();
|
|
475
|
+
if (!rects.length || !text) return setSelection(null);
|
|
476
|
+
const prev = selectionRef.current;
|
|
477
|
+
if (prev && prev.page === page && prev.text === text) return;
|
|
478
|
+
setComposer(false);
|
|
479
|
+
setSelection({ page, rects, text });
|
|
480
|
+
}, [readOnly]);
|
|
481
|
+
useEffect(() => {
|
|
482
|
+
if (!selection) setComposer(false);
|
|
483
|
+
}, [selection]);
|
|
484
|
+
|
|
485
|
+
// Pointer selections settle on pointerup; keyboard and touch ones
|
|
486
|
+
// (shift+arrows, the handles on a phone) through selectionchange.
|
|
487
|
+
const pointerDown = useRef(false);
|
|
488
|
+
useEffect(() => {
|
|
489
|
+
if (readOnly) return undefined;
|
|
490
|
+
let timer = 0;
|
|
491
|
+
const onChange = () => {
|
|
492
|
+
if (pointerDown.current) return;
|
|
493
|
+
clearTimeout(timer);
|
|
494
|
+
timer = setTimeout(readSelection, 180);
|
|
495
|
+
};
|
|
496
|
+
document.addEventListener("selectionchange", onChange);
|
|
497
|
+
return () => {
|
|
498
|
+
clearTimeout(timer);
|
|
499
|
+
document.removeEventListener("selectionchange", onChange);
|
|
500
|
+
};
|
|
501
|
+
}, [readOnly, readSelection]);
|
|
502
|
+
|
|
503
|
+
const save = async (fn, rollback, failTitle) => {
|
|
504
|
+
let res;
|
|
505
|
+
try {
|
|
506
|
+
res = await fn();
|
|
507
|
+
} catch (e) {
|
|
508
|
+
res = { ok: false, error: e?.message };
|
|
509
|
+
}
|
|
510
|
+
if (!res?.ok) {
|
|
511
|
+
rollback();
|
|
512
|
+
toast.error(failTitle, { description: res?.error ? String(res.error) : "Check your connection and try again." });
|
|
513
|
+
}
|
|
514
|
+
return res;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
const createHighlight = async (note = "") => {
|
|
518
|
+
if (!selection || !onHighlight) return;
|
|
519
|
+
const temp = { id: `new-${Date.now()}`, page: selection.page, rects: selection.rects, text: selection.text, note, tone: "warning", pending: true };
|
|
520
|
+
setItems((list) => [...list, temp]);
|
|
521
|
+
window.getSelection?.()?.removeAllRanges();
|
|
522
|
+
setSelection(null);
|
|
523
|
+
setComposer(false);
|
|
524
|
+
setActiveId(temp.id);
|
|
525
|
+
if (wide && showNotes) setNotesOpen(true);
|
|
526
|
+
const { id: _id, pending: _p, ...data } = temp;
|
|
527
|
+
const res = await save(
|
|
528
|
+
() => onHighlight(data),
|
|
529
|
+
() => setItems((list) => list.filter((x) => x.id !== temp.id)),
|
|
530
|
+
"Couldn't save the highlight",
|
|
531
|
+
);
|
|
532
|
+
if (res?.ok) {
|
|
533
|
+
const id = res.id ?? temp.id;
|
|
534
|
+
setItems((list) => list.map((x) => (x.id === temp.id ? { ...x, id, pending: false } : x)));
|
|
535
|
+
setActiveId((a) => (a === temp.id ? id : a));
|
|
536
|
+
}
|
|
537
|
+
};
|
|
538
|
+
|
|
539
|
+
const change = async (h, patch) => {
|
|
540
|
+
if (h.pending) return toast.info("Still saving that highlight", { description: "Try again in a moment." });
|
|
541
|
+
const before = items.find((x) => x.id === h.id);
|
|
542
|
+
setItems((list) => list.map((x) => (x.id === h.id ? { ...x, ...patch } : x)));
|
|
543
|
+
if (!onHighlightChange) return;
|
|
544
|
+
await save(
|
|
545
|
+
() => onHighlightChange(h.id, patch),
|
|
546
|
+
() => setItems((list) => list.map((x) => (x.id === h.id ? before : x))),
|
|
547
|
+
patch.note !== undefined ? "Couldn't save the note" : "Couldn't change the colour",
|
|
548
|
+
);
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
const remove = (h) => {
|
|
552
|
+
if (h.pending) return;
|
|
553
|
+
const index = items.findIndex((x) => x.id === h.id);
|
|
554
|
+
setItems((list) => list.filter((x) => x.id !== h.id));
|
|
555
|
+
if (activeId === h.id) setActiveId(null);
|
|
556
|
+
const restore = () =>
|
|
557
|
+
setItems((list) => {
|
|
558
|
+
const next = [...list];
|
|
559
|
+
next.splice(Math.min(index, next.length), 0, h);
|
|
560
|
+
return next;
|
|
561
|
+
});
|
|
562
|
+
// The delete waits while Undo is offered; Undo simply never sends it.
|
|
563
|
+
const timer = setTimeout(async () => {
|
|
564
|
+
deletes.current.delete(h.id);
|
|
565
|
+
if (!onHighlightDelete) return;
|
|
566
|
+
await save(() => onHighlightDelete(h.id), restore, "Couldn't delete the highlight");
|
|
567
|
+
}, 5000);
|
|
568
|
+
deletes.current.set(h.id, { timer, run: () => onHighlightDelete?.(h.id) });
|
|
569
|
+
toast("Highlight deleted", {
|
|
570
|
+
description: h.text ? `“${h.text.slice(0, 60)}${h.text.length > 60 ? "…" : ""}”` : undefined,
|
|
571
|
+
action: {
|
|
572
|
+
label: "Undo",
|
|
573
|
+
onClick: () => {
|
|
574
|
+
clearTimeout(timer);
|
|
575
|
+
deletes.current.delete(h.id);
|
|
576
|
+
restore();
|
|
577
|
+
},
|
|
578
|
+
},
|
|
579
|
+
});
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
// Leaving the viewer sends the deletes that were waiting on Undo.
|
|
583
|
+
useEffect(
|
|
584
|
+
() => () => {
|
|
585
|
+
for (const { timer, run } of deletes.current.values()) {
|
|
586
|
+
clearTimeout(timer);
|
|
587
|
+
Promise.resolve()
|
|
588
|
+
.then(run)
|
|
589
|
+
.catch(() => {});
|
|
590
|
+
}
|
|
591
|
+
deletes.current.clear();
|
|
592
|
+
},
|
|
593
|
+
[],
|
|
594
|
+
);
|
|
595
|
+
|
|
596
|
+
const copy = async (h) => {
|
|
597
|
+
try {
|
|
598
|
+
await navigator.clipboard.writeText(h.text ?? "");
|
|
599
|
+
toast.success("Copied");
|
|
600
|
+
} catch {
|
|
601
|
+
toast.error("Couldn't copy", { description: "Your browser blocked the clipboard." });
|
|
602
|
+
}
|
|
603
|
+
};
|
|
604
|
+
|
|
605
|
+
// A click on a highlight (the text layer lies over them, so this is a
|
|
606
|
+
// hit test at the point) selects it and shows its note.
|
|
607
|
+
const onPageClick = (e, page) => {
|
|
608
|
+
const sel = window.getSelection?.();
|
|
609
|
+
if (sel && !sel.isCollapsed) return;
|
|
610
|
+
const box = e.currentTarget.getBoundingClientRect();
|
|
611
|
+
const h = highlightAt(shown, page, (e.clientX - box.left) / box.width, (e.clientY - box.top) / box.height);
|
|
612
|
+
if (!h) return setActiveId(null);
|
|
613
|
+
setActiveId(h.id);
|
|
614
|
+
if (wide) setNotesOpen(true);
|
|
615
|
+
else setNotesSheet(true);
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
const openNote = (id) => {
|
|
619
|
+
setEditingId(id);
|
|
620
|
+
if (id) {
|
|
621
|
+
setActiveId(id);
|
|
622
|
+
if (wide) setNotesOpen(true);
|
|
623
|
+
else setNotesSheet(true);
|
|
624
|
+
}
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
// ---- the handle and the keys ---------------------------------------------------
|
|
628
|
+
|
|
629
|
+
useImperativeHandle(
|
|
630
|
+
viewerRef,
|
|
631
|
+
() => ({
|
|
632
|
+
goToPage: (n) => goToPage(n, { smooth: true }),
|
|
633
|
+
goToHighlight: (id) => goToHighlight(shown.find((h) => h.id === id)),
|
|
634
|
+
search: (q) => {
|
|
635
|
+
setSearchOpen(true);
|
|
636
|
+
ensureTexts();
|
|
637
|
+
setQuery(q ?? "");
|
|
638
|
+
setMatchIndex(0);
|
|
639
|
+
},
|
|
640
|
+
setZoom: (z) => zoomTo(z),
|
|
641
|
+
get page() {
|
|
642
|
+
return current;
|
|
643
|
+
},
|
|
644
|
+
get pageCount() {
|
|
645
|
+
return pageCount;
|
|
646
|
+
},
|
|
647
|
+
}),
|
|
648
|
+
[goToPage, goToHighlight, shown, ensureTexts, zoomTo, current, pageCount],
|
|
649
|
+
);
|
|
650
|
+
|
|
651
|
+
const onKeyDown = (e) => {
|
|
652
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
653
|
+
if (mod && e.key.toLowerCase() === "f" && search && pdf) {
|
|
654
|
+
e.preventDefault();
|
|
655
|
+
openSearch();
|
|
656
|
+
} else if (mod && (e.key === "=" || e.key === "+")) {
|
|
657
|
+
e.preventDefault();
|
|
658
|
+
zoomTo(stepZoom(scale, 1));
|
|
659
|
+
} else if (mod && e.key === "-") {
|
|
660
|
+
e.preventDefault();
|
|
661
|
+
zoomTo(stepZoom(scale, -1));
|
|
662
|
+
} else if (mod && e.key === "0") {
|
|
663
|
+
e.preventDefault();
|
|
664
|
+
zoomTo("fit");
|
|
665
|
+
} else if (e.key === "Escape" && selection) {
|
|
666
|
+
setSelection(null);
|
|
667
|
+
setComposer(false);
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
|
|
671
|
+
const onDownload = () => {
|
|
672
|
+
const name = fileName || `${(title || "document").replace(/[\\/:*?"<>|]+/g, " ").trim()}.pdf`;
|
|
673
|
+
if (typeof file === "string") {
|
|
674
|
+
// raw: an <a download> is how a browser saves a URL; it is never shown.
|
|
675
|
+
const a = document.createElement("a");
|
|
676
|
+
a.href = file;
|
|
677
|
+
a.download = name;
|
|
678
|
+
a.rel = "noopener";
|
|
679
|
+
document.body.appendChild(a);
|
|
680
|
+
a.click();
|
|
681
|
+
a.remove();
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
const blob = file instanceof Blob ? file : new Blob([file?.data ?? file], { type: "application/pdf" });
|
|
685
|
+
const url = URL.createObjectURL(blob);
|
|
686
|
+
const a = document.createElement("a");
|
|
687
|
+
a.href = url;
|
|
688
|
+
a.download = name;
|
|
689
|
+
document.body.appendChild(a);
|
|
690
|
+
a.click();
|
|
691
|
+
a.remove();
|
|
692
|
+
setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
693
|
+
};
|
|
694
|
+
|
|
695
|
+
// ---- drawing ------------------------------------------------------------------
|
|
696
|
+
|
|
697
|
+
const zoomLabel = zoom === "fit" ? "Fit" : zoom === "page" ? "Page" : `${Math.round(scale * 100)}%`;
|
|
698
|
+
const noteCount = items.length;
|
|
699
|
+
|
|
700
|
+
const toolbar = (
|
|
701
|
+
<Toolbar
|
|
702
|
+
compact={compact}
|
|
703
|
+
ready={Boolean(pdf && sizes)}
|
|
704
|
+
title={title}
|
|
705
|
+
current={current}
|
|
706
|
+
pageCount={pageCount}
|
|
707
|
+
onPage={(n) => goToPage(n)}
|
|
708
|
+
rail={thumbnails ? { open: wide ? railOpen : railSheet, toggle: () => (wide ? setRailOpen((o) => !o) : setRailSheet(true)) } : null}
|
|
709
|
+
search={
|
|
710
|
+
search
|
|
711
|
+
? {
|
|
712
|
+
open: searchOpen,
|
|
713
|
+
query,
|
|
714
|
+
count: matches.length,
|
|
715
|
+
index: matchIndex,
|
|
716
|
+
ready: textsReady,
|
|
717
|
+
inputRef: searchInput,
|
|
718
|
+
onOpen: openSearch,
|
|
719
|
+
onClose: closeSearch,
|
|
720
|
+
onQuery: (q) => {
|
|
721
|
+
setQuery(q);
|
|
722
|
+
setMatchIndex(0);
|
|
723
|
+
ensureTexts();
|
|
724
|
+
},
|
|
725
|
+
onStep: step,
|
|
726
|
+
}
|
|
727
|
+
: null
|
|
728
|
+
}
|
|
729
|
+
zoom={{ label: zoomLabel, value: typeof zoom === "number" ? String(zoom) : zoom, scale, onZoom: zoomTo }}
|
|
730
|
+
notes={showNotes ? { open: wide ? notesOpen : notesSheet, count: noteCount, toggle: () => (wide ? setNotesOpen((o) => !o) : setNotesSheet(true)) } : null}
|
|
731
|
+
onDownload={download && file ? onDownload : null}
|
|
732
|
+
/>
|
|
733
|
+
);
|
|
734
|
+
|
|
735
|
+
if (loading) {
|
|
736
|
+
return (
|
|
737
|
+
<Frame className={className} height={height} label={title} rootRef={rootRef}>
|
|
738
|
+
<ToolbarSkeleton />
|
|
739
|
+
<PagesSkeleton />
|
|
740
|
+
</Frame>
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
if (!file) {
|
|
745
|
+
return (
|
|
746
|
+
<Frame className={className} height={height} label={title} rootRef={rootRef}>
|
|
747
|
+
<div className="grid flex-1 place-items-center bg-muted/40 p-6">
|
|
748
|
+
<Placeholder icon="file" title={emptyTitle} body={emptyBody} />
|
|
749
|
+
</div>
|
|
750
|
+
</Frame>
|
|
751
|
+
);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
const notesPanel = (
|
|
755
|
+
<NotesPanel
|
|
756
|
+
highlights={shown}
|
|
757
|
+
activeId={activeId}
|
|
758
|
+
editingId={editingId}
|
|
759
|
+
readOnly={readOnly}
|
|
760
|
+
onJump={(h) => {
|
|
761
|
+
goToHighlight(h);
|
|
762
|
+
if (!wide) setNotesSheet(false);
|
|
763
|
+
}}
|
|
764
|
+
onEdit={openNote}
|
|
765
|
+
onSaveNote={(h, note) => note !== (h.note ?? "") && change(h, { note })}
|
|
766
|
+
onTone={(h, tone) => change(h, { tone })}
|
|
767
|
+
onDelete={remove}
|
|
768
|
+
onCopy={copy}
|
|
769
|
+
/>
|
|
770
|
+
);
|
|
771
|
+
|
|
772
|
+
return (
|
|
773
|
+
<Frame className={className} height={height} label={title} rootRef={rootRef} onKeyDown={onKeyDown}>
|
|
774
|
+
{toolbar}
|
|
775
|
+
<Document
|
|
776
|
+
key={attempt}
|
|
777
|
+
file={source}
|
|
778
|
+
options={options}
|
|
779
|
+
suspense={false}
|
|
780
|
+
onLoadSuccess={onLoadSuccess}
|
|
781
|
+
onLoadError={onLoadError}
|
|
782
|
+
onSourceError={onLoadError}
|
|
783
|
+
onPassword={onPassword}
|
|
784
|
+
onItemClick={({ pageNumber }) => goToPage(pageNumber, { smooth: true })}
|
|
785
|
+
loading={() => (password ? <PasswordGate {...password} /> : <PagesSkeleton />)}
|
|
786
|
+
error={() => <LoadError error={loadError} onRetry={() => setAttempt((n) => n + 1)} />}
|
|
787
|
+
noData={() => <Placeholder icon="file" title={emptyTitle} body={emptyBody} />}
|
|
788
|
+
className="flex min-h-0 flex-1"
|
|
789
|
+
>
|
|
790
|
+
{thumbnails && wide && railOpen ? (
|
|
791
|
+
<aside className="w-40 shrink-0 overflow-y-auto border-r border-border bg-muted/40" aria-label="Pages">
|
|
792
|
+
<PageRail sizes={sizes} current={current} onPick={(n) => goToPage(n)} />
|
|
793
|
+
</aside>
|
|
794
|
+
) : null}
|
|
795
|
+
|
|
796
|
+
<div
|
|
797
|
+
ref={scroller}
|
|
798
|
+
onScroll={onScroll}
|
|
799
|
+
onPointerDown={() => {
|
|
800
|
+
pointerDown.current = true;
|
|
801
|
+
}}
|
|
802
|
+
onPointerUp={(e) => {
|
|
803
|
+
pointerDown.current = false;
|
|
804
|
+
if (!e.target.closest?.("[data-selection-bar]")) setTimeout(readSelection, 0);
|
|
805
|
+
}}
|
|
806
|
+
tabIndex={0}
|
|
807
|
+
role="region"
|
|
808
|
+
aria-label={`${title || "Document"}, page ${current} of ${pageCount || "…"}`}
|
|
809
|
+
className="relative min-w-0 flex-1 overflow-auto overscroll-contain bg-muted/50 outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
|
810
|
+
>
|
|
811
|
+
{!sizes ? (
|
|
812
|
+
<PagesSkeleton />
|
|
813
|
+
) : (
|
|
814
|
+
<div className="relative" style={{ height: layout.total, width: Math.max(view.width, maxWidth + PAD * 2) }}>
|
|
815
|
+
{sizes.map((s, i) => {
|
|
816
|
+
const n = i + 1;
|
|
817
|
+
const w = Math.round(s.width * scale);
|
|
818
|
+
const h = layout.heights[i];
|
|
819
|
+
const live = n >= first && n <= last;
|
|
820
|
+
return (
|
|
821
|
+
<div key={n} className="absolute inset-x-0 flex justify-center" style={{ top: layout.tops[i], height: h }}>
|
|
822
|
+
{live ? (
|
|
823
|
+
// raw: the page's paper; a click on it hit-tests the
|
|
824
|
+
// highlights, which lie under pdf.js's text layer.
|
|
825
|
+
<div className="relative shadow-sm ring-1 ring-border" style={{ width: w, height: h }} onClick={(e) => onPageClick(e, n)}>
|
|
826
|
+
<Page
|
|
827
|
+
pageNumber={n}
|
|
828
|
+
scale={scale}
|
|
829
|
+
renderTextLayer
|
|
830
|
+
renderAnnotationLayer
|
|
831
|
+
customTextRenderer={renderText}
|
|
832
|
+
onRenderTextLayerSuccess={onTextDrawn}
|
|
833
|
+
loading={<Skeleton shape="rect" className="h-full w-full rounded-none" />}
|
|
834
|
+
>
|
|
835
|
+
<HighlightLayer items={shown} page={n} activeId={activeId} onOpen={(h) => openNote(h.id)} />
|
|
836
|
+
{selection?.page === n ? (
|
|
837
|
+
<SelectionBar
|
|
838
|
+
selection={selection}
|
|
839
|
+
composer={composer}
|
|
840
|
+
onHighlight={() => createHighlight("")}
|
|
841
|
+
onNote={() => setComposer(true)}
|
|
842
|
+
onSaveNote={(note) => createHighlight(note)}
|
|
843
|
+
onCancel={() => {
|
|
844
|
+
setComposer(false);
|
|
845
|
+
setSelection(null);
|
|
846
|
+
window.getSelection?.()?.removeAllRanges();
|
|
847
|
+
}}
|
|
848
|
+
onCopy={() => copy(selection)}
|
|
849
|
+
/>
|
|
850
|
+
) : null}
|
|
851
|
+
</Page>
|
|
852
|
+
</div>
|
|
853
|
+
) : (
|
|
854
|
+
<div aria-hidden="true" className="rounded-[2px] bg-card ring-1 ring-border" style={{ width: w, height: h }} />
|
|
855
|
+
)}
|
|
856
|
+
</div>
|
|
857
|
+
);
|
|
858
|
+
})}
|
|
859
|
+
</div>
|
|
860
|
+
)}
|
|
861
|
+
</div>
|
|
862
|
+
|
|
863
|
+
{showNotes && wide && notesOpen ? (
|
|
864
|
+
<aside className="flex w-80 shrink-0 flex-col border-l border-border bg-background" aria-label="Highlights and notes">
|
|
865
|
+
<div className="flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border px-4">
|
|
866
|
+
<p className="text-sm font-medium">
|
|
867
|
+
Highlights <span className="ml-1 text-muted-foreground tabular-nums">{noteCount || ""}</span>
|
|
868
|
+
</p>
|
|
869
|
+
<IconButton icon="x" label="Close highlights" size="sm" onClick={() => setNotesOpen(false)} className="-mr-2" />
|
|
870
|
+
</div>
|
|
871
|
+
<div className="min-h-0 flex-1 overflow-y-auto">{notesPanel}</div>
|
|
872
|
+
</aside>
|
|
873
|
+
) : null}
|
|
874
|
+
|
|
875
|
+
{/* On a narrow viewer the rails are sheets. */}
|
|
876
|
+
{thumbnails && !wide ? (
|
|
877
|
+
<Sheet open={railSheet} onClose={() => setRailSheet(false)} side="start" title="Pages" description={pageCount ? `${pageCount} ${pageCount === 1 ? "page" : "pages"}` : undefined}>
|
|
878
|
+
<PageRail
|
|
879
|
+
sizes={sizes}
|
|
880
|
+
current={current}
|
|
881
|
+
grid
|
|
882
|
+
onPick={(n) => {
|
|
883
|
+
setRailSheet(false);
|
|
884
|
+
goToPage(n);
|
|
885
|
+
}}
|
|
886
|
+
/>
|
|
887
|
+
</Sheet>
|
|
888
|
+
) : null}
|
|
889
|
+
{showNotes && !wide ? (
|
|
890
|
+
<Sheet open={notesSheet} onClose={() => setNotesSheet(false)} title="Highlights" description={noteCount ? `${noteCount} in this document` : undefined}>
|
|
891
|
+
<div className="-mx-4 -my-4">{notesPanel}</div>
|
|
892
|
+
</Sheet>
|
|
893
|
+
) : null}
|
|
894
|
+
</Document>
|
|
895
|
+
</Frame>
|
|
896
|
+
);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const reducedMotion = () => typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
900
|
+
|
|
901
|
+
// ---- the toolbar ----------------------------------------------------------------
|
|
902
|
+
|
|
903
|
+
function Toolbar({ compact, ready, title, current, pageCount, onPage, rail, search, zoom, notes, onDownload }) {
|
|
904
|
+
const [draft, setDraft] = useState(String(current));
|
|
905
|
+
const [editing, setEditing] = useState(false);
|
|
906
|
+
useEffect(() => {
|
|
907
|
+
if (!editing) setDraft(String(current));
|
|
908
|
+
}, [current, editing]);
|
|
909
|
+
const commit = () => {
|
|
910
|
+
setEditing(false);
|
|
911
|
+
const n = parseInt(draft, 10);
|
|
912
|
+
if (Number.isFinite(n)) onPage(n);
|
|
913
|
+
else setDraft(String(current));
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const searchField = search ? (
|
|
917
|
+
<InputGroup
|
|
918
|
+
ref={search.inputRef}
|
|
919
|
+
icon="search"
|
|
920
|
+
type="search"
|
|
921
|
+
value={search.query}
|
|
922
|
+
placeholder="Find in document"
|
|
923
|
+
aria-label="Find in document"
|
|
924
|
+
aria-keyshortcuts="Meta+F Control+F"
|
|
925
|
+
autoComplete="off"
|
|
926
|
+
spellCheck={false}
|
|
927
|
+
className={compact ? "w-full" : "w-64"}
|
|
928
|
+
suffix={
|
|
929
|
+
search.query.trim().length >= 2 ? (
|
|
930
|
+
<span className="tabular-nums text-xs" aria-live="polite">
|
|
931
|
+
{!search.ready ? "Reading…" : search.count ? `${search.index + 1} of ${search.count}` : "No matches"}
|
|
932
|
+
</span>
|
|
933
|
+
) : null
|
|
934
|
+
}
|
|
935
|
+
onChange={(e) => search.onQuery(e.target.value)}
|
|
936
|
+
onKeyDown={(e) => {
|
|
937
|
+
if (e.key === "Enter") {
|
|
938
|
+
e.preventDefault();
|
|
939
|
+
search.onStep(e.shiftKey ? -1 : 1);
|
|
940
|
+
} else if (e.key === "Escape") {
|
|
941
|
+
e.preventDefault();
|
|
942
|
+
e.stopPropagation();
|
|
943
|
+
search.onClose();
|
|
944
|
+
}
|
|
945
|
+
}}
|
|
946
|
+
/>
|
|
947
|
+
) : null;
|
|
948
|
+
|
|
949
|
+
const steps = search?.open ? (
|
|
950
|
+
<div className="flex shrink-0 items-center">
|
|
951
|
+
<Tooltip label="Previous match (Shift+Enter)">
|
|
952
|
+
<IconButton icon="chevron-up" label="Previous match" size="sm" disabled={!search.count} onClick={() => search.onStep(-1)} />
|
|
953
|
+
</Tooltip>
|
|
954
|
+
<Tooltip label="Next match (Enter)">
|
|
955
|
+
<IconButton icon="chevron-down" label="Next match" size="sm" disabled={!search.count} onClick={() => search.onStep(1)} />
|
|
956
|
+
</Tooltip>
|
|
957
|
+
<IconButton icon="x" label="Close search" size="sm" onClick={search.onClose} />
|
|
958
|
+
</div>
|
|
959
|
+
) : null;
|
|
960
|
+
|
|
961
|
+
return (
|
|
962
|
+
<div className="shrink-0 border-b border-border bg-background">
|
|
963
|
+
<div className="flex h-14 items-center gap-1 px-2">
|
|
964
|
+
{rail ? (
|
|
965
|
+
<Tooltip label={rail.open ? "Hide pages" : "Show pages"}>
|
|
966
|
+
<IconButton icon="panel-left" label={rail.open ? "Hide pages" : "Show pages"} aria-pressed={rail.open} disabled={!ready} onClick={rail.toggle} />
|
|
967
|
+
</Tooltip>
|
|
968
|
+
) : null}
|
|
969
|
+
{title && !compact ? <p className="mx-1 min-w-0 max-w-64 truncate text-sm font-medium" title={title}>{title}</p> : null}
|
|
970
|
+
|
|
971
|
+
{/* The pager. */}
|
|
972
|
+
<div className={cx("flex items-center gap-0.5", !compact && "mx-auto")} aria-label="Page" role="group">
|
|
973
|
+
{!compact ? <IconButton icon="chevron-left" label="Previous page" size="sm" disabled={!ready || current <= 1} onClick={() => onPage(current - 1)} /> : null}
|
|
974
|
+
<div className="w-14 shrink-0">
|
|
975
|
+
<Input
|
|
976
|
+
value={ready ? draft : ""}
|
|
977
|
+
inputMode="numeric"
|
|
978
|
+
aria-label={`Page number, ${pageCount} pages`}
|
|
979
|
+
disabled={!ready}
|
|
980
|
+
className="text-center tabular-nums"
|
|
981
|
+
onFocus={(e) => {
|
|
982
|
+
setEditing(true);
|
|
983
|
+
e.target.select();
|
|
984
|
+
}}
|
|
985
|
+
onChange={(e) => setDraft(e.target.value.replace(/[^\d]/g, ""))}
|
|
986
|
+
onBlur={commit}
|
|
987
|
+
onKeyDown={(e) => {
|
|
988
|
+
if (e.key === "Enter") e.currentTarget.blur();
|
|
989
|
+
if (e.key === "Escape") {
|
|
990
|
+
setDraft(String(current));
|
|
991
|
+
setEditing(false);
|
|
992
|
+
e.currentTarget.blur();
|
|
993
|
+
}
|
|
994
|
+
}}
|
|
995
|
+
/>
|
|
996
|
+
</div>
|
|
997
|
+
<span className="whitespace-nowrap px-1.5 text-sm text-muted-foreground tabular-nums">of {ready ? pageCount : "–"}</span>
|
|
998
|
+
{!compact ? <IconButton icon="chevron-right" label="Next page" size="sm" disabled={!ready || current >= pageCount} onClick={() => onPage(current + 1)} /> : null}
|
|
999
|
+
</div>
|
|
1000
|
+
|
|
1001
|
+
<div className={cx("flex items-center gap-1", compact && "ml-auto")}>
|
|
1002
|
+
{search && !compact ? (
|
|
1003
|
+
search.open ? (
|
|
1004
|
+
<div className="flex items-center gap-0.5">
|
|
1005
|
+
{searchField}
|
|
1006
|
+
{steps}
|
|
1007
|
+
</div>
|
|
1008
|
+
) : (
|
|
1009
|
+
<Tooltip label="Find (⌘F)">
|
|
1010
|
+
<IconButton icon="search" label="Find in document" disabled={!ready} onClick={search.onOpen} />
|
|
1011
|
+
</Tooltip>
|
|
1012
|
+
)
|
|
1013
|
+
) : null}
|
|
1014
|
+
{search && compact && !search.open ? <IconButton icon="search" label="Find in document" disabled={!ready} onClick={search.onOpen} /> : null}
|
|
1015
|
+
|
|
1016
|
+
<div className="flex items-center">
|
|
1017
|
+
{!compact ? (
|
|
1018
|
+
<Tooltip label="Zoom out (⌘−)">
|
|
1019
|
+
<IconButton icon="minus" label="Zoom out" size="sm" disabled={!ready} onClick={() => zoom.onZoom(stepZoom(zoom.scale, -1))} />
|
|
1020
|
+
</Tooltip>
|
|
1021
|
+
) : null}
|
|
1022
|
+
<Menu
|
|
1023
|
+
label={`Zoom, ${zoom.label}`}
|
|
1024
|
+
size="md"
|
|
1025
|
+
align="end"
|
|
1026
|
+
trigger={<span className="min-w-10 text-center text-sm tabular-nums">{zoom.label}</span>}
|
|
1027
|
+
items={[
|
|
1028
|
+
{
|
|
1029
|
+
type: "radio",
|
|
1030
|
+
value: ZOOM_CHOICES.some((c) => c.value === zoom.value) ? zoom.value : "",
|
|
1031
|
+
onValueChange: (v) => zoom.onZoom(v === "fit" || v === "page" ? v : Number(v)),
|
|
1032
|
+
options: ZOOM_CHOICES,
|
|
1033
|
+
},
|
|
1034
|
+
]}
|
|
1035
|
+
/>
|
|
1036
|
+
{!compact ? (
|
|
1037
|
+
<Tooltip label="Zoom in (⌘+)">
|
|
1038
|
+
<IconButton icon="plus" label="Zoom in" size="sm" disabled={!ready} onClick={() => zoom.onZoom(stepZoom(zoom.scale, 1))} />
|
|
1039
|
+
</Tooltip>
|
|
1040
|
+
) : null}
|
|
1041
|
+
</div>
|
|
1042
|
+
|
|
1043
|
+
{notes ? (
|
|
1044
|
+
<Tooltip label={notes.open ? "Hide highlights" : "Show highlights"}>
|
|
1045
|
+
<Button variant="quiet" icon="highlighter" aria-pressed={notes.open} aria-label={`Highlights, ${notes.count}`} iconOnly={compact || !notes.count} disabled={!ready} onClick={notes.toggle}>
|
|
1046
|
+
{!compact && notes.count ? <span className="tabular-nums">{notes.count}</span> : null}
|
|
1047
|
+
</Button>
|
|
1048
|
+
</Tooltip>
|
|
1049
|
+
) : null}
|
|
1050
|
+
{onDownload ? (
|
|
1051
|
+
<Tooltip label="Download">
|
|
1052
|
+
<IconButton icon="download" label="Download PDF" disabled={!ready} onClick={onDownload} />
|
|
1053
|
+
</Tooltip>
|
|
1054
|
+
) : null}
|
|
1055
|
+
</div>
|
|
1056
|
+
</div>
|
|
1057
|
+
|
|
1058
|
+
{/* On a narrow viewer the search is a row of its own. */}
|
|
1059
|
+
{search && compact && search.open ? (
|
|
1060
|
+
<div className="flex items-center gap-1 px-2 pb-2">
|
|
1061
|
+
<div className="min-w-0 flex-1">{searchField}</div>
|
|
1062
|
+
{steps}
|
|
1063
|
+
</div>
|
|
1064
|
+
) : null}
|
|
1065
|
+
</div>
|
|
1066
|
+
);
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// ---- the page rail --------------------------------------------------------------
|
|
1070
|
+
|
|
1071
|
+
function PageRail({ sizes, current, onPick, grid = false }) {
|
|
1072
|
+
const currentRef = useRef(null);
|
|
1073
|
+
useEffect(() => {
|
|
1074
|
+
currentRef.current?.scrollIntoView?.({ block: "nearest" });
|
|
1075
|
+
}, [current]);
|
|
1076
|
+
if (!sizes) {
|
|
1077
|
+
return (
|
|
1078
|
+
<div className="grid gap-4 p-4" aria-hidden="true">
|
|
1079
|
+
{[0, 1, 2].map((i) => (
|
|
1080
|
+
<Skeleton key={i} shape="rect" className="mx-auto aspect-[3/4] w-24" />
|
|
1081
|
+
))}
|
|
1082
|
+
</div>
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
const width = grid ? 120 : 104;
|
|
1086
|
+
return (
|
|
1087
|
+
<ol className={cx("p-3", grid ? "grid grid-cols-2 gap-x-3 gap-y-4 sm:grid-cols-3" : "grid gap-4")}>
|
|
1088
|
+
{sizes.map((s, i) => {
|
|
1089
|
+
const n = i + 1;
|
|
1090
|
+
const on = n === current;
|
|
1091
|
+
return (
|
|
1092
|
+
<li key={n} ref={on ? currentRef : undefined} className="grid justify-items-center gap-1.5">
|
|
1093
|
+
{/* raw: a page's picture is the button; no kit part holds a canvas. */}
|
|
1094
|
+
<button
|
|
1095
|
+
type="button"
|
|
1096
|
+
onClick={() => onPick(n)}
|
|
1097
|
+
aria-label={`Page ${n}`}
|
|
1098
|
+
aria-current={on ? "page" : undefined}
|
|
1099
|
+
className={cx(
|
|
1100
|
+
"block overflow-hidden rounded-[3px] shadow-sm outline-none ring-1 transition-shadow duration-150 motion-reduce:transition-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
|
|
1101
|
+
on ? "ring-2 ring-primary" : "ring-border hover:ring-foreground/30",
|
|
1102
|
+
)}
|
|
1103
|
+
>
|
|
1104
|
+
<LazyThumb width={width} height={Math.round((s.height / s.width) * width)}>
|
|
1105
|
+
<Page pageNumber={n} width={width} renderTextLayer={false} renderAnnotationLayer={false} loading={<Skeleton shape="rect" className="h-full w-full rounded-none" />} />
|
|
1106
|
+
</LazyThumb>
|
|
1107
|
+
</button>
|
|
1108
|
+
<span className={cx("text-xs tabular-nums", on ? "font-medium text-foreground" : "text-muted-foreground")}>{n}</span>
|
|
1109
|
+
</li>
|
|
1110
|
+
);
|
|
1111
|
+
})}
|
|
1112
|
+
</ol>
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/** Draws its child only once it has come near the visible part of the
|
|
1117
|
+
* rail; until then, blank paper of the right size. */
|
|
1118
|
+
function LazyThumb({ width, height, children }) {
|
|
1119
|
+
const ref = useRef(null);
|
|
1120
|
+
const [near, setNear] = useState(false);
|
|
1121
|
+
useEffect(() => {
|
|
1122
|
+
const el = ref.current;
|
|
1123
|
+
if (!el || near) return undefined;
|
|
1124
|
+
const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && setNear(true), { rootMargin: "400px 0px" });
|
|
1125
|
+
io.observe(el);
|
|
1126
|
+
return () => io.disconnect();
|
|
1127
|
+
}, [near]);
|
|
1128
|
+
return (
|
|
1129
|
+
<div ref={ref} className="bg-card" style={{ width, height }}>
|
|
1130
|
+
{near ? children : null}
|
|
1131
|
+
</div>
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
// ---- on the page ------------------------------------------------------------------
|
|
1136
|
+
|
|
1137
|
+
function HighlightLayer({ items, page, activeId, onOpen }) {
|
|
1138
|
+
const here = items.filter((h) => h.page === page && h.rects?.length);
|
|
1139
|
+
if (!here.length) return null;
|
|
1140
|
+
return (
|
|
1141
|
+
<>
|
|
1142
|
+
{/* The marker strokes. The LAYER multiplies into the page (a blend
|
|
1143
|
+
inside a layer of its own would only blend with that layer), so
|
|
1144
|
+
the ink shows through the tone as through a highlighter pen. */}
|
|
1145
|
+
<div className="pointer-events-none absolute inset-0 z-[1] mix-blend-multiply" aria-hidden="true">
|
|
1146
|
+
{here.flatMap((h) => {
|
|
1147
|
+
const tone = toneOf(h);
|
|
1148
|
+
const active = h.id === activeId;
|
|
1149
|
+
return h.rects.map((r, i) => (
|
|
1150
|
+
<div
|
|
1151
|
+
key={`${h.id}-${i}`}
|
|
1152
|
+
className={cx(
|
|
1153
|
+
"absolute rounded-[2px] transition-[outline-color,opacity] duration-150 motion-reduce:transition-none",
|
|
1154
|
+
TONE_FILL[tone],
|
|
1155
|
+
h.pending && "opacity-60",
|
|
1156
|
+
active && cx("outline-2 outline-offset-1", TONE_RING[tone]),
|
|
1157
|
+
)}
|
|
1158
|
+
style={{ left: `${r.x * 100}%`, top: `${r.y * 100}%`, width: `${r.w * 100}%`, height: `${r.h * 100}%` }}
|
|
1159
|
+
/>
|
|
1160
|
+
));
|
|
1161
|
+
})}
|
|
1162
|
+
</div>
|
|
1163
|
+
{/* The note pins, unblended, at the end of each highlight that has one. */}
|
|
1164
|
+
<div className="pointer-events-none absolute inset-0 z-[2]">
|
|
1165
|
+
{here
|
|
1166
|
+
.filter((h) => h.note)
|
|
1167
|
+
.map((h) => {
|
|
1168
|
+
const last = h.rects[h.rects.length - 1];
|
|
1169
|
+
return (
|
|
1170
|
+
// raw: a 20px pin on the page; the kit's smallest button is
|
|
1171
|
+
// 36px and would cover the text it annotates.
|
|
1172
|
+
<button
|
|
1173
|
+
key={h.id}
|
|
1174
|
+
type="button"
|
|
1175
|
+
aria-label={`Note on page ${page}: ${h.note.slice(0, 80)}`}
|
|
1176
|
+
title={h.note}
|
|
1177
|
+
onClick={(e) => {
|
|
1178
|
+
e.stopPropagation();
|
|
1179
|
+
onOpen(h);
|
|
1180
|
+
}}
|
|
1181
|
+
className="pointer-events-auto absolute grid size-5 -translate-y-1/2 place-items-center rounded-full bg-background text-foreground shadow-sm ring-1 ring-border transition-transform duration-150 hover:scale-110 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring motion-reduce:transition-none"
|
|
1182
|
+
style={{ left: `min(calc(${(last.x + last.w) * 100}% + 4px), calc(100% - 24px))`, top: `${(last.y + last.h / 2) * 100}%` }}
|
|
1183
|
+
>
|
|
1184
|
+
<Icon name="message-square" size={12} />
|
|
1185
|
+
</button>
|
|
1186
|
+
);
|
|
1187
|
+
})}
|
|
1188
|
+
</div>
|
|
1189
|
+
</>
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
function SelectionBar({ selection, composer, onHighlight, onNote, onSaveNote, onCancel, onCopy }) {
|
|
1194
|
+
const [note, setNote] = useState("");
|
|
1195
|
+
const top = Math.min(...selection.rects.map((r) => r.y));
|
|
1196
|
+
const bottom = Math.max(...selection.rects.map((r) => r.y + r.h));
|
|
1197
|
+
const first = selection.rects[0];
|
|
1198
|
+
const x = Math.min(0.85, Math.max(0.15, first.x + first.w / 2));
|
|
1199
|
+
// Above the selection, unless it starts near the top of the page.
|
|
1200
|
+
const above = top > 0.08;
|
|
1201
|
+
return (
|
|
1202
|
+
// raw: a floating bar pinned to the selection inside the page; mousedown
|
|
1203
|
+
// is swallowed so pressing a button does not clear the selection.
|
|
1204
|
+
<div
|
|
1205
|
+
role="toolbar"
|
|
1206
|
+
aria-label="Selected text"
|
|
1207
|
+
data-selection-bar=""
|
|
1208
|
+
onMouseDown={(e) => {
|
|
1209
|
+
if (!e.target.closest("textarea")) e.preventDefault();
|
|
1210
|
+
}}
|
|
1211
|
+
onClick={(e) => e.stopPropagation()}
|
|
1212
|
+
className={cx("absolute z-10 -translate-x-1/2", above ? "-translate-y-full" : "")}
|
|
1213
|
+
style={{ left: `${x * 100}%`, top: above ? `calc(${top * 100}% - 8px)` : `calc(${bottom * 100}% + 8px)` }}
|
|
1214
|
+
>
|
|
1215
|
+
<div className="rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg">
|
|
1216
|
+
{composer ? (
|
|
1217
|
+
<form
|
|
1218
|
+
className="grid w-72 max-w-[80vw] gap-2 p-1"
|
|
1219
|
+
onSubmit={(e) => {
|
|
1220
|
+
e.preventDefault();
|
|
1221
|
+
onSaveNote(note.trim());
|
|
1222
|
+
}}
|
|
1223
|
+
>
|
|
1224
|
+
<p className="line-clamp-2 border-l-2 border-warning pl-2 text-xs text-muted-foreground">{selection.text}</p>
|
|
1225
|
+
<textarea
|
|
1226
|
+
// raw: the kit Textarea's 44px minimum and resize corner are
|
|
1227
|
+
// too heavy for a note typed in a floating bar over the page.
|
|
1228
|
+
autoFocus
|
|
1229
|
+
rows={3}
|
|
1230
|
+
value={note}
|
|
1231
|
+
onChange={(e) => setNote(e.target.value)}
|
|
1232
|
+
placeholder="Write a note…"
|
|
1233
|
+
aria-label="Note"
|
|
1234
|
+
onKeyDown={(e) => {
|
|
1235
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
|
1236
|
+
e.preventDefault();
|
|
1237
|
+
onSaveNote(note.trim());
|
|
1238
|
+
} else if (e.key === "Escape") {
|
|
1239
|
+
e.preventDefault();
|
|
1240
|
+
e.stopPropagation();
|
|
1241
|
+
onCancel();
|
|
1242
|
+
}
|
|
1243
|
+
}}
|
|
1244
|
+
className="w-full resize-none rounded-md border border-input bg-background px-2.5 py-2 text-sm outline-none placeholder:text-muted-foreground focus-visible:ring-2 focus-visible:ring-ring"
|
|
1245
|
+
/>
|
|
1246
|
+
<div className="flex items-center justify-between gap-2">
|
|
1247
|
+
<span className="hidden text-xs text-muted-foreground sm:inline">
|
|
1248
|
+
<Kbd>⌘</Kbd> <Kbd>↵</Kbd> to save
|
|
1249
|
+
</span>
|
|
1250
|
+
<div className="ml-auto flex gap-1">
|
|
1251
|
+
<Button size="sm" variant="quiet" onClick={onCancel}>
|
|
1252
|
+
Cancel
|
|
1253
|
+
</Button>
|
|
1254
|
+
<Button size="sm" variant="primary" type="submit">
|
|
1255
|
+
Save
|
|
1256
|
+
</Button>
|
|
1257
|
+
</div>
|
|
1258
|
+
</div>
|
|
1259
|
+
</form>
|
|
1260
|
+
) : (
|
|
1261
|
+
<div className="flex items-center gap-0.5">
|
|
1262
|
+
<Button size="sm" variant="quiet" icon="highlighter" onClick={onHighlight}>
|
|
1263
|
+
Highlight
|
|
1264
|
+
</Button>
|
|
1265
|
+
<Button size="sm" variant="quiet" icon="message-square" onClick={onNote}>
|
|
1266
|
+
Add note
|
|
1267
|
+
</Button>
|
|
1268
|
+
<IconButton size="sm" icon="copy" label="Copy text" onClick={onCopy} />
|
|
1269
|
+
</div>
|
|
1270
|
+
)}
|
|
1271
|
+
</div>
|
|
1272
|
+
</div>
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
// ---- states -----------------------------------------------------------------------
|
|
1277
|
+
|
|
1278
|
+
function ToolbarSkeleton() {
|
|
1279
|
+
return (
|
|
1280
|
+
<div className="flex h-14 shrink-0 items-center gap-3 border-b border-border px-3" aria-hidden="true">
|
|
1281
|
+
<Skeleton shape="rect" className="size-8" />
|
|
1282
|
+
<Skeleton className="mx-auto w-24" />
|
|
1283
|
+
<Skeleton shape="rect" className="h-8 w-20" />
|
|
1284
|
+
</div>
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
function PagesSkeleton() {
|
|
1289
|
+
return (
|
|
1290
|
+
<div className="grid flex-1 content-start justify-items-center gap-4 overflow-hidden bg-muted/50 p-4" aria-busy="true" aria-label="Loading document">
|
|
1291
|
+
{[0, 1].map((i) => (
|
|
1292
|
+
<div key={i} className="grid aspect-[1/1.414] w-full max-w-2xl content-start gap-3 rounded-[2px] bg-card p-[8%] shadow-sm ring-1 ring-border">
|
|
1293
|
+
<Skeleton className="h-5 w-1/3" />
|
|
1294
|
+
<Skeleton className="mt-4 w-full" />
|
|
1295
|
+
<Skeleton className="w-11/12" />
|
|
1296
|
+
<Skeleton className="w-4/5" />
|
|
1297
|
+
<Skeleton className="mt-4 w-full" />
|
|
1298
|
+
<Skeleton className="w-2/3" />
|
|
1299
|
+
</div>
|
|
1300
|
+
))}
|
|
1301
|
+
</div>
|
|
1302
|
+
);
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
function Placeholder({ icon, title, body, children }) {
|
|
1306
|
+
return (
|
|
1307
|
+
<div className="grid max-w-sm justify-items-center gap-2 text-center">
|
|
1308
|
+
<span className="grid size-12 place-items-center rounded-full bg-background text-muted-foreground ring-1 ring-border">
|
|
1309
|
+
<Icon name={icon} size={20} />
|
|
1310
|
+
</span>
|
|
1311
|
+
<p className="mt-1 text-base font-medium">{title}</p>
|
|
1312
|
+
{body ? <p className="text-sm text-muted-foreground">{body}</p> : null}
|
|
1313
|
+
{children ? <div className="mt-3">{children}</div> : null}
|
|
1314
|
+
</div>
|
|
1315
|
+
);
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
function LoadError({ error, onRetry }) {
|
|
1319
|
+
const msg = String(error?.message ?? "");
|
|
1320
|
+
const why = /invalid pdf|invalid or corrupted/i.test(msg)
|
|
1321
|
+
? "The file is damaged, or it isn't a PDF."
|
|
1322
|
+
: /missing pdf|404|not found/i.test(msg)
|
|
1323
|
+
? "The file couldn't be found. It may have been moved or deleted."
|
|
1324
|
+
: /network|fetch|cors/i.test(msg)
|
|
1325
|
+
? "The file couldn't be downloaded. Check your connection and try again."
|
|
1326
|
+
: "Something went wrong while opening it.";
|
|
1327
|
+
return (
|
|
1328
|
+
<div className="grid flex-1 place-items-center bg-muted/40 p-6">
|
|
1329
|
+
<Placeholder icon="warning" title="This PDF didn't open" body={why}>
|
|
1330
|
+
<Button icon="refresh" onClick={onRetry}>
|
|
1331
|
+
Try again
|
|
1332
|
+
</Button>
|
|
1333
|
+
</Placeholder>
|
|
1334
|
+
</div>
|
|
1335
|
+
);
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
function PasswordGate({ submit, wrong }) {
|
|
1339
|
+
const [value, setValue] = useState("");
|
|
1340
|
+
const [busy, setBusy] = useState(false);
|
|
1341
|
+
useEffect(() => setBusy(false), [wrong, submit]);
|
|
1342
|
+
return (
|
|
1343
|
+
<div className="grid flex-1 place-items-center bg-muted/40 p-6">
|
|
1344
|
+
<form
|
|
1345
|
+
className="grid w-full max-w-sm gap-4 rounded-lg border border-border bg-background p-5 shadow-sm"
|
|
1346
|
+
onSubmit={(e) => {
|
|
1347
|
+
e.preventDefault();
|
|
1348
|
+
if (!value) return;
|
|
1349
|
+
setBusy(true);
|
|
1350
|
+
submit(value);
|
|
1351
|
+
}}
|
|
1352
|
+
>
|
|
1353
|
+
<div className="grid gap-1">
|
|
1354
|
+
<p className="flex items-center gap-2 text-base font-medium">
|
|
1355
|
+
<Icon name="lock" size={16} /> This PDF is protected
|
|
1356
|
+
</p>
|
|
1357
|
+
<p className="text-sm text-muted-foreground">Enter its password to open it.</p>
|
|
1358
|
+
</div>
|
|
1359
|
+
<Field id="pdf-password" label="Password" error={wrong && !busy ? "That password didn't work. Try again." : undefined}>
|
|
1360
|
+
<PasswordInput id="pdf-password" value={value} autoFocus invalid={wrong && !busy} onChange={(e) => setValue(e.target.value)} />
|
|
1361
|
+
</Field>
|
|
1362
|
+
<Button type="submit" variant="primary" loading={busy} disabled={!value}>
|
|
1363
|
+
Open
|
|
1364
|
+
</Button>
|
|
1365
|
+
</form>
|
|
1366
|
+
</div>
|
|
1367
|
+
);
|
|
1368
|
+
}
|