Vela UI Docsvenizia.ai

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-image
npx shadcn@latest add @velaui/doc-image

Examples

  • Default
  • Upload
  • Retina
  • Empty read only

View code

Doc Image · Default

Installation

pnpm dlx velaui add @velaui/doc-image
pnpm dlx velaui add @velaui/doc-image

Code

doc-image-default.tsx
import * as React from "react";

import { DocImage } from "@/components/ui/doc-image";

export default function Example() {
  const [src, setSrc] = React.useState("");
  const [caption, setCaption] = React.useState("");
  return (
    <div className="w-full max-w-xl p-6">
      <DocImage src={src} caption={caption} onSrcChange={setSrc} onCaptionChange={setCaption} />
    </div>
  );
}

View code

Doc Image · Upload

Installation

pnpm dlx velaui add @velaui/doc-image
pnpm dlx velaui add @velaui/doc-image

Code

doc-image-upload.tsx
import * as React from "react";

import { DocImage, type DocImageAlign } from "@/components/ui/doc-image";

export default function Example() {
  const [src, setSrc] = React.useState("");
  const [scale, setScale] = React.useState<number>();
  const [caption, setCaption] = React.useState("");
  const [layout, setLayout] = React.useState<{ align: DocImageAlign; width?: number }>({ align: "center" });
  return (
    <div className="w-full max-w-xl space-y-2 p-6">
      <p className="text-sm text-text-muted">
        Choose, drop or paste a PNG, JPEG, GIF, WebP, AVIF, SVG, BMP or ICO. A 144 dpi screenshot or a name ending in
        @2x is shown at half its pixel width. Select the image to replace it, align it, or resize it by its edges or pixel sizes.
      </p>
      <DocImage
        src={src}
        scale={scale}
        caption={caption}
        align={layout.align}
        width={layout.width}
        onLayoutChange={setLayout}
        onSrcChange={(next, detected) => (setSrc(next), setScale(detected))}
        onCaptionChange={setCaption}
      />
      {src && <p className="font-mono text-xs text-text-muted">scale {scale ?? 1}x</p>}
    </div>
  );
}

View code

Doc Image · Retina

Installation

pnpm dlx velaui add @velaui/doc-image
pnpm dlx velaui add @velaui/doc-image

Code

doc-image-retina.tsx
import { DocImage } from "@/components/ui/doc-image";

// A 600x300 picture; a retina one is drawn at twice the density, so it shows at 300 CSS px wide.
const PICTURE = `data:image/svg+xml,${encodeURIComponent(
  '<svg xmlns="http://www.w3.org/2000/svg" width="600" height="300"><rect width="600" height="300" fill="#e0e7ff"/><circle cx="300" cy="150" r="90" fill="#6366f1"/></svg>',
)}`;

export default function Example() {
  return (
    <div className="w-full max-w-xl space-y-4 p-6">
      <DocImage readOnly src={PICTURE} scale={1} caption="1x: shown at 600 px" />
      <DocImage readOnly src={PICTURE} scale={2} caption="2x (retina): the same pixels shown at 300 px" />
    </div>
  );
}

View code

Doc Image · Empty read only

Installation

pnpm dlx velaui add @velaui/doc-image
pnpm dlx velaui add @velaui/doc-image

Code

doc-image-empty-read-only.tsx
import { DocImage } from "@/components/ui/doc-image";

export default function Example() {
  return (
    <div className="w-full max-w-xl p-6">
      <DocImage readOnly />
    </div>
  );
}

Usage

import * as React from "react";

import { DocImage } from "@/components/ui/doc-image";

export default function Example() {
  const [src, setSrc] = React.useState("");
  const [caption, setCaption] = React.useState("");
  return (
    <div className="w-full max-w-xl p-6">
      <DocImage src={src} caption={caption} onSrcChange={setSrc} onCaptionChange={setCaption} />
    </div>
  );
}

Props

PropTypeDefault
alignDocImageAlign"center"
captionstring""
classNamestring—
onCaptionChange(caption: string) => void—
onLayoutChange(layout: { align: DocImageAlign; width?: number; }) => void—
onSrcChange(src: string, scale?: number) => void—
onUpload(file: File) => string | Promise<string>—
readOnlyboolean—
renderPicker(onPick: (src: string, scale?: number) => void) => React.ReactNode—
scalenumber—
srcstring—
widthnumber—

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 };