Primary Source Viewer

Document viewer for historical primary sources with zoom, rotate, region annotations, transcription panel, and metadata footer.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/primary-source-viewer.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

3 stories available:

Code

"use client"; import { type ComponentPropsWithoutRef, createContext, type ReactNode, use, useCallback, useId, useMemo, useState, } from "react"; import { cn } from "../../lib/utils"; const MIN_ZOOM = 0.25; const MAX_ZOOM = 8; const ZOOM_STEP = 1.25; const ROTATE_STEP = 90; /** * Image source for {@link PrimarySourceViewer}. * * @public */ export type PrimarySource = { /** Required alt text for assistive tech. */ alt: string; /** Image URL. */ src: string; /** Source kind. Image is the supported value today. */ type: "image"; }; /** * Color theme for {@link PrimarySourceAnnotation}. * * @public */ export type AnnotationColor = | "amber" | "blue" | "emerald" | "purple" | "red" | "rose"; const ANNOTATION_PALETTE: Record< AnnotationColor, { border: string; chip: string; fill: string } > = { amber: { border: "border-amber-500", chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300", fill: "bg-amber-500/15", }, blue: { border: "border-blue-500", chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300", fill: "bg-blue-500/15", }, emerald: { border: "border-emerald-500", chip: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300", fill: "bg-emerald-500/15", }, purple: { border: "border-purple-500", chip: "bg-purple-500/15 text-purple-700 dark:text-purple-300", fill: "bg-purple-500/15", }, red: { border: "border-red-500", chip: "bg-red-500/15 text-red-700 dark:text-red-300", fill: "bg-red-500/15", }, rose: { border: "border-rose-500", chip: "bg-rose-500/15 text-rose-700 dark:text-rose-300", fill: "bg-rose-500/15", }, }; /** * Localizable strings. * * @public */ export type PrimarySourceViewerLabels = { /** Aria-label for the viewer region. Defaults to `"Primary source viewer"`. */ region?: string; /** Aria-label for the rotate button. Defaults to `"Rotate"`. */ rotate?: string; /** Aria-label for the zoom-in button. Defaults to `"Zoom in"`. */ zoomIn?: string; /** Aria-label for the zoom-out button. Defaults to `"Zoom out"`. */ zoomOut?: string; }; const DEFAULT_LABELS = { region: "Primary source viewer", rotate: "Rotate", zoomIn: "Zoom in", zoomOut: "Zoom out", } as const satisfies Required<PrimarySourceViewerLabels>; type ViewerCtx = { labels: Required<PrimarySourceViewerLabels>; rotate: () => void; rotation: number; zoom: number; zoomIn: () => void; zoomOut: () => void; }; const ViewerContext = createContext<null | ViewerCtx>(null); function useViewerContext(): ViewerCtx { const ctx = use(ViewerContext); if (!ctx) { throw new Error("PrimarySourceViewer subcomponent used outside its root."); } return ctx; } function clamp(value: number, min: number, max: number): number { return Math.min(Math.max(value, min), max); } function useViewerState( resolvedLabels: Required<PrimarySourceViewerLabels>, ): ViewerCtx { const [zoom, setZoom] = useState(1); const [rotation, setRotation] = useState(0); const zoomIn = useCallback(() => { setZoom((current) => clamp(current * ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)); }, []); const zoomOut = useCallback(() => { setZoom((current) => clamp(current / ZOOM_STEP, MIN_ZOOM, MAX_ZOOM)); }, []); const rotate = useCallback(() => { setRotation((current) => (current + ROTATE_STEP) % 360); }, []); return useMemo( () => ({ labels: resolvedLabels, rotate, rotation, zoom, zoomIn, zoomOut, }), [resolvedLabels, rotate, rotation, zoom, zoomIn, zoomOut], ); } /** * Toolbar slot. Render the zoom / rotate buttons as children. * * @public */ export const PrimarySourceToolbar = ({ children, className, ref, ...rest }: ComponentPropsWithoutRef<"div"> & { ref?: React.Ref<HTMLDivElement> }) => ( <div className={cn( "flex flex-wrap items-center gap-2 border-b border-border bg-muted/40 px-4 py-2", className, )} ref={ref} role="toolbar" {...rest} > {children} </div> ); PrimarySourceToolbar.displayName = "PrimarySourceToolbar"; type ToolbarButtonProps = { ariaLabel: string; glyph: ReactNode; onActivate: () => void; } & Omit<ComponentPropsWithoutRef<"button">, "aria-label" | "onClick" | "type">; const ToolbarButton = ({ ariaLabel, className, glyph, onActivate, ref, ...rest }: ToolbarButtonProps & { ref?: React.Ref<HTMLButtonElement> }) => ( <button aria-label={ariaLabel} className={cn( "inline-flex h-8 min-w-8 items-center justify-center rounded-md border border-border bg-background px-2 text-sm font-medium hover:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring", className, )} onClick={onActivate} ref={ref} type="button" {...rest} > {glyph} </button> ); ToolbarButton.displayName = "ToolbarButton"; /** * Zoom-in button. * * @public */ export const PrimarySourceZoomIn = ({ ref, ...rest }: Omit< ComponentPropsWithoutRef<"button">, "aria-label" | "onClick" | "type" > & { ref?: React.Ref<HTMLButtonElement> }) => { const { labels, zoomIn } = useViewerContext(); return ( <ToolbarButton ariaLabel={labels.zoomIn} glyph="+" onActivate={zoomIn} ref={ref} {...rest} /> ); }; PrimarySourceZoomIn.displayName = "PrimarySourceZoomIn"; /** * Zoom-out button. * * @public */ export const PrimarySourceZoomOut = ({ ref, ...rest }: Omit< ComponentPropsWithoutRef<"button">, "aria-label" | "onClick" | "type" > & { ref?: React.Ref<HTMLButtonElement> }) => { const { labels, zoomOut } = useViewerContext(); return ( <ToolbarButton ariaLabel={labels.zoomOut} glyph="−" onActivate={zoomOut} ref={ref} {...rest} /> ); }; PrimarySourceZoomOut.displayName = "PrimarySourceZoomOut"; /** * Rotate-90-degrees button. * * @public */ export const PrimarySourceRotate = ({ ref, ...rest }: Omit< ComponentPropsWithoutRef<"button">, "aria-label" | "onClick" | "type" > & { ref?: React.Ref<HTMLButtonElement> }) => { const { labels, rotate } = useViewerContext(); return ( <ToolbarButton ariaLabel={labels.rotate} glyph="⟳" onActivate={rotate} ref={ref} {...rest} /> ); }; PrimarySourceRotate.displayName = "PrimarySourceRotate"; /** * Region in the source image, expressed as percentages of width / height. * * @public */ export type AnnotationRegion = { /** Height as a percentage of the source height (0–100). */ height: number; /** Width as a percentage of the source width (0–100). */ width: number; /** X offset as a percentage of the source width (0–100). */ x: number; /** Y offset as a percentage of the source height (0–100). */ y: number; }; /** * Props for {@link PrimarySourceAnnotation}. * * @public */ export type PrimarySourceAnnotationProps = { /** Optional category label rendered as a chip in the tooltip. */ category?: ReactNode; /** Color theme. Defaults to `"amber"`. */ color?: AnnotationColor; /** Stable id. Defaults to a generated id. */ id?: string; /** Note text rendered in the tooltip. */ note: ReactNode; /** Highlighted region (percentages 0–100). */ region: AnnotationRegion; } & Omit<ComponentPropsWithoutRef<"button">, "id" | "type">; /** * Container slot for the annotation overlay. * * @public */ export const PrimarySourceAnnotations = ({ children, className, ref, ...rest }: ComponentPropsWithoutRef<"div"> & { ref?: React.Ref<HTMLDivElement> }) => ( <div aria-label="Annotations" className={cn("pointer-events-none absolute inset-0 z-10", className)} ref={ref} {...rest} > {children} </div> ); PrimarySourceAnnotations.displayName = "PrimarySourceAnnotations"; type AnnotationTooltipProps = { category?: ReactNode; color: AnnotationColor; note: ReactNode; tooltipId: string; }; function AnnotationTooltip({ category, color, note, tooltipId, }: AnnotationTooltipProps): ReactNode { const palette = ANNOTATION_PALETTE[color]; return ( <span className="pointer-events-none absolute left-0 top-full z-10 mt-1 hidden min-w-44 max-w-sm rounded-md border bg-popover px-2 py-1 text-left text-xs text-popover-foreground shadow-md group-hover:block group-focus-visible:block" id={tooltipId} role="tooltip" > {category ? ( <span className={cn( "mb-1 inline-block rounded px-1 text-[10px] font-medium uppercase tracking-wide", palette.chip, )} > {category} </span> ) : null} <span className="block">{note}</span> </span> ); } /** * A single annotated region. * * @public */ export const PrimarySourceAnnotation = ({ ref, ...props }: PrimarySourceAnnotationProps & { ref?: React.Ref<HTMLButtonElement> }) => { const { category, className, color = "amber", id, note, region, ...rest } = props; const generatedId = useId(); const annotationId = id ?? generatedId; const palette = ANNOTATION_PALETTE[color]; const tooltipId = `${annotationId}-tooltip`; const noteText = typeof note === "string" ? note : "Annotation"; return ( <button aria-describedby={tooltipId} aria-label={noteText} className={cn( "group pointer-events-auto absolute rounded-md border-2 outline-none transition-colors hover:bg-foreground/10 focus-visible:bg-foreground/10", palette.border, palette.fill, className, )} data-annotation-id={annotationId} ref={ref} style={{ height: `${region.height.toString()}%`, left: `${region.x.toString()}%`, top: `${region.y.toString()}%`, width: `${region.width.toString()}%`, }} type="button" {...rest} > <AnnotationTooltip category={category} color={color} note={note} tooltipId={tooltipId} /> </button> ); }; PrimarySourceAnnotation.displayName = "PrimarySourceAnnotation"; /** * Side panel for transcription text. * * @public */ export const PrimarySourceTranscription = ({ children, className, ref, ...rest }: ComponentPropsWithoutRef<"aside"> & { ref?: React.Ref<HTMLDivElement> }) => ( <aside aria-label="Transcription" className={cn( "flex h-full flex-col gap-2 border-l border-border bg-background p-4 text-sm leading-relaxed", className, )} ref={ref} {...rest} > <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Transcription </h3> <div className="space-y-2 text-foreground">{children}</div> </aside> ); PrimarySourceTranscription.displayName = "PrimarySourceTranscription"; /** * Wrapper for metadata + discussion-questions slots beneath the viewer. * * @public */ export const PrimarySourceContext = ({ children, className, ref, ...rest }: ComponentPropsWithoutRef<"footer"> & { ref?: React.Ref<HTMLElement> }) => ( <footer className={cn( "grid gap-6 border-t border-border bg-muted/30 p-4 md:grid-cols-2", className, )} ref={ref} {...rest} > {children} </footer> ); PrimarySourceContext.displayName = "PrimarySourceContext"; /** * Metadata block. Wrap any markup; pair `<dt>` and `<dd>` for traditional * key/value rows. * * @public */ export const PrimarySourceMetadata = ({ children, className, ref, ...rest }: ComponentPropsWithoutRef<"dl"> & { ref?: React.Ref<HTMLDListElement> }) => ( <dl aria-label="Metadata" className={cn( "grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm", className, )} ref={ref} {...rest} > {children} </dl> ); PrimarySourceMetadata.displayName = "PrimarySourceMetadata"; /** * Discussion-questions block. * * @public */ export const PrimarySourceQuestions = ({ children, className, ref, ...rest }: ComponentPropsWithoutRef<"div"> & { ref?: React.Ref<HTMLDivElement> }) => ( <div aria-label="Discussion questions" className={cn("space-y-2 text-sm", className)} ref={ref} {...rest} > <h3 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> Discussion questions </h3> <div className="space-y-1 text-foreground">{children}</div> </div> ); PrimarySourceQuestions.displayName = "PrimarySourceQuestions"; type ChildBuckets = { annotations: ReactNode; context: ReactNode; toolbar: ReactNode; transcription: ReactNode; }; const SLOT_DISPLAY_NAMES = { annotations: PrimarySourceAnnotations.displayName, context: PrimarySourceContext.displayName, toolbar: PrimarySourceToolbar.displayName, transcription: PrimarySourceTranscription.displayName, } as const; type SlotKey = keyof ChildBuckets; const SLOT_KEY_BY_NAME: Record<string, SlotKey> = { [SLOT_DISPLAY_NAMES.annotations]: "annotations", [SLOT_DISPLAY_NAMES.context]: "context", [SLOT_DISPLAY_NAMES.toolbar]: "toolbar", [SLOT_DISPLAY_NAMES.transcription]: "transcription", }; function bucketChildren(children: ReactNode): ChildBuckets { const list: ReactNode[] = Array.isArray(children) ? children : [children]; return list.reduce<ChildBuckets>( (accumulator, child) => { const name = displayName(child); if (!name) return accumulator; const key = SLOT_KEY_BY_NAME[name]; if (!key) return accumulator; accumulator[key] = child; return accumulator; }, { annotations: null, context: null, toolbar: null, transcription: null }, ); } function displayName(child: ReactNode): string | undefined { if (typeof child !== "object" || child === null) return undefined; if (!("type" in child)) return undefined; const type = (child as { type: unknown }).type; if (typeof type !== "object" && typeof type !== "function") return undefined; const name = (type as { displayName?: unknown }).displayName; return typeof name === "string" ? name : undefined; } type StageProps = { annotations: ReactNode; source: PrimarySource; }; function Stage({ annotations, source }: StageProps): ReactNode { const { rotation, zoom } = useViewerContext(); return ( <div className="relative h-full w-full overflow-auto bg-muted" data-rotation={rotation} data-zoom={zoom} > <div className="relative inline-block" style={{ transform: `rotate(${rotation.toString()}deg) scale(${zoom.toString()})`, transformOrigin: "top left", }} > <img alt={source.alt} className="block h-auto max-w-none select-none" draggable={false} loading="lazy" src={source.src} /> {annotations} </div> </div> ); } /** * Props for {@link PrimarySourceViewer}. * * @public */ export type PrimarySourceViewerProps = { /** Localizable strings. */ labels?: PrimarySourceViewerLabels; /** Geographic origin (e.g. `"England"`). */ origin?: ReactNode; /** Historical period (e.g. `"Medieval"`). */ period?: ReactNode; /** Image source. */ source: PrimarySource; /** Document title. */ title: ReactNode; } & ComponentPropsWithoutRef<"section">; /** * Document viewer for historical primary sources. Renders an image * viewer with button-driven zoom + rotate, region-based annotation * overlay, an optional transcription side panel, and a footer slot for * metadata and discussion questions. * * @example * ```tsx * <PrimarySourceViewer * title="Magna Carta (1215)" * period="Medieval" * origin="England" * source={{ type: "image", src: "/magna-carta.jpg", alt: "Magna Carta manuscript" }} * > * <PrimarySourceToolbar> * <PrimarySourceZoomIn /> * <PrimarySourceZoomOut /> * <PrimarySourceRotate /> * </PrimarySourceToolbar> * <PrimarySourceAnnotations> * <PrimarySourceAnnotation * region={{ x: 12, y: 8, width: 22, height: 6 }} * category="Artifact" * note="Royal seal of King John" * /> * </PrimarySourceAnnotations> * </PrimarySourceViewer> * ``` * * @public */ export const PrimarySourceViewer = ({ ref, ...props }: PrimarySourceViewerProps & { ref?: React.Ref<HTMLElement> }) => { const { children, className, labels, origin, period, source, title, ...rest } = props; const titleId = useId(); const resolvedLabels = useMemo( () => ({ ...DEFAULT_LABELS, ...labels }), [labels], ); const ctx = useViewerState(resolvedLabels); const buckets = useMemo(() => bucketChildren(children), [children]); return ( <ViewerContext.Provider value={ctx}> <section aria-labelledby={titleId} className={cn( "flex w-full flex-col overflow-hidden rounded-2xl border bg-background text-foreground", className, )} ref={ref} {...rest} > <header className="flex flex-col gap-1 border-b border-border px-4 py-3"> <h2 className="text-lg font-semibold tracking-tight" id={titleId}> {title} </h2> {period || origin ? ( <p className="text-xs uppercase tracking-wide text-muted-foreground"> {period} {period && origin ? " · " : null} {origin} </p> ) : null} </header> {buckets.toolbar} <div className="grid gap-0 md:grid-cols-[2fr_1fr]"> <div className="relative h-[420px] md:h-[520px]"> <Stage annotations={buckets.annotations} source={source} /> </div> {buckets.transcription} </div> {buckets.context} </section> </ViewerContext.Provider> ); }; PrimarySourceViewer.displayName = "PrimarySourceViewer";

Dependencies

  • @vllnt/ui@^0.3.0