@elabs-ai/components-viewer 5.3.1 → 5.5.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 CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # `@elabs-ai/components-viewer`
5
5
 
6
- > FileViewer — any file (image, text, JSON, CSV) via a pluggable adapter registry.
6
+ > FileViewer — any file (image, audio, video, text, code, JSON, CSV, markdown, PDF, docx, pptx, xlsx) via a pluggable adapter registry.
7
7
 
8
8
  Part of **brand-ui**, a source-owned, token-driven React component system.
9
9
  Published to the **public npm registry** under the `@elabs-ai` scope — it
@@ -28,6 +28,7 @@ import "../../chunk-CIT2HHRI.js";
28
28
  // src/adapters/docx/docx-adapter.tsx
29
29
  import {
30
30
  cn,
31
+ Image as UiImage,
31
32
  ProseHeading,
32
33
  ProseLink,
33
34
  ProseList,
@@ -136,12 +137,14 @@ function Block({
136
137
  }
137
138
  if (block.type === "image") {
138
139
  return /* @__PURE__ */ jsx(
139
- "img",
140
+ UiImage,
140
141
  {
141
142
  src: block.src,
142
143
  alt: block.alt ?? "",
143
144
  ...block.alt ? {} : { "aria-hidden": true },
144
- className: "my-2 block h-auto max-w-full rounded-md"
145
+ fallback: null,
146
+ loading: "lazy",
147
+ className: "my-2 h-auto rounded-md"
145
148
  }
146
149
  );
147
150
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/docx/docx-adapter.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * Word adapter — a `.docx` becomes blocks, and blocks become brand-ui prose.\n *\n * mammoth does the hard part (unzipping OOXML, resolving styles, numbering,\n * images); `docx-model.ts` then parses its HTML into a model and discards the\n * markup, so nothing here writes `innerHTML`. That single decision is what makes\n * a Word document themeable, Trusted-Types-safe and free of a sanitizer\n * dependency — the reasoning lives in `docx-model.ts`.\n *\n * What a reader gets is the document's STRUCTURE in this system's typography:\n * real headings, real lists, a real `Table`, images with their own alt text. It\n * is not a pixel reproduction of Word's page layout — no page breaks, no columns,\n * no margins — and it does not pretend to be. For a byte-faithful rendering the\n * honest answer is to download the file, which the toolbar already offers.\n */\n\nimport type { ProseHeadingLevel, ResolvedFileSource } from \"@elabs-ai/components-ui\";\nimport {\n cn,\n ProseHeading,\n ProseLink,\n ProseList,\n ProseListItem,\n ProseText,\n StatePanel,\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n useLocale,\n} from \"@elabs-ai/components-ui\";\n\nimport { useMemo, useRef } from \"react\";\n\nimport { MarkedText } from \"../../components/marked-text\";\nimport { toViewerError } from \"../../core/errors\";\nimport { toMarkRanges, type MarkRanges } from \"../../core/highlight-marks\";\nimport type { TextIndex, TextSpan } from \"../../core/text-index\";\nimport { useScrollActiveHighlightIntoView } from \"../../core/use-highlight-scroll\";\nimport type {\n AdapterDocument,\n AdapterLoadContext,\n AdapterModule,\n AdapterRendererProps,\n FileAdapter,\n} from \"../../core/types\";\nimport {\n blocksToTextWithMap,\n DOCX_CELL_SEPARATOR,\n DOCX_HEAD_ROW,\n DOCX_LIST_BULLET,\n htmlToBlocks,\n type DocxBlock,\n type DocxRef,\n type DocxRun,\n} from \"./docx-model\";\nimport { docxManifest } from \"./docx-manifest\";\n\nexport interface DocxDocument extends AdapterDocument {\n kind: \"docx\";\n blocks: DocxBlock[];\n /** Non-fatal conversion notes mammoth reported (an unsupported style, a dropped field). */\n warnings: string[];\n /** Which block, item or row each stretch of `text` came from. */\n textIndex?: TextIndex<DocxRef>;\n}\n\n/**\n * Parse markup with the DOM the runtime already has.\n *\n * `DOMParser` is the browser's own parser and jsdom's in tests — it does NOT\n * execute anything it parses, which is why building the model from it is safe\n * even though the input is third-party markup.\n */\nfunction parseMarkup(markup: string): Document {\n return new DOMParser().parseFromString(markup, \"text/html\");\n}\n\nclass DocxAdapter implements FileAdapter {\n async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<DocxDocument> {\n let buffer: ArrayBuffer;\n try {\n buffer = await source.bytes(context.signal);\n } catch (error) {\n throw toViewerError(error, \"read-failed\", { fileName: source.name });\n }\n\n // Dynamic: the ONLY edge to the optional peer (heavy-deps:check).\n const mammoth = await import(\"mammoth\");\n\n try {\n const result = await mammoth.convertToHtml({ arrayBuffer: buffer });\n const blocks = htmlToBlocks(result.value, parseMarkup);\n const textIndex = blocksToTextWithMap(blocks);\n return {\n kind: \"docx\",\n blocks,\n // mammoth's messages are about the CONVERSION, not about the file being\n // broken — a document that lost one custom style still reads fine, so\n // they are carried as notes rather than raised as a failure.\n warnings: result.messages.map((message) => message.message),\n text: textIndex.text,\n textIndex,\n };\n } catch (error) {\n throw toViewerError(error, \"parse-failed\", { fileName: source.name });\n }\n }\n}\n\n/* -------------------------------------------------------------------------- */\n/* Renderer */\n/* -------------------------------------------------------------------------- */\n\nfunction Runs({ runs, marks, start }: { runs: DocxRun[]; marks: MarkRanges; start?: number }) {\n let offset = start;\n return (\n <>\n {runs.map((run, index) => {\n const from = offset;\n if (offset !== undefined) offset += run.text.length;\n // Runs are joined with nothing in the projection, so a run's offset is\n // simply the sum of the ones before it — the same accumulation the code\n // adapter does over syntax tokens.\n const content = <MarkedText text={run.text} marks={marks} start={from} />;\n const bolded = run.bold ? <strong>{content}</strong> : content;\n const styled = run.italic ? <em>{bolded}</em> : bolded;\n if (run.href) {\n return (\n <ProseLink key={index} href={run.href}>\n {styled}\n </ProseLink>\n );\n }\n return <span key={index}>{styled}</span>;\n })}\n </>\n );\n}\n\n/** Shared empty list, so a block with no spans does not remount on every render. */\nconst EMPTY_SPANS: readonly TextSpan<DocxRef>[] = [];\n\n/** The projection offset of one row's `index`-th cell, given the row's own start. */\nfunction cellStart(row: readonly string[], index: number, rowStart?: number): number | undefined {\n if (rowStart === undefined) return undefined;\n let offset = rowStart;\n for (let i = 0; i < index; i += 1) {\n offset += (row[i]?.length ?? 0) + DOCX_CELL_SEPARATOR.length;\n }\n return offset;\n}\n\nfunction Block({\n block,\n baseHeadingLevel,\n marks,\n spans,\n}: {\n block: DocxBlock;\n baseHeadingLevel: number;\n marks: MarkRanges;\n /** The projection spans belonging to THIS block, in document order. */\n spans: readonly TextSpan<DocxRef>[];\n}) {\n const startOf = (match: (ref: DocxRef) => boolean) =>\n spans.find((span) => match(span.ref))?.start;\n\n if (block.type === \"heading\") {\n return (\n // Offset, not absolute: Word's \"Heading 1\" is the top of THAT document,\n // not of the page showing it. Never past h6 — an h7 is not an element.\n <ProseHeading\n level={Math.min(6, Math.max(1, block.level + baseHeadingLevel - 1)) as ProseHeadingLevel}\n >\n <Runs runs={block.runs} marks={marks} start={startOf(() => true)} />\n </ProseHeading>\n );\n }\n if (block.type === \"paragraph\") {\n return (\n <ProseText className=\"whitespace-pre-wrap\">\n <Runs runs={block.runs} marks={marks} start={startOf(() => true)} />\n </ProseText>\n );\n }\n if (block.type === \"list\") {\n return (\n <ProseList ordered={block.ordered}>\n {block.items.map((item, index) => {\n // The bullet is in the projection but not in the DOM — the list\n // element draws it — so an item's own text starts after it.\n const start = startOf((ref) => ref.item === index);\n return (\n <ProseListItem key={index}>\n <Runs\n runs={item}\n marks={marks}\n start={start === undefined ? undefined : start + DOCX_LIST_BULLET.length}\n />\n </ProseListItem>\n );\n })}\n </ProseList>\n );\n }\n if (block.type === \"image\") {\n return (\n <img\n src={block.src}\n // The document's own alt text when the author wrote one. An image with\n // none is decoration as far as the reader can tell, and a filename would\n // be noise, not a description.\n alt={block.alt ?? \"\"}\n {...(block.alt ? {} : { \"aria-hidden\": true })}\n className=\"my-2 block h-auto max-w-full rounded-md\"\n />\n );\n }\n const headStart = startOf((ref) => ref.row === DOCX_HEAD_ROW);\n return (\n <Table className=\"my-2\">\n {block.head && (\n <TableHeader>\n <TableRow>\n {block.head.map((cell, index) => (\n <TableHead key={index} scope=\"col\">\n <MarkedText\n text={cell}\n marks={marks}\n start={cellStart(block.head ?? [], index, headStart)}\n />\n </TableHead>\n ))}\n </TableRow>\n </TableHeader>\n )}\n <TableBody>\n {block.rows.map((row, rowIndex) => {\n const rowStart = startOf((ref) => ref.row === rowIndex);\n return (\n <TableRow key={rowIndex}>\n {row.map((cell, cellIndex) => (\n <TableCell key={cellIndex} className=\"whitespace-pre-wrap align-top\">\n <MarkedText\n text={cell}\n marks={marks}\n start={cellStart(row, cellIndex, rowStart)}\n />\n </TableCell>\n ))}\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n );\n}\n\nfunction DocxRenderer({\n document: doc,\n className,\n baseHeadingLevel = 2,\n highlights,\n activeHighlightId,\n}: AdapterRendererProps) {\n const docx = doc as DocxDocument;\n const { t } = useLocale();\n const container = useRef<HTMLElement>(null);\n\n const marks = useMemo(\n () => toMarkRanges(highlights, docx.text?.length ?? 0),\n [highlights, docx.text],\n );\n // Grouped once per document rather than searched per block: a long report is\n // thousands of blocks, and a linear scan inside the render loop would make it\n // quadratic on every keystroke of a find-as-you-type.\n const spansByBlock = useMemo(() => {\n const map = new Map<number, TextSpan<DocxRef>[]>();\n for (const span of docx.textIndex?.spans ?? []) {\n const list = map.get(span.ref.block);\n if (list) list.push(span);\n else map.set(span.ref.block, [span]);\n }\n return map;\n }, [docx.textIndex]);\n\n useScrollActiveHighlightIntoView(container, activeHighlightId);\n\n if (docx.blocks.length === 0) {\n return (\n <div className={cn(\"flex min-h-full flex-col justify-center p-4\", className)}>\n <StatePanel kind=\"empty\" title={t(\"viewer.docx.empty\")} />\n </div>\n );\n }\n\n return (\n // `max-w-prose` because this is genuine multi-sentence prose in a pane that\n // can be very wide — the one case styling-and-tokens.md says to cap the\n // measure. Centred so the column does not hug one edge. No `overflow-auto`:\n // `FileViewerContent` is the scroll boundary, and a second one clips the\n // last paragraph above the outer pane's padding.\n <article ref={container} className={cn(\"mx-auto max-w-prose space-y-2\", className)}>\n {docx.blocks.map((block, index) => (\n <Block\n key={index}\n block={block}\n baseHeadingLevel={baseHeadingLevel}\n marks={marks}\n spans={spansByBlock.get(index) ?? EMPTY_SPANS}\n />\n ))}\n </article>\n );\n}\n\nconst adapterModule: AdapterModule = {\n manifest: docxManifest,\n create: () => new DocxAdapter(),\n Renderer: DocxRenderer,\n};\n\nexport default adapterModule;\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/adapters/docx/docx-adapter.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * Word adapter — a `.docx` becomes blocks, and blocks become brand-ui prose.\n *\n * mammoth does the hard part (unzipping OOXML, resolving styles, numbering,\n * images); `docx-model.ts` then parses its HTML into a model and discards the\n * markup, so nothing here writes `innerHTML`. That single decision is what makes\n * a Word document themeable, Trusted-Types-safe and free of a sanitizer\n * dependency — the reasoning lives in `docx-model.ts`.\n *\n * What a reader gets is the document's STRUCTURE in this system's typography:\n * real headings, real lists, a real `Table`, images with their own alt text. It\n * is not a pixel reproduction of Word's page layout — no page breaks, no columns,\n * no margins — and it does not pretend to be. For a byte-faithful rendering the\n * honest answer is to download the file, which the toolbar already offers.\n */\n\nimport type { ProseHeadingLevel, ResolvedFileSource } from \"@elabs-ai/components-ui\";\nimport {\n cn,\n Image as UiImage,\n ProseHeading,\n ProseLink,\n ProseList,\n ProseListItem,\n ProseText,\n StatePanel,\n Table,\n TableBody,\n TableCell,\n TableHead,\n TableHeader,\n TableRow,\n useLocale,\n} from \"@elabs-ai/components-ui\";\n\nimport { useMemo, useRef } from \"react\";\n\nimport { MarkedText } from \"../../components/marked-text\";\nimport { toViewerError } from \"../../core/errors\";\nimport { toMarkRanges, type MarkRanges } from \"../../core/highlight-marks\";\nimport type { TextIndex, TextSpan } from \"../../core/text-index\";\nimport { useScrollActiveHighlightIntoView } from \"../../core/use-highlight-scroll\";\nimport type {\n AdapterDocument,\n AdapterLoadContext,\n AdapterModule,\n AdapterRendererProps,\n FileAdapter,\n} from \"../../core/types\";\nimport {\n blocksToTextWithMap,\n DOCX_CELL_SEPARATOR,\n DOCX_HEAD_ROW,\n DOCX_LIST_BULLET,\n htmlToBlocks,\n type DocxBlock,\n type DocxRef,\n type DocxRun,\n} from \"./docx-model\";\nimport { docxManifest } from \"./docx-manifest\";\n\nexport interface DocxDocument extends AdapterDocument {\n kind: \"docx\";\n blocks: DocxBlock[];\n /** Non-fatal conversion notes mammoth reported (an unsupported style, a dropped field). */\n warnings: string[];\n /** Which block, item or row each stretch of `text` came from. */\n textIndex?: TextIndex<DocxRef>;\n}\n\n/**\n * Parse markup with the DOM the runtime already has.\n *\n * `DOMParser` is the browser's own parser and jsdom's in tests — it does NOT\n * execute anything it parses, which is why building the model from it is safe\n * even though the input is third-party markup.\n */\nfunction parseMarkup(markup: string): Document {\n return new DOMParser().parseFromString(markup, \"text/html\");\n}\n\nclass DocxAdapter implements FileAdapter {\n async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<DocxDocument> {\n let buffer: ArrayBuffer;\n try {\n buffer = await source.bytes(context.signal);\n } catch (error) {\n throw toViewerError(error, \"read-failed\", { fileName: source.name });\n }\n\n // Dynamic: the ONLY edge to the optional peer (heavy-deps:check).\n const mammoth = await import(\"mammoth\");\n\n try {\n const result = await mammoth.convertToHtml({ arrayBuffer: buffer });\n const blocks = htmlToBlocks(result.value, parseMarkup);\n const textIndex = blocksToTextWithMap(blocks);\n return {\n kind: \"docx\",\n blocks,\n // mammoth's messages are about the CONVERSION, not about the file being\n // broken — a document that lost one custom style still reads fine, so\n // they are carried as notes rather than raised as a failure.\n warnings: result.messages.map((message) => message.message),\n text: textIndex.text,\n textIndex,\n };\n } catch (error) {\n throw toViewerError(error, \"parse-failed\", { fileName: source.name });\n }\n }\n}\n\n/* -------------------------------------------------------------------------- */\n/* Renderer */\n/* -------------------------------------------------------------------------- */\n\nfunction Runs({ runs, marks, start }: { runs: DocxRun[]; marks: MarkRanges; start?: number }) {\n let offset = start;\n return (\n <>\n {runs.map((run, index) => {\n const from = offset;\n if (offset !== undefined) offset += run.text.length;\n // Runs are joined with nothing in the projection, so a run's offset is\n // simply the sum of the ones before it — the same accumulation the code\n // adapter does over syntax tokens.\n const content = <MarkedText text={run.text} marks={marks} start={from} />;\n const bolded = run.bold ? <strong>{content}</strong> : content;\n const styled = run.italic ? <em>{bolded}</em> : bolded;\n if (run.href) {\n return (\n <ProseLink key={index} href={run.href}>\n {styled}\n </ProseLink>\n );\n }\n return <span key={index}>{styled}</span>;\n })}\n </>\n );\n}\n\n/** Shared empty list, so a block with no spans does not remount on every render. */\nconst EMPTY_SPANS: readonly TextSpan<DocxRef>[] = [];\n\n/** The projection offset of one row's `index`-th cell, given the row's own start. */\nfunction cellStart(row: readonly string[], index: number, rowStart?: number): number | undefined {\n if (rowStart === undefined) return undefined;\n let offset = rowStart;\n for (let i = 0; i < index; i += 1) {\n offset += (row[i]?.length ?? 0) + DOCX_CELL_SEPARATOR.length;\n }\n return offset;\n}\n\nfunction Block({\n block,\n baseHeadingLevel,\n marks,\n spans,\n}: {\n block: DocxBlock;\n baseHeadingLevel: number;\n marks: MarkRanges;\n /** The projection spans belonging to THIS block, in document order. */\n spans: readonly TextSpan<DocxRef>[];\n}) {\n const startOf = (match: (ref: DocxRef) => boolean) =>\n spans.find((span) => match(span.ref))?.start;\n\n if (block.type === \"heading\") {\n return (\n // Offset, not absolute: Word's \"Heading 1\" is the top of THAT document,\n // not of the page showing it. Never past h6 — an h7 is not an element.\n <ProseHeading\n level={Math.min(6, Math.max(1, block.level + baseHeadingLevel - 1)) as ProseHeadingLevel}\n >\n <Runs runs={block.runs} marks={marks} start={startOf(() => true)} />\n </ProseHeading>\n );\n }\n if (block.type === \"paragraph\") {\n return (\n <ProseText className=\"whitespace-pre-wrap\">\n <Runs runs={block.runs} marks={marks} start={startOf(() => true)} />\n </ProseText>\n );\n }\n if (block.type === \"list\") {\n return (\n <ProseList ordered={block.ordered}>\n {block.items.map((item, index) => {\n // The bullet is in the projection but not in the DOM — the list\n // element draws it — so an item's own text starts after it.\n const start = startOf((ref) => ref.item === index);\n return (\n <ProseListItem key={index}>\n <Runs\n runs={item}\n marks={marks}\n start={start === undefined ? undefined : start + DOCX_LIST_BULLET.length}\n />\n </ProseListItem>\n );\n })}\n </ProseList>\n );\n }\n if (block.type === \"image\") {\n return (\n <UiImage\n src={block.src}\n // The document's own alt text when the author wrote one. An image with\n // none is decoration as far as the reader can tell, and a filename would\n // be noise, not a description.\n alt={block.alt ?? \"\"}\n {...(block.alt ? {} : { \"aria-hidden\": true })}\n // A broken embedded image renders nothing rather than ui's placeholder\n // box: the document reads on without it.\n fallback={null}\n loading=\"lazy\"\n className=\"my-2 h-auto rounded-md\"\n />\n );\n }\n const headStart = startOf((ref) => ref.row === DOCX_HEAD_ROW);\n return (\n <Table className=\"my-2\">\n {block.head && (\n <TableHeader>\n <TableRow>\n {block.head.map((cell, index) => (\n <TableHead key={index} scope=\"col\">\n <MarkedText\n text={cell}\n marks={marks}\n start={cellStart(block.head ?? [], index, headStart)}\n />\n </TableHead>\n ))}\n </TableRow>\n </TableHeader>\n )}\n <TableBody>\n {block.rows.map((row, rowIndex) => {\n const rowStart = startOf((ref) => ref.row === rowIndex);\n return (\n <TableRow key={rowIndex}>\n {row.map((cell, cellIndex) => (\n <TableCell key={cellIndex} className=\"whitespace-pre-wrap align-top\">\n <MarkedText\n text={cell}\n marks={marks}\n start={cellStart(row, cellIndex, rowStart)}\n />\n </TableCell>\n ))}\n </TableRow>\n );\n })}\n </TableBody>\n </Table>\n );\n}\n\nfunction DocxRenderer({\n document: doc,\n className,\n baseHeadingLevel = 2,\n highlights,\n activeHighlightId,\n}: AdapterRendererProps) {\n const docx = doc as DocxDocument;\n const { t } = useLocale();\n const container = useRef<HTMLElement>(null);\n\n const marks = useMemo(\n () => toMarkRanges(highlights, docx.text?.length ?? 0),\n [highlights, docx.text],\n );\n // Grouped once per document rather than searched per block: a long report is\n // thousands of blocks, and a linear scan inside the render loop would make it\n // quadratic on every keystroke of a find-as-you-type.\n const spansByBlock = useMemo(() => {\n const map = new Map<number, TextSpan<DocxRef>[]>();\n for (const span of docx.textIndex?.spans ?? []) {\n const list = map.get(span.ref.block);\n if (list) list.push(span);\n else map.set(span.ref.block, [span]);\n }\n return map;\n }, [docx.textIndex]);\n\n useScrollActiveHighlightIntoView(container, activeHighlightId);\n\n if (docx.blocks.length === 0) {\n return (\n <div className={cn(\"flex min-h-full flex-col justify-center p-4\", className)}>\n <StatePanel kind=\"empty\" title={t(\"viewer.docx.empty\")} />\n </div>\n );\n }\n\n return (\n // `max-w-prose` because this is genuine multi-sentence prose in a pane that\n // can be very wide — the one case styling-and-tokens.md says to cap the\n // measure. Centred so the column does not hug one edge. No `overflow-auto`:\n // `FileViewerContent` is the scroll boundary, and a second one clips the\n // last paragraph above the outer pane's padding.\n <article ref={container} className={cn(\"mx-auto max-w-prose space-y-2\", className)}>\n {docx.blocks.map((block, index) => (\n <Block\n key={index}\n block={block}\n baseHeadingLevel={baseHeadingLevel}\n marks={marks}\n spans={spansByBlock.get(index) ?? EMPTY_SPANS}\n />\n ))}\n </article>\n );\n}\n\nconst adapterModule: AdapterModule = {\n manifest: docxManifest,\n create: () => new DocxAdapter(),\n Renderer: DocxRenderer,\n};\n\nexport default adapterModule;\n"],"mappings":"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","names":[]}
@@ -9,7 +9,7 @@ import {
9
9
  import "../../chunk-CIT2HHRI.js";
10
10
 
11
11
  // src/adapters/image/image-adapter.tsx
12
- import { cn, StatePanel, useLocale } from "@elabs-ai/components-ui";
12
+ import { cn, StatePanel, Image as UiImage, useLocale } from "@elabs-ai/components-ui";
13
13
  import { useEffect, useState } from "react";
14
14
  import { jsx } from "react/jsx-runtime";
15
15
  function measure(url, signal) {
@@ -70,15 +70,17 @@ function ImageRenderer({
70
70
  const scaled = fitting || image.width === void 0 || image.height === void 0 ? void 0 : { width: image.width * zoom, height: image.height * zoom };
71
71
  const boxed = quarter && scaled !== void 0;
72
72
  const img = /* @__PURE__ */ jsx(
73
- "img",
73
+ UiImage,
74
74
  {
75
75
  src: image.url,
76
76
  alt: source.alt ?? "",
77
77
  width: image.width,
78
78
  height: image.height,
79
+ fit: "contain",
80
+ showSkeleton: false,
81
+ fallback: null,
79
82
  onError: () => setFailed(true),
80
83
  className: cn(
81
- "block object-contain",
82
84
  !quarter && zoom === "fit-page" && "max-h-full max-w-full",
83
85
  !quarter && zoom === "fit-width" && "h-auto max-w-full",
84
86
  // Turned on its side, the pane's HEIGHT caps the image's width and its
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/image/image-adapter.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * Image adapter — the reference implementation of \"adapters emit data\".\n *\n * It renders an `<img>`, never a `<canvas>`. anyview draws images to a canvas\n * (`ImageAdapter.ts:118`, `PageRenderer.tsx:128`), which throws away the alt\n * text, the browser's own decoding and zoom, and the ability to select or save\n * the image — with no route back, because its API has no alt-text path at all.\n * Here the description travels on the `FileSource` itself, so a screen reader\n * user gets the same information a sighted user does.\n */\n\nimport { cn, StatePanel, useLocale } from \"@elabs-ai/components-ui\";\nimport { useEffect, useState } from \"react\";\n\nimport type {\n AdapterDocument,\n AdapterLoadContext,\n AdapterModule,\n AdapterRendererProps,\n FileAdapter,\n} from \"../../core/types\";\nimport { imageManifest } from \"./image-manifest\";\nimport { toViewerError } from \"../../core/errors\";\nimport type { ResolvedFileSource } from \"@elabs-ai/components-ui\";\n\nexport interface ImageDocument extends AdapterDocument {\n kind: \"image\";\n /** A URL an `<img>` can load — the source's own URL, or a minted object URL. */\n url: string;\n /** Intrinsic size, once decoded. Used to reserve the box and to label the file. */\n width?: number;\n height?: number;\n}\n\n/** Decode just enough to learn the intrinsic size. Never rejects the load. */\nfunction measure(\n url: string,\n signal?: AbortSignal,\n): Promise<{ width: number; height: number } | undefined> {\n if (typeof Image !== \"function\") return Promise.resolve(undefined);\n return new Promise((resolve) => {\n const img = new Image();\n const done = (value?: { width: number; height: number }) => {\n img.onload = null;\n img.onerror = null;\n resolve(value);\n };\n img.onload = () => done({ width: img.naturalWidth, height: img.naturalHeight });\n // A measurement failure is not a load failure — the <img> will surface it.\n img.onerror = () => done(undefined);\n signal?.addEventListener(\"abort\", () => done(undefined), { once: true });\n img.src = url;\n });\n}\n\nclass ImageAdapter implements FileAdapter {\n #source?: ResolvedFileSource;\n\n async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<ImageDocument> {\n this.#source = source;\n try {\n const url = await source.url(context.signal);\n const size = await measure(url, context.signal);\n return { kind: \"image\", url, ...size };\n } catch (error) {\n throw toViewerError(error, \"read-failed\", { fileName: source.name });\n }\n }\n\n dispose(): void {\n // Releases the object URL this load minted, if any. A remote public image\n // never minted one, and `revoke()` is a no-op there.\n this.#source?.revoke();\n this.#source = undefined;\n }\n}\n\nfunction ImageRenderer({\n document: doc,\n source,\n className,\n zoom = \"fit-page\",\n rotation = 0,\n}: AdapterRendererProps) {\n const image = doc as ImageDocument;\n const { t } = useLocale();\n const [failed, setFailed] = useState(false);\n\n useEffect(() => setFailed(false), [image.url]);\n\n if (failed) {\n // A terminal, settled failure (loading-states.md): the browser tried and\n // gave up, so this is not a transient not-ready state.\n return (\n <div className={cn(\"flex min-h-full flex-col justify-center p-4\", className)}>\n <StatePanel\n kind=\"error\"\n title={t(\"viewer.error.imageFailedTitle\")}\n description={t(\"viewer.error.imageFailed\", { name: source.name })}\n />\n </div>\n );\n }\n\n const fitting = typeof zoom !== \"number\";\n // A quarter turn swaps the axes: what was the image's height is now the width\n // the reader sees. Everything below that treats 90/270 differently is that one\n // fact — 180 leaves the bounds alone and needs none of it.\n const quarter = rotation === 90 || rotation === 270;\n const scaled =\n fitting || image.width === undefined || image.height === undefined\n ? undefined\n : { width: image.width * zoom, height: image.height * zoom };\n // A `transform` does not change layout, so a turned image at a fixed scale\n // needs a box with the ROTATED bounds. Without one it overflows the pane on\n // every side, and the half above the top edge is unreachable — scrolling only\n // ever reaches transform overflow past the END edges.\n const boxed = quarter && scaled !== undefined;\n\n const img = (\n <img\n src={image.url}\n // An image with no author description is decorative to AT — an empty alt\n // is correct and deliberate, not a missing label.\n alt={source.alt ?? \"\"}\n width={image.width}\n height={image.height}\n onError={() => setFailed(true)}\n className={cn(\n \"block object-contain\",\n !quarter && zoom === \"fit-page\" && \"max-h-full max-w-full\",\n !quarter && zoom === \"fit-width\" && \"h-auto max-w-full\",\n // Turned on its side, the pane's HEIGHT caps the image's width and its\n // width caps the height. Container units read the pane directly, so the\n // fit needs no measurement. Both fit modes converge here deliberately:\n // an image whose rotated width filled the pane would overflow the top\n // edge as well, which is the unreachable half described above.\n fitting && quarter && \"max-h-[100cqw] max-w-[100cqh]\",\n // At a fixed scale the intrinsic cap has to come off, or \"200%\" would\n // silently stop at the pane's width.\n !fitting && \"max-w-none\",\n boxed && \"absolute top-1/2 left-1/2\",\n )}\n style={{\n ...scaled,\n ...(rotation === 0\n ? undefined\n : {\n transform: boxed\n ? `translate(-50%, -50%) rotate(${rotation}deg)`\n : `rotate(${rotation}deg)`,\n }),\n }}\n />\n );\n\n return (\n // Centred on both axes: a small image pinned to the top-left of a tall pane\n // reads as a layout accident. While fitting, the box takes the pane's height\n // so `max-h-full` has something definite to resolve against; at a fixed\n // scale it grows instead, and `FileViewerContent` scrolls it.\n <div\n className={cn(\n \"flex items-center justify-center\",\n fitting ? \"h-full\" : \"min-h-full\",\n // Only a size container can answer `cqh`, and only a definite height\n // makes one — which is exactly the fitting case.\n fitting && quarter && \"[container-type:size]\",\n className,\n )}\n >\n {quarter && scaled ? (\n <div className=\"relative shrink-0\" style={{ width: scaled.height, height: scaled.width }}>\n {img}\n </div>\n ) : (\n img\n )}\n </div>\n );\n}\n\nconst adapterModule: AdapterModule = {\n manifest: imageManifest,\n create: () => new ImageAdapter(),\n Renderer: ImageRenderer,\n};\n\nexport default adapterModule;\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../../src/adapters/image/image-adapter.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * Image adapter — the reference implementation of \"adapters emit data\".\n *\n * It renders an `<img>` (through ui `Image`, ADR 0041), never a `<canvas>`. anyview draws images to a canvas\n * (`ImageAdapter.ts:118`, `PageRenderer.tsx:128`), which throws away the alt\n * text, the browser's own decoding and zoom, and the ability to select or save\n * the image — with no route back, because its API has no alt-text path at all.\n * Here the description travels on the `FileSource` itself, so a screen reader\n * user gets the same information a sighted user does.\n */\n\n// Aliased on purpose: `measure()` below uses the GLOBAL `Image` constructor, and\n// an unaliased import would shadow it silently (`typeof Image` would still be\n// \"function\", so nothing would fail loudly).\nimport { cn, StatePanel, Image as UiImage, useLocale } from \"@elabs-ai/components-ui\";\nimport { useEffect, useState } from \"react\";\n\nimport type {\n AdapterDocument,\n AdapterLoadContext,\n AdapterModule,\n AdapterRendererProps,\n FileAdapter,\n} from \"../../core/types\";\nimport { imageManifest } from \"./image-manifest\";\nimport { toViewerError } from \"../../core/errors\";\nimport type { ResolvedFileSource } from \"@elabs-ai/components-ui\";\n\nexport interface ImageDocument extends AdapterDocument {\n kind: \"image\";\n /** A URL an `<img>` can load — the source's own URL, or a minted object URL. */\n url: string;\n /** Intrinsic size, once decoded. Used to reserve the box and to label the file. */\n width?: number;\n height?: number;\n}\n\n/** Decode just enough to learn the intrinsic size. Never rejects the load. */\nfunction measure(\n url: string,\n signal?: AbortSignal,\n): Promise<{ width: number; height: number } | undefined> {\n if (typeof Image !== \"function\") return Promise.resolve(undefined);\n return new Promise((resolve) => {\n const img = new Image();\n const done = (value?: { width: number; height: number }) => {\n img.onload = null;\n img.onerror = null;\n resolve(value);\n };\n img.onload = () => done({ width: img.naturalWidth, height: img.naturalHeight });\n // A measurement failure is not a load failure — the <img> will surface it.\n img.onerror = () => done(undefined);\n signal?.addEventListener(\"abort\", () => done(undefined), { once: true });\n img.src = url;\n });\n}\n\nclass ImageAdapter implements FileAdapter {\n #source?: ResolvedFileSource;\n\n async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<ImageDocument> {\n this.#source = source;\n try {\n const url = await source.url(context.signal);\n const size = await measure(url, context.signal);\n return { kind: \"image\", url, ...size };\n } catch (error) {\n throw toViewerError(error, \"read-failed\", { fileName: source.name });\n }\n }\n\n dispose(): void {\n // Releases the object URL this load minted, if any. A remote public image\n // never minted one, and `revoke()` is a no-op there.\n this.#source?.revoke();\n this.#source = undefined;\n }\n}\n\nfunction ImageRenderer({\n document: doc,\n source,\n className,\n zoom = \"fit-page\",\n rotation = 0,\n}: AdapterRendererProps) {\n const image = doc as ImageDocument;\n const { t } = useLocale();\n const [failed, setFailed] = useState(false);\n\n useEffect(() => setFailed(false), [image.url]);\n\n if (failed) {\n // A terminal, settled failure (loading-states.md): the browser tried and\n // gave up, so this is not a transient not-ready state.\n return (\n <div className={cn(\"flex min-h-full flex-col justify-center p-4\", className)}>\n <StatePanel\n kind=\"error\"\n title={t(\"viewer.error.imageFailedTitle\")}\n description={t(\"viewer.error.imageFailed\", { name: source.name })}\n />\n </div>\n );\n }\n\n const fitting = typeof zoom !== \"number\";\n // A quarter turn swaps the axes: what was the image's height is now the width\n // the reader sees. Everything below that treats 90/270 differently is that one\n // fact — 180 leaves the bounds alone and needs none of it.\n const quarter = rotation === 90 || rotation === 270;\n const scaled =\n fitting || image.width === undefined || image.height === undefined\n ? undefined\n : { width: image.width * zoom, height: image.height * zoom };\n // A `transform` does not change layout, so a turned image at a fixed scale\n // needs a box with the ROTATED bounds. Without one it overflows the pane on\n // every side, and the half above the top edge is unreachable — scrolling only\n // ever reaches transform overflow past the END edges.\n const boxed = quarter && scaled !== undefined;\n\n const img = (\n <UiImage\n src={image.url}\n // An image with no author description is decorative to AT — an empty alt\n // is correct and deliberate, not a missing label.\n alt={source.alt ?? \"\"}\n width={image.width}\n height={image.height}\n // `contain` keeps the letterboxing the fit caps below rely on. Zoom and\n // rotation stay adapter-owned (ADR 0026), so no skeleton frame: the bare\n // <img> is the root and every class and style lands on it. The adapter's\n // own `failed` state below owns the error UI; ui's fallback never mounts.\n fit=\"contain\"\n showSkeleton={false}\n fallback={null}\n onError={() => setFailed(true)}\n className={cn(\n !quarter && zoom === \"fit-page\" && \"max-h-full max-w-full\",\n !quarter && zoom === \"fit-width\" && \"h-auto max-w-full\",\n // Turned on its side, the pane's HEIGHT caps the image's width and its\n // width caps the height. Container units read the pane directly, so the\n // fit needs no measurement. Both fit modes converge here deliberately:\n // an image whose rotated width filled the pane would overflow the top\n // edge as well, which is the unreachable half described above.\n fitting && quarter && \"max-h-[100cqw] max-w-[100cqh]\",\n // At a fixed scale the intrinsic cap has to come off, or \"200%\" would\n // silently stop at the pane's width.\n !fitting && \"max-w-none\",\n boxed && \"absolute top-1/2 left-1/2\",\n )}\n style={{\n ...scaled,\n ...(rotation === 0\n ? undefined\n : {\n transform: boxed\n ? `translate(-50%, -50%) rotate(${rotation}deg)`\n : `rotate(${rotation}deg)`,\n }),\n }}\n />\n );\n\n return (\n // Centred on both axes: a small image pinned to the top-left of a tall pane\n // reads as a layout accident. While fitting, the box takes the pane's height\n // so `max-h-full` has something definite to resolve against; at a fixed\n // scale it grows instead, and `FileViewerContent` scrolls it.\n <div\n className={cn(\n \"flex items-center justify-center\",\n fitting ? \"h-full\" : \"min-h-full\",\n // Only a size container can answer `cqh`, and only a definite height\n // makes one — which is exactly the fitting case.\n fitting && quarter && \"[container-type:size]\",\n className,\n )}\n >\n {quarter && scaled ? (\n <div className=\"relative shrink-0\" style={{ width: scaled.height, height: scaled.width }}>\n {img}\n </div>\n ) : (\n img\n )}\n </div>\n );\n}\n\nconst adapterModule: AdapterModule = {\n manifest: imageManifest,\n create: () => new ImageAdapter(),\n Renderer: ImageRenderer,\n};\n\nexport default adapterModule;\n"],"mappings":"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","names":[]}
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import {
3
3
  createDefaultRegistry
4
- } from "../chunk-QIPKBOZJ.js";
4
+ } from "../chunk-IZG6TYQQ.js";
5
5
  import "../chunk-BWMXMHVZ.js";
6
6
  import {
7
7
  pptxManifest
@@ -9,7 +9,13 @@ import {
9
9
  import "../../chunk-CIT2HHRI.js";
10
10
 
11
11
  // src/adapters/media/media-adapter.tsx
12
- import { cn, StatePanel, useLocale } from "@elabs-ai/components-ui";
12
+ import {
13
+ Audio,
14
+ cn,
15
+ StatePanel,
16
+ useLocale,
17
+ Video
18
+ } from "@elabs-ai/components-ui";
13
19
  import { useEffect, useState } from "react";
14
20
  import { jsx } from "react/jsx-runtime";
15
21
  var MediaAdapter = class {
@@ -45,25 +51,25 @@ function MediaRenderer({ document: doc, source, className }) {
45
51
  }
46
52
  const label = t("viewer.media.label", { name: source.name });
47
53
  if (media.media === "audio") {
48
- return /* @__PURE__ */ jsx("div", { className: cn("flex min-h-full items-center justify-center", className), children: /* @__PURE__ */ jsx(
49
- "audio",
54
+ return /* @__PURE__ */ jsx("div", { className: cn("flex min-h-full items-center justify-center p-4", className), children: /* @__PURE__ */ jsx(
55
+ Audio,
50
56
  {
51
57
  src: media.url,
52
- controls: true,
53
- "aria-label": label,
58
+ preload: "metadata",
59
+ label,
54
60
  onError: () => setFailed(true),
55
61
  className: "w-full max-w-lg"
56
62
  }
57
63
  ) });
58
64
  }
59
- return /* @__PURE__ */ jsx("div", { className: cn("flex min-h-full items-center justify-center", className), children: /* @__PURE__ */ jsx(
60
- "video",
65
+ return /* @__PURE__ */ jsx("div", { className: cn("flex min-h-full items-center justify-center p-4", className), children: /* @__PURE__ */ jsx(
66
+ Video,
61
67
  {
62
68
  src: media.url,
63
- controls: true,
64
- "aria-label": label,
69
+ preload: "metadata",
70
+ label,
65
71
  onError: () => setFailed(true),
66
- className: "max-h-full max-w-full"
72
+ className: "w-full max-w-4xl"
67
73
  }
68
74
  ) });
69
75
  }
@@ -1 +1 @@
1
- {"version":3,"sources":["../../../src/adapters/media/media-adapter.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * Video / audio adapter — native elements, deliberately.\n *\n * `@elabs-ai/components-ai` ships a designed `AudioPlayer` built on\n * `media-chrome`, and this adapter does NOT reach for it: `ai` is a Layer-2\n * sibling, so importing it would be a sideways edge the dependency gate rejects\n * (and would pull a chat package into a file viewer). Native `<video controls>`\n * / `<audio controls>` are the honest answer here — they bring the platform's\n * own accessible transport, keyboard handling, picture-in-picture, captions\n * menu and OS media keys, none of which a custom skin gets for free.\n *\n * The plan's \"extract `AudioPlayer` down to `ui`\" would let both sides share one\n * skinned player. That is a real refactor of an existing component with its own\n * tests and stories, so it is tracked separately rather than smuggled in here.\n */\n\nimport { cn, StatePanel, useLocale, type ResolvedFileSource } from \"@elabs-ai/components-ui\";\nimport { useEffect, useState } from \"react\";\n\nimport { toViewerError } from \"../../core/errors\";\nimport type {\n AdapterDocument,\n AdapterLoadContext,\n AdapterModule,\n AdapterRendererProps,\n FileAdapter,\n} from \"../../core/types\";\nimport { mediaManifest } from \"./media-manifest\";\n\nexport interface MediaDocument extends AdapterDocument {\n kind: \"media\";\n /** A URL the element can play — the source's own URL, or a minted object URL. */\n url: string;\n /** Which element to render. `resolveFileKind` already decided this. */\n media: \"video\" | \"audio\";\n}\n\nclass MediaAdapter implements FileAdapter {\n #source?: ResolvedFileSource;\n\n async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<MediaDocument> {\n this.#source = source;\n try {\n // Deliberately `url()`, never `bytes()`: an object URL lets the browser\n // stream and seek. Buffering a 2 GB video into memory to hand it to a\n // `<video>` element would be the wrong shape at every size.\n const url = await source.url(context.signal);\n return { kind: \"media\", url, media: source.category === \"audio\" ? \"audio\" : \"video\" };\n } catch (error) {\n throw toViewerError(error, \"read-failed\", { fileName: source.name });\n }\n }\n\n dispose(): void {\n this.#source?.revoke();\n this.#source = undefined;\n }\n}\n\nfunction MediaRenderer({ document: doc, source, className }: AdapterRendererProps) {\n const media = doc as MediaDocument;\n const { t } = useLocale();\n const [failed, setFailed] = useState(false);\n\n useEffect(() => setFailed(false), [media.url]);\n\n if (failed) {\n // Terminal: the browser has no decoder for this codec. Retrying cannot help,\n // so this states the fact rather than offering a button that does nothing.\n // The panel carries `role=\"alert\"` itself, and sits centred like every other\n // viewer state — a message pinned to the top-left of an empty pane reads as\n // a broken render.\n return (\n <div className={cn(\"flex min-h-full flex-col justify-center p-4\", className)}>\n <StatePanel\n kind=\"error\"\n title={t(\"viewer.media.unsupportedTitle\")}\n description={t(\"viewer.media.unsupported\", { name: source.name })}\n />\n </div>\n );\n }\n\n const label = t(\"viewer.media.label\", { name: source.name });\n\n if (media.media === \"audio\") {\n return (\n <div className={cn(\"flex min-h-full items-center justify-center\", className)}>\n <audio\n src={media.url}\n controls\n aria-label={label}\n onError={() => setFailed(true)}\n className=\"w-full max-w-lg\"\n />\n </div>\n );\n }\n\n return (\n <div className={cn(\"flex min-h-full items-center justify-center\", className)}>\n <video\n src={media.url}\n controls\n // No autoplay: a viewer opens files the reader chose to look at, not to\n // listen to. Sound starting on its own is the reason browsers block it.\n aria-label={label}\n onError={() => setFailed(true)}\n className=\"max-h-full max-w-full\"\n />\n </div>\n );\n}\n\nconst adapterModule: AdapterModule = {\n manifest: mediaManifest,\n create: () => new MediaAdapter(),\n Renderer: MediaRenderer,\n};\n\nexport default adapterModule;\n"],"mappings":";;;;;;;;;;;AAkBA,SAAS,IAAI,YAAY,iBAA0C;AACnE,SAAS,WAAW,gBAAgB;AAyD5B;AArCR,IAAM,eAAN,MAA0C;AAAA,EACxC;AAAA,EAEA,MAAM,KAAK,QAA4B,SAAqD;AAC1F,SAAK,UAAU;AACf,QAAI;AAIF,YAAM,MAAM,MAAM,OAAO,IAAI,QAAQ,MAAM;AAC3C,aAAO,EAAE,MAAM,SAAS,KAAK,OAAO,OAAO,aAAa,UAAU,UAAU,QAAQ;AAAA,IACtF,SAAS,OAAO;AACd,YAAM,cAAc,OAAO,eAAe,EAAE,UAAU,OAAO,KAAK,CAAC;AAAA,IACrE;AAAA,EACF;AAAA,EAEA,UAAgB;AACd,SAAK,SAAS,OAAO;AACrB,SAAK,UAAU;AAAA,EACjB;AACF;AAEA,SAAS,cAAc,EAAE,UAAU,KAAK,QAAQ,UAAU,GAAyB;AACjF,QAAM,QAAQ;AACd,QAAM,EAAE,EAAE,IAAI,UAAU;AACxB,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,KAAK;AAE1C,YAAU,MAAM,UAAU,KAAK,GAAG,CAAC,MAAM,GAAG,CAAC;AAE7C,MAAI,QAAQ;AAMV,WACE,oBAAC,SAAI,WAAW,GAAG,+CAA+C,SAAS,GACzE;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,OAAO,EAAE,+BAA+B;AAAA,QACxC,aAAa,EAAE,4BAA4B,EAAE,MAAM,OAAO,KAAK,CAAC;AAAA;AAAA,IAClE,GACF;AAAA,EAEJ;AAEA,QAAM,QAAQ,EAAE,sBAAsB,EAAE,MAAM,OAAO,KAAK,CAAC;AAE3D,MAAI,MAAM,UAAU,SAAS;AAC3B,WACE,oBAAC,SAAI,WAAW,GAAG,+CAA+C,SAAS,GACzE;AAAA,MAAC;AAAA;AAAA,QACC,KAAK,MAAM;AAAA,QACX,UAAQ;AAAA,QACR,cAAY;AAAA,QACZ,SAAS,MAAM,UAAU,IAAI;AAAA,QAC7B,WAAU;AAAA;AAAA,IACZ,GACF;AAAA,EAEJ;AAEA,SACE,oBAAC,SAAI,WAAW,GAAG,+CAA+C,SAAS,GACzE;AAAA,IAAC;AAAA;AAAA,MACC,KAAK,MAAM;AAAA,MACX,UAAQ;AAAA,MAGR,cAAY;AAAA,MACZ,SAAS,MAAM,UAAU,IAAI;AAAA,MAC7B,WAAU;AAAA;AAAA,EACZ,GACF;AAEJ;AAEA,IAAM,gBAA+B;AAAA,EACnC,UAAU;AAAA,EACV,QAAQ,MAAM,IAAI,aAAa;AAAA,EAC/B,UAAU;AACZ;AAEA,IAAO,wBAAQ;","names":[]}
1
+ {"version":3,"sources":["../../../src/adapters/media/media-adapter.tsx"],"sourcesContent":["\"use client\";\n\n/**\n * Video / audio adapter — renders ui `Audio` / `Video` (ADR 0041).\n *\n * The player lives in `@elabs-ai/components-ui`, one layer down, so the viewer\n * composes it without a sideways edge into `@elabs-ai/components-ai` (whose\n * `AudioPlayer` is itself a preset over the same `MediaPlayer*` parts). That\n * gives token-styled controls that follow the theme, keyboard shortcuts on the\n * player root, and one loading / error model — where the browser's native\n * chrome ignored tokens and differed per engine.\n *\n * An undecodable file is still the adapter's call: the element's `error` event\n * swaps the whole player for the viewer's own `StatePanel`, so ui's compact\n * error panel never shows here.\n */\n\nimport {\n Audio,\n cn,\n StatePanel,\n useLocale,\n Video,\n type ResolvedFileSource,\n} from \"@elabs-ai/components-ui\";\nimport { useEffect, useState } from \"react\";\n\nimport { toViewerError } from \"../../core/errors\";\nimport type {\n AdapterDocument,\n AdapterLoadContext,\n AdapterModule,\n AdapterRendererProps,\n FileAdapter,\n} from \"../../core/types\";\nimport { mediaManifest } from \"./media-manifest\";\n\nexport interface MediaDocument extends AdapterDocument {\n kind: \"media\";\n /** A URL the element can play — the source's own URL, or a minted object URL. */\n url: string;\n /** Which element to render. `resolveFileKind` already decided this. */\n media: \"video\" | \"audio\";\n}\n\nclass MediaAdapter implements FileAdapter {\n #source?: ResolvedFileSource;\n\n async load(source: ResolvedFileSource, context: AdapterLoadContext): Promise<MediaDocument> {\n this.#source = source;\n try {\n // Deliberately `url()`, never `bytes()`: an object URL lets the browser\n // stream and seek. Buffering a 2 GB video into memory to hand it to a\n // `<video>` element would be the wrong shape at every size.\n const url = await source.url(context.signal);\n return { kind: \"media\", url, media: source.category === \"audio\" ? \"audio\" : \"video\" };\n } catch (error) {\n throw toViewerError(error, \"read-failed\", { fileName: source.name });\n }\n }\n\n dispose(): void {\n this.#source?.revoke();\n this.#source = undefined;\n }\n}\n\nfunction MediaRenderer({ document: doc, source, className }: AdapterRendererProps) {\n const media = doc as MediaDocument;\n const { t } = useLocale();\n const [failed, setFailed] = useState(false);\n\n useEffect(() => setFailed(false), [media.url]);\n\n if (failed) {\n // Terminal: the browser has no decoder for this codec. Retrying cannot help,\n // so this states the fact rather than offering a button that does nothing.\n // The panel carries `role=\"alert\"` itself, and sits centred like every other\n // viewer state — a message pinned to the top-left of an empty pane reads as\n // a broken render.\n return (\n <div className={cn(\"flex min-h-full flex-col justify-center p-4\", className)}>\n <StatePanel\n kind=\"error\"\n title={t(\"viewer.media.unsupportedTitle\")}\n description={t(\"viewer.media.unsupported\", { name: source.name })}\n />\n </div>\n );\n }\n\n const label = t(\"viewer.media.label\", { name: source.name });\n\n if (media.media === \"audio\") {\n return (\n <div className={cn(\"flex min-h-full items-center justify-center p-4\", className)}>\n <Audio\n src={media.url}\n preload=\"metadata\"\n label={label}\n onError={() => setFailed(true)}\n className=\"w-full max-w-lg\"\n />\n </div>\n );\n }\n\n return (\n <div className={cn(\"flex min-h-full items-center justify-center p-4\", className)}>\n {/* No autoplay: a viewer opens files the reader chose to look at, not to\n listen to. Sound starting on its own is the reason browsers block it.\n No `aspectRatio`: an arbitrary file's ratio is unknown until metadata\n loads, so the video sizes by its own intrinsic dimensions inside a\n width cap, and the pane scrolls if a tall one outgrows it. */}\n <Video\n src={media.url}\n preload=\"metadata\"\n label={label}\n onError={() => setFailed(true)}\n className=\"w-full max-w-4xl\"\n />\n </div>\n );\n}\n\nconst adapterModule: AdapterModule = {\n manifest: mediaManifest,\n create: () => new MediaAdapter(),\n Renderer: MediaRenderer,\n};\n\nexport default adapterModule;\n"],"mappings":";;;;;;;;;;;AAiBA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OAEK;AACP,SAAS,WAAW,gBAAgB;AAyD5B;AArCR,IAAM,eAAN,MAA0C;AAAA,EACxC;AAAA,EAEA,MAAM,KAAK,QAA4B,SAAqD;AAC1F,SAAK,UAAU;AACf,QAAI;AAIF,YAAM,MAAM,MAAM,OAAO,IAAI,QAAQ,MAAM;AAC3C,aAAO,EAAE,MAAM,SAAS,KAAK,OAAO,OAAO,aAAa,UAAU,UAAU,QAAQ;AAAA,IACtF,SAAS,OAAO;AACd,YAAM,cAAc,OAAO,eAAe,EAAE,UAAU,OAAO,KAAK,CAAC;AAAA,IACrE;AAAA,EACF;AAAA,EAEA,UAAgB;AACd,SAAK,SAAS,OAAO;AACrB,SAAK,UAAU;AAAA,EACjB;AACF;AAEA,SAAS,cAAc,EAAE,UAAU,KAAK,QAAQ,UAAU,GAAyB;AACjF,QAAM,QAAQ;AACd,QAAM,EAAE,EAAE,IAAI,UAAU;AACxB,QAAM,CAAC,QAAQ,SAAS,IAAI,SAAS,KAAK;AAE1C,YAAU,MAAM,UAAU,KAAK,GAAG,CAAC,MAAM,GAAG,CAAC;AAE7C,MAAI,QAAQ;AAMV,WACE,oBAAC,SAAI,WAAW,GAAG,+CAA+C,SAAS,GACzE;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,OAAO,EAAE,+BAA+B;AAAA,QACxC,aAAa,EAAE,4BAA4B,EAAE,MAAM,OAAO,KAAK,CAAC;AAAA;AAAA,IAClE,GACF;AAAA,EAEJ;AAEA,QAAM,QAAQ,EAAE,sBAAsB,EAAE,MAAM,OAAO,KAAK,CAAC;AAE3D,MAAI,MAAM,UAAU,SAAS;AAC3B,WACE,oBAAC,SAAI,WAAW,GAAG,mDAAmD,SAAS,GAC7E;AAAA,MAAC;AAAA;AAAA,QACC,KAAK,MAAM;AAAA,QACX,SAAQ;AAAA,QACR;AAAA,QACA,SAAS,MAAM,UAAU,IAAI;AAAA,QAC7B,WAAU;AAAA;AAAA,IACZ,GACF;AAAA,EAEJ;AAEA,SACE,oBAAC,SAAI,WAAW,GAAG,mDAAmD,SAAS,GAM7E;AAAA,IAAC;AAAA;AAAA,MACC,KAAK,MAAM;AAAA,MACX,SAAQ;AAAA,MACR;AAAA,MACA,SAAS,MAAM,UAAU,IAAI;AAAA,MAC7B,WAAU;AAAA;AAAA,EACZ,GACF;AAEJ;AAEA,IAAM,gBAA+B;AAAA,EACnC,UAAU;AAAA,EACV,QAAQ,MAAM,IAAI,aAAa;AAAA,EAC/B,UAAU;AACZ;AAEA,IAAO,wBAAQ;","names":[]}
@@ -27,7 +27,7 @@ import {
27
27
  } from "./chunk-SASY77ZL.js";
28
28
  import {
29
29
  createDefaultRegistry
30
- } from "./chunk-QIPKBOZJ.js";
30
+ } from "./chunk-IZG6TYQQ.js";
31
31
  import {
32
32
  ViewerError,
33
33
  isAbort,
@@ -730,4 +730,4 @@ export {
730
730
  FileViewerContent,
731
731
  FileViewer
732
732
  };
733
- //# sourceMappingURL=chunk-T4Y4YRHJ.js.map
733
+ //# sourceMappingURL=chunk-F2EZVKZN.js.map
@@ -56,4 +56,4 @@ function createDefaultRegistry() {
56
56
  export {
57
57
  createDefaultRegistry
58
58
  };
59
- //# sourceMappingURL=chunk-QIPKBOZJ.js.map
59
+ //# sourceMappingURL=chunk-IZG6TYQQ.js.map
@@ -10,7 +10,7 @@ import {
10
10
  FileViewerProvider,
11
11
  FileViewerSkeleton,
12
12
  FileViewerToolbar
13
- } from "../chunk-T4Y4YRHJ.js";
13
+ } from "../chunk-F2EZVKZN.js";
14
14
  import "../chunk-PAIVK4N6.js";
15
15
  import "../chunk-LRPZ373O.js";
16
16
  import "../chunk-PKYDKXCO.js";
@@ -18,7 +18,7 @@ import "../chunk-CLOI4HBA.js";
18
18
  import "../chunk-FQHZEQUH.js";
19
19
  import "../chunk-CO2VJRSU.js";
20
20
  import "../chunk-SASY77ZL.js";
21
- import "../chunk-QIPKBOZJ.js";
21
+ import "../chunk-IZG6TYQQ.js";
22
22
  import "../chunk-BWMXMHVZ.js";
23
23
  import "../chunk-FDT4QOIG.js";
24
24
  import "../chunk-ZAVXZRIO.js";
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import {
9
9
  FileViewerProvider,
10
10
  FileViewerSkeleton,
11
11
  FileViewerToolbar
12
- } from "./chunk-T4Y4YRHJ.js";
12
+ } from "./chunk-F2EZVKZN.js";
13
13
  import {
14
14
  FileViewerFind,
15
15
  isFindShortcut
@@ -46,7 +46,7 @@ import {
46
46
  } from "./chunk-SBG7WPGO.js";
47
47
  import {
48
48
  createDefaultRegistry
49
- } from "./chunk-QIPKBOZJ.js";
49
+ } from "./chunk-IZG6TYQQ.js";
50
50
  import {
51
51
  createRegistry,
52
52
  scoreManifest
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elabs-ai/components-viewer",
3
- "version": "5.3.1",
3
+ "version": "5.5.0",
4
4
  "license": "MIT",
5
5
  "repository": {
6
6
  "type": "git",
@@ -39,8 +39,8 @@
39
39
  "jszip": "^3.10.1",
40
40
  "shiki": "^3.22.0",
41
41
  "streamdown": "^2.4.0",
42
- "@elabs-ai/components-tokens": "^5.3.1",
43
- "@elabs-ai/components-ui": "^5.3.1"
42
+ "@elabs-ai/components-tokens": "^5.5.0",
43
+ "@elabs-ai/components-ui": "^5.5.0"
44
44
  },
45
45
  "peerDependenciesMeta": {
46
46
  "papaparse": {
@@ -88,9 +88,9 @@
88
88
  "typescript": "^5.7.3",
89
89
  "vitest": "^3.0.2",
90
90
  "@elabs-ai/components-eslint-config": "0.1.0",
91
- "@elabs-ai/components-tokens": "5.3.1",
91
+ "@elabs-ai/components-tokens": "5.5.0",
92
92
  "@elabs-ai/components-typescript-config": "0.1.0",
93
- "@elabs-ai/components-ui": "5.3.1"
93
+ "@elabs-ai/components-ui": "5.5.0"
94
94
  },
95
95
  "scripts": {
96
96
  "build": "tsup && node ../../scripts/link-dist-css.mjs",
@@ -19,6 +19,7 @@
19
19
  import type { ProseHeadingLevel, ResolvedFileSource } from "@elabs-ai/components-ui";
20
20
  import {
21
21
  cn,
22
+ Image as UiImage,
22
23
  ProseHeading,
23
24
  ProseLink,
24
25
  ProseList,
@@ -210,14 +211,18 @@ function Block({
210
211
  }
211
212
  if (block.type === "image") {
212
213
  return (
213
- <img
214
+ <UiImage
214
215
  src={block.src}
215
216
  // The document's own alt text when the author wrote one. An image with
216
217
  // none is decoration as far as the reader can tell, and a filename would
217
218
  // be noise, not a description.
218
219
  alt={block.alt ?? ""}
219
220
  {...(block.alt ? {} : { "aria-hidden": true })}
220
- className="my-2 block h-auto max-w-full rounded-md"
221
+ // A broken embedded image renders nothing rather than ui's placeholder
222
+ // box: the document reads on without it.
223
+ fallback={null}
224
+ loading="lazy"
225
+ className="my-2 h-auto rounded-md"
221
226
  />
222
227
  );
223
228
  }
@@ -3,7 +3,7 @@
3
3
  /**
4
4
  * Image adapter — the reference implementation of "adapters emit data".
5
5
  *
6
- * It renders an `<img>`, never a `<canvas>`. anyview draws images to a canvas
6
+ * It renders an `<img>` (through ui `Image`, ADR 0041), never a `<canvas>`. anyview draws images to a canvas
7
7
  * (`ImageAdapter.ts:118`, `PageRenderer.tsx:128`), which throws away the alt
8
8
  * text, the browser's own decoding and zoom, and the ability to select or save
9
9
  * the image — with no route back, because its API has no alt-text path at all.
@@ -11,7 +11,10 @@
11
11
  * user gets the same information a sighted user does.
12
12
  */
13
13
 
14
- import { cn, StatePanel, useLocale } from "@elabs-ai/components-ui";
14
+ // Aliased on purpose: `measure()` below uses the GLOBAL `Image` constructor, and
15
+ // an unaliased import would shadow it silently (`typeof Image` would still be
16
+ // "function", so nothing would fail loudly).
17
+ import { cn, StatePanel, Image as UiImage, useLocale } from "@elabs-ai/components-ui";
15
18
  import { useEffect, useState } from "react";
16
19
 
17
20
  import type {
@@ -120,16 +123,22 @@ function ImageRenderer({
120
123
  const boxed = quarter && scaled !== undefined;
121
124
 
122
125
  const img = (
123
- <img
126
+ <UiImage
124
127
  src={image.url}
125
128
  // An image with no author description is decorative to AT — an empty alt
126
129
  // is correct and deliberate, not a missing label.
127
130
  alt={source.alt ?? ""}
128
131
  width={image.width}
129
132
  height={image.height}
133
+ // `contain` keeps the letterboxing the fit caps below rely on. Zoom and
134
+ // rotation stay adapter-owned (ADR 0026), so no skeleton frame: the bare
135
+ // <img> is the root and every class and style lands on it. The adapter's
136
+ // own `failed` state below owns the error UI; ui's fallback never mounts.
137
+ fit="contain"
138
+ showSkeleton={false}
139
+ fallback={null}
130
140
  onError={() => setFailed(true)}
131
141
  className={cn(
132
- "block object-contain",
133
142
  !quarter && zoom === "fit-page" && "max-h-full max-w-full",
134
143
  !quarter && zoom === "fit-width" && "h-auto max-w-full",
135
144
  // Turned on its side, the pane's HEIGHT caps the image's width and its
@@ -52,17 +52,24 @@ describe("media adapter — load", () => {
52
52
  });
53
53
 
54
54
  describe("media adapter — rendering", () => {
55
- it("renders a native, controllable video with an accessible name", () => {
55
+ it("renders a controllable ui video player with an accessible name", () => {
56
56
  const { container } = renderMedia("video", "briefing.mp4");
57
+ // The player is named on its region root; the native element carries no
58
+ // browser chrome — ui's token-styled bar is the transport.
59
+ const player = screen.getByRole("region", { name: "briefing.mp4 player" });
60
+ expect(player).toHaveAttribute("data-kind", "video");
61
+ expect(player.querySelector('[data-slot="media-player-controls"]')).toBeInTheDocument();
57
62
  const video = container.querySelector("video");
58
- expect(video).toHaveAttribute("controls");
59
- expect(video).toHaveAttribute("aria-label", "briefing.mp4 player");
63
+ expect(video).not.toHaveAttribute("controls");
60
64
  // Sound that starts on its own is the reason browsers block autoplay.
61
65
  expect(video).not.toHaveAttribute("autoplay");
62
66
  });
63
67
 
64
- it("renders an audio element for audio", () => {
68
+ it("renders a ui audio player for audio", () => {
65
69
  const { container } = renderMedia("audio", "call.mp3");
70
+ const player = screen.getByRole("region", { name: "call.mp3 player" });
71
+ expect(player).toHaveAttribute("data-kind", "audio");
72
+ expect(player.querySelector('[data-slot="media-player-controls"]')).toBeInTheDocument();
66
73
  expect(container.querySelector("audio")).toBeInTheDocument();
67
74
  expect(container.querySelector("video")).toBeNull();
68
75
  });
@@ -1,22 +1,28 @@
1
1
  "use client";
2
2
 
3
3
  /**
4
- * Video / audio adapter — native elements, deliberately.
4
+ * Video / audio adapter — renders ui `Audio` / `Video` (ADR 0041).
5
5
  *
6
- * `@elabs-ai/components-ai` ships a designed `AudioPlayer` built on
7
- * `media-chrome`, and this adapter does NOT reach for it: `ai` is a Layer-2
8
- * sibling, so importing it would be a sideways edge the dependency gate rejects
9
- * (and would pull a chat package into a file viewer). Native `<video controls>`
10
- * / `<audio controls>` are the honest answer here — they bring the platform's
11
- * own accessible transport, keyboard handling, picture-in-picture, captions
12
- * menu and OS media keys, none of which a custom skin gets for free.
6
+ * The player lives in `@elabs-ai/components-ui`, one layer down, so the viewer
7
+ * composes it without a sideways edge into `@elabs-ai/components-ai` (whose
8
+ * `AudioPlayer` is itself a preset over the same `MediaPlayer*` parts). That
9
+ * gives token-styled controls that follow the theme, keyboard shortcuts on the
10
+ * player root, and one loading / error model — where the browser's native
11
+ * chrome ignored tokens and differed per engine.
13
12
  *
14
- * The plan's "extract `AudioPlayer` down to `ui`" would let both sides share one
15
- * skinned player. That is a real refactor of an existing component with its own
16
- * tests and stories, so it is tracked separately rather than smuggled in here.
13
+ * An undecodable file is still the adapter's call: the element's `error` event
14
+ * swaps the whole player for the viewer's own `StatePanel`, so ui's compact
15
+ * error panel never shows here.
17
16
  */
18
17
 
19
- import { cn, StatePanel, useLocale, type ResolvedFileSource } from "@elabs-ai/components-ui";
18
+ import {
19
+ Audio,
20
+ cn,
21
+ StatePanel,
22
+ useLocale,
23
+ Video,
24
+ type ResolvedFileSource,
25
+ } from "@elabs-ai/components-ui";
20
26
  import { useEffect, useState } from "react";
21
27
 
22
28
  import { toViewerError } from "../../core/errors";
@@ -87,11 +93,11 @@ function MediaRenderer({ document: doc, source, className }: AdapterRendererProp
87
93
 
88
94
  if (media.media === "audio") {
89
95
  return (
90
- <div className={cn("flex min-h-full items-center justify-center", className)}>
91
- <audio
96
+ <div className={cn("flex min-h-full items-center justify-center p-4", className)}>
97
+ <Audio
92
98
  src={media.url}
93
- controls
94
- aria-label={label}
99
+ preload="metadata"
100
+ label={label}
95
101
  onError={() => setFailed(true)}
96
102
  className="w-full max-w-lg"
97
103
  />
@@ -100,15 +106,18 @@ function MediaRenderer({ document: doc, source, className }: AdapterRendererProp
100
106
  }
101
107
 
102
108
  return (
103
- <div className={cn("flex min-h-full items-center justify-center", className)}>
104
- <video
109
+ <div className={cn("flex min-h-full items-center justify-center p-4", className)}>
110
+ {/* No autoplay: a viewer opens files the reader chose to look at, not to
111
+ listen to. Sound starting on its own is the reason browsers block it.
112
+ No `aspectRatio`: an arbitrary file's ratio is unknown until metadata
113
+ loads, so the video sizes by its own intrinsic dimensions inside a
114
+ width cap, and the pane scrolls if a tall one outgrows it. */}
115
+ <Video
105
116
  src={media.url}
106
- controls
107
- // No autoplay: a viewer opens files the reader chose to look at, not to
108
- // listen to. Sound starting on its own is the reason browsers block it.
109
- aria-label={label}
117
+ preload="metadata"
118
+ label={label}
110
119
  onError={() => setFailed(true)}
111
- className="max-h-full max-w-full"
120
+ className="w-full max-w-4xl"
112
121
  />
113
122
  </div>
114
123
  );
@@ -274,13 +274,14 @@ export const Pdf: Story = {
274
274
  };
275
275
 
276
276
  /**
277
- * Video and audio use the NATIVE elements on purpose — the platform's own
278
- * transport brings keyboard control, captions, picture-in-picture and the OS
279
- * media keys, none of which a custom skin gets for free. The adapter streams
280
- * from a URL and never buffers the bytes, so a 2 GB recording seeks instantly.
277
+ * Video and audio render through ui `Audio` / `Video` (ADR 0041): a
278
+ * token-styled control bar — play, seek, time, volume — that follows the theme,
279
+ * with keyboard shortcuts on the player, in place of the browser's native
280
+ * chrome. The adapter streams from a URL and never buffers the bytes, so a
281
+ * 2 GB recording seeks instantly.
281
282
  *
282
283
  * The fixture is audio because a real, playable video cannot be synthesized
283
- * inline; the element and the chrome are the same for both.
284
+ * inline; the video player shares the same parts, with a viewport above the bar.
284
285
  */
285
286
  export const Audio: Story = {
286
287
  args: {