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.
Files changed (478) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +41 -1
  3. package/THIRD-PARTY-NOTICES.md +180 -0
  4. package/package.json +15 -8
  5. package/r/annotate.json +62 -0
  6. package/r/approval-request.json +52 -0
  7. package/r/audio-player.json +74 -0
  8. package/r/auth-form.json +55 -0
  9. package/r/board.json +55 -0
  10. package/r/calendar-view.json +105 -0
  11. package/r/cart.json +62 -0
  12. package/r/chart.json +83 -0
  13. package/r/chat.json +95 -0
  14. package/r/code-editor.json +94 -0
  15. package/r/comment-box.json +64 -0
  16. package/r/contact-section.json +53 -0
  17. package/r/core.json +341 -0
  18. package/r/csv-import.json +63 -0
  19. package/r/data-table.json +69 -0
  20. package/r/diagram.json +74 -0
  21. package/r/doc-editor.json +82 -0
  22. package/r/doc-page.json +61 -0
  23. package/r/email-render.json +73 -0
  24. package/r/faq.json +50 -0
  25. package/r/file-upload.json +54 -0
  26. package/r/flow-canvas.json +93 -0
  27. package/r/footer.json +52 -0
  28. package/r/gantt.json +70 -0
  29. package/r/job-panel.json +56 -0
  30. package/r/json-editor.json +66 -0
  31. package/r/map.json +89 -0
  32. package/r/menubar.json +52 -0
  33. package/r/mind-map.json +67 -0
  34. package/r/navigation-menu.json +52 -0
  35. package/r/order-detail.json +54 -0
  36. package/r/org-chart.json +66 -0
  37. package/r/page-builder.json +105 -0
  38. package/r/payment-methods.json +51 -0
  39. package/r/pdf-document.json +79 -0
  40. package/r/pdf-viewer.json +88 -0
  41. package/r/product-detail.json +76 -0
  42. package/r/product-grid.json +65 -0
  43. package/r/record-form.json +67 -0
  44. package/r/record-notes.json +55 -0
  45. package/r/registry.json +3612 -0
  46. package/r/search-dialog.json +51 -0
  47. package/r/settings-list.json +66 -0
  48. package/r/setup-checklist.json +51 -0
  49. package/r/share-dialog.json +52 -0
  50. package/r/sidebar.json +69 -0
  51. package/r/signature-pad.json +72 -0
  52. package/r/sketch-canvas.json +99 -0
  53. package/r/spreadsheet.json +90 -0
  54. package/r/stat-cards.json +66 -0
  55. package/r/task-list.json +63 -0
  56. package/r/task-model.json +98 -0
  57. package/r/testimonials.json +52 -0
  58. package/r/transaction-list.json +53 -0
  59. package/r/whiteboard.json +66 -0
  60. package/registry/blocks/annotate/README.md +179 -0
  61. package/registry/blocks/annotate/annotate-data.js +250 -0
  62. package/registry/blocks/annotate/annotate.jsx +646 -0
  63. package/registry/blocks/annotate/block.json +10 -0
  64. package/registry/blocks/annotate/demo.jsx +135 -0
  65. package/registry/blocks/annotate/index.js +15 -0
  66. package/registry/blocks/approval-request/README.md +116 -0
  67. package/registry/blocks/approval-request/approval-request.jsx +386 -0
  68. package/registry/blocks/approval-request/block.json +10 -0
  69. package/registry/blocks/approval-request/demo.jsx +127 -0
  70. package/registry/blocks/approval-request/index.js +4 -0
  71. package/registry/blocks/audio-player/README.md +167 -0
  72. package/registry/blocks/audio-player/audio-player.jsx +623 -0
  73. package/registry/blocks/audio-player/block.json +14 -0
  74. package/registry/blocks/audio-player/colors.js +109 -0
  75. package/registry/blocks/audio-player/demo.jsx +209 -0
  76. package/registry/blocks/audio-player/index.js +6 -0
  77. package/registry/blocks/audio-player/time.js +67 -0
  78. package/registry/blocks/audio-player/wavesurfer.js +9 -0
  79. package/registry/blocks/auth-form/README.md +126 -0
  80. package/registry/blocks/auth-form/auth-form.jsx +568 -0
  81. package/registry/blocks/auth-form/block.json +10 -0
  82. package/registry/blocks/auth-form/demo.jsx +131 -0
  83. package/registry/blocks/auth-form/index.js +3 -0
  84. package/registry/blocks/board/README.md +64 -13
  85. package/registry/blocks/board/block.json +2 -2
  86. package/registry/blocks/board/board.jsx +237 -94
  87. package/registry/blocks/board/demo.jsx +103 -24
  88. package/registry/blocks/calendar-view/README.md +180 -0
  89. package/registry/blocks/calendar-view/agenda.jsx +147 -0
  90. package/registry/blocks/calendar-view/block.json +76 -0
  91. package/registry/blocks/calendar-view/calendar-model.js +442 -0
  92. package/registry/blocks/calendar-view/calendar.jsx +627 -0
  93. package/registry/blocks/calendar-view/demo.jsx +195 -0
  94. package/registry/blocks/calendar-view/draft.jsx +49 -0
  95. package/registry/blocks/calendar-view/index.js +39 -0
  96. package/registry/blocks/calendar-view/month.jsx +346 -0
  97. package/registry/blocks/calendar-view/paint.js +69 -0
  98. package/registry/blocks/calendar-view/time-grid.jsx +527 -0
  99. package/registry/blocks/calendar-view/tray.jsx +87 -0
  100. package/registry/blocks/cart/README.md +146 -0
  101. package/registry/blocks/cart/block.json +10 -0
  102. package/registry/blocks/cart/cart.jsx +597 -0
  103. package/registry/blocks/cart/demo.jsx +98 -0
  104. package/registry/blocks/cart/index.js +4 -0
  105. package/registry/blocks/cart/money.js +28 -0
  106. package/registry/blocks/chart/README.md +316 -0
  107. package/registry/blocks/chart/block.json +10 -0
  108. package/registry/blocks/chart/cartesian.jsx +642 -0
  109. package/registry/blocks/chart/core.jsx +262 -0
  110. package/registry/blocks/chart/demo.jsx +357 -0
  111. package/registry/blocks/chart/heatmap.jsx +336 -0
  112. package/registry/blocks/chart/index.js +8 -0
  113. package/registry/blocks/chart/pie.jsx +245 -0
  114. package/registry/blocks/chart/sparkline.jsx +88 -0
  115. package/registry/blocks/chat/README.md +273 -0
  116. package/registry/blocks/chat/attachment.jsx +95 -0
  117. package/registry/blocks/chat/block.json +10 -0
  118. package/registry/blocks/chat/chat.jsx +132 -0
  119. package/registry/blocks/chat/composer.jsx +364 -0
  120. package/registry/blocks/chat/demo.jsx +578 -0
  121. package/registry/blocks/chat/header.jsx +34 -0
  122. package/registry/blocks/chat/index.js +9 -0
  123. package/registry/blocks/chat/message.jsx +450 -0
  124. package/registry/blocks/chat/scroller.jsx +247 -0
  125. package/registry/blocks/chat/text.jsx +104 -0
  126. package/registry/blocks/code-editor/README.md +210 -0
  127. package/registry/blocks/code-editor/block.json +22 -0
  128. package/registry/blocks/code-editor/code-editor.jsx +534 -0
  129. package/registry/blocks/code-editor/codemirror.js +38 -0
  130. package/registry/blocks/code-editor/demo.jsx +205 -0
  131. package/registry/blocks/code-editor/index.js +8 -0
  132. package/registry/blocks/code-editor/json.js +317 -0
  133. package/registry/blocks/code-editor/sql.js +136 -0
  134. package/registry/blocks/code-editor/text.js +101 -0
  135. package/registry/blocks/code-editor/theme.js +186 -0
  136. package/registry/blocks/comment-box/README.md +123 -0
  137. package/registry/blocks/comment-box/block.json +10 -0
  138. package/registry/blocks/comment-box/comment-body.jsx +217 -0
  139. package/registry/blocks/comment-box/comment-box.jsx +198 -0
  140. package/registry/blocks/comment-box/comment-time.jsx +77 -0
  141. package/registry/blocks/comment-box/demo.jsx +141 -0
  142. package/registry/blocks/comment-box/index.js +8 -0
  143. package/registry/blocks/contact-section/README.md +152 -0
  144. package/registry/blocks/contact-section/block.json +11 -0
  145. package/registry/blocks/contact-section/contact-section.jsx +509 -0
  146. package/registry/blocks/contact-section/demo.jsx +93 -0
  147. package/registry/blocks/contact-section/index.js +4 -0
  148. package/registry/blocks/csv-import/README.md +133 -0
  149. package/registry/blocks/csv-import/block.json +13 -0
  150. package/registry/blocks/csv-import/csv-import.jsx +479 -0
  151. package/registry/blocks/csv-import/demo.jsx +106 -0
  152. package/registry/blocks/csv-import/import.js +200 -0
  153. package/registry/blocks/csv-import/index.js +7 -0
  154. package/registry/blocks/data-table/README.md +386 -0
  155. package/registry/blocks/data-table/block.json +10 -0
  156. package/registry/blocks/data-table/data-table.jsx +1164 -0
  157. package/registry/blocks/data-table/demo.jsx +588 -0
  158. package/registry/blocks/data-table/index.js +5 -0
  159. package/registry/blocks/data-table/model.js +169 -0
  160. package/registry/blocks/diagram/README.md +108 -0
  161. package/registry/blocks/diagram/block.json +13 -0
  162. package/registry/blocks/diagram/demo.jsx +113 -0
  163. package/registry/blocks/diagram/diagram.jsx +481 -0
  164. package/registry/blocks/diagram/index.js +7 -0
  165. package/registry/blocks/diagram/mermaid.js +52 -0
  166. package/registry/blocks/diagram/palette.js +313 -0
  167. package/registry/blocks/diagram/source.js +98 -0
  168. package/registry/blocks/doc-editor/README.md +215 -0
  169. package/registry/blocks/doc-editor/block.json +15 -0
  170. package/registry/blocks/doc-editor/demo.jsx +225 -0
  171. package/registry/blocks/doc-editor/doc-editor.jsx +291 -0
  172. package/registry/blocks/doc-editor/doc.js +309 -0
  173. package/registry/blocks/doc-editor/index.js +7 -0
  174. package/registry/blocks/doc-editor/schema.jsx +56 -0
  175. package/registry/blocks/doc-editor/shadcn-classes.js +110 -0
  176. package/registry/blocks/doc-editor/theme.js +82 -0
  177. package/registry/blocks/doc-page/README.md +119 -0
  178. package/registry/blocks/doc-page/autosave.js +104 -0
  179. package/registry/blocks/doc-page/block.json +10 -0
  180. package/registry/blocks/doc-page/demo.jsx +137 -0
  181. package/registry/blocks/doc-page/doc-page.jsx +276 -0
  182. package/registry/blocks/doc-page/index.js +4 -0
  183. package/registry/blocks/email-render/README.md +151 -0
  184. package/registry/blocks/email-render/block.json +14 -0
  185. package/registry/blocks/email-render/demo.jsx +153 -0
  186. package/registry/blocks/email-render/doc-email.jsx +210 -0
  187. package/registry/blocks/email-render/email.jsx +221 -0
  188. package/registry/blocks/email-render/index.js +10 -0
  189. package/registry/blocks/email-render/render.js +50 -0
  190. package/registry/blocks/email-render/theme.js +215 -0
  191. package/registry/blocks/faq/README.md +101 -0
  192. package/registry/blocks/faq/block.json +11 -0
  193. package/registry/blocks/faq/demo.jsx +89 -0
  194. package/registry/blocks/faq/faq.jsx +220 -0
  195. package/registry/blocks/faq/index.js +4 -0
  196. package/registry/blocks/file-upload/README.md +138 -0
  197. package/registry/blocks/file-upload/block.json +10 -0
  198. package/registry/blocks/file-upload/demo.jsx +204 -0
  199. package/registry/blocks/file-upload/file-upload.jsx +635 -0
  200. package/registry/blocks/file-upload/index.js +4 -0
  201. package/registry/blocks/flow-canvas/README.md +420 -0
  202. package/registry/blocks/flow-canvas/block.json +13 -0
  203. package/registry/blocks/flow-canvas/board.js +819 -0
  204. package/registry/blocks/flow-canvas/chrome.jsx +649 -0
  205. package/registry/blocks/flow-canvas/context.jsx +35 -0
  206. package/registry/blocks/flow-canvas/demo.jsx +249 -0
  207. package/registry/blocks/flow-canvas/edges.jsx +107 -0
  208. package/registry/blocks/flow-canvas/flow-canvas.jsx +1329 -0
  209. package/registry/blocks/flow-canvas/index.js +41 -0
  210. package/registry/blocks/flow-canvas/nodes.jsx +392 -0
  211. package/registry/blocks/flow-canvas/theme.js +174 -0
  212. package/registry/blocks/footer/README.md +133 -0
  213. package/registry/blocks/footer/block.json +11 -0
  214. package/registry/blocks/footer/demo.jsx +119 -0
  215. package/registry/blocks/footer/footer.jsx +406 -0
  216. package/registry/blocks/footer/index.js +4 -0
  217. package/registry/blocks/gantt/README.md +220 -0
  218. package/registry/blocks/gantt/block.json +10 -0
  219. package/registry/blocks/gantt/demo.jsx +148 -0
  220. package/registry/blocks/gantt/gantt.jsx +923 -0
  221. package/registry/blocks/gantt/index.js +7 -0
  222. package/registry/blocks/gantt/rows.jsx +468 -0
  223. package/registry/blocks/gantt/schedule.js +278 -0
  224. package/registry/blocks/job-panel/README.md +86 -2
  225. package/registry/blocks/job-panel/block.json +3 -3
  226. package/registry/blocks/job-panel/demo.jsx +230 -3
  227. package/registry/blocks/job-panel/job-panel.jsx +169 -3
  228. package/registry/blocks/json-editor/README.md +133 -0
  229. package/registry/blocks/json-editor/block.json +10 -0
  230. package/registry/blocks/json-editor/demo.jsx +92 -0
  231. package/registry/blocks/json-editor/index.js +7 -0
  232. package/registry/blocks/json-editor/json-editor.jsx +305 -0
  233. package/registry/blocks/json-editor/json-tree.jsx +393 -0
  234. package/registry/blocks/json-editor/tree.js +225 -0
  235. package/registry/blocks/map/README.md +221 -0
  236. package/registry/blocks/map/block.json +14 -0
  237. package/registry/blocks/map/demo.jsx +201 -0
  238. package/registry/blocks/map/geo.js +198 -0
  239. package/registry/blocks/map/index.js +8 -0
  240. package/registry/blocks/map/list.jsx +128 -0
  241. package/registry/blocks/map/map-view.jsx +715 -0
  242. package/registry/blocks/map/maplibre.js +30 -0
  243. package/registry/blocks/map/marks.jsx +146 -0
  244. package/registry/blocks/map/theme.js +142 -0
  245. package/registry/blocks/menubar/README.md +109 -0
  246. package/registry/blocks/menubar/block.json +10 -0
  247. package/registry/blocks/menubar/demo.jsx +128 -0
  248. package/registry/blocks/menubar/index.js +4 -0
  249. package/registry/blocks/menubar/menubar.jsx +199 -0
  250. package/registry/blocks/mind-map/README.md +206 -0
  251. package/registry/blocks/mind-map/block.json +10 -0
  252. package/registry/blocks/mind-map/demo.jsx +103 -0
  253. package/registry/blocks/mind-map/index.js +31 -0
  254. package/registry/blocks/mind-map/mind-map.jsx +1125 -0
  255. package/registry/blocks/mind-map/topic.jsx +143 -0
  256. package/registry/blocks/mind-map/tree.js +645 -0
  257. package/registry/blocks/navigation-menu/README.md +124 -0
  258. package/registry/blocks/navigation-menu/block.json +10 -0
  259. package/registry/blocks/navigation-menu/demo.jsx +84 -0
  260. package/registry/blocks/navigation-menu/index.js +4 -0
  261. package/registry/blocks/navigation-menu/navigation-menu.jsx +448 -0
  262. package/registry/blocks/order-detail/README.md +109 -0
  263. package/registry/blocks/order-detail/block.json +10 -0
  264. package/registry/blocks/order-detail/demo.jsx +88 -0
  265. package/registry/blocks/order-detail/index.js +4 -0
  266. package/registry/blocks/order-detail/order-detail.jsx +354 -0
  267. package/registry/blocks/org-chart/README.md +145 -0
  268. package/registry/blocks/org-chart/block.json +10 -0
  269. package/registry/blocks/org-chart/demo.jsx +94 -0
  270. package/registry/blocks/org-chart/index.js +7 -0
  271. package/registry/blocks/org-chart/org-chart.jsx +631 -0
  272. package/registry/blocks/org-chart/org.js +245 -0
  273. package/registry/blocks/org-chart/person.jsx +96 -0
  274. package/registry/blocks/page-builder/README.md +257 -0
  275. package/registry/blocks/page-builder/block.json +14 -0
  276. package/registry/blocks/page-builder/demo.jsx +145 -0
  277. package/registry/blocks/page-builder/grapes.js +18 -0
  278. package/registry/blocks/page-builder/index.js +11 -0
  279. package/registry/blocks/page-builder/page-builder.jsx +704 -0
  280. package/registry/blocks/page-builder/page-view.jsx +33 -0
  281. package/registry/blocks/page-builder/page.js +118 -0
  282. package/registry/blocks/page-builder/panels.jsx +588 -0
  283. package/registry/blocks/page-builder/sections.js +209 -0
  284. package/registry/blocks/page-builder/theme.js +87 -0
  285. package/registry/blocks/page-builder/thumbs.jsx +189 -0
  286. package/registry/blocks/page-builder/tokens.js +329 -0
  287. package/registry/blocks/payment-methods/README.md +104 -0
  288. package/registry/blocks/payment-methods/block.json +10 -0
  289. package/registry/blocks/payment-methods/demo.jsx +71 -0
  290. package/registry/blocks/payment-methods/index.js +4 -0
  291. package/registry/blocks/payment-methods/payment-methods.jsx +302 -0
  292. package/registry/blocks/pdf-document/README.md +213 -0
  293. package/registry/blocks/pdf-document/block.json +13 -0
  294. package/registry/blocks/pdf-document/demo.jsx +136 -0
  295. package/registry/blocks/pdf-document/index.js +33 -0
  296. package/registry/blocks/pdf-document/money.js +83 -0
  297. package/registry/blocks/pdf-document/pdf-document.jsx +144 -0
  298. package/registry/blocks/pdf-document/primitives.jsx +271 -0
  299. package/registry/blocks/pdf-document/quote.jsx +179 -0
  300. package/registry/blocks/pdf-document/theme.js +193 -0
  301. package/registry/blocks/pdf-viewer/README.md +191 -0
  302. package/registry/blocks/pdf-viewer/block.json +14 -0
  303. package/registry/blocks/pdf-viewer/demo.jsx +252 -0
  304. package/registry/blocks/pdf-viewer/helpers.js +270 -0
  305. package/registry/blocks/pdf-viewer/index.js +8 -0
  306. package/registry/blocks/pdf-viewer/notes.jsx +195 -0
  307. package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1368 -0
  308. package/registry/blocks/pdf-viewer/pdfjs.js +37 -0
  309. package/registry/blocks/pdf-viewer/ssr-shim-end.js +6 -0
  310. package/registry/blocks/pdf-viewer/ssr-shim.js +44 -0
  311. package/registry/blocks/product-detail/README.md +217 -0
  312. package/registry/blocks/product-detail/block.json +10 -0
  313. package/registry/blocks/product-detail/demo.jsx +233 -0
  314. package/registry/blocks/product-detail/index.js +7 -0
  315. package/registry/blocks/product-detail/price.jsx +115 -0
  316. package/registry/blocks/product-detail/product-detail.jsx +348 -0
  317. package/registry/blocks/product-detail/product-media.jsx +102 -0
  318. package/registry/blocks/product-detail/product-options.jsx +144 -0
  319. package/registry/blocks/product-grid/README.md +162 -0
  320. package/registry/blocks/product-grid/block.json +10 -0
  321. package/registry/blocks/product-grid/demo.jsx +255 -0
  322. package/registry/blocks/product-grid/index.js +5 -0
  323. package/registry/blocks/product-grid/product-card.jsx +232 -0
  324. package/registry/blocks/product-grid/product-grid.jsx +235 -0
  325. package/registry/blocks/record-form/README.md +196 -0
  326. package/registry/blocks/record-form/block.json +10 -0
  327. package/registry/blocks/record-form/demo.jsx +525 -0
  328. package/registry/blocks/record-form/index.js +4 -0
  329. package/registry/blocks/record-form/record-fields.jsx +457 -0
  330. package/registry/blocks/record-form/record-form.jsx +415 -0
  331. package/registry/blocks/record-form/rules.js +81 -0
  332. package/registry/blocks/record-notes/README.md +139 -0
  333. package/registry/blocks/record-notes/block.json +10 -0
  334. package/registry/blocks/record-notes/demo.jsx +179 -0
  335. package/registry/blocks/record-notes/index.js +4 -0
  336. package/registry/blocks/record-notes/record-notes.jsx +266 -0
  337. package/registry/blocks/search-dialog/README.md +108 -0
  338. package/registry/blocks/search-dialog/block.json +10 -0
  339. package/registry/blocks/search-dialog/demo.jsx +116 -0
  340. package/registry/blocks/search-dialog/index.js +3 -0
  341. package/registry/blocks/search-dialog/search-dialog.jsx +235 -0
  342. package/registry/blocks/settings-list/README.md +171 -0
  343. package/registry/blocks/settings-list/block.json +10 -0
  344. package/registry/blocks/settings-list/demo.jsx +333 -0
  345. package/registry/blocks/settings-list/index.js +4 -0
  346. package/registry/blocks/settings-list/row-state.jsx +93 -0
  347. package/registry/blocks/settings-list/settings-list.jsx +349 -0
  348. package/registry/blocks/settings-list/settings-matrix.jsx +118 -0
  349. package/registry/blocks/setup-checklist/README.md +142 -0
  350. package/registry/blocks/setup-checklist/block.json +10 -0
  351. package/registry/blocks/setup-checklist/demo.jsx +159 -0
  352. package/registry/blocks/setup-checklist/index.js +4 -0
  353. package/registry/blocks/setup-checklist/setup-checklist.jsx +446 -0
  354. package/registry/blocks/share-dialog/README.md +113 -0
  355. package/registry/blocks/share-dialog/block.json +10 -0
  356. package/registry/blocks/share-dialog/demo.jsx +140 -0
  357. package/registry/blocks/share-dialog/index.js +3 -0
  358. package/registry/blocks/share-dialog/share-dialog.jsx +384 -0
  359. package/registry/blocks/sidebar/README.md +190 -0
  360. package/registry/blocks/sidebar/block.json +43 -0
  361. package/registry/blocks/sidebar/demo.jsx +577 -0
  362. package/registry/blocks/sidebar/index.js +4 -0
  363. package/registry/blocks/sidebar/sidebar-panel.jsx +149 -0
  364. package/registry/blocks/sidebar/sidebar.jsx +864 -0
  365. package/registry/blocks/signature-pad/README.md +137 -0
  366. package/registry/blocks/signature-pad/block.json +13 -0
  367. package/registry/blocks/signature-pad/colors.js +115 -0
  368. package/registry/blocks/signature-pad/demo.jsx +94 -0
  369. package/registry/blocks/signature-pad/index.js +6 -0
  370. package/registry/blocks/signature-pad/pad.js +7 -0
  371. package/registry/blocks/signature-pad/signature-pad.jsx +434 -0
  372. package/registry/blocks/signature-pad/signature.js +68 -0
  373. package/registry/blocks/sketch-canvas/README.md +280 -0
  374. package/registry/blocks/sketch-canvas/block.json +13 -0
  375. package/registry/blocks/sketch-canvas/demo.jsx +159 -0
  376. package/registry/blocks/sketch-canvas/excalidraw.js +40 -0
  377. package/registry/blocks/sketch-canvas/index.js +9 -0
  378. package/registry/blocks/sketch-canvas/palette.js +226 -0
  379. package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1188 -0
  380. package/registry/blocks/sketch-canvas/sketch.js +179 -0
  381. package/registry/blocks/sketch-canvas/ssr-shim-end.js +6 -0
  382. package/registry/blocks/sketch-canvas/ssr-shim.js +45 -0
  383. package/registry/blocks/sketch-canvas/stamps.js +104 -0
  384. package/registry/blocks/sketch-canvas/theme.js +131 -0
  385. package/registry/blocks/spreadsheet/README.md +307 -0
  386. package/registry/blocks/spreadsheet/block.json +13 -0
  387. package/registry/blocks/spreadsheet/cells.jsx +455 -0
  388. package/registry/blocks/spreadsheet/chrome.jsx +325 -0
  389. package/registry/blocks/spreadsheet/demo.jsx +177 -0
  390. package/registry/blocks/spreadsheet/grid.js +387 -0
  391. package/registry/blocks/spreadsheet/index.js +34 -0
  392. package/registry/blocks/spreadsheet/spreadsheet.jsx +1732 -0
  393. package/registry/blocks/spreadsheet/types.js +473 -0
  394. package/registry/blocks/stat-cards/README.md +173 -0
  395. package/registry/blocks/stat-cards/block.json +10 -0
  396. package/registry/blocks/stat-cards/demo.jsx +257 -0
  397. package/registry/blocks/stat-cards/index.js +4 -0
  398. package/registry/blocks/stat-cards/stat-cards.jsx +307 -0
  399. package/registry/blocks/stat-cards/usage.jsx +243 -0
  400. package/registry/blocks/task-list/README.md +176 -0
  401. package/registry/blocks/task-list/block.json +10 -0
  402. package/registry/blocks/task-list/demo.jsx +134 -0
  403. package/registry/blocks/task-list/index.js +5 -0
  404. package/registry/blocks/task-list/task-list.jsx +907 -0
  405. package/registry/blocks/task-model/README.md +455 -0
  406. package/registry/blocks/task-model/block.json +167 -0
  407. package/registry/blocks/task-model/changes.js +89 -0
  408. package/registry/blocks/task-model/dates.js +371 -0
  409. package/registry/blocks/task-model/demo.jsx +215 -0
  410. package/registry/blocks/task-model/dnd.jsx +240 -0
  411. package/registry/blocks/task-model/index.js +97 -0
  412. package/registry/blocks/task-model/model.js +919 -0
  413. package/registry/blocks/task-model/parts.jsx +449 -0
  414. package/registry/blocks/task-model/toolbar.jsx +271 -0
  415. package/registry/blocks/testimonials/README.md +139 -0
  416. package/registry/blocks/testimonials/block.json +11 -0
  417. package/registry/blocks/testimonials/demo.jsx +137 -0
  418. package/registry/blocks/testimonials/index.js +4 -0
  419. package/registry/blocks/testimonials/testimonials.jsx +430 -0
  420. package/registry/blocks/transaction-list/README.md +126 -0
  421. package/registry/blocks/transaction-list/block.json +10 -0
  422. package/registry/blocks/transaction-list/demo.jsx +79 -0
  423. package/registry/blocks/transaction-list/index.js +4 -0
  424. package/registry/blocks/transaction-list/transaction-list.jsx +383 -0
  425. package/registry/blocks/whiteboard/README.md +179 -0
  426. package/registry/blocks/whiteboard/block.json +10 -0
  427. package/registry/blocks/whiteboard/demo.jsx +169 -0
  428. package/registry/blocks/whiteboard/index.js +6 -0
  429. package/registry/blocks/whiteboard/templates.js +150 -0
  430. package/registry/blocks/whiteboard/whiteboard.jsx +721 -0
  431. package/src/install.mjs +18 -0
  432. package/src/shadcn.mjs +171 -0
  433. package/templates/next/files/README.md +145 -11
  434. package/templates/next/files/app/globals.css +55 -4
  435. package/templates/next/files/check/run.mjs +3 -3
  436. package/templates/next/files/components/parts/fixture.jsx +1 -1
  437. package/templates/next/files/components/parts/sample.js +15 -0
  438. package/templates/next/files/components/ui/avatar.jsx +141 -0
  439. package/templates/next/files/components/ui/breadcrumb.jsx +149 -0
  440. package/templates/next/files/components/ui/button-group.jsx +70 -0
  441. package/templates/next/files/components/ui/button.jsx +132 -12
  442. package/templates/next/files/components/ui/calendar.jsx +609 -0
  443. package/templates/next/files/components/ui/carousel.jsx +281 -0
  444. package/templates/next/files/components/ui/collapsible.jsx +208 -0
  445. package/templates/next/files/components/ui/combobox.jsx +608 -0
  446. package/templates/next/files/components/ui/command.jsx +577 -0
  447. package/templates/next/files/components/ui/context-menu.jsx +191 -0
  448. package/templates/next/files/components/ui/date-picker.jsx +360 -0
  449. package/templates/next/files/components/ui/distribution.jsx +117 -0
  450. package/templates/next/files/components/ui/feedback.jsx +143 -0
  451. package/templates/next/files/components/ui/field.jsx +115 -3
  452. package/templates/next/files/components/ui/file-input.jsx +234 -0
  453. package/templates/next/files/components/ui/icon.jsx +130 -8
  454. package/templates/next/files/components/ui/image.jsx +143 -0
  455. package/templates/next/files/components/ui/index.js +47 -2
  456. package/templates/next/files/components/ui/input-group.jsx +213 -0
  457. package/templates/next/files/components/ui/input-otp.jsx +170 -0
  458. package/templates/next/files/components/ui/item.jsx +206 -0
  459. package/templates/next/files/components/ui/menu.jsx +454 -82
  460. package/templates/next/files/components/ui/notice.jsx +126 -14
  461. package/templates/next/files/components/ui/pagination.jsx +156 -0
  462. package/templates/next/files/components/ui/popover.jsx +284 -0
  463. package/templates/next/files/components/ui/radio.jsx +193 -0
  464. package/templates/next/files/components/ui/rating.jsx +131 -0
  465. package/templates/next/files/components/ui/resizable.jsx +338 -0
  466. package/templates/next/files/components/ui/scroll-area.jsx +123 -0
  467. package/templates/next/files/components/ui/separator.jsx +119 -0
  468. package/templates/next/files/components/ui/sheet.jsx +96 -11
  469. package/templates/next/files/components/ui/slider.jsx +174 -0
  470. package/templates/next/files/components/ui/stat.jsx +574 -0
  471. package/templates/next/files/components/ui/steps.jsx +338 -0
  472. package/templates/next/files/components/ui/swatch.jsx +33 -0
  473. package/templates/next/files/components/ui/table.jsx +130 -0
  474. package/templates/next/files/components/ui/toast.jsx +440 -0
  475. package/templates/next/files/components/ui/toggle.jsx +195 -0
  476. package/templates/next/files/components/ui/tooltip.jsx +270 -0
  477. package/templates/next/files/lib/sample.js +4 -12
  478. package/templates/next/template.json +264 -8
@@ -0,0 +1,79 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "pdf-document",
4
+ "type": "registry:block",
5
+ "title": "PdfDocument",
6
+ "description": "Make PDFs in the workspace theme (@react-pdf/renderer): a small set of document primitives (headings, text, label/value fields, tables with repeating headers, totals, callouts, badges, rows and columns) and a clean ready-made quote / estimate / invoice layout, rendered in the browser to a Blob with renderPdf(doc). The theme's colours are read from the page when the PDF is made (a PDF cannot read CSS variables): white paper, near-black ink and the theme's primary as the one accent. Also a live-preview hook (usePdf), a Download PDF button, and the quote sums and money formatting as plain functions.",
7
+ "registryDependencies": [
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/core.json"
9
+ ],
10
+ "dependencies": [
11
+ "@react-pdf/renderer@4.9.0"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "registry/blocks/pdf-document/index.js",
16
+ "type": "registry:component",
17
+ "target": "@components/makersclaw/blocks/pdf-document/index.js",
18
+ "content": "// The PdfDocument block — `makersclaw add pdf-document`. Fixed: compose it\n// through its props (README.md beside this file). Themed PDF primitives,\n// a ready invoice/quote layout, and renderPdf(doc) → Blob. The blocks that\n// print (quote-builder, proposal-accept…) import this one, never\n// @react-pdf/renderer directly. The sums and formatters in money.js and\n// the theme maths in theme.js run anywhere, the server included.\nexport { renderPdf, usePdf, PdfDownloadButton, downloadBlob, registerPdfFont } from \"./pdf-document\";\nexport {\n PdfDocument,\n PdfStack,\n PdfRow,\n PdfCol,\n PdfText,\n PdfHeading,\n PdfLabel,\n PdfField,\n PdfDivider,\n PdfCallout,\n PdfBadge,\n PdfTable,\n PdfTotals,\n PdfLines,\n PdfView,\n PdfRawText,\n PdfImage,\n PdfLink,\n PdfThemeProvider,\n usePdfTheme,\n PDF_TYPE,\n} from \"./primitives\";\nexport { QuoteDocument } from \"./quote\";\nexport { quoteTotals, lineAmount, round2, formatMoney, formatQuantity, formatPdfDate } from \"./money\";\nexport { resolvePdfTheme, buildPdfTheme, DEFAULT_PDF_THEME } from \"./theme\";\n"
19
+ },
20
+ {
21
+ "path": "registry/blocks/pdf-document/pdf-document.jsx",
22
+ "type": "registry:component",
23
+ "target": "@components/makersclaw/blocks/pdf-document/pdf-document.jsx",
24
+ "content": "\"use client\";\n\n// Making the PDF: renderPdf turns a document (the primitives, or\n// QuoteDocument) into a Blob in the workspace's theme; usePdf keeps one\n// fresh while its data changes (a live preview); PdfDownloadButton is the\n// \"Download PDF\" a screen puts beside it.\n//\n// The theme is read from the page at the moment of rendering (./theme.js):\n// a PDF cannot read CSS variables, so its colours are written in as values.\n// @react-pdf/renderer lays the document out in JS (no browser print, no\n// server), in the browser or in node alike.\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport { Font, pdf } from \"@react-pdf/renderer\";\nimport { Button, toast } from \"../../ui\";\nimport { PdfThemeProvider } from \"./primitives\";\nimport { buildPdfTheme, resolvePdfTheme } from \"./theme\";\n\nlet hyphenationOff = false;\n\n/** Render a PDF document to a Blob.\n *\n * doc a <PdfDocument>…</PdfDocument> or <QuoteDocument … /> element\n * options theme a theme from resolvePdfTheme/buildPdfTheme (default:\n * read from the page now; the fallback on the server)\n * root the element whose theme to read (default <body>)\n * scheme \"paper\" (default: white paper, the theme's accent)\n * or \"theme\" (the workspace's own background and ink)\n *\n * Resolves to a Blob of type application/pdf; rejects when the document\n * cannot be laid out (a broken image URL, a font that will not load). */\nexport async function renderPdf(doc, { theme, root, scheme = \"paper\" } = {}) {\n if (!hyphenationOff) {\n // Whole words only: a business document never splits \"invoice\" as\n // \"in-voice\" at a line end.\n Font.registerHyphenationCallback((word) => [word]);\n hyphenationOff = true;\n }\n const t = theme ?? (typeof document === \"undefined\" ? buildPdfTheme({}, { scheme }) : resolvePdfTheme(root, { scheme }));\n return pdf(<PdfThemeProvider theme={t}>{doc}</PdfThemeProvider>).toBlob();\n}\n\n/** Use a brand face in PDFs: registerPdfFont({ family: \"Inter\", regular:\n * url, bold: url }) — TTF or WOFF files the browser can fetch — then pass\n * `font: { family: \"Inter\", bold: \"Inter-Bold\" }` to buildPdfTheme, or\n * let renderPdf's theme carry it. Without one, PDFs use Helvetica, which\n * every reader has built in (it draws Latin text; for other scripts or\n * symbols like ₹, register a font that has them). */\nexport function registerPdfFont({ family, regular, bold }) {\n if (!family || !regular) return null;\n Font.register({ family, src: regular });\n if (bold) Font.register({ family: `${family}-Bold`, src: bold });\n return { family, bold: bold ? `${family}-Bold` : family };\n}\n\n/** Keep a PDF of `build()` up to date: a live preview.\n *\n * const { url, blob, loading, error, refresh } = usePdf(() => <QuoteDocument {...q} />, [q]);\n *\n * Re-renders `delay` ms (default 400) after the deps settle, so typing in\n * a form does not lay out a PDF per key. `url` is an object URL for the\n * current Blob (revoked when it is replaced); `error` is the last\n * failure, cleared by the next success. `options` go to renderPdf. */\nexport function usePdf(build, deps = [], { delay = 400, enabled = true, ...options } = {}) {\n const [state, setState] = useState({ blob: null, url: null, loading: enabled, error: null });\n const buildRef = useRef(build);\n buildRef.current = build;\n const optsRef = useRef(options);\n optsRef.current = options;\n const urlRef = useRef(null);\n const run = useRef(0);\n const [tick, setTick] = useState(0);\n\n useEffect(() => {\n if (!enabled) return undefined;\n const id = ++run.current;\n setState((s) => ({ ...s, loading: true }));\n const timer = setTimeout(async () => {\n try {\n const blob = await renderPdf(buildRef.current(), optsRef.current);\n if (id !== run.current) return;\n const url = URL.createObjectURL(blob);\n if (urlRef.current) URL.revokeObjectURL(urlRef.current);\n urlRef.current = url;\n setState({ blob, url, loading: false, error: null });\n } catch (error) {\n if (id !== run.current) return;\n setState((s) => ({ ...s, loading: false, error }));\n }\n }, delay);\n return () => clearTimeout(timer);\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [enabled, delay, tick, ...deps]);\n\n useEffect(\n () => () => {\n run.current++;\n if (urlRef.current) URL.revokeObjectURL(urlRef.current);\n },\n [],\n );\n\n const refresh = useCallback(() => setTick((n) => n + 1), []);\n return { ...state, refresh };\n}\n\n/** Save a Blob as a file, the way a download link would. */\nexport function downloadBlob(blob, fileName = \"document.pdf\") {\n if (typeof document === \"undefined\" || !blob) return;\n const url = URL.createObjectURL(blob);\n // raw: an <a download> is how a browser saves a Blob; it is never shown.\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = fileName;\n a.rel = \"noopener\";\n document.body.appendChild(a);\n a.click();\n a.remove();\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n}\n\n/** A \"Download PDF\" button: renders `document` (an element, or a function\n * returning one) when pressed, then saves it as `fileName`. Busy while it\n * renders; a failure says so in a toast and the button is ready again.\n * Other props (variant, size, icon, label, disabled) are the kit\n * Button's; `options` go to renderPdf. */\nexport function PdfDownloadButton({ document: doc, fileName = \"document.pdf\", label = \"Download PDF\", icon = \"download\", variant = \"secondary\", size = \"md\", options, disabled, className }) {\n const [busy, setBusy] = useState(false);\n const onClick = async () => {\n setBusy(true);\n try {\n const blob = await renderPdf(typeof doc === \"function\" ? doc() : doc, options);\n downloadBlob(blob, fileName);\n } catch (e) {\n toast.error(\"Couldn't make the PDF\", { description: e?.message ? `${e.message}. Try again in a moment.` : \"Try again in a moment.\" });\n } finally {\n setBusy(false);\n }\n };\n return (\n <Button variant={variant} size={size} icon={icon} loading={busy} disabled={disabled || !doc} onClick={onClick} className={className}>\n {label}\n </Button>\n );\n}\n"
25
+ },
26
+ {
27
+ "path": "registry/blocks/pdf-document/primitives.jsx",
28
+ "type": "registry:component",
29
+ "target": "@components/makersclaw/blocks/pdf-document/primitives.jsx",
30
+ "content": "\"use client\";\n\n// The PDF primitives: a small set of parts that draw a clean business\n// document in the workspace's theme. They render INSIDE a PDF (the\n// @react-pdf/renderer tree), never into the page: compose them into a\n// document and pass it to renderPdf (./pdf-document) or PdfDownloadButton.\n//\n// Every colour comes from the theme in context (./theme.js — resolved from\n// the workspace's CSS variables when the PDF is made); a part takes a\n// `tone` name, never a colour. Type is one family (Helvetica, built into\n// every PDF reader, unless a brand font is registered) at a few fixed\n// sizes, on a 4pt rhythm.\nimport { createContext, useContext } from \"react\";\nimport { Document, Image, Link, Page, Text, View } from \"@react-pdf/renderer\";\nimport { DEFAULT_PDF_THEME } from \"./theme\";\n\nconst ThemeContext = createContext(DEFAULT_PDF_THEME);\n\n/** Wraps a PDF tree in a theme; renderPdf does this for you. */\nexport function PdfThemeProvider({ theme, children }) {\n return <ThemeContext.Provider value={theme ?? DEFAULT_PDF_THEME}>{children}</ThemeContext.Provider>;\n}\n\n/** The theme the PDF is being drawn with (./theme.js buildPdfTheme). */\nexport function usePdfTheme() {\n return useContext(ThemeContext);\n}\n\n/** The type scale, in points. Body is 9.5pt: a business document is read\n * at arm's length, and this is the size a good invoice uses. */\nexport const PDF_TYPE = { xs: 7.5, sm: 8.5, md: 9.5, lg: 12, xl: 16, xxl: 24 };\n\nconst TONE = (t, tone) =>\n ({ default: t.ink, muted: t.muted, accent: t.accentInk, success: t.success, warning: t.warning, danger: t.danger, info: t.info, onAccent: t.onAccent })[tone] ?? t.ink;\n\n// Body leading, set on each text (a leading on the page is measured wrong\n// by the layout and lines overlap).\nconst LEADING = 1.4; // always with an explicit fontSize: the leading is read against it\n\nconst MARGINS = { compact: 36, normal: 48, roomy: 64 };\n\n/** The document: one flowing page (it breaks onto more by itself), the\n * brand's accent bar along the top edge, and a footer with the page\n * count on every page.\n *\n * title, author, subject the PDF's properties (a reader shows title)\n * size \"A4\" (default) · \"LETTER\" · \"LEGAL\" · [width, height] pt\n * landscape false\n * margin compact (36pt) · normal (48pt, default) · roomy (64pt)\n * accentBar true: a 4pt bar of the accent along the top of each page\n * footer a line at the foot of each page (left); the page count\n * sits at the right. null: no footer at all. */\nexport function PdfDocument({ title, author, subject, size = \"A4\", landscape = false, margin = \"normal\", accentBar = true, footer, children }) {\n const t = usePdfTheme();\n const m = MARGINS[margin] ?? MARGINS.normal;\n return (\n <Document title={title} author={author} subject={subject} creator={author} producer={author}>\n <Page\n size={size}\n orientation={landscape ? \"landscape\" : \"portrait\"}\n style={{ paddingTop: m, paddingBottom: m + (footer === null ? 0 : 20), paddingHorizontal: m, backgroundColor: t.paper, color: t.ink, fontFamily: t.font, fontSize: PDF_TYPE.md }}\n >\n {accentBar ? <View fixed style={{ position: \"absolute\", top: 0, left: 0, right: 0, height: 4, backgroundColor: t.accent }} /> : null}\n {children}\n {footer === null ? null : (\n <View fixed style={{ position: \"absolute\", bottom: m - 16, left: m, right: m, flexDirection: \"row\", justifyContent: \"space-between\", fontSize: PDF_TYPE.xs, color: t.muted }}>\n <Text>{footer ?? \"\"}</Text>\n <Text render={({ pageNumber, totalPages }) => (totalPages > 1 ? `Page ${pageNumber} of ${totalPages}` : \"\")} />\n </View>\n )}\n </Page>\n </Document>\n );\n}\n\n/** A column with a gap between its children (pt). */\nexport function PdfStack({ gap = 8, style, children, wrap, break: pageBreak }) {\n return (\n <View wrap={wrap} break={pageBreak} style={[{ flexDirection: \"column\", gap }, style ?? {}]}>\n {children}\n </View>\n );\n}\n\n/** A row. `justify` start · between · end; `align` start · center · end ·\n * baseline. Children share the width by their own `flex` (see PdfCol). */\nexport function PdfRow({ gap = 16, justify = \"start\", align = \"start\", style, children, wrap }) {\n const J = { start: \"flex-start\", between: \"space-between\", end: \"flex-end\", center: \"center\" };\n const A = { start: \"flex-start\", center: \"center\", end: \"flex-end\", baseline: \"baseline\", stretch: \"stretch\" };\n return (\n <View wrap={wrap} style={[{ flexDirection: \"row\", gap, justifyContent: J[justify] ?? J.start, alignItems: A[align] ?? A.start }, style ?? {}]}>\n {children}\n </View>\n );\n}\n\n/** One column of a PdfRow: `flex` its share (default 1), or `width` pt. */\nexport function PdfCol({ flex = 1, width, style, children }) {\n const size = width ? { width } : flex === 0 ? { flexGrow: 0, flexShrink: 0 } : { flexGrow: flex, flexShrink: 1, flexBasis: 0, minWidth: 0 };\n return <View style={[size, style ?? {}]}>{children}</View>;\n}\n\n/** Text. size xs · sm · md (default) · lg · xl · xxl; tone default · muted\n * · accent · success · warning · danger · info; bold; align. */\nexport function PdfText({ size = \"md\", tone = \"default\", bold = false, align, style, children, ...rest }) {\n const t = usePdfTheme();\n return (\n <Text\n {...rest}\n style={[{ fontSize: PDF_TYPE[size] ?? PDF_TYPE.md, lineHeight: LEADING, color: TONE(t, tone), fontFamily: bold ? t.fontBold : t.font, textAlign: align }, style ?? {}]}\n >\n {children}\n </Text>\n );\n}\n\n/** A heading: level 1 (24pt, the document's name) · 2 (12pt, a section)\n * · 3 (9.5pt bold, a group inside one). Tight leading, never all caps. */\nexport function PdfHeading({ level = 2, tone = \"default\", align, style, children }) {\n const t = usePdfTheme();\n const L = {\n 1: { fontSize: PDF_TYPE.xxl, lineHeight: 1.15, letterSpacing: -0.4 },\n 2: { fontSize: PDF_TYPE.lg, lineHeight: 1.3 },\n 3: { fontSize: PDF_TYPE.md, lineHeight: 1.4 },\n };\n return <Text style={[{ fontFamily: t.fontBold, color: TONE(t, tone), textAlign: align }, L[level] ?? L[2], style ?? {}]}>{children}</Text>;\n}\n\n/** The small muted line above a value: \"Billed to\", \"Due\". */\nexport function PdfLabel({ align, style, children }) {\n const t = usePdfTheme();\n return <Text style={[{ fontSize: PDF_TYPE.sm, color: t.muted, marginBottom: 3, textAlign: align }, style ?? {}]}>{children}</Text>;\n}\n\n/** A label over a value, the unit of a document's header. */\nexport function PdfField({ label, align, strong = false, tone, children }) {\n return (\n <View>\n <PdfLabel align={align}>{label}</PdfLabel>\n <PdfText bold={strong} tone={tone} align={align} size={strong ? \"lg\" : \"md\"}>\n {children}\n </PdfText>\n </View>\n );\n}\n\n/** A hairline rule. `space` is the room above and below it (pt). */\nexport function PdfDivider({ space = 16, strong = false }) {\n const t = usePdfTheme();\n return <View style={{ height: strong ? 1 : 0.75, backgroundColor: strong ? t.ink : t.rule, marginVertical: space }} />;\n}\n\n/** A soft box: a note, a callout, payment details. tone accent (default,\n * the accent's tint with a bar at its edge) · neutral (the faint band). */\nexport function PdfCallout({ tone = \"accent\", title, style, children }) {\n const t = usePdfTheme();\n const accent = tone === \"accent\";\n return (\n <View\n wrap={false}\n style={[\n { backgroundColor: accent ? t.accentSoft : t.band, borderLeftWidth: accent ? 2 : 0, borderLeftColor: t.accent, paddingVertical: 10, paddingHorizontal: 12, borderRadius: 3 },\n style ?? {},\n ]}\n >\n {title ? <PdfHeading level={3} style={{ marginBottom: 3 }}>{title}</PdfHeading> : null}\n {typeof children === \"string\" ? <PdfText>{children}</PdfText> : children}\n </View>\n );\n}\n\n/** A small pill: \"Paid\", \"Accepted\". tone accent · success · warning ·\n * danger · neutral. An outline, so it prints well in black and white. */\nexport function PdfBadge({ tone = \"accent\", children }) {\n const t = usePdfTheme();\n const c = { accent: t.accentInk, success: t.success, warning: t.warning, danger: t.danger, neutral: t.muted }[tone] ?? t.accentInk;\n return (\n <View style={{ alignSelf: \"flex-start\", borderWidth: 1, borderColor: c, borderRadius: 3, paddingHorizontal: 6, paddingVertical: 2 }}>\n <Text style={{ fontSize: PDF_TYPE.sm, fontFamily: t.fontBold, color: c }}>{children}</Text>\n </View>\n );\n}\n\n/** A table: a quiet header on the faint band, hairlines between rows, the\n * header repeated on every page it runs onto, a row never split.\n *\n * columns [{ key, label, flex? (default 1) | width? (pt), align?:\n * \"left\" | \"right\" | \"center\", render?(row) → string | node }]\n * rows [{ … }]; a row's `detail` (string) prints as a muted second\n * line under the FIRST column\n * empty what to print when there are no rows */\nexport function PdfTable({ columns = [], rows = [], empty = \"Nothing here.\", style }) {\n const t = usePdfTheme();\n const cell = (c) => [c.width ? { width: c.width } : { flex: c.flex ?? 1, minWidth: 0 }, { paddingHorizontal: 6, textAlign: c.align ?? \"left\" }];\n return (\n <View style={style}>\n <View fixed style={{ flexDirection: \"row\", backgroundColor: t.band, borderRadius: 3, paddingVertical: 6 }}>\n {columns.map((c) => (\n <Text key={c.key} style={[...cell(c), { fontSize: PDF_TYPE.sm, color: t.muted }]}>\n {c.label}\n </Text>\n ))}\n </View>\n {rows.length === 0 ? (\n <Text style={{ paddingVertical: 12, paddingHorizontal: 6, color: t.muted }}>{empty}</Text>\n ) : (\n rows.map((row, i) => (\n <View key={row.id ?? i} wrap={false} style={{ flexDirection: \"row\", paddingVertical: 8, borderBottomWidth: 0.75, borderBottomColor: t.rule }}>\n {columns.map((c, j) => {\n const value = c.render ? c.render(row, i) : row[c.key];\n return (\n <View key={c.key} style={cell(c)}>\n {typeof value === \"string\" || typeof value === \"number\" ? (\n <Text style={{ textAlign: c.align ?? \"left\", fontFamily: j === 0 ? t.fontBold : t.font }}>{String(value)}</Text>\n ) : (\n value ?? null\n )}\n {j === 0 && row.detail ? <Text style={{ color: t.muted, fontSize: PDF_TYPE.sm, lineHeight: LEADING, marginTop: 2 }}>{row.detail}</Text> : null}\n </View>\n );\n })}\n </View>\n ))\n )}\n </View>\n );\n}\n\n/** The sums under a table, flush right: label and value per line, the\n * last `strong` line set larger over a rule.\n *\n * rows [{ label, value, strong?, tone? }]\n * width the block's width (pt, default 220) */\nexport function PdfTotals({ rows = [], width = 220 }) {\n const t = usePdfTheme();\n return (\n <View wrap={false} style={{ alignSelf: \"flex-end\", width, marginTop: 12 }}>\n {rows.filter(Boolean).map((r, i) => (\n <View\n key={r.label ?? i}\n style={[\n { flexDirection: \"row\", justifyContent: \"space-between\", paddingVertical: 3, paddingHorizontal: 6 },\n r.strong ? { borderTopWidth: 1, borderTopColor: t.ink, marginTop: 6, paddingTop: 8 } : {},\n ]}\n >\n <Text style={{ color: r.strong ? t.ink : t.muted, fontFamily: r.strong ? t.fontBold : t.font, fontSize: r.strong ? PDF_TYPE.lg : PDF_TYPE.md }}>{r.label}</Text>\n <Text style={{ color: TONE(t, r.tone ?? \"default\"), fontFamily: r.strong ? t.fontBold : t.font, fontSize: r.strong ? PDF_TYPE.lg : PDF_TYPE.md }}>{r.value}</Text>\n </View>\n ))}\n </View>\n );\n}\n\n/** Lines of plain text (an address): each on its own line, muted after\n * the first unless `muted` is false. Empty lines are dropped. */\nexport function PdfLines({ lines = [], first = true, muted = true, align }) {\n const t = usePdfTheme();\n return (\n <View>\n {lines.filter(Boolean).map((line, i) => (\n <Text key={i} style={{ fontSize: PDF_TYPE.md, lineHeight: LEADING, textAlign: align, color: muted && !(first && i === 0) ? t.muted : t.ink, fontFamily: first && i === 0 ? t.fontBold : t.font }}>\n {line}\n </Text>\n ))}\n </View>\n );\n}\n\n// The rest of @react-pdf/renderer's parts, under our names, so a block\n// built on this one never imports the library itself.\nexport { View as PdfView, Text as PdfRawText, Image as PdfImage, Link as PdfLink };\n"
31
+ },
32
+ {
33
+ "path": "registry/blocks/pdf-document/quote.jsx",
34
+ "type": "registry:component",
35
+ "target": "@components/makersclaw/blocks/pdf-document/quote.jsx",
36
+ "content": "\"use client\";\n\n// QuoteDocument — the default business document: a quote, an estimate or\n// an invoice, from data. The quote-builder and proposal blocks print\n// through it; an app can too (renderPdf(<QuoteDocument … />)).\n//\n// The layout is the classic one people trust: who it is from at the top\n// left, what it is and its number at the top right, then who it is for,\n// the dates and the amount in a row of labelled fields, the lines in a\n// table, the sums flush right, and notes, terms and how to pay at the\n// foot. One accent (the theme's primary): the bar along the top edge and\n// the amount due. Everything else is ink and grey on paper.\nimport { Image, View } from \"@react-pdf/renderer\";\nimport {\n PdfBadge,\n PdfCallout,\n PdfCol,\n PdfDivider,\n PdfDocument,\n PdfField,\n PdfHeading,\n PdfLabel,\n PdfLines,\n PdfRow,\n PdfStack,\n PdfTable,\n PdfText,\n PdfTotals,\n} from \"./primitives\";\nimport { formatMoney, formatPdfDate, formatQuantity, quoteTotals } from \"./money\";\n\nconst WORDS = {\n quote: { title: \"Quote\", to: \"Prepared for\", date: \"Issued\", until: \"Valid until\", total: \"Total\" },\n estimate: { title: \"Estimate\", to: \"Prepared for\", date: \"Issued\", until: \"Valid until\", total: \"Estimated total\" },\n invoice: { title: \"Invoice\", to: \"Billed to\", date: \"Issued\", until: \"Due\", total: \"Amount due\" },\n};\n\n/** A quote, estimate or invoice. See README ## QuoteDocument for every prop. */\nexport function QuoteDocument({\n kind = \"quote\",\n number,\n title,\n issued,\n until,\n from = {},\n to = {},\n items = [],\n currency = \"USD\",\n locale = \"en-US\",\n dateLocale = \"en-GB\",\n taxRate,\n taxLabel,\n discount,\n paid,\n status,\n statusTone = \"success\",\n notes,\n terms,\n payment,\n reference,\n footer,\n size = \"A4\",\n}) {\n const w = WORDS[kind] ?? WORDS.quote;\n const sums = quoteTotals({ items, discount, taxRate, paid });\n const money = (n) => formatMoney(n, currency, locale);\n const docTitle = title ?? w.title;\n const fromLines = [from.name, ...(from.lines ?? []), from.email, from.phone, from.taxId ? `Tax ID ${from.taxId}` : null];\n const toLines = [to.name, to.company, ...(to.lines ?? []), to.email];\n const amountLabel = kind === \"invoice\" && sums.paid > 0 ? \"Balance due\" : w.total;\n const hasSums = sums.discount > 0 || sums.tax > 0 || sums.paid > 0;\n\n return (\n <PdfDocument\n title={[docTitle, number].filter(Boolean).join(\" \")}\n author={from.name}\n subject={to.company ?? to.name}\n size={size}\n footer={footer ?? [from.name, from.email, from.website].filter(Boolean).join(\" · \")}\n >\n {/* Top: the sender (logo or name), and what this is. */}\n <PdfRow justify=\"between\" align=\"start\" gap={24}>\n <PdfCol>\n {from.logo ? <Image src={from.logo} style={{ height: 28, objectFit: \"contain\", objectPosition: \"left\", marginBottom: 10, alignSelf: \"flex-start\" }} /> : null}\n <PdfLines lines={fromLines} first={!from.logo} />\n </PdfCol>\n <PdfCol flex={0} style={{ alignItems: \"flex-end\" }}>\n <PdfHeading level={1} align=\"right\">\n {docTitle}\n </PdfHeading>\n {number ? (\n <PdfText tone=\"muted\" align=\"right\" style={{ marginTop: 4 }}>\n {number}\n </PdfText>\n ) : null}\n {status ? (\n <View style={{ marginTop: 8, alignSelf: \"flex-end\" }}>\n <PdfBadge tone={statusTone}>{status}</PdfBadge>\n </View>\n ) : null}\n </PdfCol>\n </PdfRow>\n\n <PdfDivider space={24} />\n\n {/* Who it is for, the dates, and the one number that matters. */}\n <PdfRow gap={24} align=\"start\">\n <PdfCol flex={1.6}>\n <PdfLabel>{w.to}</PdfLabel>\n <PdfLines lines={toLines} />\n </PdfCol>\n <PdfCol>\n <PdfStack gap={10}>\n {issued ? <PdfField label={w.date}>{formatPdfDate(issued, dateLocale)}</PdfField> : null}\n {until ? <PdfField label={w.until}>{formatPdfDate(until, dateLocale)}</PdfField> : null}\n {reference ? <PdfField label=\"Reference\">{reference}</PdfField> : null}\n </PdfStack>\n </PdfCol>\n <PdfCol style={{ alignItems: \"flex-end\" }}>\n <PdfLabel align=\"right\">{amountLabel}</PdfLabel>\n <PdfText bold size=\"xl\" tone=\"accent\" align=\"right\">\n {money(sums.due)}\n </PdfText>\n </PdfCol>\n </PdfRow>\n\n {/* The lines. */}\n <PdfTable\n style={{ marginTop: 28 }}\n empty=\"No line items yet.\"\n columns={[\n { key: \"description\", label: \"Description\", flex: 3 },\n { key: \"quantity\", label: \"Qty\", width: 48, align: \"right\", render: (r) => [formatQuantity(r.quantity ?? 1, locale), r.unit].filter(Boolean).join(\" \") },\n { key: \"unitPrice\", label: \"Unit price\", width: 84, align: \"right\", render: (r) => money(r.unitPrice) },\n { key: \"amount\", label: \"Amount\", width: 88, align: \"right\", render: (r, i) => money(sums.lines[i]) },\n ]}\n rows={items.map((it, i) => ({ ...it, id: it.id ?? i, detail: [it.detail, it.discountPercent ? `${formatQuantity(it.discountPercent, locale)}% off` : null].filter(Boolean).join(\" · \") || undefined }))}\n />\n\n <PdfTotals\n rows={[\n hasSums && { label: \"Subtotal\", value: money(sums.subtotal) },\n sums.discount > 0 && { label: discount?.label ?? (discount?.percent ? `Discount (${formatQuantity(discount.percent, locale)}%)` : \"Discount\"), value: `-${money(sums.discount)}` },\n sums.tax > 0 && { label: taxLabel ?? `Tax (${formatQuantity(taxRate, locale)}%)`, value: money(sums.tax) },\n sums.paid > 0 && { label: \"Total\", value: money(sums.total) },\n sums.paid > 0 && { label: \"Paid\", value: `-${money(sums.paid)}` },\n { label: amountLabel, value: money(sums.due), strong: true },\n ]}\n />\n\n {/* The foot: notes, terms, how to pay. */}\n {notes || terms || payment ? (\n <PdfStack gap={16} style={{ marginTop: 32 }} wrap={false}>\n {notes ? <PdfCallout title=\"Notes\">{notes}</PdfCallout> : null}\n {terms || payment ? (\n <PdfRow gap={24}>\n {terms ? (\n <PdfCol>\n <PdfHeading level={3} style={{ marginBottom: 3 }}>\n Terms\n </PdfHeading>\n <PdfText tone=\"muted\">{terms}</PdfText>\n </PdfCol>\n ) : null}\n {payment ? (\n <PdfCol>\n <PdfHeading level={3} style={{ marginBottom: 3 }}>\n How to pay\n </PdfHeading>\n {Array.isArray(payment) ? <PdfLines lines={payment} first={false} /> : <PdfText tone=\"muted\">{payment}</PdfText>}\n </PdfCol>\n ) : null}\n </PdfRow>\n ) : null}\n </PdfStack>\n ) : null}\n </PdfDocument>\n );\n}\n"
37
+ },
38
+ {
39
+ "path": "registry/blocks/pdf-document/money.js",
40
+ "type": "registry:component",
41
+ "target": "@components/makersclaw/blocks/pdf-document/money.js",
42
+ "content": "// Quote and invoice sums — plain functions, so a server action, a test or\n// the quote builder's live preview all get the same numbers the PDF prints.\n//\n// Amounts are in the currency's major unit (dollars, not cents) and every\n// sum is rounded to the cent line by line, the way an accountant writes\n// it: each line's amount is rounded, then added.\n\n/** Round to 2 decimals without the 1.005 → 1.00 float trap. */\nexport const round2 = (n) => Math.round((Number(n) + Number.EPSILON) * 100) / 100;\n\nconst num = (v) => {\n const n = typeof v === \"string\" ? Number(v.replace(/[, ]/g, \"\")) : Number(v);\n return Number.isFinite(n) ? n : 0;\n};\n\n/** One line's amount: quantity × unit price, less its own discount (%). */\nexport function lineAmount(item) {\n const gross = num(item?.quantity ?? 1) * num(item?.unitPrice);\n const off = item?.discountPercent ? (gross * num(item.discountPercent)) / 100 : 0;\n return round2(gross - off);\n}\n\n/** The sums of a quote or invoice.\n *\n * items [{ description, quantity, unitPrice, discountPercent?,\n * taxable? (default true) }]\n * discount { percent } or { amount } off the subtotal (optional)\n * taxRate percent, on the taxable lines after the discount (optional)\n * paid what has already been paid (an invoice's deposit)\n *\n * → { lines: [amount…], subtotal, discount, tax, total, paid, due } */\nexport function quoteTotals({ items = [], discount, taxRate, paid } = {}) {\n const lines = items.map(lineAmount);\n const subtotal = round2(lines.reduce((s, a) => s + a, 0));\n const taxableBase = round2(lines.reduce((s, a, i) => (items[i]?.taxable === false ? s : s + a), 0));\n let off = 0;\n if (discount?.percent) off = round2((subtotal * num(discount.percent)) / 100);\n else if (discount?.amount) off = round2(Math.min(num(discount.amount), subtotal));\n // The discount is shared across lines in proportion, so tax falls on\n // what is actually charged for the taxable ones.\n const share = subtotal > 0 ? taxableBase / subtotal : 0;\n const tax = taxRate ? round2(((taxableBase - off * share) * num(taxRate)) / 100) : 0;\n const total = round2(subtotal - off + tax);\n const already = round2(num(paid));\n return { lines, subtotal, discount: off, tax, total, paid: already, due: round2(total - already) };\n}\n\n// Characters the PDF's built-in faces (WinAnsi) cannot draw, which Intl\n// puts in some locales' numbers: narrow and no-break spaces, the Unicode\n// minus. Swapped for their plain forms.\nconst PLAIN = [\n [/[   ]/g, \" \"],\n [/−/g, \"-\"],\n];\n\n/** 1234.5 → \"$1,234.50\" in `currency` and `locale`, drawable by the PDF's\n * built-in fonts. A bad currency code falls back to the plain number. */\nexport function formatMoney(amount, currency = \"USD\", locale = \"en-US\") {\n let out;\n try {\n out = new Intl.NumberFormat(locale, { style: \"currency\", currency, minimumFractionDigits: 2, maximumFractionDigits: 2 }).format(num(amount));\n } catch {\n out = `${num(amount).toFixed(2)} ${currency ?? \"\"}`.trim();\n }\n for (const [re, to] of PLAIN) out = out.replace(re, to);\n return out;\n}\n\n/** A quantity as people write it: 3, 1.5, 0.25 — no trailing zeros. */\nexport function formatQuantity(q, locale = \"en-US\") {\n const out = new Intl.NumberFormat(locale, { maximumFractionDigits: 3 }).format(num(q));\n return PLAIN.reduce((s, [re, to]) => s.replace(re, to), out);\n}\n\n/** \"2026-09-23\" (or a Date) → \"23 Sep 2026\" in `locale`; \"\" when empty.\n * A bare date string is read as that calendar day, not as UTC midnight. */\nexport function formatPdfDate(value, locale = \"en-GB\") {\n if (!value) return \"\";\n const d = value instanceof Date ? value : /^\\d{4}-\\d{2}-\\d{2}$/.test(String(value)) ? new Date(`${value}T12:00:00`) : new Date(value);\n if (Number.isNaN(d.getTime())) return String(value);\n const out = new Intl.DateTimeFormat(locale, { day: \"numeric\", month: \"short\", year: \"numeric\" }).format(d);\n return PLAIN.reduce((s, [re, to]) => s.replace(re, to), out);\n}\n"
43
+ },
44
+ {
45
+ "path": "registry/blocks/pdf-document/theme.js",
46
+ "type": "registry:component",
47
+ "target": "@components/makersclaw/blocks/pdf-document/theme.js",
48
+ "content": "// The PDF's colours, from the workspace theme.\n//\n// A PDF cannot read a CSS variable: every colour in it is a concrete\n// value written into the file. So the theme is read ONCE, at the moment\n// the PDF is made, from the page's own variables (--color-primary…), and\n// passed into the document as plain values. Change the theme and the next\n// PDF follows; a PDF already made keeps the colours it was made with.\n//\n// Two schemes:\n// paper (the default) — what gets printed, emailed, signed. White paper,\n// near-black ink, grey rules, and the theme's PRIMARY as the one\n// accent. A dark workspace still sends a light invoice. When the\n// theme's primary is too pale to read on white (a mint, a lemon),\n// the text uses a darker shade of it (accentInk, 4.5:1 or more)\n// while bars and fills keep the colour itself.\n// theme — the workspace's own background, text and borders, for a PDF\n// meant to be read on screen in the brand's look.\n//\n// No colour value is written in this file: the paper constants are byte\n// triples turned into strings at run time, and everything else is read\n// from the page. Everything but resolvePdfTheme() is a plain function, so\n// it runs in node and on the server (where the fallback theme is used).\n\n/** The slots read from the page. */\nexport const PDF_SLOTS = [\"background\", \"foreground\", \"muted\", \"muted-foreground\", \"border\", \"primary\", \"primary-foreground\", \"success\", \"warning\", \"destructive\", \"info\"];\n\nconst clamp = (n, lo = 0, hi = 255) => Math.max(lo, Math.min(hi, n));\nconst byte = (n) => clamp(Math.round(n)).toString(16).padStart(2, \"0\");\n\n/** [r, g, b] (0-255) → a \"#rrggbb\" string. */\nexport function toHex([r, g, b]) {\n return \"#\" + byte(r) + byte(g) + byte(b);\n}\n\n/** \"#rrggbb\" → [r, g, b], or null. */\nexport function parseHex(value) {\n const m = /^#?([0-9a-f]{6})$/i.exec(String(value ?? \"\").trim());\n if (!m) return null;\n const n = parseInt(m[1], 16);\n return [(n >> 16) & 255, (n >> 8) & 255, n & 255];\n}\n\n/** t of colour a over (1 - t) of colour b. */\nexport function mix(a, b, t) {\n const x = parseHex(a);\n const y = parseHex(b);\n if (!x || !y) return a ?? b;\n const k = clamp(t, 0, 1);\n return toHex([0, 1, 2].map((i) => x[i] * k + y[i] * (1 - k)));\n}\n\n/** WCAG relative luminance of a hex colour (0 black … 1 white). */\nexport function luminance(hex) {\n const c = parseHex(hex);\n if (!c) return 0;\n const lin = c.map((v) => {\n const s = v / 255;\n return s <= 0.04045 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;\n });\n return 0.2126 * lin[0] + 0.7152 * lin[1] + 0.0722 * lin[2];\n}\n\n/** WCAG contrast ratio of two hex colours (1 … 21). */\nexport function contrast(a, b) {\n const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);\n return (hi + 0.05) / (lo + 0.05);\n}\n\n/** `colour`, darkened toward `ink` (or lightened, on a dark ground) until\n * it reads on `ground` at `ratio` — the shade used for accent TEXT. */\nexport function readableOn(colour, ground, ink, ratio = 4.5) {\n if (!parseHex(colour)) return ink;\n if (contrast(colour, ground) >= ratio) return colour;\n for (let t = 0.9; t >= 0; t -= 0.05) {\n const c = mix(colour, ink, t);\n if (contrast(c, ground) >= ratio) return c;\n }\n return ink;\n}\n\n/** Black or white, whichever reads better on `fill`. */\nexport function inkOn(fill) {\n return luminance(fill) > 0.179 ? PAPER.ink : PAPER.paper;\n}\n\n// Print constants: paper, ink, a quiet grey for secondary text (4.5:1 on\n// the paper), a hairline rule and a faint band.\nconst PAPER = {\n paper: toHex([255, 255, 255]),\n ink: toHex([23, 23, 27]),\n muted: toHex([107, 107, 118]),\n rule: toHex([226, 226, 231]),\n band: toHex([246, 246, 248]),\n};\n\n// The fallback accent and tones: used on the server and wherever the page\n// has no theme to read.\nconst FALLBACK = {\n primary: toHex([36, 99, 235]),\n success: toHex([21, 112, 58]),\n warning: toHex([154, 74, 6]),\n destructive: toHex([192, 28, 28]),\n info: toHex([29, 78, 216]),\n};\n\n/** The theme a PDF is drawn with, from slot → hex (as read from the page).\n *\n * paper, ink, muted, rule, band the page, body text, secondary text,\n * hairlines, a faint fill (table head)\n * accent the theme's primary, for bars and fills\n * accentInk the accent as text: 4.5:1 on the paper\n * accentSoft a light tint of it, for a callout\n * onAccent text on an accent fill\n * success, warning, danger, info status tones, readable on the paper\n * font, fontBold the PDF's faces (see registerPdfFont)\n */\nexport function buildPdfTheme(slots = {}, { scheme = \"paper\", font } = {}) {\n const s = Object.fromEntries(Object.entries(slots).filter(([, v]) => parseHex(v)));\n const own = scheme === \"theme\" && s.background && s.foreground;\n const paper = own ? s.background : PAPER.paper;\n const ink = own ? s.foreground : PAPER.ink;\n const muted = own ? readableOn(s[\"muted-foreground\"] ?? mix(ink, paper, 0.62), paper, ink, 4.5) : PAPER.muted;\n const rule = own ? (s.border ?? mix(ink, paper, 0.14)) : PAPER.rule;\n const band = own ? (s.muted ?? mix(ink, paper, 0.05)) : PAPER.band;\n const accent = s.primary ?? FALLBACK.primary;\n const tone = (slot) => readableOn(s[slot] ?? FALLBACK[slot], paper, ink, 4.5);\n return {\n scheme: own ? \"theme\" : \"paper\",\n paper,\n ink,\n muted,\n rule,\n band,\n accent,\n accentInk: readableOn(accent, paper, ink, 4.5),\n accentSoft: mix(accent, paper, 0.08),\n onAccent: own && s[\"primary-foreground\"] && contrast(s[\"primary-foreground\"], accent) >= 4.5 ? s[\"primary-foreground\"] : inkOn(accent),\n success: tone(\"success\"),\n warning: tone(\"warning\"),\n danger: tone(\"destructive\"),\n info: tone(\"info\"),\n font: font?.family ?? \"Helvetica\",\n fontBold: font?.bold ?? font?.family ?? \"Helvetica-Bold\",\n };\n}\n\n/** The theme with nothing read from a page: paper, ink and a blue accent. */\nexport const DEFAULT_PDF_THEME = buildPdfTheme({});\n\n// ---- the browser half -------------------------------------------------------\n\n/** Read the workspace theme from the CSS variables in effect at `root`\n * (default: <body>, so a data-theme or a theme scoped to an ancestor\n * counts) and build the PDF theme. A hidden probe takes\n * `color: var(--color-<slot>)`; the browser resolves var(), color-mix()\n * and oklch() into its computed colour, and a 1×1 canvas turns that into\n * bytes whatever syntax it came back in. On the server it returns the\n * fallback theme. */\nexport function resolvePdfTheme(root, options) {\n if (typeof document === \"undefined\") return buildPdfTheme({}, options);\n const host = root ?? document.body;\n if (!host) return buildPdfTheme({}, options);\n const probe = document.createElement(\"span\");\n probe.setAttribute(\"aria-hidden\", \"true\");\n probe.style.position = \"absolute\";\n probe.style.visibility = \"hidden\";\n probe.style.pointerEvents = \"none\";\n host.appendChild(probe);\n const canvas = document.createElement(\"canvas\");\n canvas.width = 1;\n canvas.height = 1;\n const ctx = canvas.getContext(\"2d\", { willReadFrequently: true });\n const slots = {};\n try {\n for (const slot of PDF_SLOTS) {\n probe.style.color = \"\";\n probe.style.color = `var(--color-${slot})`;\n const computed = getComputedStyle(probe).color;\n if (!computed || !ctx) continue;\n ctx.clearRect(0, 0, 1, 1);\n ctx.fillStyle = \"transparent\";\n ctx.fillStyle = computed;\n ctx.fillRect(0, 0, 1, 1);\n const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;\n if (a === 0) continue;\n // A translucent token (a tint) is laid on the paper, as it would print.\n slots[slot] = a >= 255 ? toHex([r, g, b]) : mix(toHex([r, g, b]), PAPER.paper, a / 255);\n }\n } finally {\n probe.remove();\n }\n return buildPdfTheme(slots, options);\n}\n"
49
+ },
50
+ {
51
+ "path": "registry/blocks/pdf-document/README.md",
52
+ "type": "registry:file",
53
+ "target": "@components/makersclaw/blocks/pdf-document/README.md",
54
+ "content": "# PdfDocument\n\nMake PDFs in the workspace theme, in the browser. A small set of document\nparts — headings, text, label-over-value fields, tables whose header\nrepeats on every page, totals, callouts, badges, rows and columns — and\na ready quote / estimate / invoice layout built from them. `renderPdf`\nturns a document into a `Blob` to download, preview, upload or attach;\n`usePdf` keeps one fresh while a form changes (a live preview); and\n`PdfDownloadButton` is the \"Download PDF\" beside it.\n\nThe look is a clean business document: white paper, near-black ink,\ngrey for secondary text, hairline rules, one accent — the theme's\nprimary — on a bar along the top edge and on the amount that matters.\nType is one face at a few sizes on a 4pt rhythm, sentence case, figures\naligned in their columns. It prints well in black and white.\n\nIt is [@react-pdf/renderer](https://github.com/diegomura/react-pdf)\n(MIT) underneath: it lays the document out in JavaScript and writes the\nPDF, no browser print dialog and no server. The blocks that print\n(quote-builder, proposal-accept…) import this one, never\n`@react-pdf/renderer`.\n\n```jsx\nimport { QuoteDocument, renderPdf, PdfDownloadButton } from \"../components/blocks/pdf-document\"; // a Makersclaw app (\"./blocks/pdf-document\" from components/)\nimport { QuoteDocument, renderPdf, PdfDownloadButton } from \"@/components/makersclaw/blocks/pdf-document\"; // a shadcn project\n```\n\nRender from a `\"use client\"` component. `quoteTotals`, `formatMoney`,\n`formatQuantity`, `formatPdfDate`, `buildPdfTheme` are plain functions\nand work on the server (use the same sums on the screen and in the PDF).\n\n## Colours: why the theme is read at render time\n\nA PDF cannot read CSS variables — every colour in it is a value written\ninto the file. So `renderPdf` reads the workspace theme from the page at\nthe moment it makes the PDF (a hidden probe resolves `--color-primary`\nand the other tokens to plain colours) and passes them in. Change the\ntheme and the next PDF follows; a PDF already made keeps its colours.\n\nTwo schemes:\n\n- `paper` (the default) — what gets printed, emailed or signed: white\n paper and dark ink whatever the workspace looks like, with the theme's\n primary as the accent. A dark workspace still sends a light invoice.\n If the primary is too pale to read on white (a mint, a lemon), text\n uses a darker shade of it (4.5:1 or more) while bars keep the colour.\n- `theme` — the workspace's own background, ink and borders, for a PDF\n meant to be read on screen in the brand's look.\n\nNothing in a document names a colour: parts take a `tone` (`default` ·\n`muted` · `accent` · `success` · `warning` · `danger` · `info`).\n\nFonts: Helvetica, which every PDF reader has built in, so a PDF is small\nand nothing is fetched. It draws Latin text; for other scripts, or\nsymbols like ₹, register a font that has them (`registerPdfFont`).\n\n## QuoteDocument\n\nA quote, an estimate or an invoice from data. The sender and the\ndocument's name at the top, then who it is for, the dates and the amount\ndue; the lines; the sums; notes, terms and how to pay.\n\n```jsx\n<QuoteDocument\n kind=\"invoice\" // quote (default) · estimate · invoice\n number=\"INV-2026-041\"\n issued=\"2026-09-23\" // \"YYYY-MM-DD\" or a Date\n until=\"2026-10-07\" // valid until (quote) · due (invoice)\n from={{ name: \"Northwind Studio\", lines: [\"18 Harbour Street\", \"Bristol BS1 4QA\"], email: \"billing@northwind.studio\", logo: \"/logo.png\" }}\n to={{ name: \"Priya Raman\", company: \"Juniper Health\", lines: [\"400 Market St\"], email: \"priya@juniper.health\" }}\n items={[{ description: \"Design system\", detail: \"32 components\", quantity: 1, unit: \"\", unitPrice: 9600, discountPercent: 0, taxable: true }]}\n currency=\"GBP\" locale=\"en-GB\"\n taxRate={20} taxLabel=\"VAT (20%)\"\n discount={{ percent: 5 }} // or { amount: 250, label: \"Loyalty\" }\n paid={3000} // a deposit: prints Total, Paid, Balance due\n status=\"Deposit received\" statusTone=\"success\" // an outlined badge under the number\n notes=\"Thanks for the conversation…\" // a tinted callout\n terms=\"Payment is due within 14 days.\"\n payment={[\"Northwind Studio Ltd\", \"Sort code 00-00-00\"]} // \"How to pay\" (lines or a string)\n/>\n```\n\nEvery prop but the data you have is optional: `title` overrides the\ndocument's name, `reference` adds a \"Reference\" field (a PO number),\n`footer` replaces the footer line (default: sender, email, website),\n`dateLocale` formats dates (default `en-GB`: 23 Sep 2026), `size` is\n`\"A4\"` (default) or `\"LETTER\"`. Amounts are in the currency's main unit\n(pounds, not pence); `quoteTotals` does the sums — each line rounded,\nthe discount before tax, tax only on `taxable` lines.\n\n## The parts\n\nCompose your own document from these; they only render inside a\n`PdfDocument`, passed to `renderPdf` (never into the page).\n\n- `PdfDocument` — `title`, `author`, `subject` (the file's properties),\n `size` (`\"A4\"` · `\"LETTER\"` · `\"LEGAL\"` · `[w, h]` in points),\n `landscape`, `margin` (`compact` 36pt · `normal` 48pt · `roomy` 64pt),\n `accentBar` (default `true`), `footer` (a line at the foot of every\n page, with \"Page 2 of 3\" at the right when there is more than one;\n `null` for none). Content flows onto new pages by itself.\n- `PdfStack` — a column with `gap` (pt, default 8); `break` starts it on\n a new page; `wrap={false}` keeps it on one page.\n- `PdfRow` — a row: `gap` (default 16), `justify` (`start` · `between` ·\n `end` · `center`), `align` (`start` · `center` · `end` · `baseline`).\n `PdfCol` — one column of it: `flex` (share, default 1; `0` = as wide as\n its content) or `width` (pt).\n- `PdfHeading` — `level` 1 (24pt, the document's name) · 2 (12pt, a\n section) · 3 (bold body size); `tone`, `align`.\n- `PdfText` — `size` (`xs` · `sm` · `md` · `lg` · `xl` · `xxl`), `tone`,\n `bold`, `align`.\n- `PdfLabel` — the small muted line above a value. `PdfField` — a label\n over a value (`strong` sets the value large and bold).\n- `PdfLines` — lines of an address: the first bold, the rest muted\n (`first={false}`, `muted={false}` to change).\n- `PdfTable` — `columns` `[{ key, label, flex? | width?, align?,\n render?(row, i) }]`, `rows` (a row's `detail` prints as a muted second\n line under the first column), `empty`. The header repeats on each page;\n a row never splits.\n- `PdfTotals` — `rows` `[{ label, value, strong?, tone? }]` flush right;\n the `strong` row is larger, over a rule. `width` (default 220pt).\n- `PdfCallout` — a tinted box with an accent edge (`tone=\"neutral\"` for\n grey), optional `title`.\n- `PdfBadge` — an outlined pill: `tone` `accent` · `success` · `warning`\n · `danger` · `neutral`.\n- `PdfDivider` — a hairline; `space` above and below (pt), `strong` for\n an ink rule.\n- `PdfImage` (`src`: a URL the browser can fetch, or a data URL),\n `PdfLink` (`src`), `PdfView`, `PdfRawText` — the library's own parts,\n for anything else; style them with `usePdfTheme()` values.\n- `usePdfTheme()` — inside a part: `{ paper, ink, muted, rule, band,\n accent, accentInk, accentSoft, onAccent, success, warning, danger,\n info, font, fontBold }`. `PDF_TYPE` is the size scale in points.\n\n## Functions\n\n- `renderPdf(doc, { theme?, root?, scheme? })` → `Promise<Blob>`\n (`application/pdf`). `theme` from `resolvePdfTheme(root, { scheme })`\n or `buildPdfTheme(slots, { scheme, font })`; by default it is read from\n `<body>` (or `root`) now. Rejects when the document cannot be made (an\n image that will not load, a font that will not fetch).\n- `usePdf(build, deps, { delay = 400, enabled = true, ...renderOptions })`\n → `{ blob, url, loading, error, refresh }`. Re-renders `delay` ms after\n `deps` settle; `url` is an object URL, revoked when replaced.\n- `PdfDownloadButton` — `document` (an element, or a function returning\n one, called on press), `fileName`, `label` (\"Download PDF\"), `icon`,\n `variant`, `size`, `disabled`, `options` (for renderPdf). Busy while it\n renders; a failure shows a toast and the button is ready again.\n- `downloadBlob(blob, fileName)` — save any Blob as a file.\n- `registerPdfFont({ family, regular, bold? })` → `{ family, bold }` —\n a TTF/WOFF by URL; pass the result as `font` to `buildPdfTheme`.\n- `quoteTotals({ items, discount?, taxRate?, paid? })` → `{ lines,\n subtotal, discount, tax, total, paid, due }`; `lineAmount(item)`;\n `round2(n)`.\n- `formatMoney(n, currency = \"USD\", locale = \"en-US\")` (\"£1,234.50\"),\n `formatQuantity(q, locale)` (\"1.5\"), `formatPdfDate(value, locale =\n \"en-GB\")` (\"23 Sep 2026\") — in characters the PDF's face can draw.\n\n## Example\n\nA quote screen: the form, a live preview in the `pdf-viewer` block, and\nthe download.\n\n```jsx\n\"use client\";\nimport { PdfViewer } from \"../components/blocks/pdf-viewer\";\nimport { PdfDownloadButton, QuoteDocument, usePdf } from \"../components/blocks/pdf-document\";\n\nexport function QuotePreview({ quote }) {\n const doc = () => <QuoteDocument {...quote} />;\n const { blob, error } = usePdf(doc, [quote]);\n return (\n <div className=\"grid gap-3\">\n <div className=\"flex justify-end\">\n <PdfDownloadButton variant=\"primary\" document={doc} fileName={`${quote.number}.pdf`} />\n </div>\n <PdfViewer file={blob} loading={!blob && !error} title={`Quote ${quote.number}`} thumbnails={false} />\n </div>\n );\n}\n```\n\nYour own document:\n\n```jsx\nconst blob = await renderPdf(\n <PdfDocument title=\"Statement\" footer=\"Juniper Health · Statement for August\">\n <PdfHeading level={1}>Statement</PdfHeading>\n <PdfText tone=\"muted\">1–31 August 2026</PdfText>\n <PdfDivider />\n <PdfTable columns={[{ key: \"date\", label: \"Date\", width: 80 }, { key: \"what\", label: \"Description\", flex: 3 }, { key: \"amount\", label: \"Amount\", width: 90, align: \"right\" }]} rows={rows} />\n <PdfTotals rows={[{ label: \"Closing balance\", value: formatMoney(balance), strong: true }]} />\n </PdfDocument>,\n);\n```\n\n## What it will not do\n\n- Render the parts into the page: they are PDF parts, not HTML. Preview\n a PDF with the `pdf-viewer` block.\n- Edit or fill an existing PDF, or sign one (see `signature-pad`).\n- Use the workspace's web font on its own: register one with\n `registerPdfFont`, or the PDF uses Helvetica.\n- Make PDFs on the server from this block (it is a client block that\n reads the theme from the page); `quoteTotals` and the formatters do run\n there.\n- Write right-to-left text or hyphenate: words are never split at a line\n end.\n\n## Credits\n\nBuilt on [@react-pdf/renderer](https://github.com/diegomura/react-pdf) by\nDiego Muracciole (MIT), used unmodified. See THIRD-PARTY-NOTICES.md.\n"
55
+ }
56
+ ],
57
+ "categories": [
58
+ "pdf",
59
+ "document",
60
+ "invoice",
61
+ "quote",
62
+ "estimate",
63
+ "receipt",
64
+ "print",
65
+ "download",
66
+ "export",
67
+ "react-pdf",
68
+ "billing",
69
+ "proposal",
70
+ "statement"
71
+ ],
72
+ "docs": "Installed to components/makersclaw/blocks/pdf-document. Import: import { renderPdf, usePdf, PdfDownloadButton, downloadBlob, registerPdfFont, PdfDocument, PdfStack, PdfRow, PdfCol, PdfText, PdfHeading, PdfLabel, PdfField, PdfDivider, PdfCallout, PdfBadge, PdfTable, PdfTotals, PdfLines, PdfView, PdfRawText, PdfImage, PdfLink, PdfThemeProvider, usePdfTheme, PDF_TYPE, QuoteDocument, quoteTotals, lineAmount, round2, formatMoney, formatQuantity, formatPdfDate, resolvePdfTheme, buildPdfTheme, DEFAULT_PDF_THEME } from \"@/components/makersclaw/blocks/pdf-document\". Props and an example: components/makersclaw/blocks/pdf-document/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
73
+ "meta": {
74
+ "makersclaw": {
75
+ "version": "0.3.0",
76
+ "useWhen": "A screen produces a document someone downloads, prints, emails or signs: a quote, an invoice, an estimate, a receipt, a statement, a certificate, a one-page report. The quote-builder and proposal blocks print through it. Not for showing an existing PDF on screen (pdf-viewer) or for a web page people read in the app (doc-page)."
77
+ }
78
+ }
79
+ }
@@ -0,0 +1,88 @@
1
+ {
2
+ "$schema": "https://ui.shadcn.com/schema/registry-item.json",
3
+ "name": "pdf-viewer",
4
+ "type": "registry:block",
5
+ "title": "PdfViewer",
6
+ "description": "A PDF on screen, in the workspace theme (react-pdf + pdf.js): pages in one smooth scrolling column (only the pages near the window are drawn, so long files stay fast), a thumbnail rail, a page box, zoom by steps, fit width, fit page and Ctrl/⌘ + wheel or pinch, find in the text with every match marked, and highlights with notes: select text, choose Highlight or Add note, and it saves through onHighlight; notes are edited in a side list, colours are theme tokens, delete has Undo. Opens a URL, a Blob or bytes; password-protected files ask for the password; a narrow viewer turns its rails into sheets. The pdf.js worker ships with the app (no public/ file, no CDN).",
7
+ "registryDependencies": [
8
+ "https://cdn.jsdelivr.net/npm/makersclaw@0.3.0/r/core.json"
9
+ ],
10
+ "dependencies": [
11
+ "react-pdf@11.0.0",
12
+ "pdfjs-dist@6.3.289"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "registry/blocks/pdf-viewer/index.js",
17
+ "type": "registry:component",
18
+ "target": "@components/makersclaw/blocks/pdf-viewer/index.js",
19
+ "content": "// The PdfViewer block — `makersclaw add pdf-viewer`. Fixed: compose it\n// through its props (README.md beside this file). The helpers (search,\n// page geometry, highlight rectangles) run anywhere, the server included;\n// the blocks built on this one (proposal-accept, contract review…) import\n// it, never react-pdf or pdfjs-dist directly.\nexport { PdfViewer, usePdfViewer } from \"./pdf-viewer\";\nexport { HIGHLIGHT_TONES } from \"./notes\";\nexport { findMatches, toPageRects, mergeRects, sortHighlights, highlightAt, fitScale, ZOOM_STEPS } from \"./helpers\";\n"
20
+ },
21
+ {
22
+ "path": "registry/blocks/pdf-viewer/pdf-viewer.jsx",
23
+ "type": "registry:component",
24
+ "target": "@components/makersclaw/blocks/pdf-viewer/pdf-viewer.jsx",
25
+ "content": "\"use client\";\n\n// PdfViewer — a PDF on screen: pages in one scrolling column, a page\n// rail of thumbnails, zoom (steps, fit width, fit page, Ctrl/⌘ + wheel or\n// pinch), find in the text, and highlights with notes that save through\n// callbacks. pdf.js (via react-pdf, see ./pdfjs.js) draws the pages; this\n// file is the viewer around them, in the kit and the workspace's tokens.\n//\n// Client only, one static bundle. pdf.js needs the DOM and a worker, and\n// the app's check refuses import() (so no next/dynamic): the modules load\n// with the page (the server half through ssr-shim.js), and the viewer is\n// created only after the first effect — the mount guard. Until then, on\n// the server and in the first paint, the same frame shows with skeleton\n// pages, so nothing jumps.\n//\n// Only the pages near the window are drawn (a 300-page contract scrolls\n// like a 3-page quote); the others hold their exact place as blank paper.\nimport { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from \"react\";\nimport { Button, Field, Icon, IconButton, Input, InputGroup, Kbd, Menu, PasswordInput, Sheet, Skeleton, Tooltip, cx, toast } from \"../../ui\";\nimport { Document, Page } from \"./pdfjs\";\nimport { NotesPanel, TONE_FILL, TONE_RING, toneOf } from \"./notes\";\nimport {\n anchorAt,\n clampZoom,\n findMatches,\n fitScale,\n highlightAt,\n itemBox,\n indexMatches,\n layoutPages,\n markItem,\n pageAt,\n rectsForQuote,\n stepZoom,\n toPageRects,\n visiblePages,\n} from \"./helpers\";\n\nconst GAP = 16; // px between pages\nconst PAD = 16; // px around the column\nconst WIDE = 900; // viewer width (px) from which the rails sit beside the pages\nconst COMPACT = 640; // under this the toolbar folds its search and zoom steps\n\n// The search marks, painted into pdf.js's transparent text layer.\nconst MARK = \"rounded-[2px] bg-warning/35 text-transparent\";\nconst MARK_CURRENT = \"rounded-[2px] bg-primary/40 text-transparent outline-2 outline-primary\";\n\n// Handed to pdf.js; copied once per load attempt (react-pdf reloads the\n// document when `options` changes identity, so never per render).\nconst OPTIONS = { isEvalSupported: false };\n\nconst ZOOM_CHOICES = [\n { value: \"fit\", label: \"Fit width\" },\n { value: \"page\", label: \"Fit page\" },\n { value: \"0.5\", label: \"50%\" },\n { value: \"0.75\", label: \"75%\" },\n { value: \"1\", label: \"100%\" },\n { value: \"1.25\", label: \"125%\" },\n { value: \"1.5\", label: \"150%\" },\n { value: \"2\", label: \"200%\" },\n { value: \"3\", label: \"300%\" },\n];\n\n/** A ref for the viewer's handle: `const v = usePdfViewer()`, then\n * `<PdfViewer viewerRef={v} … />` and `v.current?.goToPage(3)`. */\nexport function usePdfViewer() {\n return useRef(null);\n}\n\nexport function PdfViewer(props) {\n const [mounted, setMounted] = useState(false);\n useEffect(() => setMounted(true), []);\n if (!mounted) {\n return (\n <Frame className={props.className} height={props.height} label={props.title}>\n <ToolbarSkeleton />\n <PagesSkeleton />\n </Frame>\n );\n }\n return <LiveViewer {...props} />;\n}\n\nfunction Frame({ className, height = \"min(80vh, 860px)\", label, children, rootRef, onKeyDown }) {\n return (\n <section\n ref={rootRef}\n aria-label={label || \"PDF document\"}\n onKeyDown={onKeyDown}\n style={{ height }}\n className={cx(\"flex min-h-96 flex-col overflow-hidden rounded-lg border border-border bg-background\", className)}\n >\n {children}\n </section>\n );\n}\n\n// ---- the live viewer --------------------------------------------------------\n\n/** What react-pdf is given: a URL as is, a Blob as is, bytes COPIED (pdf.js\n * moves an ArrayBuffer into its worker, which empties the caller's). */\nfunction toSource(file) {\n if (!file) return null;\n if (typeof file === \"string\") return file;\n if (typeof ArrayBuffer !== \"undefined\" && file instanceof ArrayBuffer) return { data: new Uint8Array(file.slice(0)) };\n if (ArrayBuffer.isView?.(file)) return { data: new Uint8Array(file.buffer.slice(file.byteOffset, file.byteOffset + file.byteLength)) };\n return file;\n}\n\nfunction LiveViewer({\n file,\n title,\n fileName,\n highlights,\n onHighlight,\n onHighlightChange,\n onHighlightDelete,\n initialPage = 1,\n onPageChange,\n defaultZoom = \"fit\",\n thumbnails = true,\n search = true,\n download = true,\n notes,\n viewerRef,\n emptyTitle = \"No document yet\",\n emptyBody = \"When a PDF is attached, it shows here.\",\n height,\n loading = false,\n className,\n}) {\n const readOnly = !onHighlight;\n const [attempt, setAttempt] = useState(0);\n const source = useMemo(() => toSource(file), [file, attempt]); // eslint-disable-line react-hooks/exhaustive-deps\n // A retry is a new load: react-pdf caches a document by (file, options).\n const options = useMemo(() => ({ ...OPTIONS }), [attempt]);\n\n // The document and its geometry.\n const [pdf, setPdf] = useState(null);\n const [sizes, setSizes] = useState(null); // [{ width, height }] at scale 1\n const [loadError, setLoadError] = useState(null);\n const [password, setPassword] = useState(null); // { submit(pw), wrong }\n const texts = useRef(null); // [[str]] per page, read on the first search\n const geo = useRef(null); // [[{ str, x, y, w, h }]] per page, the same read\n const reading = useRef(null); // the read in progress\n const [textsReady, setTextsReady] = useState(false);\n\n // The frame's own width decides the layout, not the window's: the viewer\n // may sit in a narrow column of a wide screen.\n const rootRef = useRef(null);\n const [rootWidth, setRootWidth] = useState(1024);\n const wide = rootWidth >= WIDE;\n const compact = rootWidth < COMPACT;\n\n // The scrolling column.\n const scroller = useRef(null);\n const [view, setView] = useState({ top: 0, height: 600, width: 600 });\n const anchor = useRef(null); // { page, frac, offset } kept across a zoom\n\n // Zoom: \"fit\" (width) · \"page\" · a number.\n const [zoom, setZoom] = useState(defaultZoom);\n const scale = useMemo(() => {\n if (!sizes) return 1;\n if (zoom === \"fit\") return fitScale(sizes, view.width - PAD * 2);\n if (zoom === \"page\") return fitScale(sizes, view.width - PAD * 2, view.height - PAD * 2);\n return clampZoom(zoom);\n }, [zoom, sizes, view.width, view.height]);\n const layout = useMemo(() => layoutPages(sizes ?? [], scale, GAP, PAD), [sizes, scale]);\n const pageCount = sizes?.length ?? 0;\n const maxWidth = sizes?.length ? Math.max(...sizes.map((s) => s.width)) * scale : 0;\n const current = pageCount ? pageAt(layout.tops, view.top + Math.min(view.height / 3, 240)) : 1;\n const [first, last] = visiblePages(layout, view.top, view.top + view.height, 1);\n\n // Panels.\n const [railOpen, setRailOpen] = useState(thumbnails);\n const [railSheet, setRailSheet] = useState(false);\n const [notesOpen, setNotesOpen] = useState(notes ?? (highlights?.length ?? 0) > 0);\n const [notesSheet, setNotesSheet] = useState(false);\n const showNotes = !readOnly || (highlights?.length ?? 0) > 0;\n\n // Search.\n const [searchOpen, setSearchOpen] = useState(false);\n const [query, setQuery] = useState(\"\");\n const [matchIndex, setMatchIndex] = useState(0);\n const searchInput = useRef(null);\n const jump = useRef(false); // scroll to the current match once its page has drawn its text\n\n // Highlights: the parent's list, with this viewer's unsaved changes on top.\n const [items, setItems] = useState(() => highlights ?? []);\n useEffect(() => setItems(highlights ?? []), [highlights]);\n const [activeId, setActiveId] = useState(null);\n const [editingId, setEditingId] = useState(null);\n const [selection, setSelection] = useState(null); // { page, rects, text }\n const [composer, setComposer] = useState(false); // the \"Add note\" field in the selection bar\n const deletes = useRef(new Map()); // id → timer, while its Undo is offered\n\n // ---- loading --------------------------------------------------------------\n\n const onLoadSuccess = useCallback(async (doc) => {\n setLoadError(null);\n setPassword(null);\n texts.current = null;\n geo.current = null;\n reading.current = null;\n setTextsReady(false);\n const out = [];\n for (let i = 1; i <= doc.numPages; i++) {\n const p = await doc.getPage(i);\n const vp = p.getViewport({ scale: 1 });\n out.push({ width: vp.width, height: vp.height });\n }\n setPdf(doc);\n setSizes(out);\n }, []);\n\n const onLoadError = useCallback((e) => {\n setPdf(null);\n setSizes(null);\n setLoadError(e ?? new Error(\"Unknown error\"));\n }, []);\n\n const onPassword = useCallback((submit, reason) => {\n setPassword({ submit, wrong: reason === 2 });\n }, []);\n\n useEffect(() => {\n setPdf(null);\n setSizes(null);\n setLoadError(null);\n setPassword(null);\n setSelection(null);\n setMatchIndex(0);\n }, [source]);\n\n // The words of every page, read once, on the first search.\n const ensureTexts = useCallback(() => {\n if (!pdf || texts.current) return;\n if (reading.current?.pdf === pdf) return;\n const job = { pdf };\n reading.current = job;\n (async () => {\n const out = [];\n const boxes = [];\n for (let i = 1; i <= pdf.numPages; i++) {\n const p = await pdf.getPage(i);\n const tc = await p.getTextContent();\n out.push(tc.items.map((it) => it.str ?? \"\"));\n boxes.push(tc.items.map(itemBox));\n }\n if (reading.current !== job) return;\n texts.current = out;\n geo.current = boxes;\n setTextsReady(true);\n })().catch(() => {\n if (reading.current === job) reading.current = null;\n });\n }, [pdf]);\n\n // ---- the column's size and scroll ------------------------------------------\n\n useLayoutEffect(() => {\n const root = rootRef.current;\n if (!root) return undefined;\n const ro = new ResizeObserver(() => setRootWidth(root.clientWidth));\n ro.observe(root);\n setRootWidth(root.clientWidth);\n return () => ro.disconnect();\n }, []);\n\n const measure = useCallback(() => {\n const el = scroller.current;\n if (!el) return;\n setView((v) => (v.top === el.scrollTop && v.height === el.clientHeight && v.width === el.clientWidth ? v : { top: el.scrollTop, height: el.clientHeight, width: el.clientWidth }));\n }, []);\n\n const layoutRef = useRef(layout);\n layoutRef.current = layout;\n useLayoutEffect(() => {\n const el = scroller.current;\n if (!el) return undefined;\n let width = el.clientWidth;\n const ro = new ResizeObserver(() => {\n // A fit zoom changes with the width: keep the same line in view.\n if (el.clientWidth !== width && layoutRef.current.tops.length) anchor.current = { ...anchorAt(layoutRef.current, el.scrollTop), offset: 0 };\n width = el.clientWidth;\n measure();\n });\n ro.observe(el);\n measure();\n return () => ro.disconnect();\n }, [measure, sizes]);\n\n const frame = useRef(0);\n const onScroll = () => {\n cancelAnimationFrame(frame.current);\n frame.current = requestAnimationFrame(measure);\n };\n\n // After a zoom, put the anchored line back where it was.\n useLayoutEffect(() => {\n const el = scroller.current;\n const a = anchor.current;\n if (!el || !a || !layout.tops.length) return;\n anchor.current = null;\n const top = layout.tops[a.page - 1] + a.frac * layout.heights[a.page - 1] - (a.offset ?? 0);\n el.scrollTop = Math.max(0, top);\n measure();\n }, [layout, measure]);\n\n const zoomTo = useCallback(\n (next, offset = 0) => {\n if (next === zoom) return;\n const el = scroller.current;\n if (el && layout.tops.length) anchor.current = { ...anchorAt(layout, el.scrollTop + offset), offset };\n setZoom(next);\n },\n [layout, zoom],\n );\n\n // Ctrl/⌘ + wheel (and a trackpad pinch, which arrives as one) zooms\n // around the pointer instead of zooming the whole page.\n useEffect(() => {\n const el = scroller.current;\n if (!el) return undefined;\n const onWheel = (e) => {\n if (!e.ctrlKey && !e.metaKey) return;\n e.preventDefault();\n const offset = e.clientY - el.getBoundingClientRect().top;\n zoomTo(clampZoom(Math.round(scale * Math.exp(-e.deltaY * 0.01) * 100) / 100), offset);\n };\n el.addEventListener(\"wheel\", onWheel, { passive: false });\n return () => el.removeEventListener(\"wheel\", onWheel);\n }, [scale, zoomTo, sizes]);\n\n // ---- going places -----------------------------------------------------------\n\n const goToPage = useCallback(\n (n, { frac = 0, smooth = false } = {}) => {\n const el = scroller.current;\n if (!el || !pageCount) return;\n const p = Math.min(pageCount, Math.max(1, Math.round(n) || 1));\n const top = layout.tops[p - 1] + frac * layout.heights[p - 1] - (frac ? el.clientHeight / 3 : PAD);\n el.scrollTo({ top: Math.max(0, top), behavior: smooth && !reducedMotion() ? \"smooth\" : \"auto\" });\n },\n [layout, pageCount],\n );\n\n const goToHighlight = useCallback(\n (h) => {\n if (!h) return;\n setActiveId(h.id);\n if (!h.page) return;\n const y = Math.min(...(h.rects?.length ? h.rects.map((r) => r.y) : [0]));\n goToPage(h.page, { frac: y, smooth: true });\n },\n [goToPage],\n );\n\n // The first page to open on, once the geometry is known.\n const opened = useRef(false);\n useEffect(() => {\n if (!sizes || opened.current) return;\n opened.current = true;\n if (initialPage > 1) requestAnimationFrame(() => goToPage(initialPage));\n }, [sizes, initialPage, goToPage]);\n\n const lastReported = useRef(null);\n useEffect(() => {\n if (!pageCount || lastReported.current === current) return;\n lastReported.current = current;\n onPageChange?.(current, pageCount);\n }, [current, pageCount, onPageChange]);\n\n // A highlight given by its words alone ({ text, page? }, no rects) is\n // placed once the pages' text has been read.\n const unplaced = items.some((h) => !h.rects?.length && h.text);\n useEffect(() => {\n if (unplaced && pdf) ensureTexts();\n }, [unplaced, pdf, ensureTexts]);\n const shown = useMemo(() => {\n if (!unplaced) return items;\n return items.map((h) => {\n if (h.rects?.length || !h.text) return h;\n if (!textsReady || !geo.current || !sizes) return { ...h, rects: [] };\n const pages = h.page ? [h.page] : sizes.map((_, i) => i + 1);\n for (const n of pages) {\n const rects = rectsForQuote(geo.current[n - 1], h.text, sizes[n - 1]);\n if (rects.length) return { ...h, page: n, rects };\n }\n return { ...h, page: h.page ?? null, rects: [] };\n });\n }, [items, unplaced, textsReady, sizes]);\n\n // ---- search -----------------------------------------------------------------\n\n const matches = useMemo(() => (textsReady && query ? findMatches(texts.current, query) : []), [textsReady, query]);\n const byItem = useMemo(() => indexMatches(matches), [matches]);\n const currentMatch = matches[matchIndex] ?? null;\n\n const renderText = useMemo(() => {\n if (!matches.length) return undefined;\n return ({ pageNumber, itemIndex, str }) => {\n const here = byItem.get(`${pageNumber}:${itemIndex}`);\n if (!here) return markItem(str, null);\n const cur = currentMatch && currentMatch.page === pageNumber && currentMatch.item === itemIndex ? currentMatch.start : -1;\n return markItem(str, here, { current: cur, markClass: MARK, currentClass: MARK_CURRENT });\n };\n }, [matches, byItem, currentMatch]);\n\n const scrollToMark = useCallback(() => {\n const mark = scroller.current?.querySelector('mark[data-current=\"true\"]');\n if (!mark) return false;\n mark.scrollIntoView({ block: \"center\", inline: \"nearest\", behavior: reducedMotion() ? \"auto\" : \"smooth\" });\n return true;\n }, []);\n\n // A new current match: its page's text layer redraws with the new mark\n // (or the page is drawn, once the column is taken there), and when it\n // has, onTextDrawn centres the mark.\n const drawn = useRef([first, last]);\n drawn.current = [first, last];\n useEffect(() => {\n if (!currentMatch) return;\n jump.current = true;\n const [a, b] = drawn.current;\n if (currentMatch.page < a || currentMatch.page > b) goToPage(currentMatch.page);\n }, [currentMatch, goToPage]);\n\n const onTextDrawn = useCallback(() => {\n if (jump.current && scrollToMark()) jump.current = false;\n }, [scrollToMark]);\n\n const openSearch = () => {\n setSearchOpen(true);\n ensureTexts();\n requestAnimationFrame(() => {\n searchInput.current?.focus();\n searchInput.current?.select();\n });\n };\n const step = (dir) => {\n if (!matches.length) return;\n setMatchIndex((i) => (i + dir + matches.length) % matches.length);\n };\n const closeSearch = () => {\n setQuery(\"\");\n setMatchIndex(0);\n setSearchOpen(false);\n };\n\n // ---- highlights ---------------------------------------------------------------\n\n const selectionRef = useRef(null);\n selectionRef.current = selection;\n const composerRef = useRef(false);\n composerRef.current = composer;\n const readSelection = useCallback(() => {\n if (readOnly) return;\n // Typing a note in the selection bar moves the browser's selection into\n // its field: that is not a new selection of the page's text.\n if (document.activeElement?.closest?.(\"[data-selection-bar]\")) return;\n const sel = window.getSelection?.();\n if (!sel || sel.isCollapsed || !sel.rangeCount) {\n if (!composerRef.current) setSelection(null);\n return;\n }\n const range = sel.getRangeAt(0);\n const start = range.startContainer.nodeType === 1 ? range.startContainer : range.startContainer.parentElement;\n if (start?.closest?.(\"[data-selection-bar]\")) return;\n const pageEl = start?.closest?.(\".react-pdf__Page\");\n if (!pageEl || !scroller.current?.contains(pageEl)) return setSelection(null);\n const page = Number(pageEl.dataset.pageNumber);\n const rects = toPageRects([...range.getClientRects()], pageEl.getBoundingClientRect());\n const text = sel.toString().replace(/\\s+/g, \" \").trim();\n if (!rects.length || !text) return setSelection(null);\n const prev = selectionRef.current;\n if (prev && prev.page === page && prev.text === text) return;\n setComposer(false);\n setSelection({ page, rects, text });\n }, [readOnly]);\n useEffect(() => {\n if (!selection) setComposer(false);\n }, [selection]);\n\n // Pointer selections settle on pointerup; keyboard and touch ones\n // (shift+arrows, the handles on a phone) through selectionchange.\n const pointerDown = useRef(false);\n useEffect(() => {\n if (readOnly) return undefined;\n let timer = 0;\n const onChange = () => {\n if (pointerDown.current) return;\n clearTimeout(timer);\n timer = setTimeout(readSelection, 180);\n };\n document.addEventListener(\"selectionchange\", onChange);\n return () => {\n clearTimeout(timer);\n document.removeEventListener(\"selectionchange\", onChange);\n };\n }, [readOnly, readSelection]);\n\n const save = async (fn, rollback, failTitle) => {\n let res;\n try {\n res = await fn();\n } catch (e) {\n res = { ok: false, error: e?.message };\n }\n if (!res?.ok) {\n rollback();\n toast.error(failTitle, { description: res?.error ? String(res.error) : \"Check your connection and try again.\" });\n }\n return res;\n };\n\n const createHighlight = async (note = \"\") => {\n if (!selection || !onHighlight) return;\n const temp = { id: `new-${Date.now()}`, page: selection.page, rects: selection.rects, text: selection.text, note, tone: \"warning\", pending: true };\n setItems((list) => [...list, temp]);\n window.getSelection?.()?.removeAllRanges();\n setSelection(null);\n setComposer(false);\n setActiveId(temp.id);\n if (wide && showNotes) setNotesOpen(true);\n const { id: _id, pending: _p, ...data } = temp;\n const res = await save(\n () => onHighlight(data),\n () => setItems((list) => list.filter((x) => x.id !== temp.id)),\n \"Couldn't save the highlight\",\n );\n if (res?.ok) {\n const id = res.id ?? temp.id;\n setItems((list) => list.map((x) => (x.id === temp.id ? { ...x, id, pending: false } : x)));\n setActiveId((a) => (a === temp.id ? id : a));\n }\n };\n\n const change = async (h, patch) => {\n if (h.pending) return toast.info(\"Still saving that highlight\", { description: \"Try again in a moment.\" });\n const before = items.find((x) => x.id === h.id);\n setItems((list) => list.map((x) => (x.id === h.id ? { ...x, ...patch } : x)));\n if (!onHighlightChange) return;\n await save(\n () => onHighlightChange(h.id, patch),\n () => setItems((list) => list.map((x) => (x.id === h.id ? before : x))),\n patch.note !== undefined ? \"Couldn't save the note\" : \"Couldn't change the colour\",\n );\n };\n\n const remove = (h) => {\n if (h.pending) return;\n const index = items.findIndex((x) => x.id === h.id);\n setItems((list) => list.filter((x) => x.id !== h.id));\n if (activeId === h.id) setActiveId(null);\n const restore = () =>\n setItems((list) => {\n const next = [...list];\n next.splice(Math.min(index, next.length), 0, h);\n return next;\n });\n // The delete waits while Undo is offered; Undo simply never sends it.\n const timer = setTimeout(async () => {\n deletes.current.delete(h.id);\n if (!onHighlightDelete) return;\n await save(() => onHighlightDelete(h.id), restore, \"Couldn't delete the highlight\");\n }, 5000);\n deletes.current.set(h.id, { timer, run: () => onHighlightDelete?.(h.id) });\n toast(\"Highlight deleted\", {\n description: h.text ? `“${h.text.slice(0, 60)}${h.text.length > 60 ? \"…\" : \"\"}”` : undefined,\n action: {\n label: \"Undo\",\n onClick: () => {\n clearTimeout(timer);\n deletes.current.delete(h.id);\n restore();\n },\n },\n });\n };\n\n // Leaving the viewer sends the deletes that were waiting on Undo.\n useEffect(\n () => () => {\n for (const { timer, run } of deletes.current.values()) {\n clearTimeout(timer);\n Promise.resolve()\n .then(run)\n .catch(() => {});\n }\n deletes.current.clear();\n },\n [],\n );\n\n const copy = async (h) => {\n try {\n await navigator.clipboard.writeText(h.text ?? \"\");\n toast.success(\"Copied\");\n } catch {\n toast.error(\"Couldn't copy\", { description: \"Your browser blocked the clipboard.\" });\n }\n };\n\n // A click on a highlight (the text layer lies over them, so this is a\n // hit test at the point) selects it and shows its note.\n const onPageClick = (e, page) => {\n const sel = window.getSelection?.();\n if (sel && !sel.isCollapsed) return;\n const box = e.currentTarget.getBoundingClientRect();\n const h = highlightAt(shown, page, (e.clientX - box.left) / box.width, (e.clientY - box.top) / box.height);\n if (!h) return setActiveId(null);\n setActiveId(h.id);\n if (wide) setNotesOpen(true);\n else setNotesSheet(true);\n };\n\n const openNote = (id) => {\n setEditingId(id);\n if (id) {\n setActiveId(id);\n if (wide) setNotesOpen(true);\n else setNotesSheet(true);\n }\n };\n\n // ---- the handle and the keys ---------------------------------------------------\n\n useImperativeHandle(\n viewerRef,\n () => ({\n goToPage: (n) => goToPage(n, { smooth: true }),\n goToHighlight: (id) => goToHighlight(shown.find((h) => h.id === id)),\n search: (q) => {\n setSearchOpen(true);\n ensureTexts();\n setQuery(q ?? \"\");\n setMatchIndex(0);\n },\n setZoom: (z) => zoomTo(z),\n get page() {\n return current;\n },\n get pageCount() {\n return pageCount;\n },\n }),\n [goToPage, goToHighlight, shown, ensureTexts, zoomTo, current, pageCount],\n );\n\n const onKeyDown = (e) => {\n const mod = e.metaKey || e.ctrlKey;\n if (mod && e.key.toLowerCase() === \"f\" && search && pdf) {\n e.preventDefault();\n openSearch();\n } else if (mod && (e.key === \"=\" || e.key === \"+\")) {\n e.preventDefault();\n zoomTo(stepZoom(scale, 1));\n } else if (mod && e.key === \"-\") {\n e.preventDefault();\n zoomTo(stepZoom(scale, -1));\n } else if (mod && e.key === \"0\") {\n e.preventDefault();\n zoomTo(\"fit\");\n } else if (e.key === \"Escape\" && selection) {\n setSelection(null);\n setComposer(false);\n }\n };\n\n const onDownload = () => {\n const name = fileName || `${(title || \"document\").replace(/[\\\\/:*?\"<>|]+/g, \" \").trim()}.pdf`;\n if (typeof file === \"string\") {\n // raw: an <a download> is how a browser saves a URL; it is never shown.\n const a = document.createElement(\"a\");\n a.href = file;\n a.download = name;\n a.rel = \"noopener\";\n document.body.appendChild(a);\n a.click();\n a.remove();\n return;\n }\n const blob = file instanceof Blob ? file : new Blob([file?.data ?? file], { type: \"application/pdf\" });\n const url = URL.createObjectURL(blob);\n const a = document.createElement(\"a\");\n a.href = url;\n a.download = name;\n document.body.appendChild(a);\n a.click();\n a.remove();\n setTimeout(() => URL.revokeObjectURL(url), 1000);\n };\n\n // ---- drawing ------------------------------------------------------------------\n\n const zoomLabel = zoom === \"fit\" ? \"Fit\" : zoom === \"page\" ? \"Page\" : `${Math.round(scale * 100)}%`;\n const noteCount = items.length;\n\n const toolbar = (\n <Toolbar\n compact={compact}\n ready={Boolean(pdf && sizes)}\n title={title}\n current={current}\n pageCount={pageCount}\n onPage={(n) => goToPage(n)}\n rail={thumbnails ? { open: wide ? railOpen : railSheet, toggle: () => (wide ? setRailOpen((o) => !o) : setRailSheet(true)) } : null}\n search={\n search\n ? {\n open: searchOpen,\n query,\n count: matches.length,\n index: matchIndex,\n ready: textsReady,\n inputRef: searchInput,\n onOpen: openSearch,\n onClose: closeSearch,\n onQuery: (q) => {\n setQuery(q);\n setMatchIndex(0);\n ensureTexts();\n },\n onStep: step,\n }\n : null\n }\n zoom={{ label: zoomLabel, value: typeof zoom === \"number\" ? String(zoom) : zoom, scale, onZoom: zoomTo }}\n notes={showNotes ? { open: wide ? notesOpen : notesSheet, count: noteCount, toggle: () => (wide ? setNotesOpen((o) => !o) : setNotesSheet(true)) } : null}\n onDownload={download && file ? onDownload : null}\n />\n );\n\n if (loading) {\n return (\n <Frame className={className} height={height} label={title} rootRef={rootRef}>\n <ToolbarSkeleton />\n <PagesSkeleton />\n </Frame>\n );\n }\n\n if (!file) {\n return (\n <Frame className={className} height={height} label={title} rootRef={rootRef}>\n <div className=\"grid flex-1 place-items-center bg-muted/40 p-6\">\n <Placeholder icon=\"file\" title={emptyTitle} body={emptyBody} />\n </div>\n </Frame>\n );\n }\n\n const notesPanel = (\n <NotesPanel\n highlights={shown}\n activeId={activeId}\n editingId={editingId}\n readOnly={readOnly}\n onJump={(h) => {\n goToHighlight(h);\n if (!wide) setNotesSheet(false);\n }}\n onEdit={openNote}\n onSaveNote={(h, note) => note !== (h.note ?? \"\") && change(h, { note })}\n onTone={(h, tone) => change(h, { tone })}\n onDelete={remove}\n onCopy={copy}\n />\n );\n\n return (\n <Frame className={className} height={height} label={title} rootRef={rootRef} onKeyDown={onKeyDown}>\n {toolbar}\n <Document\n key={attempt}\n file={source}\n options={options}\n suspense={false}\n onLoadSuccess={onLoadSuccess}\n onLoadError={onLoadError}\n onSourceError={onLoadError}\n onPassword={onPassword}\n onItemClick={({ pageNumber }) => goToPage(pageNumber, { smooth: true })}\n loading={() => (password ? <PasswordGate {...password} /> : <PagesSkeleton />)}\n error={() => <LoadError error={loadError} onRetry={() => setAttempt((n) => n + 1)} />}\n noData={() => <Placeholder icon=\"file\" title={emptyTitle} body={emptyBody} />}\n className=\"flex min-h-0 flex-1\"\n >\n {thumbnails && wide && railOpen ? (\n <aside className=\"w-40 shrink-0 overflow-y-auto border-r border-border bg-muted/40\" aria-label=\"Pages\">\n <PageRail sizes={sizes} current={current} onPick={(n) => goToPage(n)} />\n </aside>\n ) : null}\n\n <div\n ref={scroller}\n onScroll={onScroll}\n onPointerDown={() => {\n pointerDown.current = true;\n }}\n onPointerUp={(e) => {\n pointerDown.current = false;\n if (!e.target.closest?.(\"[data-selection-bar]\")) setTimeout(readSelection, 0);\n }}\n tabIndex={0}\n role=\"region\"\n aria-label={`${title || \"Document\"}, page ${current} of ${pageCount || \"…\"}`}\n 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\"\n >\n {!sizes ? (\n <PagesSkeleton />\n ) : (\n <div className=\"relative\" style={{ height: layout.total, width: Math.max(view.width, maxWidth + PAD * 2) }}>\n {sizes.map((s, i) => {\n const n = i + 1;\n const w = Math.round(s.width * scale);\n const h = layout.heights[i];\n const live = n >= first && n <= last;\n return (\n <div key={n} className=\"absolute inset-x-0 flex justify-center\" style={{ top: layout.tops[i], height: h }}>\n {live ? (\n // raw: the page's paper; a click on it hit-tests the\n // highlights, which lie under pdf.js's text layer.\n <div className=\"relative shadow-sm ring-1 ring-border\" style={{ width: w, height: h }} onClick={(e) => onPageClick(e, n)}>\n <Page\n pageNumber={n}\n scale={scale}\n renderTextLayer\n renderAnnotationLayer\n customTextRenderer={renderText}\n onRenderTextLayerSuccess={onTextDrawn}\n loading={<Skeleton shape=\"rect\" className=\"h-full w-full rounded-none\" />}\n >\n <HighlightLayer items={shown} page={n} activeId={activeId} onOpen={(h) => openNote(h.id)} />\n {selection?.page === n ? (\n <SelectionBar\n selection={selection}\n composer={composer}\n onHighlight={() => createHighlight(\"\")}\n onNote={() => setComposer(true)}\n onSaveNote={(note) => createHighlight(note)}\n onCancel={() => {\n setComposer(false);\n setSelection(null);\n window.getSelection?.()?.removeAllRanges();\n }}\n onCopy={() => copy(selection)}\n />\n ) : null}\n </Page>\n </div>\n ) : (\n <div aria-hidden=\"true\" className=\"rounded-[2px] bg-card ring-1 ring-border\" style={{ width: w, height: h }} />\n )}\n </div>\n );\n })}\n </div>\n )}\n </div>\n\n {showNotes && wide && notesOpen ? (\n <aside className=\"flex w-80 shrink-0 flex-col border-l border-border bg-background\" aria-label=\"Highlights and notes\">\n <div className=\"flex h-12 shrink-0 items-center justify-between gap-2 border-b border-border px-4\">\n <p className=\"text-sm font-medium\">\n Highlights <span className=\"ml-1 text-muted-foreground tabular-nums\">{noteCount || \"\"}</span>\n </p>\n <IconButton icon=\"x\" label=\"Close highlights\" size=\"sm\" onClick={() => setNotesOpen(false)} className=\"-mr-2\" />\n </div>\n <div className=\"min-h-0 flex-1 overflow-y-auto\">{notesPanel}</div>\n </aside>\n ) : null}\n\n {/* On a narrow viewer the rails are sheets. */}\n {thumbnails && !wide ? (\n <Sheet open={railSheet} onClose={() => setRailSheet(false)} side=\"start\" title=\"Pages\" description={pageCount ? `${pageCount} ${pageCount === 1 ? \"page\" : \"pages\"}` : undefined}>\n <PageRail\n sizes={sizes}\n current={current}\n grid\n onPick={(n) => {\n setRailSheet(false);\n goToPage(n);\n }}\n />\n </Sheet>\n ) : null}\n {showNotes && !wide ? (\n <Sheet open={notesSheet} onClose={() => setNotesSheet(false)} title=\"Highlights\" description={noteCount ? `${noteCount} in this document` : undefined}>\n <div className=\"-mx-4 -my-4\">{notesPanel}</div>\n </Sheet>\n ) : null}\n </Document>\n </Frame>\n );\n}\n\nconst reducedMotion = () => typeof window !== \"undefined\" && window.matchMedia?.(\"(prefers-reduced-motion: reduce)\").matches;\n\n// ---- the toolbar ----------------------------------------------------------------\n\nfunction Toolbar({ compact, ready, title, current, pageCount, onPage, rail, search, zoom, notes, onDownload }) {\n const [draft, setDraft] = useState(String(current));\n const [editing, setEditing] = useState(false);\n useEffect(() => {\n if (!editing) setDraft(String(current));\n }, [current, editing]);\n const commit = () => {\n setEditing(false);\n const n = parseInt(draft, 10);\n if (Number.isFinite(n)) onPage(n);\n else setDraft(String(current));\n };\n\n const searchField = search ? (\n <InputGroup\n ref={search.inputRef}\n icon=\"search\"\n type=\"search\"\n value={search.query}\n placeholder=\"Find in document\"\n aria-label=\"Find in document\"\n aria-keyshortcuts=\"Meta+F Control+F\"\n autoComplete=\"off\"\n spellCheck={false}\n className={compact ? \"w-full\" : \"w-64\"}\n suffix={\n search.query.trim().length >= 2 ? (\n <span className=\"tabular-nums text-xs\" aria-live=\"polite\">\n {!search.ready ? \"Reading…\" : search.count ? `${search.index + 1} of ${search.count}` : \"No matches\"}\n </span>\n ) : null\n }\n onChange={(e) => search.onQuery(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") {\n e.preventDefault();\n search.onStep(e.shiftKey ? -1 : 1);\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n search.onClose();\n }\n }}\n />\n ) : null;\n\n const steps = search?.open ? (\n <div className=\"flex shrink-0 items-center\">\n <Tooltip label=\"Previous match (Shift+Enter)\">\n <IconButton icon=\"chevron-up\" label=\"Previous match\" size=\"sm\" disabled={!search.count} onClick={() => search.onStep(-1)} />\n </Tooltip>\n <Tooltip label=\"Next match (Enter)\">\n <IconButton icon=\"chevron-down\" label=\"Next match\" size=\"sm\" disabled={!search.count} onClick={() => search.onStep(1)} />\n </Tooltip>\n <IconButton icon=\"x\" label=\"Close search\" size=\"sm\" onClick={search.onClose} />\n </div>\n ) : null;\n\n return (\n <div className=\"shrink-0 border-b border-border bg-background\">\n <div className=\"flex h-14 items-center gap-1 px-2\">\n {rail ? (\n <Tooltip label={rail.open ? \"Hide pages\" : \"Show pages\"}>\n <IconButton icon=\"panel-left\" label={rail.open ? \"Hide pages\" : \"Show pages\"} aria-pressed={rail.open} disabled={!ready} onClick={rail.toggle} />\n </Tooltip>\n ) : null}\n {title && !compact ? <p className=\"mx-1 min-w-0 max-w-64 truncate text-sm font-medium\" title={title}>{title}</p> : null}\n\n {/* The pager. */}\n <div className={cx(\"flex items-center gap-0.5\", !compact && \"mx-auto\")} aria-label=\"Page\" role=\"group\">\n {!compact ? <IconButton icon=\"chevron-left\" label=\"Previous page\" size=\"sm\" disabled={!ready || current <= 1} onClick={() => onPage(current - 1)} /> : null}\n <div className=\"w-14 shrink-0\">\n <Input\n value={ready ? draft : \"\"}\n inputMode=\"numeric\"\n aria-label={`Page number, ${pageCount} pages`}\n disabled={!ready}\n className=\"text-center tabular-nums\"\n onFocus={(e) => {\n setEditing(true);\n e.target.select();\n }}\n onChange={(e) => setDraft(e.target.value.replace(/[^\\d]/g, \"\"))}\n onBlur={commit}\n onKeyDown={(e) => {\n if (e.key === \"Enter\") e.currentTarget.blur();\n if (e.key === \"Escape\") {\n setDraft(String(current));\n setEditing(false);\n e.currentTarget.blur();\n }\n }}\n />\n </div>\n <span className=\"whitespace-nowrap px-1.5 text-sm text-muted-foreground tabular-nums\">of {ready ? pageCount : \"–\"}</span>\n {!compact ? <IconButton icon=\"chevron-right\" label=\"Next page\" size=\"sm\" disabled={!ready || current >= pageCount} onClick={() => onPage(current + 1)} /> : null}\n </div>\n\n <div className={cx(\"flex items-center gap-1\", compact && \"ml-auto\")}>\n {search && !compact ? (\n search.open ? (\n <div className=\"flex items-center gap-0.5\">\n {searchField}\n {steps}\n </div>\n ) : (\n <Tooltip label=\"Find (⌘F)\">\n <IconButton icon=\"search\" label=\"Find in document\" disabled={!ready} onClick={search.onOpen} />\n </Tooltip>\n )\n ) : null}\n {search && compact && !search.open ? <IconButton icon=\"search\" label=\"Find in document\" disabled={!ready} onClick={search.onOpen} /> : null}\n\n <div className=\"flex items-center\">\n {!compact ? (\n <Tooltip label=\"Zoom out (⌘−)\">\n <IconButton icon=\"minus\" label=\"Zoom out\" size=\"sm\" disabled={!ready} onClick={() => zoom.onZoom(stepZoom(zoom.scale, -1))} />\n </Tooltip>\n ) : null}\n <Menu\n label={`Zoom, ${zoom.label}`}\n size=\"md\"\n align=\"end\"\n trigger={<span className=\"min-w-10 text-center text-sm tabular-nums\">{zoom.label}</span>}\n items={[\n {\n type: \"radio\",\n value: ZOOM_CHOICES.some((c) => c.value === zoom.value) ? zoom.value : \"\",\n onValueChange: (v) => zoom.onZoom(v === \"fit\" || v === \"page\" ? v : Number(v)),\n options: ZOOM_CHOICES,\n },\n ]}\n />\n {!compact ? (\n <Tooltip label=\"Zoom in (⌘+)\">\n <IconButton icon=\"plus\" label=\"Zoom in\" size=\"sm\" disabled={!ready} onClick={() => zoom.onZoom(stepZoom(zoom.scale, 1))} />\n </Tooltip>\n ) : null}\n </div>\n\n {notes ? (\n <Tooltip label={notes.open ? \"Hide highlights\" : \"Show highlights\"}>\n <Button variant=\"quiet\" icon=\"highlighter\" aria-pressed={notes.open} aria-label={`Highlights, ${notes.count}`} iconOnly={compact || !notes.count} disabled={!ready} onClick={notes.toggle}>\n {!compact && notes.count ? <span className=\"tabular-nums\">{notes.count}</span> : null}\n </Button>\n </Tooltip>\n ) : null}\n {onDownload ? (\n <Tooltip label=\"Download\">\n <IconButton icon=\"download\" label=\"Download PDF\" disabled={!ready} onClick={onDownload} />\n </Tooltip>\n ) : null}\n </div>\n </div>\n\n {/* On a narrow viewer the search is a row of its own. */}\n {search && compact && search.open ? (\n <div className=\"flex items-center gap-1 px-2 pb-2\">\n <div className=\"min-w-0 flex-1\">{searchField}</div>\n {steps}\n </div>\n ) : null}\n </div>\n );\n}\n\n// ---- the page rail --------------------------------------------------------------\n\nfunction PageRail({ sizes, current, onPick, grid = false }) {\n const currentRef = useRef(null);\n useEffect(() => {\n currentRef.current?.scrollIntoView?.({ block: \"nearest\" });\n }, [current]);\n if (!sizes) {\n return (\n <div className=\"grid gap-4 p-4\" aria-hidden=\"true\">\n {[0, 1, 2].map((i) => (\n <Skeleton key={i} shape=\"rect\" className=\"mx-auto aspect-[3/4] w-24\" />\n ))}\n </div>\n );\n }\n const width = grid ? 120 : 104;\n return (\n <ol className={cx(\"p-3\", grid ? \"grid grid-cols-2 gap-x-3 gap-y-4 sm:grid-cols-3\" : \"grid gap-4\")}>\n {sizes.map((s, i) => {\n const n = i + 1;\n const on = n === current;\n return (\n <li key={n} ref={on ? currentRef : undefined} className=\"grid justify-items-center gap-1.5\">\n {/* raw: a page's picture is the button; no kit part holds a canvas. */}\n <button\n type=\"button\"\n onClick={() => onPick(n)}\n aria-label={`Page ${n}`}\n aria-current={on ? \"page\" : undefined}\n className={cx(\n \"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\",\n on ? \"ring-2 ring-primary\" : \"ring-border hover:ring-foreground/30\",\n )}\n >\n <LazyThumb width={width} height={Math.round((s.height / s.width) * width)}>\n <Page pageNumber={n} width={width} renderTextLayer={false} renderAnnotationLayer={false} loading={<Skeleton shape=\"rect\" className=\"h-full w-full rounded-none\" />} />\n </LazyThumb>\n </button>\n <span className={cx(\"text-xs tabular-nums\", on ? \"font-medium text-foreground\" : \"text-muted-foreground\")}>{n}</span>\n </li>\n );\n })}\n </ol>\n );\n}\n\n/** Draws its child only once it has come near the visible part of the\n * rail; until then, blank paper of the right size. */\nfunction LazyThumb({ width, height, children }) {\n const ref = useRef(null);\n const [near, setNear] = useState(false);\n useEffect(() => {\n const el = ref.current;\n if (!el || near) return undefined;\n const io = new IntersectionObserver((entries) => entries.some((e) => e.isIntersecting) && setNear(true), { rootMargin: \"400px 0px\" });\n io.observe(el);\n return () => io.disconnect();\n }, [near]);\n return (\n <div ref={ref} className=\"bg-card\" style={{ width, height }}>\n {near ? children : null}\n </div>\n );\n}\n\n// ---- on the page ------------------------------------------------------------------\n\nfunction HighlightLayer({ items, page, activeId, onOpen }) {\n const here = items.filter((h) => h.page === page && h.rects?.length);\n if (!here.length) return null;\n return (\n <>\n {/* The marker strokes. The LAYER multiplies into the page (a blend\n inside a layer of its own would only blend with that layer), so\n the ink shows through the tone as through a highlighter pen. */}\n <div className=\"pointer-events-none absolute inset-0 z-[1] mix-blend-multiply\" aria-hidden=\"true\">\n {here.flatMap((h) => {\n const tone = toneOf(h);\n const active = h.id === activeId;\n return h.rects.map((r, i) => (\n <div\n key={`${h.id}-${i}`}\n className={cx(\n \"absolute rounded-[2px] transition-[outline-color,opacity] duration-150 motion-reduce:transition-none\",\n TONE_FILL[tone],\n h.pending && \"opacity-60\",\n active && cx(\"outline-2 outline-offset-1\", TONE_RING[tone]),\n )}\n style={{ left: `${r.x * 100}%`, top: `${r.y * 100}%`, width: `${r.w * 100}%`, height: `${r.h * 100}%` }}\n />\n ));\n })}\n </div>\n {/* The note pins, unblended, at the end of each highlight that has one. */}\n <div className=\"pointer-events-none absolute inset-0 z-[2]\">\n {here\n .filter((h) => h.note)\n .map((h) => {\n const last = h.rects[h.rects.length - 1];\n return (\n // raw: a 20px pin on the page; the kit's smallest button is\n // 36px and would cover the text it annotates.\n <button\n key={h.id}\n type=\"button\"\n aria-label={`Note on page ${page}: ${h.note.slice(0, 80)}`}\n title={h.note}\n onClick={(e) => {\n e.stopPropagation();\n onOpen(h);\n }}\n 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\"\n style={{ left: `min(calc(${(last.x + last.w) * 100}% + 4px), calc(100% - 24px))`, top: `${(last.y + last.h / 2) * 100}%` }}\n >\n <Icon name=\"message-square\" size={12} />\n </button>\n );\n })}\n </div>\n </>\n );\n}\n\nfunction SelectionBar({ selection, composer, onHighlight, onNote, onSaveNote, onCancel, onCopy }) {\n const [note, setNote] = useState(\"\");\n const top = Math.min(...selection.rects.map((r) => r.y));\n const bottom = Math.max(...selection.rects.map((r) => r.y + r.h));\n const first = selection.rects[0];\n const x = Math.min(0.85, Math.max(0.15, first.x + first.w / 2));\n // Above the selection, unless it starts near the top of the page.\n const above = top > 0.08;\n return (\n // raw: a floating bar pinned to the selection inside the page; mousedown\n // is swallowed so pressing a button does not clear the selection.\n <div\n role=\"toolbar\"\n aria-label=\"Selected text\"\n data-selection-bar=\"\"\n onMouseDown={(e) => {\n if (!e.target.closest(\"textarea\")) e.preventDefault();\n }}\n onClick={(e) => e.stopPropagation()}\n className={cx(\"absolute z-10 -translate-x-1/2\", above ? \"-translate-y-full\" : \"\")}\n style={{ left: `${x * 100}%`, top: above ? `calc(${top * 100}% - 8px)` : `calc(${bottom * 100}% + 8px)` }}\n >\n <div className=\"rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg\">\n {composer ? (\n <form\n className=\"grid w-72 max-w-[80vw] gap-2 p-1\"\n onSubmit={(e) => {\n e.preventDefault();\n onSaveNote(note.trim());\n }}\n >\n <p className=\"line-clamp-2 border-l-2 border-warning pl-2 text-xs text-muted-foreground\">{selection.text}</p>\n <textarea\n // raw: the kit Textarea's 44px minimum and resize corner are\n // too heavy for a note typed in a floating bar over the page.\n autoFocus\n rows={3}\n value={note}\n onChange={(e) => setNote(e.target.value)}\n placeholder=\"Write a note…\"\n aria-label=\"Note\"\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n e.preventDefault();\n onSaveNote(note.trim());\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n onCancel();\n }\n }}\n 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\"\n />\n <div className=\"flex items-center justify-between gap-2\">\n <span className=\"hidden text-xs text-muted-foreground sm:inline\">\n <Kbd>⌘</Kbd> <Kbd>↵</Kbd> to save\n </span>\n <div className=\"ml-auto flex gap-1\">\n <Button size=\"sm\" variant=\"quiet\" onClick={onCancel}>\n Cancel\n </Button>\n <Button size=\"sm\" variant=\"primary\" type=\"submit\">\n Save\n </Button>\n </div>\n </div>\n </form>\n ) : (\n <div className=\"flex items-center gap-0.5\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"highlighter\" onClick={onHighlight}>\n Highlight\n </Button>\n <Button size=\"sm\" variant=\"quiet\" icon=\"message-square\" onClick={onNote}>\n Add note\n </Button>\n <IconButton size=\"sm\" icon=\"copy\" label=\"Copy text\" onClick={onCopy} />\n </div>\n )}\n </div>\n </div>\n );\n}\n\n// ---- states -----------------------------------------------------------------------\n\nfunction ToolbarSkeleton() {\n return (\n <div className=\"flex h-14 shrink-0 items-center gap-3 border-b border-border px-3\" aria-hidden=\"true\">\n <Skeleton shape=\"rect\" className=\"size-8\" />\n <Skeleton className=\"mx-auto w-24\" />\n <Skeleton shape=\"rect\" className=\"h-8 w-20\" />\n </div>\n );\n}\n\nfunction PagesSkeleton() {\n return (\n <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\">\n {[0, 1].map((i) => (\n <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\">\n <Skeleton className=\"h-5 w-1/3\" />\n <Skeleton className=\"mt-4 w-full\" />\n <Skeleton className=\"w-11/12\" />\n <Skeleton className=\"w-4/5\" />\n <Skeleton className=\"mt-4 w-full\" />\n <Skeleton className=\"w-2/3\" />\n </div>\n ))}\n </div>\n );\n}\n\nfunction Placeholder({ icon, title, body, children }) {\n return (\n <div className=\"grid max-w-sm justify-items-center gap-2 text-center\">\n <span className=\"grid size-12 place-items-center rounded-full bg-background text-muted-foreground ring-1 ring-border\">\n <Icon name={icon} size={20} />\n </span>\n <p className=\"mt-1 text-base font-medium\">{title}</p>\n {body ? <p className=\"text-sm text-muted-foreground\">{body}</p> : null}\n {children ? <div className=\"mt-3\">{children}</div> : null}\n </div>\n );\n}\n\nfunction LoadError({ error, onRetry }) {\n const msg = String(error?.message ?? \"\");\n const why = /invalid pdf|invalid or corrupted/i.test(msg)\n ? \"The file is damaged, or it isn't a PDF.\"\n : /missing pdf|404|not found/i.test(msg)\n ? \"The file couldn't be found. It may have been moved or deleted.\"\n : /network|fetch|cors/i.test(msg)\n ? \"The file couldn't be downloaded. Check your connection and try again.\"\n : \"Something went wrong while opening it.\";\n return (\n <div className=\"grid flex-1 place-items-center bg-muted/40 p-6\">\n <Placeholder icon=\"warning\" title=\"This PDF didn't open\" body={why}>\n <Button icon=\"refresh\" onClick={onRetry}>\n Try again\n </Button>\n </Placeholder>\n </div>\n );\n}\n\nfunction PasswordGate({ submit, wrong }) {\n const [value, setValue] = useState(\"\");\n const [busy, setBusy] = useState(false);\n useEffect(() => setBusy(false), [wrong, submit]);\n return (\n <div className=\"grid flex-1 place-items-center bg-muted/40 p-6\">\n <form\n className=\"grid w-full max-w-sm gap-4 rounded-lg border border-border bg-background p-5 shadow-sm\"\n onSubmit={(e) => {\n e.preventDefault();\n if (!value) return;\n setBusy(true);\n submit(value);\n }}\n >\n <div className=\"grid gap-1\">\n <p className=\"flex items-center gap-2 text-base font-medium\">\n <Icon name=\"lock\" size={16} /> This PDF is protected\n </p>\n <p className=\"text-sm text-muted-foreground\">Enter its password to open it.</p>\n </div>\n <Field id=\"pdf-password\" label=\"Password\" error={wrong && !busy ? \"That password didn't work. Try again.\" : undefined}>\n <PasswordInput id=\"pdf-password\" value={value} autoFocus invalid={wrong && !busy} onChange={(e) => setValue(e.target.value)} />\n </Field>\n <Button type=\"submit\" variant=\"primary\" loading={busy} disabled={!value}>\n Open\n </Button>\n </form>\n </div>\n );\n}\n"
26
+ },
27
+ {
28
+ "path": "registry/blocks/pdf-viewer/notes.jsx",
29
+ "type": "registry:component",
30
+ "target": "@components/makersclaw/blocks/pdf-viewer/notes.jsx",
31
+ "content": "\"use client\";\n\n// The highlights list: every highlight in reading order, its quote, its\n// note, and what can be done with it. A rail beside the pages on a wide\n// viewer, a sheet on a narrow one (pdf-viewer.jsx decides). A click on an\n// entry takes the pages to it; its note is edited in place.\nimport { useEffect, useRef, useState } from \"react\";\nimport { Button, Icon, Menu, Textarea, cx } from \"../../ui\";\nimport { sortHighlights } from \"./helpers\";\n\n/** The colours a highlight can take, as token names (never a colour). */\nexport const HIGHLIGHT_TONES = [\n { value: \"warning\", label: \"Amber\" },\n { value: \"info\", label: \"Blue\" },\n { value: \"success\", label: \"Green\" },\n { value: \"destructive\", label: \"Red\" },\n { value: \"primary\", label: \"Accent\" },\n];\n\n// Literal classes so Tailwind sees every one. On the page a highlight is\n// a marker stroke: the tone, translucent, multiplied into the paper so\n// the ink shows through. In the list it is a bar at the quote's edge.\nexport const TONE_FILL = {\n warning: \"bg-warning/45\",\n info: \"bg-info/30\",\n success: \"bg-success/30\",\n destructive: \"bg-destructive/25\",\n primary: \"bg-primary/30\",\n};\nexport const TONE_RING = {\n warning: \"outline-warning\",\n info: \"outline-info\",\n success: \"outline-success\",\n destructive: \"outline-destructive\",\n primary: \"outline-primary\",\n};\nconst TONE_BAR = {\n warning: \"border-warning\",\n info: \"border-info\",\n success: \"border-success\",\n destructive: \"border-destructive\",\n primary: \"border-primary\",\n};\nexport const toneOf = (h) => (TONE_FILL[h?.tone] ? h.tone : \"warning\");\n\nexport function NotesPanel({ highlights, activeId, editingId, readOnly, onJump, onEdit, onSaveNote, onTone, onDelete, onCopy, className }) {\n const list = sortHighlights(highlights);\n const activeRef = useRef(null);\n\n useEffect(() => {\n activeRef.current?.scrollIntoView?.({ block: \"nearest\", behavior: \"smooth\" });\n }, [activeId]);\n\n if (!list.length) {\n return (\n <div className={cx(\"grid place-items-center px-6 py-10 text-center\", className)}>\n <div className=\"grid max-w-60 justify-items-center gap-2\">\n <span className=\"grid size-10 place-items-center rounded-full bg-muted text-muted-foreground\">\n <Icon name=\"highlighter\" size={18} />\n </span>\n <p className=\"text-sm font-medium\">No highlights yet</p>\n <p className=\"text-sm text-muted-foreground\">\n {readOnly ? \"Highlights added to this document show here.\" : \"Select text on a page, then choose Highlight or Add note.\"}\n </p>\n </div>\n </div>\n );\n }\n\n return (\n <ol className={cx(\"grid gap-1 p-2\", className)} aria-label=\"Highlights\">\n {list.map((h, i) => {\n const prevPage = list[i - 1]?.page;\n return (\n <li key={h.id} ref={h.id === activeId ? activeRef : undefined}>\n {h.page !== prevPage ? <p className={cx(\"px-2 pb-1 text-xs text-muted-foreground tabular-nums\", i > 0 && \"pt-3\")}>{h.page ? `Page ${h.page}` : \"Not found in the text\"}</p> : null}\n <NoteItem\n h={h}\n active={h.id === activeId}\n editing={h.id === editingId}\n readOnly={readOnly}\n onJump={onJump}\n onEdit={onEdit}\n onSaveNote={onSaveNote}\n onTone={onTone}\n onDelete={onDelete}\n onCopy={onCopy}\n />\n </li>\n );\n })}\n </ol>\n );\n}\n\nfunction NoteItem({ h, active, editing, readOnly, onJump, onEdit, onSaveNote, onTone, onDelete, onCopy }) {\n const tone = toneOf(h);\n const [draft, setDraft] = useState(h.note ?? \"\");\n const area = useRef(null);\n\n useEffect(() => {\n if (editing) {\n setDraft(h.note ?? \"\");\n requestAnimationFrame(() => {\n area.current?.focus();\n const n = area.current?.value.length ?? 0;\n area.current?.setSelectionRange?.(n, n);\n });\n }\n }, [editing, h.note]);\n\n const save = () => {\n onSaveNote(h, draft.trim());\n onEdit(null);\n };\n\n return (\n <div className={cx(\"group relative rounded-md transition-colors duration-150 motion-reduce:transition-none\", active ? \"bg-accent\" : \"hover:bg-accent/60\")}>\n {/* raw: the whole quote is the button that takes the pages to it; a\n kit Button is one line, this is a block of text with a tone bar. */}\n <button\n type=\"button\"\n onClick={() => onJump(h)}\n aria-current={active ? \"true\" : undefined}\n className=\"block w-full rounded-md px-2 py-2 pr-10 text-left outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span className={cx(\"line-clamp-3 border-l-2 pl-2.5 text-sm leading-snug text-foreground\", TONE_BAR[tone], h.pending && \"opacity-60\")}>{h.text || \"Highlighted area\"}</span>\n {h.note && !editing ? <span className=\"mt-1.5 block whitespace-pre-wrap pl-3 text-sm text-muted-foreground\">{h.note}</span> : null}\n {h.author || h.createdAt ? (\n <span className=\"mt-1.5 block truncate pl-3 text-xs text-muted-foreground\">{[h.author, h.createdAt ? when(h.createdAt) : null].filter(Boolean).join(\" · \")}</span>\n ) : null}\n </button>\n <div className={cx(\"absolute right-1 top-1 transition-opacity duration-150\", active ? \"opacity-100\" : \"opacity-100 md:opacity-0 md:group-hover:opacity-100 md:group-focus-within:opacity-100\")}>\n <Menu\n label={`Highlight${h.page ? ` on page ${h.page}` : \"\"}, actions`}\n items={[\n !readOnly && { label: h.note ? \"Edit note\" : \"Add note\", icon: \"pencil\", onSelect: () => onEdit(h.id) },\n { label: \"Copy text\", icon: \"copy\", onSelect: () => onCopy(h) },\n !readOnly && {\n label: \"Colour\",\n icon: \"palette\",\n items: [{ type: \"radio\", value: tone, onValueChange: (v) => onTone(h, v), options: HIGHLIGHT_TONES }],\n },\n !readOnly && { type: \"separator\" },\n !readOnly && { label: \"Delete highlight\", icon: \"trash\", tone: \"danger\", onSelect: () => onDelete(h) },\n ]}\n />\n </div>\n {editing ? (\n <div className=\"grid gap-2 px-2 pb-2 pl-5\">\n <Textarea\n ref={area}\n autoGrow\n minRows={2}\n maxRows={8}\n value={draft}\n placeholder=\"Write a note…\"\n aria-label={`Note on page ${h.page}`}\n onChange={(e) => setDraft(e.target.value)}\n onKeyDown={(e) => {\n if (e.key === \"Enter\" && (e.metaKey || e.ctrlKey)) {\n e.preventDefault();\n save();\n } else if (e.key === \"Escape\") {\n e.preventDefault();\n e.stopPropagation();\n onEdit(null);\n }\n }}\n />\n <div className=\"flex items-center justify-end gap-1\">\n <Button size=\"sm\" variant=\"quiet\" onClick={() => onEdit(null)}>\n Cancel\n </Button>\n <Button size=\"sm\" variant=\"primary\" onClick={save}>\n Save note\n </Button>\n </div>\n </div>\n ) : !h.note && !readOnly && active ? (\n <div className=\"px-2 pb-2 pl-4\">\n <Button size=\"sm\" variant=\"quiet\" icon=\"message-square\" onClick={() => onEdit(h.id)}>\n Add note\n </Button>\n </div>\n ) : null}\n </div>\n );\n}\n\nfunction when(v) {\n const d = new Date(v);\n if (Number.isNaN(d.getTime())) return String(v);\n return new Intl.DateTimeFormat(undefined, { day: \"numeric\", month: \"short\" }).format(d);\n}\n"
32
+ },
33
+ {
34
+ "path": "registry/blocks/pdf-viewer/helpers.js",
35
+ "type": "registry:component",
36
+ "target": "@components/makersclaw/blocks/pdf-viewer/helpers.js",
37
+ "content": "// The viewer's plain maths — search, page geometry, highlight rectangles.\n// No DOM and no pdf.js here, so all of it runs in node (and is tested\n// there) and on the server.\n\n// ---- search -----------------------------------------------------------------\n\nconst fold = (s) => String(s ?? \"\").toLocaleLowerCase();\n\n/** Every match of `query` in the pages' text, in reading order.\n *\n * pages [[str, str, …], …] — each page's text items as pdf.js gives\n * them (getTextContent().items[].str), page 1 first\n * query the text looked for, case-insensitive; under 2 characters\n * finds nothing (a single letter matches everywhere)\n *\n * → [{ page (1-based), item, start, length }]. A match inside one text\n * item only: pdf.js splits lines into items, and a phrase broken across\n * two lines is not found (the same as most viewers' quick find). */\nexport function findMatches(pages, query) {\n const q = fold(query).trim();\n if (q.length < 2) return [];\n const out = [];\n (pages ?? []).forEach((items, p) => {\n (items ?? []).forEach((str, item) => {\n const s = fold(str);\n let at = s.indexOf(q);\n while (at !== -1) {\n out.push({ page: p + 1, item, start: at, length: q.length });\n at = s.indexOf(q, at + q.length);\n }\n });\n });\n return out;\n}\n\nconst ESCAPES = { \"&\": \"&amp;\", \"<\": \"&lt;\", \">\": \"&gt;\", '\"': \"&quot;\", \"'\": \"&#39;\" };\nexport const escapeHtml = (s) => String(s ?? \"\").replace(/[&<>\"']/g, (c) => ESCAPES[c]);\n\n/** One text item's HTML with its matches wrapped in <mark>: the text\n * layer's custom renderer. `current` is the start offset of the match\n * that is selected in this item (or -1); it gets `currentClass` and\n * data-current, the others `markClass`. Everything else is escaped. */\nexport function markItem(str, matches, { current = -1, markClass = \"\", currentClass = \"\" } = {}) {\n if (!matches?.length) return escapeHtml(str);\n let html = \"\";\n let at = 0;\n for (const m of [...matches].sort((a, b) => a.start - b.start)) {\n if (m.start < at) continue;\n html += escapeHtml(str.slice(at, m.start));\n const cur = m.start === current;\n html += `<mark class=\"${cur ? currentClass : markClass}\"${cur ? ' data-current=\"true\"' : \"\"}>${escapeHtml(str.slice(m.start, m.start + m.length))}</mark>`;\n at = m.start + m.length;\n }\n return html + escapeHtml(str.slice(at));\n}\n\n/** Matches grouped for fast lookup while rendering: \"page:item\" → [match]. */\nexport function indexMatches(matches) {\n const map = new Map();\n for (const m of matches ?? []) {\n const k = `${m.page}:${m.item}`;\n if (!map.has(k)) map.set(k, []);\n map.get(k).push(m);\n }\n return map;\n}\n\n/** A short excerpt around a match, for a result line: \"…the renewal fee of…\". */\nexport function excerpt(str, start, length, room = 28) {\n const s = String(str ?? \"\");\n const a = Math.max(0, start - room);\n const b = Math.min(s.length, start + length + room);\n return `${a > 0 ? \"…\" : \"\"}${s.slice(a, b).trim()}${b < s.length ? \"…\" : \"\"}`;\n}\n\n// ---- pages and zoom ---------------------------------------------------------\n\n/** The zoom steps the − and + buttons walk. */\nexport const ZOOM_STEPS = [0.5, 0.67, 0.75, 0.9, 1, 1.1, 1.25, 1.5, 1.75, 2, 2.5, 3, 4];\nexport const MIN_ZOOM = ZOOM_STEPS[0];\nexport const MAX_ZOOM = ZOOM_STEPS[ZOOM_STEPS.length - 1];\n\n/** The next zoom step past `scale` in `dir` (+1 in, -1 out). */\nexport function stepZoom(scale, dir) {\n if (dir > 0) return ZOOM_STEPS.find((z) => z > scale + 0.001) ?? MAX_ZOOM;\n return [...ZOOM_STEPS].reverse().find((z) => z < scale - 0.001) ?? MIN_ZOOM;\n}\n\nexport const clampZoom = (z) => Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, Number(z) || 1));\n\n/** The scale at which the widest page fills `width` (px) — \"fit width\" —\n * or, with `height`, at which the tallest fits both ways — \"fit page\". */\nexport function fitScale(sizes, width, height) {\n if (!sizes?.length || !(width > 0)) return 1;\n const w = Math.max(...sizes.map((s) => s.width));\n let z = width / w;\n if (height > 0) z = Math.min(z, height / Math.max(...sizes.map((s) => s.height)));\n return clampZoom(Math.round(z * 1000) / 1000);\n}\n\n/** Where each page starts in the scrolling column: the page sizes at\n * scale 1, times `scale`, with `gap` px between and `pad` px above the\n * first. → { tops: [px], heights: [px], total } */\nexport function layoutPages(sizes, scale, gap = 16, pad = 16) {\n const tops = [];\n const heights = [];\n let y = pad;\n for (const s of sizes ?? []) {\n tops.push(y);\n const h = Math.round(s.height * scale);\n heights.push(h);\n y += h + gap;\n }\n return { tops, heights, total: y - gap + pad };\n}\n\n/** The page (1-based) shown at `y` px down the column: the last page\n * whose top is at or above it. */\nexport function pageAt(tops, y) {\n if (!tops?.length) return 1;\n let lo = 0;\n let hi = tops.length - 1;\n while (lo < hi) {\n const mid = (lo + hi + 1) >> 1;\n if (tops[mid] <= y) lo = mid;\n else hi = mid - 1;\n }\n return lo + 1;\n}\n\n/** The pages (1-based, inclusive) that touch the window [top, bottom],\n * widened by `overscan` pages each way: the ones worth drawing. */\nexport function visiblePages(layout, top, bottom, overscan = 1) {\n const n = layout?.tops?.length ?? 0;\n if (!n) return [1, 0];\n const first = pageAt(layout.tops, top);\n const last = pageAt(layout.tops, bottom);\n return [Math.max(1, first - overscan), Math.min(n, last + overscan)];\n}\n\n/** Where a page is, as a share of the way down it (0…1) at `y`: kept\n * across a zoom so the same line stays in view. */\nexport function anchorAt(layout, y) {\n const page = pageAt(layout.tops, y);\n const top = layout.tops[page - 1] ?? 0;\n const h = layout.heights[page - 1] || 1;\n return { page, frac: Math.min(1, Math.max(0, (y - top) / h)) };\n}\n\n// ---- highlights -------------------------------------------------------------\n\n/** The screen rectangles of a text selection → the highlight's rectangles\n * on its page: fractions of the page's box (0…1), so they hold at any\n * zoom. Rectangles outside the page are dropped; slivers (a line end,\n * a space) are dropped; rectangles on the same line are merged.\n *\n * rects [{ left, top, width, height }] — client rects of the range\n * box { left, top, width, height } — the page's client rect */\nexport function toPageRects(rects, box) {\n if (!box?.width || !box?.height) return [];\n const out = [];\n for (const r of rects ?? []) {\n const left = Math.max(r.left, box.left);\n const right = Math.min(r.left + r.width, box.left + box.width);\n const top = Math.max(r.top, box.top);\n const bottom = Math.min(r.top + r.height, box.top + box.height);\n if (right - left < 1 || bottom - top < 2) continue;\n out.push({ x: (left - box.left) / box.width, y: (top - box.top) / box.height, w: (right - left) / box.width, h: (bottom - top) / box.height });\n }\n return mergeRects(out);\n}\n\n/** Merge rectangles on the same line (their vertical middles within half\n * a line of each other) that touch or nearly touch; sorted top to\n * bottom, left to right. Numbers are rounded to 5 places (plenty for a\n * page, and a saved highlight stays small). */\nexport function mergeRects(rects, gap = 0.01) {\n const sorted = [...(rects ?? [])].filter((r) => r.w > 0 && r.h > 0).sort((a, b) => a.y + a.h / 2 - (b.y + b.h / 2) || a.x - b.x);\n const out = [];\n for (const r of sorted) {\n const last = out[out.length - 1];\n const sameLine = last && Math.abs(last.y + last.h / 2 - (r.y + r.h / 2)) < Math.min(last.h, r.h) / 2;\n if (sameLine && r.x <= last.x + last.w + gap) {\n const x = Math.min(last.x, r.x);\n const y = Math.min(last.y, r.y);\n last.w = Math.max(last.x + last.w, r.x + r.w) - x;\n last.h = Math.max(last.y + last.h, r.y + r.h) - y;\n last.x = x;\n last.y = y;\n } else out.push({ ...r });\n }\n const r5 = (n) => Math.round(n * 1e5) / 1e5;\n return out\n .map((r) => ({ x: r5(r.x), y: r5(r.y), w: r5(r.w), h: r5(r.h) }))\n .sort((a, b) => a.y - b.y || a.x - b.x);\n}\n\n/** Is the point (fractions of the page) inside any of the rectangles? */\nexport const hitRects = (rects, px, py) => (rects ?? []).some((r) => px >= r.x && px <= r.x + r.w && py >= r.y && py <= r.y + r.h);\n\n/** The highlight under a point on a page, the newest first. */\nexport function highlightAt(highlights, page, px, py) {\n for (let i = (highlights ?? []).length - 1; i >= 0; i--) {\n const h = highlights[i];\n if (h.page === page && hitRects(h.rects, px, py)) return h;\n }\n return null;\n}\n\n/** Highlights sorted as they read: by page, then top to bottom. */\nexport function sortHighlights(highlights) {\n const top = (h) => Math.min(...(h.rects?.length ? h.rects.map((r) => r.y) : [0]));\n const page = (h) => h.page || Infinity;\n return [...(highlights ?? [])].sort((a, b) => page(a) - page(b) || top(a) - top(b));\n}\n\n/** A text item from pdf.js (getTextContent().items[i]) → its place on\n * the page in PDF units: { str, x, y (the baseline, from the bottom), w,\n * h (the font size) }. */\nexport function itemBox(item) {\n const t = item?.transform ?? [1, 0, 0, 1, 0, 0];\n return { str: item?.str ?? \"\", x: t[4], y: t[5], w: item?.width ?? 0, h: item?.height || Math.hypot(t[2], t[3]) || 0 };\n}\n\nconst escapeRe = (s) => s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n\n/** Where a quote sits on a page: the rectangles (fractions of the page) of\n * the first place `quote` appears in its text, case-insensitive, across\n * line breaks. How a highlight given only by its words ({ page?, text })\n * finds its place — an agent can flag a clause without any geometry.\n *\n * items the page's items as itemBox gives them\n * size the page's { width, height } in PDF units (its viewport at 1)\n *\n * Within an item the x of a partial match is shared out by characters —\n * close for body text, not exact for a proportional face. Unrotated\n * pages only. → [] when the words are not on the page. */\nexport function rectsForQuote(items, quote, size) {\n const q = String(quote ?? \"\").replace(/\\s+/g, \" \").trim();\n if (q.length < 2 || !size?.width || !size?.height) return [];\n let joined = \"\";\n const spans = [];\n (items ?? []).forEach((it, i) => {\n if (!it?.str) return;\n if (joined && !/\\s$/.test(joined)) joined += \" \";\n spans.push({ i, from: joined.length, to: joined.length + it.str.length });\n joined += it.str;\n });\n const m = new RegExp(escapeRe(q).replace(/ /g, \"\\\\s+\"), \"i\").exec(joined);\n if (!m) return [];\n const a = m.index;\n const b = a + m[0].length;\n const rects = [];\n for (const sp of spans) {\n if (sp.to <= a || sp.from >= b) continue;\n const it = items[sp.i];\n const len = it.str.length || 1;\n const s0 = Math.max(a, sp.from) - sp.from;\n const s1 = Math.min(b, sp.to) - sp.from;\n const x = it.x + (it.w * s0) / len;\n const w = (it.w * (s1 - s0)) / len;\n const top = it.y + it.h * 0.9;\n const bottom = it.y - it.h * 0.25;\n rects.push({ x: x / size.width, y: 1 - top / size.height, w: w / size.width, h: (top - bottom) / size.height });\n }\n return mergeRects(rects);\n}\n\n/** \"Page 3\". */\nexport const pageLabel = (n) => `Page ${n}`;\n"
38
+ },
39
+ {
40
+ "path": "registry/blocks/pdf-viewer/pdfjs.js",
41
+ "type": "registry:component",
42
+ "target": "@components/makersclaw/blocks/pdf-viewer/pdfjs.js",
43
+ "content": "// The one door to react-pdf (MIT) and pdf.js (pdfjs-dist, Apache-2.0).\n// Every file in this block reaches them through here, and the blocks built\n// on this one reach them through PdfViewer — never the packages directly.\n//\n// The order of the imports is the point: the text and annotation layers'\n// stylesheets, then ssr-shim.js (stand-ins for the browser globals pdf.js\n// reads while it loads — only on the server, where they are missing),\n// react-pdf, then ssr-shim-end.js, which takes them away again. Keep them\n// together and in this order.\n//\n// THE WORKER. pdf.js parses PDFs in a Web Worker, and must be told the\n// worker script's URL. The usual answers are a file copied into public/\n// (a file the app has to own and keep in step with the package) or a CDN\n// (a runtime fetch from someone else's server, blocked by a strict CSP).\n// Neither: `new URL(\"pdfjs-dist/build/pdf.worker.min.mjs\", import.meta.url)`\n// is a pattern the bundler (webpack in Next, and Turbopack, Vite, Parcel\n// alike) recognises at BUILD time: it resolves the package's worker file,\n// emits it as a hashed asset under /_next/static/media/, and replaces the\n// expression with that asset's URL (the app's base path included). So the\n// worker ships with the app, is always the same version as the pdf.js that\n// calls it, and is served from the app's own origin. No import(), so the\n// app's one-static-bundle check is happy.\n//\n// It is set here, after react-pdf has loaded, because react-pdf sets its\n// own default when it loads and the later assignment must win. Only in the\n// browser: the server never parses a PDF.\nimport \"react-pdf/dist/Page/TextLayer.css\";\nimport \"react-pdf/dist/Page/AnnotationLayer.css\";\nimport \"./ssr-shim\";\nimport { Document, Page, pdfjs } from \"react-pdf\";\nimport \"./ssr-shim-end\";\n\nif (typeof window !== \"undefined\") {\n pdfjs.GlobalWorkerOptions.workerSrc = new URL(\"pdfjs-dist/build/pdf.worker.min.mjs\", import.meta.url).toString();\n}\n\nexport { Document, Page, pdfjs };\n"
44
+ },
45
+ {
46
+ "path": "registry/blocks/pdf-viewer/ssr-shim.js",
47
+ "type": "registry:component",
48
+ "target": "@components/makersclaw/blocks/pdf-viewer/ssr-shim.js",
49
+ "content": "// Server only: stand-ins for the few browser globals pdf.js's viewer\n// module reads WHILE IT LOADS. See pdfjs.js for why this exists.\n//\n// react-pdf imports pdfjs-dist/web/pdf_viewer.mjs (for its link service),\n// and that module runs browser code at the top level:\n// window.requestAnimationFrame, document.documentElement.style,\n// document.createElement(\"div\") (a CSS feature probe), and react-pdf reads\n// window.location. On the server those throw \"window is not defined\" and\n// the page fails to render — though the viewer itself only ever renders in\n// the browser (the mount guard). The app is one static bundle (no\n// import(), no next/dynamic), so the module cannot be kept off the server;\n// it is let load against these stand-ins instead — the same trick as\n// sketch-canvas's ssr-shim.js.\n//\n// They live only for that load: pdfjs.js imports this file, then\n// react-pdf, then ssr-shim-end.js, which removes them — module evaluation\n// is synchronous, so no other code runs in between. If the load throws,\n// the microtask below removes them anyway, so a global `window` never\n// outlives it. In the browser `window` exists and this file does nothing.\nconst G = globalThis;\nconst SET = [];\n\nfunction stand(name, value) {\n if (typeof G[name] !== \"undefined\") return;\n G[name] = value;\n SET.push(name);\n}\n\nif (typeof window === \"undefined\") {\n const element = () => ({ style: {}, dataset: {}, classList: { add() {}, remove() {}, toggle() {} }, setAttribute() {}, appendChild() {} });\n stand(\"window\", { location: { protocol: \"\", href: \"\", origin: \"\" }, devicePixelRatio: 1, requestAnimationFrame: () => 0 });\n stand(\"document\", { createElement: element, documentElement: element(), head: element(), body: element() });\n G.__pdfViewerShim = SET;\n queueMicrotask(() => {\n if (G.__pdfViewerShim === SET) removeShim();\n });\n}\n\nexport function removeShim() {\n const set = G.__pdfViewerShim;\n if (!set) return;\n for (const name of set) delete G[name];\n delete G.__pdfViewerShim;\n}\n"
50
+ },
51
+ {
52
+ "path": "registry/blocks/pdf-viewer/ssr-shim-end.js",
53
+ "type": "registry:component",
54
+ "target": "@components/makersclaw/blocks/pdf-viewer/ssr-shim-end.js",
55
+ "content": "// Server only: removes the stand-ins ssr-shim.js laid, right after\n// react-pdf and pdf.js have loaded (see ssr-shim.js). Does nothing in the\n// browser.\nimport { removeShim } from \"./ssr-shim\";\n\nremoveShim();\n"
56
+ },
57
+ {
58
+ "path": "registry/blocks/pdf-viewer/README.md",
59
+ "type": "registry:file",
60
+ "target": "@components/makersclaw/blocks/pdf-viewer/README.md",
61
+ "content": "# PdfViewer\n\nA PDF on screen, in the workspace theme. The pages scroll in one column;\na rail of thumbnails sits on the left and a page box in the toolbar\njumps anywhere. Zoom by steps, fit the width, fit the page, or hold\nCtrl/⌘ and scroll (a trackpad pinch works too) — the line under the\npointer stays put. ⌘F finds words in the text: every match is marked on\nthe page, Enter walks them.\n\nPeople can highlight. Select a sentence and a small bar offers\n**Highlight**, **Add note** and **Copy**. A highlight shows as a marker\nstroke in a theme colour; its note sits in the Highlights list beside\nthe pages, where it is edited, recoloured or deleted (with Undo). Every\nchange shows at once and saves through your callback; when a save fails,\nthe change goes back and a toast says why.\n\nA narrow viewer (under 900px wide — a phone, a side column) turns the\npage rail and the Highlights list into sheets. Long files stay fast:\nonly the pages near the window are drawn. Password-protected files ask\nfor their password. A file that will not open says why and offers Try\nagain.\n\nIt is [React-PDF](https://github.com/wojtekmaj/react-pdf) (MIT) over\nMozilla's [PDF.js](https://github.com/mozilla/pdf.js) (Apache-2.0)\nunderneath: they parse and draw the pages and the selectable text. The\ntoolbar, rails, search, highlights and states are ours, in the kit. The\nblocks built on this one (proposal-accept, a contract review screen…)\nimport it, never `react-pdf` or `pdfjs-dist`.\n\n```jsx\nimport { PdfViewer } from \"../components/blocks/pdf-viewer\"; // a Makersclaw app (\"./blocks/pdf-viewer\" from components/)\nimport { PdfViewer } from \"@/components/makersclaw/blocks/pdf-viewer\"; // a shadcn project\n```\n\n`PdfViewer` is a client component: render it from a `\"use client\"`\ncomponent. The helpers (`findMatches`, `toPageRects`, `sortHighlights`…)\nare plain functions and work on the server.\n\n## The file, and the worker\n\n`file` is a URL (same origin, or a server that allows CORS), a `Blob` /\n`File` (an upload, or a PDF made with the `pdf-document` block), or the\nbytes (`ArrayBuffer` / `Uint8Array`, copied — your buffer stays usable).\n\nPDF.js parses in a Web Worker. Nothing to set up: the block points\nPDF.js at `new URL(\"pdfjs-dist/build/pdf.worker.min.mjs\", import.meta.url)`,\nwhich the app's bundler resolves at build time and emits as a hashed\nfile under `/_next/static/media/`, with the base path applied. So the\nworker ships with the app, always matches the PDF.js version that calls\nit, and comes from the app's own origin — no file in `public/`, no CDN.\n\n## The data\n\nA highlight is one row:\n\n```js\n{\n id: \"hl_81\", // yours\n page: 3, // 1-based\n rects: [{ x, y, w, h }], // fractions of the page (0…1), top-left origin — hold at any zoom\n text: \"Fees rise by 4% at each annual renewal\", // the words highlighted\n note: \"Ask for a 3% cap.\", // optional\n tone: \"warning\", // warning (default) · info · success · destructive · primary\n author: \"Priya Raman\", // optional, shown under the note\n createdAt: \"2026-09-21\", // optional\n}\n```\n\n`rects` may be left out: a highlight given only by its `text` (and\noptionally its `page`) is placed where those words first appear, once\nthe pages' text has been read. That is how an agent or a server flags a\nclause without knowing any geometry. A quote that is not in the text is\nlisted under \"Not found in the text\". Store the `rects` the viewer sends\nyou in `onHighlight` — they are exact; placing by words is close (the\nwidth inside a line is shared out by characters) and assumes pages that\nare not rotated.\n\nStore a highlight as a row in its own table (`document_id`, `page`,\n`rects` jsonb, `text`, `note`, `tone`, `author`, `created_at`).\n\n## Props\n\n- `file` — the PDF (see above). `null` shows the empty state.\n- `title` — names the viewer for screen readers and shows in the\n toolbar on a wide viewer. Default `\"PDF document\"` for readers, nothing\n shown.\n- `fileName` — what Download saves as. Default: the title + `.pdf`.\n- `highlights` — the rows above. Default `[]`.\n- `onHighlight(h)` — save a new highlight: `h` is `{ page, rects, text,\n note, tone }`. Return `{ ok: true, id }` (your id replaces the viewer's\n temporary one) or `{ ok: false, error }` (a throw counts as failed): it\n is removed and a toast shows `error`. **Without `onHighlight` the viewer\n is read-only**: no selection bar, and the Highlights list (shown only\n when there are highlights) cannot edit.\n- `onHighlightChange(id, patch)` — `patch` is `{ note }` or `{ tone }`.\n Same return; a failure puts the old value back.\n- `onHighlightDelete(id)` — called 5 seconds after a delete, once the\n Undo in its toast has passed (or at once if the viewer unmounts). Same\n return; a failure puts the highlight back.\n- `initialPage` — the page to open on. Default `1`.\n- `onPageChange(page, pageCount)` — whenever the page at the top of the\n window changes.\n- `defaultZoom` — `\"fit\"` (width, the default) · `\"page\"` · a number\n (`1` is 100%).\n- `thumbnails` — the page rail. Default `true`.\n- `search` — find in the text. Default `true`.\n- `download` — the Download button. Default `true`.\n- `notes` — open the Highlights list on a wide viewer at first. Default:\n open when there are highlights.\n- `height` — the viewer's height, any CSS length. Default\n `\"min(80vh, 860px)\"`; it never goes under 384px.\n- `loading` — `true` shows the skeleton (while you fetch or make the\n file) instead of the empty state.\n- `emptyTitle` · `emptyBody` — the words when `file` is empty. Default\n \"No document yet\" / \"When a PDF is attached, it shows here.\"\n- `viewerRef` — a ref from `usePdfViewer()`; `ref.current` then has\n `goToPage(n)`, `goToHighlight(id)`, `search(text)`, `setZoom(z)`, and\n `page` / `pageCount`.\n- `className` — added to the frame (a margin, a width). Its border,\n radius and height are the block's; set height through `height`.\n\nKeys, while focus is in the viewer: ⌘/Ctrl + F find, Enter / Shift+Enter\nnext / previous match, Esc closes find; ⌘/Ctrl + `+` / `-` zoom, `0`\nfits the width; Page Up / Page Down and the arrows scroll; Tab reaches\nevery control and each page thumbnail. Notes: ⌘/Ctrl + Enter saves, Esc\ncancels.\n\n## Example\n\n```jsx\n\"use client\";\nimport { useState } from \"react\";\nimport { PdfViewer } from \"../components/blocks/pdf-viewer\";\nimport { insertRow, updateRow, deleteRow } from \"../lib/actions\";\n\nconst source = \"public.highlights\";\n\nexport function ContractReview({ doc, rows }) {\n const [highlights, setHighlights] = useState(rows);\n return (\n <PdfViewer\n file={doc.url}\n title={doc.title}\n highlights={highlights}\n onHighlight={async (h) => {\n const r = await insertRow({ source, values: { document_id: doc.id, ...h } });\n if (r.ok) setHighlights((list) => [...list, { ...h, id: r.id }]);\n return r;\n }}\n onHighlightChange={async (id, patch) => {\n const r = await updateRow({ source, id, patch });\n if (r.ok) setHighlights((list) => list.map((x) => (x.id === id ? { ...x, ...patch } : x)));\n return r;\n }}\n onHighlightDelete={async (id) => {\n const r = await deleteRow({ source, id });\n if (r.ok) setHighlights((list) => list.filter((x) => x.id !== id));\n return r;\n }}\n />\n );\n}\n```\n\nPreview a PDF you make (the `pdf-document` block):\n\n```jsx\nconst { blob } = usePdf(() => <QuoteDocument {...quote} />, [quote]);\n<PdfViewer file={blob} loading={!blob} title={`Quote ${quote.number}`} thumbnails={false} />\n```\n\n## What it will not do\n\n- Edit the PDF: fill its forms, sign it, rotate or reorder pages, or\n write highlights into the file. Highlights are rows you store; the PDF\n is untouched. (For signing, the `signature-pad` block.)\n- Search across a line break in one phrase: PDF.js splits text into\n lines, and a match must sit inside one (like most quick finds).\n- Highlight in a scanned PDF (an image with no text layer): there is no\n text to select or find.\n- Keep the old pages up while a NEW file loads: a changed `file` shows\n the skeleton until it has opened.\n- Open a URL on another origin that does not allow CORS: fetch it on\n your server and pass the bytes.\n- Print; the browser's own PDF reader does that from Download.\n\n## Credits\n\nBuilt on [React-PDF](https://github.com/wojtekmaj/react-pdf) by Wojciech\nMaj (MIT) and [PDF.js](https://github.com/mozilla/pdf.js) by Mozilla\n(Apache-2.0), used unmodified. See THIRD-PARTY-NOTICES.md.\n"
62
+ }
63
+ ],
64
+ "categories": [
65
+ "pdf",
66
+ "viewer",
67
+ "document",
68
+ "reader",
69
+ "preview",
70
+ "highlight",
71
+ "annotate",
72
+ "notes",
73
+ "search",
74
+ "thumbnails",
75
+ "zoom",
76
+ "contract",
77
+ "proposal",
78
+ "react-pdf",
79
+ "pdfjs"
80
+ ],
81
+ "docs": "Installed to components/makersclaw/blocks/pdf-viewer. Import: import { PdfViewer, usePdfViewer, HIGHLIGHT_TONES, findMatches, toPageRects, mergeRects, sortHighlights, highlightAt, fitScale, ZOOM_STEPS } from \"@/components/makersclaw/blocks/pdf-viewer\". Props and an example: components/makersclaw/blocks/pdf-viewer/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
82
+ "meta": {
83
+ "makersclaw": {
84
+ "version": "0.3.0",
85
+ "useWhen": "A screen shows an existing PDF: a contract or proposal to review, a signed agreement, an uploaded invoice or statement, a report, a generated quote before it is sent; or people mark up a PDF and leave notes on passages. Pair it with pdf-document to preview a PDF you make. Not for making PDFs (pdf-document), for rich text people edit (doc-editor), or for marking up a screenshot (annotate)."
86
+ }
87
+ }
88
+ }