makersclaw 0.5.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +73 -7
- package/package.json +3 -2
- package/r/annotate.json +4 -4
- package/r/approval-request.json +3 -3
- package/r/audio-player.json +3 -3
- package/r/auth-form.json +2 -2
- package/r/board.json +4 -4
- package/r/calendar-view.json +4 -4
- package/r/cart.json +3 -3
- package/r/chart.json +3 -3
- package/r/chat.json +3 -3
- package/r/code-editor.json +4 -4
- package/r/comment-box.json +4 -4
- package/r/contact-section.json +3 -3
- package/r/core.json +3 -3
- package/r/csv-import.json +4 -4
- package/r/data-table.json +3 -3
- package/r/diagram.json +3 -3
- package/r/doc-editor.json +4 -4
- package/r/doc-page.json +4 -4
- package/r/email-render.json +2 -2
- package/r/faq.json +2 -2
- package/r/file-upload.json +3 -3
- package/r/flow-canvas.json +4 -4
- package/r/footer.json +3 -3
- package/r/gantt.json +4 -4
- package/r/job-panel.json +3 -3
- package/r/json-editor.json +4 -4
- package/r/map.json +5 -5
- package/r/menubar.json +2 -2
- package/r/mind-map.json +4 -4
- package/r/navigation-menu.json +2 -2
- package/r/order-detail.json +2 -2
- package/r/org-chart.json +4 -4
- package/r/page-builder.json +6 -6
- package/r/payment-methods.json +2 -2
- package/r/pdf-document.json +2 -2
- package/r/pdf-viewer.json +6 -6
- package/r/product-detail.json +3 -3
- package/r/product-grid.json +4 -4
- package/r/record-form.json +3 -3
- package/r/record-notes.json +5 -5
- package/r/registry.json +121 -121
- package/r/search-dialog.json +3 -3
- package/r/settings-list.json +3 -3
- package/r/setup-checklist.json +3 -3
- package/r/share-dialog.json +3 -3
- package/r/sidebar.json +2 -2
- package/r/signature-pad.json +3 -3
- package/r/sketch-canvas.json +5 -5
- package/r/spreadsheet.json +3 -3
- package/r/stat-cards.json +4 -4
- package/r/task-list.json +4 -4
- package/r/task-model.json +3 -3
- package/r/testimonials.json +2 -2
- package/r/transaction-list.json +3 -3
- package/r/whiteboard.json +4 -4
- package/registry/blocks/annotate/README.md +14 -5
- package/registry/blocks/approval-request/README.md +15 -8
- package/registry/blocks/audio-player/README.md +17 -10
- package/registry/blocks/board/README.md +13 -9
- package/registry/blocks/board/board.jsx +1 -1
- package/registry/blocks/calendar-view/README.md +28 -4
- package/registry/blocks/cart/README.md +14 -10
- package/registry/blocks/chart/README.md +14 -7
- package/registry/blocks/chat/README.md +43 -13
- package/registry/blocks/code-editor/README.md +4 -0
- package/registry/blocks/code-editor/code-editor.jsx +1 -1
- package/registry/blocks/comment-box/README.md +20 -13
- package/registry/blocks/contact-section/README.md +7 -6
- package/registry/blocks/csv-import/README.md +34 -12
- package/registry/blocks/data-table/README.md +41 -8
- package/registry/blocks/diagram/diagram.jsx +1 -1
- package/registry/blocks/doc-editor/README.md +18 -6
- package/registry/blocks/doc-editor/doc-editor.jsx +1 -1
- package/registry/blocks/doc-page/README.md +16 -6
- package/registry/blocks/file-upload/README.md +7 -1
- package/registry/blocks/flow-canvas/README.md +16 -5
- package/registry/blocks/flow-canvas/flow-canvas.jsx +1 -1
- package/registry/blocks/footer/README.md +4 -2
- package/registry/blocks/gantt/README.md +18 -12
- package/registry/blocks/job-panel/README.md +1 -1
- package/registry/blocks/json-editor/README.md +16 -4
- package/registry/blocks/map/README.md +19 -3
- package/registry/blocks/map/map-view.jsx +1 -1
- package/registry/blocks/map/maplibre.js +1 -1
- package/registry/blocks/mind-map/README.md +16 -5
- package/registry/blocks/org-chart/README.md +10 -7
- package/registry/blocks/page-builder/README.md +13 -6
- package/registry/blocks/page-builder/page-builder.jsx +1 -1
- package/registry/blocks/page-builder/page-view.jsx +1 -1
- package/registry/blocks/page-builder/page.js +1 -1
- package/registry/blocks/pdf-viewer/README.md +22 -20
- package/registry/blocks/pdf-viewer/pdf-viewer.jsx +1 -1
- package/registry/blocks/pdf-viewer/pdfjs.js +1 -1
- package/registry/blocks/pdf-viewer/ssr-shim.js +1 -1
- package/registry/blocks/product-detail/README.md +13 -7
- package/registry/blocks/product-detail/demo.jsx +1 -1
- package/registry/blocks/product-grid/README.md +12 -10
- package/registry/blocks/record-form/README.md +26 -9
- package/registry/blocks/record-notes/README.md +42 -30
- package/registry/blocks/search-dialog/README.md +38 -7
- package/registry/blocks/settings-list/README.md +29 -17
- package/registry/blocks/setup-checklist/README.md +12 -14
- package/registry/blocks/share-dialog/README.md +1 -1
- package/registry/blocks/signature-pad/README.md +15 -4
- package/registry/blocks/sketch-canvas/README.md +13 -4
- package/registry/blocks/sketch-canvas/sketch-canvas.jsx +1 -1
- package/registry/blocks/sketch-canvas/ssr-shim.js +3 -3
- package/registry/blocks/spreadsheet/README.md +17 -8
- package/registry/blocks/stat-cards/README.md +17 -8
- package/registry/blocks/task-list/README.md +14 -11
- package/registry/blocks/task-model/README.md +3 -3
- package/registry/blocks/transaction-list/README.md +15 -7
- package/registry/blocks/whiteboard/README.md +19 -13
- package/src/cli.mjs +65 -9
- package/src/install.mjs +360 -21
- package/src/lockfile.mjs +468 -0
- package/src/pm.mjs +473 -0
- package/src/registry.mjs +125 -1
- package/templates/next/browser/app/auto-refresh.jsx +67 -0
- package/templates/next/browser/app/layout.jsx +94 -0
- package/templates/next/browser/app/page.jsx +60 -0
- package/templates/next/browser/app/s/[key]/page.jsx +95 -0
- package/templates/next/browser/lib/actions.js +16 -0
- package/templates/next/browser/lib/data.js +43 -0
- package/templates/next/files/README.md +818 -82
- package/templates/next/files/app/claw-alive/route.js +2 -2
- package/templates/next/files/app/frame.js +134 -9
- package/templates/next/files/app/layout.jsx +2 -2
- package/templates/next/files/check/run.mjs +1491 -53
- package/templates/next/files/components/ui/item.jsx +2 -2
- package/templates/next/files/components/ui/notice.jsx +1 -1
- package/templates/next/files/components/ui/separator.jsx +1 -1
- package/templates/next/files/lib/actions.js +171 -52
- package/templates/next/files/lib/brain-server.js +96 -0
- package/templates/next/files/lib/brain.js +413 -0
- package/templates/next/files/lib/claw.js +11 -0
- package/templates/next/files/lib/data.js +120 -30
- package/templates/next/files/lib/db.js +97 -17
- package/templates/next/files/lib/door.js +680 -0
- package/templates/next/files/next.config.mjs +85 -9
- package/templates/next/files/package.json +3 -3
- package/templates/next/files/{middleware.js → proxy.js} +13 -6
- package/templates/next/files/surfaces.js +3 -2
- package/templates/next/pnpm-lock.yaml +5870 -0
- package/templates/next/template.json +13 -6
- package/templates/next/files/schema.sql +0 -22
package/r/pdf-viewer.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "PdfViewer",
|
|
6
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
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [
|
|
11
11
|
"react-pdf@11.0.0",
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
"path": "registry/blocks/pdf-viewer/pdf-viewer.jsx",
|
|
23
23
|
"type": "registry:component",
|
|
24
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"
|
|
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 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
26
|
},
|
|
27
27
|
{
|
|
28
28
|
"path": "registry/blocks/pdf-viewer/notes.jsx",
|
|
@@ -40,13 +40,13 @@
|
|
|
40
40
|
"path": "registry/blocks/pdf-viewer/pdfjs.js",
|
|
41
41
|
"type": "registry:component",
|
|
42
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-
|
|
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-bundle check (no import()) 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
44
|
},
|
|
45
45
|
{
|
|
46
46
|
"path": "registry/blocks/pdf-viewer/ssr-shim.js",
|
|
47
47
|
"type": "registry:component",
|
|
48
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
|
|
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 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
50
|
},
|
|
51
51
|
{
|
|
52
52
|
"path": "registry/blocks/pdf-viewer/ssr-shim-end.js",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"path": "registry/blocks/pdf-viewer/README.md",
|
|
59
59
|
"type": "registry:file",
|
|
60
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 {
|
|
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// surfaces/contract.jsx — client: one document and its highlights\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow, useRows, whenLanded } from \"../lib/data\";\nimport { deleteRow, insertRow, updateRow } from \"../lib/actions\";\nimport { PdfViewer } from \"../components/blocks/pdf-viewer\";\n\nconst source = \"highlights\";\n\n// A write answers once the highlights are read again, so the viewer never\n// shows the list from before it.\nasync function landed(res) {\n if (res.ok) await whenLanded();\n return res;\n}\n\nexport default function ContractReview() {\n const id = useSearchParams().get(\"row\");\n const { row: doc, loading } = useRow({ source: \"documents\", id });\n const { rows: highlights } = useRows({ source, filter: { document_id: id } });\n if (loading || !doc) return null;\n return (\n <PdfViewer\n file={doc.url}\n title={doc.title}\n highlights={highlights}\n onHighlight={(h) => insertRow({ source, values: { document_id: doc.id, ...h } }).then(landed)}\n onHighlightChange={(hid, patch) => updateRow({ source, id: hid, patch }).then(landed)}\n onHighlightDelete={(hid) => deleteRow({ source, id: hid }).then(landed)}\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\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
62
|
}
|
|
63
63
|
],
|
|
64
64
|
"categories": [
|
|
@@ -81,7 +81,7 @@
|
|
|
81
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
82
|
"meta": {
|
|
83
83
|
"makersclaw": {
|
|
84
|
-
"version": "0.
|
|
84
|
+
"version": "0.7.0",
|
|
85
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
86
|
}
|
|
87
87
|
}
|
package/r/product-detail.json
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
"title": "Product detail",
|
|
6
6
|
"description": "One product on its own page: pictures (thumbnails, a side rail, a carousel, a tall stack, or one scene with the buy box floating over it), brand, name, price with the old price struck through, star rating and review count, options (sizes as a segmented row, colours as swatches, long lists as a select, plans as cards with their own price), a quantity control, add to cart through an async callback that may fail, buy now, save to favourites, perks, and details in an accordion or tabs. Loading, error, not-found and sold-out states.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json"
|
|
9
9
|
],
|
|
10
10
|
"dependencies": [],
|
|
11
11
|
"files": [
|
|
@@ -43,7 +43,7 @@
|
|
|
43
43
|
"path": "registry/blocks/product-detail/README.md",
|
|
44
44
|
"type": "registry:file",
|
|
45
45
|
"target": "@components/makersclaw/blocks/product-detail/README.md",
|
|
46
|
-
"content": "# Product detail\n\nOne product on its own page: its pictures, name, price, rating, the\noptions a customer picks (size, colour, plan), how many, and **Add to\ncart**. The add goes out through `onAddToCart(line)`; while it runs the\nbutton spins, when it fails the block says why and nothing else changes,\nwhen it works it says what was added. Details (description, care,\nshipping) sit under the buy box in an accordion or tabs.\n\n```jsx\nimport { ProductDetail } from \"../components/blocks/product-detail\"; // a Makersclaw app (\"./blocks/product-detail\" from components/)\nimport { ProductDetail } from \"@/components/makersclaw/blocks/product-detail\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the product in the server surface (README §4).\n\n## The data\n\nA **product** is one plain object. Only `id`, `name` and `price` are\nneeded; everything else draws a part when it is there.\n\n```js\n{\n id: \"tee-linen\", // required\n name: \"Linen camp shirt\", // required\n price: 68, // required: a number in the currency's major unit (68 = $68.00)\n compareAt: 85, // the old price, struck through, with \"20% off\"\n currency: \"USD\", // else the block's `currency`\n image: \"https://…\", // one picture, or:\n images: [\"https://…\", { src: \"https://…\", alt: \"The back\" }],\n brand: \"Harbor & Pine\", // a small line over the name\n badge: \"New\", // an outlined label beside it\n description: \"…\",\n rating: 4.6, reviews: 214, // stars (halves) and the count\n href: \"/shop/tee-linen\", // where the product lives (carried into the cart line)\n available: false, // sold out: the button says so and is off\n stock: 3, // the quantity control stops here\n options: [ // what the customer picks, in order\n { key: \"size\", label: \"Size\", values: [\"S\", \"M\", \"L\", { value: \"XL\", disabled: true }] },\n { key: \"color\", label: \"Colour\", values: [{ value: \"sand\", label: \"Sand\", color: \"#d8c8a8\" }] },\n ],\n selected: { size: \"M\" }, // the first choices (else the first open value of each)\n details: [{ key: \"care\", title: \"Care\", content: \"Cold wash…\" }], // content: text or a node\n perks: [{ icon: \"truck\", label: \"Free shipping over $100\" }],\n stats: [{ value: \"1,234\", label: \"sales\" }],\n scene: \"https://…\", // gallery=\"overlay\": the wide picture the buy box floats on\n}\n```\n\nAn option's **values** are strings or `{ value, label?, color?, price?,\ncompareAt?, per?, description?, badge?, disabled? }`. How an option is\ndrawn follows its values (or its `display`):\n\n- `color` on the values → **swatches** (colour squares; the chosen name\n in the heading). The colour is data, drawn by the kit's `Swatch`.\n- up to 6 short values → **buttons**, a segmented row (sizes).\n- more, or longer → **select**, the native one.\n- `price` or `description` on the values → **plans**, a card each. The\n chosen value's `price` replaces the product's (a licence, a bigger box).\n\n`disabled: true` on a value marks it sold out: struck through, not\nchoosable.\n\n### The cart line\n\n`onAddToCart` and `onBuyNow` get one **line**, the same shape the `cart`\nblock takes as an item, so a surface can push it straight into its cart\nrows:\n\n```js\n{\n id: \"tee-linen\",\n key: \"tee-linen:size=M:color=sand\", // one cart row per product + choices\n name: \"Linen camp shirt\",\n price: 68, compareAt: 85, currency: \"USD\",\n image: \"https://…\",\n href: \"/shop/tee-linen\",\n quantity: 2,\n selected: { size: \"M\", color: \"sand\" }, // machine values, for your order row\n variant: \"M · Sand\", // what the cart prints under the name\n}\n```\n\n`cartLine(product, selected, quantity, currency)` builds the same line\nanywhere else (a grid's quick add).\n\n## Props\n\n- `product` — the product above. `null` (and not loading) shows \"No\n product here\".\n- `onAddToCart(line)` — put the line in the cart; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed, its message shown).\n Without it the button is off.\n- `gallery` — how the pictures sit: `\"thumbs\"` (default: one large, a\n row of thumbnails under it) · `\"rail\"` (thumbnails in a column beside\n it from lg up) · `\"carousel\"` (one at a time, swipe, arrows, dots) ·\n `\"stack\"` (every picture, the buy box sticky beside them) ·\n `\"overlay\"` (one wide `scene` picture with the buy box floating over it\n from lg up; under it on a phone).\n- `details` — `\"accordion\"` (default; the first section open) or `\"tabs\"`.\n- `onBuyNow(line)` — a second button that goes straight to checkout\n (open Paddle there); `{ ok }` as above. Absent: no button.\n- `onFavorite(product, next)` — shows a heart toggle; `next` is true to\n save, false to remove. It flips at once and flips back, with the\n error, when the save fails. `favorite` — its starting state.\n- `onViewCart()` — a \"View cart\" button in the added message.\n- `breadcrumb` — `[{ label, href? }]`, drawn above with the kit's\n `Breadcrumb`.\n- `perks` — `[{ icon, label }]` under the buttons (else `product.perks`).\n `icon` is a kit `Icon` name: `truck`, `refresh`, `shield-check`, `package`.\n- `quantity` — `false` hides the quantity control (a download); default\n `true`. `maxQuantity` — the most at once (10; `product.stock` caps it).\n- `currency` — default `\"USD\"`. `locale` — for prices and counts\n (default: the reader's).\n- `imageFit` — `\"cover\"` (default, cropped to the square) or `\"contain\"`\n (the whole picture, on a muted fill: packshots on white, screenshots).\n- `loading` — skeletons in the page's shape. `error` (a string) — the\n load failed; `onRetry` adds \"Try again\".\n- `addLabel` (`\"Add to cart\"`) · `buyLabel` (`\"Buy now\"`) — the words.\n- `headingLevel` — the name's heading, `2` by default; `1` when the block\n IS the page.\n- `className` — layout around it (width, margin). Never colour.\n\nThe parts are exported for a page of your own: `ProductMedia({ product,\nlayout, fit })`, `OptionPicker({ option, value, onChange })`,\n`QuantityControl({ value, onChange, min, max })`, `Price({ price,\ncompareAt, currency })`, `formatPrice(amount, currency, locale)`.\n\n## Example\n\n```jsx\n// surfaces/product.jsx —
|
|
46
|
+
"content": "# Product detail\n\nOne product on its own page: its pictures, name, price, rating, the\noptions a customer picks (size, colour, plan), how many, and **Add to\ncart**. The add goes out through `onAddToCart(line)`; while it runs the\nbutton spins, when it fails the block says why and nothing else changes,\nwhen it works it says what was added. Details (description, care,\nshipping) sit under the buy box in an accordion or tabs.\n\n```jsx\nimport { ProductDetail } from \"../components/blocks/product-detail\"; // a Makersclaw app (\"./blocks/product-detail\" from components/)\nimport { ProductDetail } from \"@/components/makersclaw/blocks/product-detail\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the product in the server surface (README §4).\n\n## The data\n\nA **product** is one plain object. Only `id`, `name` and `price` are\nneeded; everything else draws a part when it is there.\n\n```js\n{\n id: \"tee-linen\", // required\n name: \"Linen camp shirt\", // required\n price: 68, // required: a number in the currency's major unit (68 = $68.00)\n compareAt: 85, // the old price, struck through, with \"20% off\"\n currency: \"USD\", // else the block's `currency`\n image: \"https://…\", // one picture, or:\n images: [\"https://…\", { src: \"https://…\", alt: \"The back\" }],\n brand: \"Harbor & Pine\", // a small line over the name\n badge: \"New\", // an outlined label beside it\n description: \"…\",\n rating: 4.6, reviews: 214, // stars (halves) and the count\n href: \"/shop/tee-linen\", // where the product lives (carried into the cart line)\n available: false, // sold out: the button says so and is off\n stock: 3, // the quantity control stops here\n options: [ // what the customer picks, in order\n { key: \"size\", label: \"Size\", values: [\"S\", \"M\", \"L\", { value: \"XL\", disabled: true }] },\n { key: \"color\", label: \"Colour\", values: [{ value: \"sand\", label: \"Sand\", color: \"#d8c8a8\" }] },\n ],\n selected: { size: \"M\" }, // the first choices (else the first open value of each)\n details: [{ key: \"care\", title: \"Care\", content: \"Cold wash…\" }], // content: text or a node\n perks: [{ icon: \"truck\", label: \"Free shipping over $100\" }],\n stats: [{ value: \"1,234\", label: \"sales\" }],\n scene: \"https://…\", // gallery=\"overlay\": the wide picture the buy box floats on\n}\n```\n\nAn option's **values** are strings or `{ value, label?, color?, price?,\ncompareAt?, per?, description?, badge?, disabled? }`. How an option is\ndrawn follows its values (or its `display`):\n\n- `color` on the values → **swatches** (colour squares; the chosen name\n in the heading). The colour is data, drawn by the kit's `Swatch`.\n- up to 6 short values → **buttons**, a segmented row (sizes).\n- more, or longer → **select**, the native one.\n- `price` or `description` on the values → **plans**, a card each. The\n chosen value's `price` replaces the product's (a licence, a bigger box).\n\n`disabled: true` on a value marks it sold out: struck through, not\nchoosable.\n\n### The cart line\n\n`onAddToCart` and `onBuyNow` get one **line**, the same shape the `cart`\nblock takes as an item, so a surface can push it straight into its cart\nrows:\n\n```js\n{\n id: \"tee-linen\",\n key: \"tee-linen:size=M:color=sand\", // one cart row per product + choices\n name: \"Linen camp shirt\",\n price: 68, compareAt: 85, currency: \"USD\",\n image: \"https://…\",\n href: \"/shop/tee-linen\",\n quantity: 2,\n selected: { size: \"M\", color: \"sand\" }, // machine values, for your order row\n variant: \"M · Sand\", // what the cart prints under the name\n}\n```\n\n`cartLine(product, selected, quantity, currency)` builds the same line\nanywhere else (a grid's quick add).\n\n## Props\n\n- `product` — the product above. `null` (and not loading) shows \"No\n product here\".\n- `onAddToCart(line)` — put the line in the cart; return `{ ok: true }` or\n `{ ok: false, error }` (a throw counts as failed, its message shown).\n Without it the button is off.\n- `gallery` — how the pictures sit: `\"thumbs\"` (default: one large, a\n row of thumbnails under it) · `\"rail\"` (thumbnails in a column beside\n it from lg up) · `\"carousel\"` (one at a time, swipe, arrows, dots) ·\n `\"stack\"` (every picture, the buy box sticky beside them) ·\n `\"overlay\"` (one wide `scene` picture with the buy box floating over it\n from lg up; under it on a phone).\n- `details` — `\"accordion\"` (default; the first section open) or `\"tabs\"`.\n- `onBuyNow(line)` — a second button that goes straight to checkout\n (open Paddle there); `{ ok }` as above. Absent: no button.\n- `onFavorite(product, next)` — shows a heart toggle; `next` is true to\n save, false to remove. It flips at once and flips back, with the\n error, when the save fails. `favorite` — its starting state.\n- `onViewCart()` — a \"View cart\" button in the added message.\n- `breadcrumb` — `[{ label, href? }]`, drawn above with the kit's\n `Breadcrumb`.\n- `perks` — `[{ icon, label }]` under the buttons (else `product.perks`).\n `icon` is a kit `Icon` name: `truck`, `refresh`, `shield-check`, `package`.\n- `quantity` — `false` hides the quantity control (a download); default\n `true`. `maxQuantity` — the most at once (10; `product.stock` caps it).\n- `currency` — default `\"USD\"`. `locale` — for prices and counts\n (default: the reader's).\n- `imageFit` — `\"cover\"` (default, cropped to the square) or `\"contain\"`\n (the whole picture, on a muted fill: packshots on white, screenshots).\n- `loading` — skeletons in the page's shape. `error` (a string) — the\n load failed; `onRetry` adds \"Try again\".\n- `addLabel` (`\"Add to cart\"`) · `buyLabel` (`\"Buy now\"`) — the words.\n- `headingLevel` — the name's heading, `2` by default; `1` when the block\n IS the page.\n- `className` — layout around it (width, margin). Never colour.\n\nThe parts are exported for a page of your own: `ProductMedia({ product,\nlayout, fit })`, `OptionPicker({ option, value, onChange })`,\n`QuantityControl({ value, onChange, min, max })`, `Price({ price,\ncompareAt, currency })`, `formatPrice(amount, currency, locale)`.\n\n## Example\n\n```jsx\n// surfaces/product.jsx — client: read, hand over\n\"use client\";\nimport { useSearchParams } from \"next/navigation\";\nimport { useRow } from \"../lib/data\";\nimport ProductPage from \"../components/ProductPage\";\n\nexport default function ProductSurface() {\n const id = useSearchParams().get(\"row\"); // /s/product?row=<id>\n const { row, error, loading } = useRow({ source: \"products\", id });\n return <ProductPage product={row} loading={loading} error={error} />;\n}\n```\n\n```jsx\n// components/ProductPage.jsx — client: the page, the cart write\n\"use client\";\nimport { insertRow } from \"../lib/actions\";\nimport { ProductDetail } from \"./blocks/product-detail\";\n\nexport default function ProductPage({ product, loading, error }) {\n return (\n <ProductDetail\n product={product}\n loading={loading}\n error={error}\n breadcrumb={[{ label: \"Shop\", href: \"/shop\" }, { label: product?.name ?? \"Product\" }]}\n perks={[\n { icon: \"truck\", label: \"Free shipping over $100\" },\n { icon: \"refresh\", label: \"30-day returns\" },\n ]}\n onAddToCart={(line) =>\n insertRow({\n source: \"cart_items\",\n values: { product_id: line.id, line_key: line.key, quantity: line.quantity, selected: line.selected },\n detail: `${line.quantity} × ${line.name}`,\n })\n }\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\nA digital product with plans, no quantity, and a straight-to-checkout\nbutton:\n\n```jsx\n<ProductDetail\n product={{\n id: \"ui-kit\",\n name: \"Dashboard UI kit\",\n price: 49,\n images: [\"https://…/cover.png\"],\n rating: 4.9,\n reviews: 127,\n stats: [{ value: \"1,234\", label: \"sales\" }],\n options: [{\n key: \"licence\",\n label: \"Licence\",\n values: [\n { value: \"solo\", label: \"Solo\", price: 49, description: \"One maker, unlimited projects.\" },\n { value: \"team\", label: \"Team\", price: 149, badge: \"Popular\", description: \"Up to 10 seats.\" },\n ],\n }],\n }}\n quantity={false}\n imageFit=\"contain\"\n onAddToCart={addToCart}\n onBuyNow={(line) => openPaddleCheckout(line)}\n/>\n```\n\n## What it will not do\n\n- Take payment. \"Buy now\" hands the line to `onBuyNow`; checkout is\n Paddle's, and no card field is ever drawn here.\n- Keep the cart. It hands over one line; the cart's rows, totals and\n merging live in your data and the `cart` block.\n- Show similar products or reviews in full: put a `ProductGrid` (the\n `product-grid` block) and your review list under it.\n- Colour itself: swatch colours are data; everything else is the theme.\n\nA product page it cannot be is your own component in `components/`, built\nfrom the kit (and this block's exported parts) — not an edit to this\nblock. Its files are fixed: `npm run check` refuses a change, and\n`makersclaw add product-detail` restores them.\n\n## Credits\n\nPorted from Creative Tim UI (https://github.com/creativetimofficial/ui), MIT — a rewrite on the Makersclaw kit, none of its code, copy or pictures.\n"
|
|
47
47
|
}
|
|
48
48
|
],
|
|
49
49
|
"categories": [
|
|
@@ -69,7 +69,7 @@
|
|
|
69
69
|
"docs": "Installed to components/makersclaw/blocks/product-detail. Import: import { ProductDetail, ProductMedia, OptionPicker, QuantityControl, Price, formatPrice, cartLine } from \"@/components/makersclaw/blocks/product-detail\". Props and an example: components/makersclaw/blocks/product-detail/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
70
70
|
"meta": {
|
|
71
71
|
"makersclaw": {
|
|
72
|
-
"version": "0.
|
|
72
|
+
"version": "0.7.0",
|
|
73
73
|
"useWhen": "Selling one thing a customer looks at before buying: a product page, a template or digital download with plans, a merch item, a booking add-on. Not for many products at once (product-grid), the cart itself (cart), or a plan comparison table."
|
|
74
74
|
}
|
|
75
75
|
}
|
package/r/product-grid.json
CHANGED
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
"title": "Product grid",
|
|
6
6
|
"description": "Products as cards in a grid: picture, badge, favourite heart, brand, name, star rating, price with the old one struck through, and a quick add to cart through an async callback that may fail (\"Choose options\" when the product has sizes or colours). Facet filters as selects that also match a product's option values, a sort, a live count, a lead tile that takes a double cell (a picture or a muted looping video), and a promo card for collection tiles. Loading, error, empty and no-matches states; two columns on a phone.",
|
|
7
7
|
"registryDependencies": [
|
|
8
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
9
|
-
"https://cdn.jsdelivr.net/npm/makersclaw@0.
|
|
8
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/core.json",
|
|
9
|
+
"https://cdn.jsdelivr.net/npm/makersclaw@0.7.0/r/product-detail.json"
|
|
10
10
|
],
|
|
11
11
|
"dependencies": [],
|
|
12
12
|
"files": [
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"path": "registry/blocks/product-grid/README.md",
|
|
33
33
|
"type": "registry:file",
|
|
34
34
|
"target": "@components/makersclaw/blocks/product-grid/README.md",
|
|
35
|
-
"content": "# Product grid\n\nProducts as cards in a grid — picture, badge, heart, brand, name, stars,\nprice — with a quick **Add to cart** on each. Over the grid: facet filters,\na sort and a live count. A lead tile (a picture or a muted looping video)\ncan take a double cell, and `card=\"promo\"` turns the cards into collection\ntiles (\"New\", \"Sale\"). The add goes out through `onAddToCart(line)`; while\nit runs the card's button spins, when it fails the card says why.\n\n```jsx\nimport { ProductGrid } from \"../components/blocks/product-grid\"; // a Makersclaw app (\"./blocks/product-grid\" from components/)\nimport { ProductGrid } from \"@/components/makersclaw/blocks/product-grid\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4). It requires\n`product-detail` (added with it): the cards share its price and cart line.\n\n## The data\n\n`products` are the same objects `ProductDetail` takes (see its README):\n`{ id, name, price }` required; `compareAt`, `currency`, `image` (or\n`images`), `brand`, `badge`, `rating`, `reviews`, `href`, `available:\nfalse` (sold out), `options` all draw on the card when present. A product\nwith an option that has more than one value cannot be added blind: its\nbutton says **Choose options** and opens it (`onOpen`, else its `href`).\n\nFilters look for a value in three places on each product: its own field\n(`product.category`, a string or a list), `product.facets[key]`, and the\nvalues of its option with that key (so a **size** filter keeps what is\nsold in that size, skipping sold-out sizes).\n\nWith `card=\"promo\"` each item is a tile: `{ id, title, kicker?, body?,\nimage, href?, actionLabel?, action?: { label, href?, onClick? } }`.\n\n### The cart line\n\n`onAddToCart` gets one line, the shape the `cart` block takes as an item:\n`{ id, key, name, price, compareAt?, currency, image, href?, quantity: 1,\nselected: {}, variant: \"\" }`. `key` is the product id (plus its choices on\na product page), so adding the same thing twice is one row — merge\nquantities by `key`.\n\n## Props\n\n- `products` — the rows. Pass `useFixture(rows, SAMPLE).rows` to show\n example cards before there is data.\n- `onAddToCart(line)` — return `{ ok: true }` or `{ ok: false, error }` (a\n throw counts as failed). Without it the cards have no button.\n- `onOpen(product)` — the card becomes a button that opens the product (a\n `Sheet`, a route). A product's own `href` makes it a link instead.\n- `onFavorite(product, next)` — a heart on each picture; flips at once,\n flips back with the error when the save fails. `favorites` — the ids\n saved now.\n- `filters` — `[{ key, label, options, all?, value? }]`: one select per\n facet, first choice \"All …\" (`all` renames it, `value` starts it set).\n `options` are strings or `{ value, label }`.\n- `sort` — `true` for Featured, price up, price down, top rated; or a\n list of those keys (`\"featured\"`, `\"price-asc\"`, `\"price-desc\"`,\n `\"rating\"`, `\"newest\"` by `createdAt`) and your own `{ value, label,\n compare(a, b) }`. `defaultSort` — the key it starts on.\n- `manual` — the loader filters and sorts (a big catalogue): the block\n shows `products` as they come, reports each change through\n `onQueryChange({ filters, sort })`, and counts `total`.\n- `feature` — the lead tile, `{ kicker, title, body?, image | video +\n poster, href | onOpen, actionLabel }`, or any node. Two cells wide (and\n two tall from lg up). A video is muted and looping, has a pause, and\n never plays for a reader who asks for reduced motion.\n- `card` — `\"product\"` (default) or `\"promo\"`.\n- `columns` — how many from lg up: `2` · `3` · `4` (default 4 for\n products, 3 for tiles). A phone shows two product cards a row, one tile.\n- `currency` (`\"USD\"`) · `locale` — for prices and counts.\n- `countLabel(n)` — `\"12 products\"` by default. `addLabel` — the button's\n words.\n- `loading` — skeleton cards. `error` (a string) with `onRetry` — \"Try\n again\". `emptyTitle` · `emptyBody` — when there are no products; when\n filters hide them all it offers **Clear filters**.\n- `label` — what the grid is called to a screen reader.\n- `className` — layout around it (width, margin). Never colour.\n\n`ProductCard({ product, onAddToCart, onOpen, onFavorite, favorite })` and\n`PromoTile({ item, large })` are exported for a row of your own.\n\n## Example\n\n```jsx\n// surfaces/shop.jsx —
|
|
35
|
+
"content": "# Product grid\n\nProducts as cards in a grid — picture, badge, heart, brand, name, stars,\nprice — with a quick **Add to cart** on each. Over the grid: facet filters,\na sort and a live count. A lead tile (a picture or a muted looping video)\ncan take a double cell, and `card=\"promo\"` turns the cards into collection\ntiles (\"New\", \"Sale\"). The add goes out through `onAddToCart(line)`; while\nit runs the card's button spins, when it fails the card says why.\n\n```jsx\nimport { ProductGrid } from \"../components/blocks/product-grid\"; // a Makersclaw app (\"./blocks/product-grid\" from components/)\nimport { ProductGrid } from \"@/components/makersclaw/blocks/product-grid\"; // a shadcn project\n```\n\nIt is a client component: render it from a `\"use client\"` component, and\nload the rows in the server surface (README §4). It requires\n`product-detail` (added with it): the cards share its price and cart line.\n\n## The data\n\n`products` are the same objects `ProductDetail` takes (see its README):\n`{ id, name, price }` required; `compareAt`, `currency`, `image` (or\n`images`), `brand`, `badge`, `rating`, `reviews`, `href`, `available:\nfalse` (sold out), `options` all draw on the card when present. A product\nwith an option that has more than one value cannot be added blind: its\nbutton says **Choose options** and opens it (`onOpen`, else its `href`).\n\nFilters look for a value in three places on each product: its own field\n(`product.category`, a string or a list), `product.facets[key]`, and the\nvalues of its option with that key (so a **size** filter keeps what is\nsold in that size, skipping sold-out sizes).\n\nWith `card=\"promo\"` each item is a tile: `{ id, title, kicker?, body?,\nimage, href?, actionLabel?, action?: { label, href?, onClick? } }`.\n\n### The cart line\n\n`onAddToCart` gets one line, the shape the `cart` block takes as an item:\n`{ id, key, name, price, compareAt?, currency, image, href?, quantity: 1,\nselected: {}, variant: \"\" }`. `key` is the product id (plus its choices on\na product page), so adding the same thing twice is one row — merge\nquantities by `key`.\n\n## Props\n\n- `products` — the rows. Pass `useFixture(rows, SAMPLE).rows` to show\n example cards before there is data.\n- `onAddToCart(line)` — return `{ ok: true }` or `{ ok: false, error }` (a\n throw counts as failed). Without it the cards have no button.\n- `onOpen(product)` — the card becomes a button that opens the product (a\n `Sheet`, a route). A product's own `href` makes it a link instead.\n- `onFavorite(product, next)` — a heart on each picture; flips at once,\n flips back with the error when the save fails. `favorites` — the ids\n saved now.\n- `filters` — `[{ key, label, options, all?, value? }]`: one select per\n facet, first choice \"All …\" (`all` renames it, `value` starts it set).\n `options` are strings or `{ value, label }`.\n- `sort` — `true` for Featured, price up, price down, top rated; or a\n list of those keys (`\"featured\"`, `\"price-asc\"`, `\"price-desc\"`,\n `\"rating\"`, `\"newest\"` by `createdAt`) and your own `{ value, label,\n compare(a, b) }`. `defaultSort` — the key it starts on.\n- `manual` — the loader filters and sorts (a big catalogue): the block\n shows `products` as they come, reports each change through\n `onQueryChange({ filters, sort })`, and counts `total`.\n- `feature` — the lead tile, `{ kicker, title, body?, image | video +\n poster, href | onOpen, actionLabel }`, or any node. Two cells wide (and\n two tall from lg up). A video is muted and looping, has a pause, and\n never plays for a reader who asks for reduced motion.\n- `card` — `\"product\"` (default) or `\"promo\"`.\n- `columns` — how many from lg up: `2` · `3` · `4` (default 4 for\n products, 3 for tiles). A phone shows two product cards a row, one tile.\n- `currency` (`\"USD\"`) · `locale` — for prices and counts.\n- `countLabel(n)` — `\"12 products\"` by default. `addLabel` — the button's\n words.\n- `loading` — skeleton cards. `error` (a string) with `onRetry` — \"Try\n again\". `emptyTitle` · `emptyBody` — when there are no products; when\n filters hide them all it offers **Clear filters**.\n- `label` — what the grid is called to a screen reader.\n- `className` — layout around it (width, margin). Never colour.\n\n`ProductCard({ product, onAddToCart, onOpen, onFavorite, favorite })` and\n`PromoTile({ item, large })` are exported for a row of your own.\n\n## Example\n\n```jsx\n// surfaces/shop.jsx — client: read, hand over\n\"use client\";\nimport { useRows } from \"../lib/data\";\nimport Shop from \"../components/Shop\";\n\nexport default function ShopSurface() {\n const { rows, error, loading, reload } = useRows({ source: \"products\", orderBy: \"created_at desc\" });\n return <Shop products={rows} loading={loading} error={error} onRetry={reload} />;\n}\n```\n\n```jsx\n// components/Shop.jsx — client: the grid and the cart write\n\"use client\";\nimport { insertRow } from \"../lib/actions\";\nimport { ProductGrid } from \"./blocks/product-grid\";\n\nexport default function Shop({ products, loading, error, onRetry }) {\n return (\n <ProductGrid\n products={products}\n loading={loading}\n error={error}\n onRetry={onRetry}\n filters={[\n { key: \"category\", label: \"Category\", options: [\"Shirts\", \"Sweaters\", \"Jackets\"] },\n { key: \"size\", label: \"Size\", options: [\"S\", \"M\", \"L\", \"XL\"] },\n ]}\n sort\n onAddToCart={(line) =>\n insertRow({ source: \"cart_items\", values: { product_id: line.id, line_key: line.key, quantity: 1 }, detail: line.name })\n }\n label=\"Shop\"\n />\n );\n}\n```\n\nIn a server-mode app the same calls are `loadRows` in a server surface and the Server Actions of `lib/actions`.\n\nCollection tiles, and a lead picture beside four products:\n\n```jsx\n<ProductGrid\n card=\"promo\"\n products={[\n { id: \"new\", kicker: \"New\", title: \"The autumn edit\", image: \"/tiles/autumn.jpg\", href: \"/shop/new\" },\n { id: \"sale\", kicker: \"Sale\", title: \"Summer linen\", image: \"/tiles/linen.jpg\", href: \"/shop/sale\" },\n ]}\n label=\"Collections\"\n/>\n\n<ProductGrid\n products={skincare}\n feature={{ kicker: \"Skincare\", title: \"The morning routine\", image: \"/hero.jpg\", href: \"/routine\", actionLabel: \"Shop the routine\" }}\n onAddToCart={addToCart}\n/>\n```\n\n## What it will not do\n\n- Choose options on a card: that is the product page's job\n (`product-detail`); the card sends the customer there.\n- Page through a catalogue: pass a page of rows with `manual` and put the\n kit's `Pagination` under it.\n- Load a third-party player: a lead video is a plain `<video>` file, never\n a YouTube embed or script.\n- Colour itself: the tiles' words sit on the theme's background, so any\n picture reads in any theme.\n\nA catalogue it cannot be is your own component in `components/`, built\nfrom the kit (and `ProductCard`) — not an edit to this block. Its files\nare fixed: `npm run check` refuses a change, and `makersclaw add\nproduct-grid` restores them.\n\n## Credits\n\nPorted from Creative Tim UI (https://github.com/creativetimofficial/ui), MIT — a rewrite on the Makersclaw kit, none of its code, copy or pictures.\n"
|
|
36
36
|
}
|
|
37
37
|
],
|
|
38
38
|
"categories": [
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"docs": "Installed to components/makersclaw/blocks/product-grid. Import: import { ProductGrid, ProductCard, PromoTile, productMatches } from \"@/components/makersclaw/blocks/product-grid\". Props and an example: components/makersclaw/blocks/product-grid/README.md. Makersclaw builds whole apps from these blocks: https://makersclaw.com",
|
|
59
59
|
"meta": {
|
|
60
60
|
"makersclaw": {
|
|
61
|
-
"version": "0.
|
|
61
|
+
"version": "0.7.0",
|
|
62
62
|
"useWhen": "A shop's catalogue, a collection page, related products under a product page, a row of promotions ('New', 'Sale'). Not for records to scan and act on in columns (data-table), one product's page (product-detail), or the cart (cart)."
|
|
63
63
|
}
|
|
64
64
|
}
|