photocn

Search

Search the docs

0

Without React

Edit and export photos from plain JavaScript with createPhoto().

createPhoto() from photocn/photo loads an image and returns a Photo: a stateful object you edit with method calls. It doesn't depend on React, so you can use it in Vue, Svelte, web components, plain scripts, or a React app that only needs to process images.

"use client";

import { useEffect, useRef } from "react";
import { createPhoto } from "photocn/photo";

// Plain DOM: no React state, no components. Works the same in Vue, Svelte,
// a web component or a <script type="module">.
async function mountPhotoEditor(root: HTMLElement, signal: AbortSignal) {
  root.innerHTML = `
    <canvas class="aspect-[3/2] w-full rounded-lg bg-muted object-contain"></canvas>
    <div class="flex flex-wrap items-center gap-3 text-sm">
      <label class="flex items-center gap-2">Exposure <input name="exposure" type="range" min="-1" max="1" step="0.01" value="0" /></label>
      <label class="flex items-center gap-2">Saturation <input name="saturation" type="range" min="-1" max="1" step="0.01" value="0" /></label>
      <button data-filter="juno" class="rounded-md border px-2.5 py-1">Juno</button>
      <button data-filter="moon" class="rounded-md border px-2.5 py-1">Moon</button>
      <button data-action="rotate" class="rounded-md border px-2.5 py-1">Rotate</button>
      <button data-action="undo" class="rounded-md border px-2.5 py-1">Undo</button>
      <button data-action="export" class="rounded-md border bg-primary px-2.5 py-1 text-primary-foreground">Export</button>
      <output class="text-muted-foreground"></output>
    </div>`;

  const canvas = root.querySelector("canvas")!;
  const inputs = root.querySelectorAll<HTMLInputElement>("input[type=range]");
  const output = root.querySelector("output")!;

  const photo = await createPhoto("/samples/street.jpg");
  if (signal.aborted) return photo.dispose();
  signal.addEventListener("abort", () => photo.dispose());
  photo.attach(canvas);

  inputs.forEach((input) =>
    input.addEventListener("input", () => photo.adjust({ [input.name]: Number(input.value) }), { signal }),
  );
  root.addEventListener("click", async (event) => {
    const button = (event.target as HTMLElement).closest("button");
    if (!button) return;
    if (button.dataset.filter) photo.filter(button.dataset.filter);
    if (button.dataset.action === "rotate") photo.rotate(1);
    if (button.dataset.action === "undo") photo.undo();
    if (button.dataset.action === "export") {
      const { blob, width, height } = await photo.export({ format: "jpeg", width: 1200 });
      output.textContent = `${width}×${height}, ${Math.round(blob.size / 1024)} KB`;
    }
  }, { signal });
  // Keep the controls in sync after undo.
  photo.subscribe(() => {
    inputs[0]!.value = String(photo.params.lights.exposure);
    inputs[1]!.value = String(photo.params.colors.saturation);
  });
}

// Only this wrapper is React, to show the demo on this page.
export default function VanillaExample() {
  const ref = useRef<HTMLDivElement>(null);
  useEffect(() => {
    const controller = new AbortController();
    void mountPhotoEditor(ref.current!, controller.signal);
    return () => controller.abort();
  }, []);
  return <div className="flex flex-col gap-3" ref={ref} />;
}

Edit and export

Every edit returns the photo, so calls chain. Nothing is baked in until you export.

import { createPhoto } from "photocn/photo";

const photo = await createPhoto(file); // File, Blob, URL, ArrayBuffer or <img>

photo
  .adjust({ exposure: 0.3, contrast: 0.2, saturation: -1 })
  .filter("juno", 0.8)
  .straighten(4)
  .aspectRatio("1:1");

const { blob, width, height, filename } = await photo.export({ format: "jpeg", width: 1080 });
photo.dispose();

To process many photos the same way, export one edit as a recipe and apply it to the others:

const look = photo.recipe;
for (const file of files) {
  const next = await createPhoto(file);
  await next.applyRecipe(look!);
  upload(await next.export({ format: "webp" }));
  next.dispose();
}

Live preview

attach(canvas) renders every change into a <canvas>, in a Web Worker when the browser supports it. subscribe() tells you when to update your own controls.

const detach = photo.attach(document.querySelector("canvas")!);
const unsubscribe = photo.subscribe((photo) => {
  exposureInput.value = String(photo.params.lights.exposure);
});

A canvas handed to the worker can't be reused, so give each attach() a fresh <canvas>.

API

MemberDescription
adjust(values)Light, color and effect sliders, each -1..1: brightness, exposure, gamma, contrast, shadows, highlights, bloom, temperature, tint, vibrance, saturation, sepia, clarity, noise, vignette.
filter(name, strength?)Apply a look ("juno", "moon"…) at 0..1 strength; null removes it.
filterStrength(strength)Change the strength of the current look.
curves(channels)Tone curves [rgb, r, g, b], each a list of [input, output] points or null.
blur({ bokeh, gaussian, focus, centerX, centerY })Lens or gaussian blur around a sharp center.
rotate(1 | -1), flip(axis)Quarter turns and mirroring.
straighten(degrees)±45°. The crop shrinks to stay inside the photo.
perspective({ x, y })Keystone correction, -1..1.
crop(rect), aspectRatio(ratio)Normalized crop rect, or a ratio (16 / 9, "4:3", "original", null).
undo(), redo(), reset()One step per call.
params, setParams(params)The full edit as plain data (save it, restore it later).
recipe, applyRecipe(recipe)A portable copy of the edit.
export(options){ format, quality, width, height, preserveExif } → { blob, type, width, height, filename }.
histogram()RGB histogram of the result.
attach(canvas), detach()Live preview.
subscribe(listener)Called after every change; returns an unsubscribe function.
ready()Resolves once pending filters have loaded. export() waits for it.
size, outputSize, metadata, nameSource size, size after crop, EXIF, filename.
dispose()Free the renderer and worker.

In React, use useImageEditorState instead: it adds tools, transient slider edits, crop interactions and everything the components need.