vijayksingh

Perfect Preview

Component

About

An SVG overlay for a drawing canvas that traces a fitted clean shape over a rough hand-drawn stroke while the pen holds still, then morphs the stroke into it and lets the user turn/resize the result.

Built with React and Tailwind. Copy the prompt and paste it into Claude Code, Cursor or Lovable: your agent adds Perfect Preview to your project and fits it to your theme.

Code

How Perfect Preview is used: the demo's code. The component's own source comes with a free account.

Usage.tsx

"use client";

import * as React from "react";
import { PerfectPreview } from "@/components/ui/perfect-preview";
import { inkColor } from "@unlocalhosted/metalui";

/* Draw a rough circle or line and keep the pointer still: the clean shape traces itself, then the
 * stroke morphs into it. Move again before it closes to keep your own stroke. The fit here is a
 * stand-in for the core's shape_recognize: rectangle, ellipse, triangle, line or nothing. */

type P = [number, number];
const N = 96;

function resample(pts: P[], n = N): P[] {
  const d = [0];
  for (let i = 1; i < pts.length; i++)
    d.push(
      d[i - 1] +
        Math.hypot(pts[i][0] - pts[i - 1][0], pts[i][1] - pts[i - 1][1]),
    );
  const L = d[d.length - 1] || 1,
    out: P[] = [];
  for (let k = 0, j = 0; k < n; k++) {
    const t = (k / (n - 1)) * L;
    while (j < d.length - 2 && d[j + 1] < t) j++;
    const u = (t - d[j]) / (d[j + 1] - d[j] || 1);
    out.push([
      pts[j][0] + (pts[j + 1][0] - pts[j][0]) * u,
      pts[j][1] + (pts[j + 1][1] - pts[j][1]) * u,
    ]);
  }
  return out;
}

/** Douglas-Peucker: an open stroke reduced to its corners. */
function corners(pts: P[], eps: number): P[] {
  if (pts.length < 3) return pts;
  const [a, b] = [pts[0], pts[pts.length - 1]];
  let far = 0,
    at = 0;
  for (let i = 1; i < pts.length - 1; i++) {
    const p = pts[i],
      L = Math.hypot(b[0] - a[0], b[1] - a[1]);
    const d = L
      ? Math.abs(
          (b[0] - a[0]) * (a[1] - p[1]) - (a[0] - p[0]) * (b[1] - a[1]),
        ) / L
      : Math.hypot(p[0] - a[0], p[1] - a[1]);
    if (d > far) {
      far = d;
      at = i;
    }
  }
  if (far <= eps) return [a, b];
  return [
    ...corners(pts.slice(0, at + 1), eps).slice(0, -1),
    ...corners(pts.slice(at), eps),
  ];
}

const cross = (o: P, a: P, b: P) =>
  (a[0] - o[0]) * (b[1] - o[1]) - (a[1] - o[1]) * (b[0] - o[0]);
/** The convex hull (monotone chain), counter-clockwise in screen maths. */
function hull(pts: P[]): P[] {
  const p = [...pts].sort((a, b) => a[0] - b[0] || a[1] - b[1]),
    lo: P[] = [],
    hi: P[] = [];
  for (const q of p) {
    while (lo.length > 1 && cross(lo[lo.length - 2], lo[lo.length - 1], q) <= 0)
      lo.pop();
    lo.push(q);
  }
  for (const q of p.reverse()) {
    while (hi.length > 1 && cross(hi[hi.length - 2], hi[hi.length - 1], q) <= 0)
      hi.pop();
    hi.push(q);
  }
  return [...lo.slice(0, -1), ...hi.slice(0, -1)];
}
const areaOf = (v: P[]) =>
  v.reduce((s, p, i) => s + cross([0, 0], p, v[(i + 1) % v.length]), 0) / 2;

/** The smallest box around the hull, at any tilt: its angle, centre and half sizes. */
function minBox(h: P[]) {
  let best = { area: Infinity, th: 0, cx: 0, cy: 0, hx: 0, hy: 0 };
  for (let i = 0; i < h.length; i++) {
    const a = h[i],
      b = h[(i + 1) % h.length],
      th = Math.atan2(b[1] - a[1], b[0] - a[0]),
      c = Math.cos(th),
      s = Math.sin(th);
    const u = h.map(([x, y]) => x * c + y * s),
      w = h.map(([x, y]) => -x * s + y * c);
    const [u0, u1, w0, w1] = [
      Math.min(...u),
      Math.max(...u),
      Math.min(...w),
      Math.max(...w),
    ];
    const area = (u1 - u0) * (w1 - w0);
    if (area < best.area) {
      const mu = (u0 + u1) / 2,
        mw = (w0 + w1) / 2;
      best = {
        area,
        th,
        cx: mu * c - mw * s,
        cy: mu * s + mw * c,
        hx: (u1 - u0) / 2,
        hy: (w1 - w0) / 2,
      };
    }
  }
  // A box within 10° of level is drawn level.
  const q = Math.PI / 2,
    off = ((best.th % q) + q) % q,
    lean = off > q / 2 ? off - q : off;
  if (Math.abs(lean) < 0.17) {
    const xs = h.map((p) => p[0]),
      ys = h.map((p) => p[1]);
    const [x0, x1, y0, y1] = [
      Math.min(...xs),
      Math.max(...xs),
      Math.min(...ys),
      Math.max(...ys),
    ];
    best = {
      ...best,
      th: 0,
      cx: (x0 + x1) / 2,
      cy: (y0 + y1) / 2,
      hx: (x1 - x0) / 2,
      hy: (y1 - y0) / 2,
    };
  }
  return best;
}

/** The biggest triangle on the hull's points. */
function bigTriangle(h: P[]): P[] {
  let best: P[] = h.slice(0, 3),
    area = 0;
  for (let i = 0; i < h.length; i++)
    for (let j = i + 1; j < h.length; j++)
      for (let k = j + 1; k < h.length; k++) {
        const a = Math.abs(cross(h[i], h[j], h[k]));
        if (a > area) {
          area = a;
          best = [h[i], h[j], h[k]];
        }
      }
  return best;
}

/** The biggest quadrilateral on the hull's points (hull order keeps it simple). */
function bigQuad(h: P[]): P[] {
  const v =
    h.length > 36 ? h.filter((_, i) => i % Math.ceil(h.length / 36) === 0) : h;
  let best: P[] = v.slice(0, 4),
    area = 0;
  for (let i = 0; i < v.length; i++)
    for (let j = i + 1; j < v.length; j++)
      for (let k = j + 1; k < v.length; k++)
        for (let l = k + 1; l < v.length; l++) {
          const a = Math.abs(areaOf([v[i], v[j], v[k], v[l]]));
          if (a > area) {
            area = a;
            best = [v[i], v[j], v[k], v[l]];
          }
        }
  return best;
}

/** A closed polygon as N points, going the way the hand went and starting nearest its start. */
function ring(v: P[], stroke: P[]) {
  const poly =
    Math.sign(areaOf(v)) === Math.sign(areaOf(stroke)) ? v : [...v].reverse();
  const r = resample([...poly, poly[0]], N).slice(0, N - 1),
    from = stroke[0];
  let at = 0;
  r.forEach((p, i) => {
    if (
      Math.hypot(p[0] - from[0], p[1] - from[1]) <
      Math.hypot(r[at][0] - from[0], r[at][1] - from[1])
    )
      at = i;
  });
  const out = [...r.slice(at), ...r.slice(0, at)];
  return [...out, out[0]];
}

/* A stand-in for the core's shape_recognize. A closed stroke is judged on its convex hull, by
 * measures that do not depend on where it started or how its corners were drawn. */
const FIT = {
  closed: 0.35,
  straight: 0.08,
  triangle: 0.62,
  boxFill: 0.835,
  quad: 0.77,
  oval: [0.68, 0.835],
};

function fit(pts: P[]): P[] | null {
  const xs = pts.map((p) => p[0]),
    ys = pts.map((p) => p[1]);
  const size = Math.max(
    Math.max(...xs) - Math.min(...xs),
    Math.max(...ys) - Math.min(...ys),
    1,
  );
  const start = pts[0],
    end = pts[pts.length - 1];
  if (Math.hypot(start[0] - end[0], start[1] - end[1]) > FIT.closed * size) {
    return corners(pts, FIT.straight * size).length === 2
      ? resample([start, end])
      : null;
  }
  const h = hull(pts),
    A = Math.abs(areaOf(h)),
    box = minBox(h);
  if (!(A > 0) || !(box.hx > 0.08 * size && box.hy > 0.08 * size)) return null;
  const c = Math.cos(box.th),
    s = Math.sin(box.th),
    at = (x: number, y: number): P => [
      box.cx + x * c - y * s,
      box.cy + x * s + y * c,
    ];
  const tri = bigTriangle(h);
  if (Math.abs(areaOf(tri)) / A >= FIT.triangle) return ring(tri, pts);
  const boxFill = A / (4 * box.hx * box.hy);
  if (boxFill >= FIT.boxFill || Math.abs(areaOf(bigQuad(h))) / A >= FIT.quad) {
    return ring(
      [
        at(-box.hx, -box.hy),
        at(box.hx, -box.hy),
        at(box.hx, box.hy),
        at(-box.hx, box.hy),
      ],
      pts,
    );
  }
  if (boxFill < FIT.oval[0]) return null;
  return ring(
    Array.from({ length: 72 }, (_, k) => {
      const t = (k / 72) * Math.PI * 2;
      return at(box.hx * Math.cos(t), box.hy * Math.sin(t));
    }),
    pts,
  );
}

const pathOf = (pts: P[]) =>
  pts
    .map((p, i) => `${i ? "L" : "M"}${p[0].toFixed(1)} ${p[1].toFixed(1)}`)
    .join("");

/* HOLD TO PERFECT, then tune:
 *     0ms   the pen stops moving on a stroke that fits a shape
 *   140ms   the fitted shape starts tracing itself (the hold timer)
 *   590ms   it closes: the stroke morphs into the shape (180 ms, eased out)
 *   770ms   still holding: the shape is in the hand. Moving turns and resizes it around
 *           its centre; the turn catches at every 45° within 5°. Letting go places it.
 * Moving before the outline closes keeps the hand-drawn stroke. */
const TIMING = { still: 140, morph: 180 };
const TUNE = { catchEvery: 45, catchWithin: 5, minScale: 0.2, maxScale: 5 };

type Phase = "idle" | "holding" | "done" | "tuning";

function Play() {
  // The stroke lives in a ref: pointer events can come faster than renders.
  const stroke = React.useRef<P[]>([]);
  const [pts, setPts] = React.useState<P[]>([]);
  const fitted = React.useRef<P[] | null>(null);
  const [shape, setShape] = React.useState<P[] | null>(null);
  const [phase, setPhaseState] = React.useState<Phase>("idle");
  const phaseRef = React.useRef<Phase>("idle");
  const setPhase = (p: Phase) => {
    phaseRef.current = p;
    setPhaseState(p);
  };
  const hand = React.useRef<{ centre: P; base: P[]; from: P } | null>(null);
  const [tune, setTune] = React.useState<
    | {
        centre: { x: number; y: number };
        pointer: { x: number; y: number };
        angle: number;
        scale: number;
      }
    | undefined
  >();
  const down = React.useRef(false);
  const still = React.useRef<number | undefined>(undefined);
  const box = React.useRef<HTMLDivElement>(null);
  const at = (e: React.PointerEvent): P => {
    const r = box.current!.getBoundingClientRect();
    return [e.clientX - r.left, e.clientY - r.top];
  };

  const arm = (all: P[]) => {
    window.clearTimeout(still.current);
    setPhase("idle");
    if (all.length < 8) return;
    still.current = window.setTimeout(() => {
      const f = fit(all);
      if (!f) return;
      fitted.current = f;
      setShape(f);
      setPhase("holding");
    }, TIMING.still);
  };
  const morph = () => {
    const from = resample(stroke.current),
      to = fitted.current!;
    setPhase("done");
    const t0 = performance.now();
    const step = (now: number) => {
      const t = Math.min(1, (now - t0) / TIMING.morph),
        e = 1 - Math.pow(1 - t, 3);
      stroke.current = from.map((p, i) => [
        p[0] + (to[i][0] - p[0]) * e,
        p[1] + (to[i][1] - p[1]) * e,
      ]);
      setPts(stroke.current);
      if (t < 1) {
        requestAnimationFrame(step);
        return;
      }
      if (!down.current) {
        setPhase("idle");
        return;
      }
      // Still holding: the shape is in the hand.
      const c = to.reduce<P>(
        (m, p) => [m[0] + p[0] / to.length, m[1] + p[1] / to.length],
        [0, 0],
      );
      hand.current = { centre: c, base: to, from: last.current };
      setTune({
        centre: { x: c[0], y: c[1] },
        pointer: { x: last.current[0], y: last.current[1] },
        angle: 0,
        scale: 1,
      });
      setPhase("tuning");
    };
    requestAnimationFrame(step);
  };
  const last = React.useRef<P>([0, 0]);

  const turn = (p: P) => {
    const h = hand.current!;
    const v0 = [h.from[0] - h.centre[0], h.from[1] - h.centre[1]],
      v = [p[0] - h.centre[0], p[1] - h.centre[1]];
    const L0 = Math.hypot(v0[0], v0[1]) || 1;
    const k = Math.max(
      TUNE.minScale,
      Math.min(TUNE.maxScale, Math.hypot(v[0], v[1]) / L0),
    );
    let deg =
      ((Math.atan2(v[1], v[0]) - Math.atan2(v0[1], v0[0])) * 180) / Math.PI;
    deg = ((deg + 540) % 360) - 180;
    const near = Math.round(deg / TUNE.catchEvery) * TUNE.catchEvery;
    if (Math.abs(deg - near) < TUNE.catchWithin) deg = near;
    const r = (deg * Math.PI) / 180,
      c = Math.cos(r),
      s = Math.sin(r);
    stroke.current = h.base.map(([x, y]) => {
      const dx = (x - h.centre[0]) * k,
        dy = (y - h.centre[1]) * k;
      return [h.centre[0] + dx * c - dy * s, h.centre[1] + dx * s + dy * c];
    });
    setPts(stroke.current);
    setTune({
      centre: { x: h.centre[0], y: h.centre[1] },
      pointer: { x: p[0], y: p[1] },
      angle: deg,
      scale: k,
    });
  };

  return (
    <div
      ref={box}
      className="snap-canvas"
      style={{ height: 360, touchAction: "none", cursor: "crosshair" }}
      onPointerDown={(e) => {
        try {
          (e.target as HTMLElement).setPointerCapture(e.pointerId);
        } catch {
          /* a synthetic pointer */
        }
        down.current = true;
        setShape(null);
        setPhase("idle");
        stroke.current = [at(e)];
        last.current = at(e);
        setPts(stroke.current);
      }}
      onPointerMove={(e) => {
        if (!down.current) return;
        const p = at(e);
        last.current = p;
        if (phaseRef.current === "tuning") {
          turn(p);
          return;
        }
        if (phaseRef.current === "done") return;
        stroke.current = [...stroke.current, p];
        setPts(stroke.current);
        arm(stroke.current);
      }}
      onPointerUp={() => {
        down.current = false;
        window.clearTimeout(still.current);
        if (phaseRef.current !== "done") setPhase("idle");
      }}
    >
      <svg
        width={1}
        height={1}
        style={{ position: "absolute", left: 0, top: 0, overflow: "visible" }}
      >
        <path
          d={pathOf(pts)}
          fill="none"
          stroke={inkColor("ink")}
          strokeWidth={2.5}
          strokeLinecap="round"
          strokeLinejoin="round"
        />
      </svg>
      {shape && (
        <PerfectPreview
          d={pathOf(shape)}
          phase={phase}
          tune={tune}
          onHeld={() => down.current && morph()}
        />
      )}
      {!pts.length && (
        <span className="eng ink-hint">
          draw a rough circle, triangle, box or line, then hold still
        </span>
      )}
    </div>
  );
}

export default function PerfectPreviewDemo() {
  return (
    <div className="w-full p-6">
      <Play />
    </div>
  );
}

Dependencies

More by vijayksingh

View all
  • Weather Widget

    Weather Widget

    75
  • Meter

    Meter

    10
  • Skeleton Loader

    Skeleton Loader

    9
  • Textarea

    Textarea

    2
  • Well

    Well

    1
  • Soft Hardware Spinner

    Soft Hardware Spinner

    1
  • Settings List

    Settings List

  • Line Handles

    Line Handles