Doc Image
An image with an editable caption, shown at its true size: a retina image (a @2x name or a 144 dpi PNG or JPEG) is drawn at half its pixel width. Empty, it takes a file, a drop, a paste or a URL, in PNG, JPEG, GIF, WebP, AVIF, SVG, BMP or ICO. Selected, it offers Replace, left / center / right / full layout, pixel width and height, and edge-drag resize that keeps the aspect ratio; images under 32x32 px are inline icons. The host can supply its own picker and storage.
npx shadcn@latest add @velaui/doc-imagepnpm dlx velaui add @velaui/doc-imageyarn dlx shadcn@latest add @velaui/doc-imagebunx --bun shadcn@latest add @velaui/doc-imageveni add doc-imageInstalls the copy of vela-ui that veni-builder serves to Venizia apps; veni-builder serves the Radix UI copy only, so this tab exists for that base alone.
npx shadcn@latest add @velaui/doc-imagepnpm dlx velaui add @velaui/doc-imageyarn dlx shadcn@latest add @velaui/doc-imagebunx --bun shadcn@latest add @velaui/doc-imageExamples
- Default
- Upload
- Retina
- Empty read only
Usage
Props
| Prop | Type | Default |
|---|---|---|
align | DocImageAlign | "center" |
caption | string | "" |
className | string | — |
onCaptionChange | (caption: string) => void | — |
onLayoutChange | (layout: { align: DocImageAlign; width?: number; }) => void | — |
onSrcChange | (src: string, scale?: number) => void | — |
onUpload | (file: File) => string | Promise<string> | — |
readOnly | boolean | — |
renderPicker | (onPick: (src: string, scale?: number) => void) => React.ReactNode | — |
scale | number | — |
src | string | — |
width | number | — |
Takes these and every prop of DocImageProps.
Dependencies
- npm
- none besides React
- Installs with it
- Doc Typography
Source
components/ui/doc-image.tsx
/* eslint-disable react-refresh/only-export-components */
import * as React from "react";
import { DocTypography } from "@/components/ui/doc-typography";
import { cn } from "@/lib/utils";
/** The formats browsers draw in an `<img>`. HEIC and TIFF are left out: most browsers cannot show them. */
export const DOC_IMAGE_TYPES = [
"image/png",
"image/jpeg",
"image/gif",
"image/webp",
"image/avif",
"image/svg+xml",
"image/bmp",
"image/x-icon",
"image/vnd.microsoft.icon",
];
export const isDocImage = (file: File) => DOC_IMAGE_TYPES.includes(file.type);
/** The pixel density a name asks for: `photo@2x.png` is 2, no suffix is 1. Works on file names and URLs. */
export function imageScaleFromName(name: string) {
const suffix = /@([2-4])x(?=\.\w+(?:[?#]|$))/.exec(name);
return suffix ? Number(suffix[1]) : 1;
}
/**
* The density a file declares in its header: PNG `pHYs` and JPEG JFIF, both in dots per inch. Only 144, 216 and
* 288 dpi (2x to 4x of 72) count; a 300 dpi print scan is a big image, not a retina one.
*/
export async function imageScaleFromHeader(file: File) {
const view = new DataView(await file.slice(0, 65536).arrayBuffer());
let dpi = 0;
try {
if (file.type === "image/png" && view.byteLength > 33 && view.getUint32(0) === 0x89504e47) {
for (let at = 8; at + 12 <= view.byteLength; at += 12 + view.getUint32(at)) {
const type = view.getUint32(at + 4);
if (type === 0x70485973 && view.getUint8(at + 16) === 1) dpi = view.getUint32(at + 8) * 0.0254; // pHYs, metres
if (type === 0x70485973 || type === 0x49444154) break; // pHYs or IDAT: nothing after this matters
}
} else if (file.type === "image/jpeg" && view.byteLength > 20 && view.getUint16(0) === 0xffd8) {
for (let at = 2; at + 4 <= view.byteLength && view.getUint8(at) === 0xff; at += 2 + view.getUint16(at + 2)) {
const marker = view.getUint8(at + 1);
if (marker === 0xe0 && view.getUint32(at + 4) === 0x4a464946) {
const units = view.getUint8(at + 11);
dpi = view.getUint16(at + 12) * (units === 2 ? 2.54 : units === 1 ? 1 : 0);
}
if (marker === 0xe0 || marker === 0xda) break;
}
}
} catch {
// A header cut short or malformed: show the image at its pixel size.
}
const scale = Math.round(dpi / 72);
return scale >= 2 && scale <= 4 && Math.abs(dpi - scale * 72) < 2 ? scale : 1;
}
const toDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(file);
});
/**
* Turns a file into something an `<img>` can show, plus the pixel density to show it at. `upload` is the host's
* storage: it gets the file and returns its URL. Without it the image becomes a data URL inside the document.
*/
export async function readDocImage({
file,
upload,
}: {
file: File;
upload?: (file: File) => string | Promise<string>;
}) {
if (!isDocImage(file))
throw new Error("That file type is not supported. Use PNG, JPEG, GIF, WebP, AVIF, SVG, BMP or ICO.");
const scale = Math.max(imageScaleFromName(file.name), await imageScaleFromHeader(file));
return { src: await (upload ? upload(file) : toDataUrl(file)), scale };
}
export type DocImageAlign = "left" | "center" | "right" | "full";
/** Images smaller than this in both directions are icons: shown inline, with no caption or layout. */
export const DOC_IMAGE_ICON_SIZE = 32;
const MIN_WIDTH = 32;
export interface DocImageProps {
src?: string;
caption?: string;
/** How many image pixels make one CSS pixel (2 for a retina image). Omit to read it from the URL (`@2x`). */
scale?: number;
readOnly?: boolean;
/** An image was chosen, typed, dropped or pasted; `scale` is its detected density. */
onSrcChange?: (src: string, scale?: number) => void;
onCaptionChange?: (caption: string) => void;
/** Where the image sits in its column; centered by default. Images under 32x32 px are icons and ignore it. */
align?: DocImageAlign;
/** Display width in CSS px; omit for the image's own size (its pixel width divided by `scale`). */
width?: number;
/** The reader resized or realigned the image: the next layout, whole. */
onLayoutChange?: (layout: { align: DocImageAlign; width?: number }) => void;
/** The host's storage: gets a dropped, pasted or chosen file and returns its URL. Without it, files become data URLs. */
onUpload?: (file: File) => string | Promise<string>;
/** The host's file picker; without it the empty state offers a URL field, an upload button and drop or paste. */
renderPicker?: (onPick: (src: string, scale?: number) => void) => React.ReactNode;
className?: string;
}
const ALIGN: Record<DocImageAlign, string> = {
left: "self-start",
center: "self-center",
right: "self-end",
full: "self-stretch",
};
const tool =
"rounded px-1.5 py-0.5 hover:bg-bg-surface-2 aria-pressed:bg-bg-surface-2 aria-pressed:font-medium pointer-coarse:min-h-11 pointer-coarse:min-w-11";
/** The strip on an image's left or right edge: drag it to resize, the aspect ratio follows. */
function Edge({
side,
onStart,
onDrag,
}: {
side: "left" | "right";
onStart: () => void;
onDrag: (delta: number) => void;
}) {
const from = React.useRef<number | null>(null);
return (
<div
aria-hidden
className={cn(
"absolute inset-y-0 z-10 w-2 cursor-ew-resize touch-none hover:bg-primary-default/40 pointer-coarse:w-4",
side === "left" ? "-left-1" : "-right-1",
)}
onPointerDown={e => {
e.currentTarget.setPointerCapture(e.pointerId);
from.current = e.clientX;
onStart();
}}
onPointerMove={e => from.current !== null && onDrag((e.clientX - from.current) * (side === "left" ? -1 : 1))}
onPointerUp={() => (from.current = null)}
onPointerCancel={() => (from.current = null)}
/>
);
}
/**
* An image shown at its true size (retina aware) with a caption. Empty, it takes a file, a drop, a paste or a URL.
* Selected, it offers Replace, left / center / right / full layout, pixel sizes and edge-drag resize.
*/
function DocImage({
src,
caption = "",
scale,
align = "center",
width,
readOnly,
onSrcChange,
onCaptionChange,
onLayoutChange,
onUpload,
renderPicker,
className,
}: DocImageProps) {
const [natural, setNatural] = React.useState<{ w: number; h: number }>();
const [over, setOver] = React.useState(false);
const [error, setError] = React.useState<string>();
const frame = React.useRef<HTMLDivElement>(null);
const startWidth = React.useRef(0);
const density = scale ?? (src ? imageScaleFromName(src) : 1);
const own = natural && natural.w / density;
const icon = !!natural && natural.w / density < DOC_IMAGE_ICON_SIZE && natural.h / density < DOC_IMAGE_ICON_SIZE;
const layout = icon ? "left" : align;
const shown = own && (width ?? own);
const ratio = natural ? natural.h / natural.w : 1;
const editable = !readOnly && !icon && !!onLayoutChange;
const take = async (file: File | undefined) => {
if (!file) return;
try {
const { src: next, scale: detected } = await readDocImage({ file, upload: onUpload });
setError(undefined);
setNatural(undefined);
onSrcChange?.(next, detected);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
/** A width that fits the column; `full` is dropped because a typed or dragged size is not full. */
const resize = (w: number) => {
const column = frame.current?.parentElement?.clientWidth ?? w;
onLayoutChange?.({
align: align === "full" ? "center" : align,
width: Math.round(Math.min(Math.max(w, MIN_WIDTH), column)),
});
};
return (
<figure className={cn(icon ? "inline-block align-middle" : "my-2 flex flex-col", className)}>
{src ? (
<div
ref={frame}
tabIndex={editable ? 0 : undefined}
className={cn("group/img relative max-w-full outline-none", ALIGN[layout])}
style={layout === "full" || !shown ? undefined : { width: shown }}
>
<img
src={src}
alt={caption}
onLoad={e => setNatural({ w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })}
className={cn("h-auto rounded-md", layout === "full" ? "w-full" : "max-w-full")}
style={!icon && layout !== "full" && shown ? { width: "100%" } : undefined}
/>
{editable && shown && (
<>
<Edge
side="left"
onStart={() => (startWidth.current = shown)}
onDrag={delta => resize(startWidth.current + delta * (layout === "center" ? 2 : 1))}
/>
<Edge
side="right"
onStart={() => (startWidth.current = shown)}
onDrag={delta => resize(startWidth.current + delta * (layout === "center" ? 2 : 1))}
/>
<div
role="toolbar"
aria-label="Image"
className="absolute top-2 left-2 z-20 flex max-w-[calc(100%-1rem)] flex-wrap items-center gap-1 rounded-md border border-border-default bg-bg-main p-1 text-xs opacity-0 shadow-sm transition-opacity duration-100 group-focus-within/img:opacity-100 group-hover/img:opacity-100 pointer-coarse:opacity-100"
>
<label className={cn(tool, "cursor-pointer")}>
Replace
<input
type="file"
accept={DOC_IMAGE_TYPES.join(",")}
className="sr-only"
onChange={e => (void take(e.target.files?.[0]), (e.target.value = ""))}
/>
</label>
<span role="group" aria-label="Layout" className="flex gap-0.5">
{(["left", "center", "right", "full"] as const).map(a => (
<button
key={a}
type="button"
aria-pressed={layout === a}
className={cn(tool, "capitalize")}
onClick={() => onLayoutChange?.({ align: a, width })}
>
{a === "full" ? "Full width" : a}
</button>
))}
</span>
{[
["Width", Math.round(shown), (v: number) => resize(v)],
["Height", Math.round(shown * ratio), (v: number) => resize(v / ratio)],
].map(([label, value, commit]) => (
<label key={String(label)} className="flex items-center gap-1 text-text-muted">
{String(label)}
<input
// Remounts when the size changes elsewhere, so a typed value never goes stale.
key={Number(value)}
type="number"
min={MIN_WIDTH}
disabled={layout === "full"}
defaultValue={Number(value)}
aria-label={`${String(label)} in pixels`}
className="w-16 rounded border border-border-default bg-transparent px-1 py-0.5 text-text-strong disabled:opacity-40"
onKeyDown={e => e.key === "Enter" && e.currentTarget.blur()}
onBlur={e => {
const v = Number(e.target.value);
if (v > 0 && typeof commit === "function") commit(v);
}}
/>
</label>
))}
</div>
</>
)}
{!icon && (
<figcaption>
<DocTypography
aria-label="Caption"
value={caption}
readOnly={readOnly}
onChange={onCaptionChange}
placeholder={readOnly ? undefined : "Add caption…"}
className="mt-1 text-xs text-text-muted"
/>
</figcaption>
)}
</div>
) : readOnly ? (
<div className="flex h-24 items-center justify-center rounded-md border-2 border-dashed border-border-default text-sm text-text-muted">
No image selected
</div>
) : (
(renderPicker?.((s, d) => onSrcChange?.(s, d)) ?? (
<div
className={cn(
"flex flex-wrap items-center gap-2 rounded-md border-2 border-dashed p-3",
over ? "border-primary-default bg-bg-surface-2" : "border-border-default",
)}
onDragOver={e => (e.preventDefault(), setOver(true))}
onDragLeave={() => setOver(false)}
onDrop={e => {
e.preventDefault();
e.stopPropagation();
setOver(false);
void take(e.dataTransfer.files[0]);
}}
>
<input
aria-label="Image URL"
placeholder="Paste an image or URL, or drop a file"
className="min-w-0 flex-1 rounded-md border border-border-default bg-transparent px-3 py-2 text-sm outline-none"
onKeyDown={e =>
e.key === "Enter" &&
onSrcChange?.(e.currentTarget.value.trim(), imageScaleFromName(e.currentTarget.value))
}
onPaste={e => {
const file = e.clipboardData.files[0];
if (file) (e.preventDefault(), void take(file));
}}
/>
<label className="cursor-pointer rounded px-2 py-1 text-sm text-text-muted hover:bg-bg-surface-2 pointer-coarse:min-h-11 pointer-coarse:content-center">
Upload
<input
type="file"
accept={DOC_IMAGE_TYPES.join(",")}
className="sr-only"
onChange={e => (void take(e.target.files?.[0]), (e.target.value = ""))}
/>
</label>
</div>
))
)}
{error && (
<p role="alert" className="mt-1 text-sm text-negative-middle">
{error}
</p>
)}
{!src && !icon && (
<figcaption>
<DocTypography
aria-label="Caption"
value={caption}
readOnly={readOnly}
onChange={onCaptionChange}
placeholder={readOnly ? undefined : "Add caption…"}
className="mt-1 text-xs text-text-muted"
/>
</figcaption>
)}
</figure>
);
}
export { DocImage };