canvas-free.openapps.xyz

Source

This app’s code and revision history are public. Download a copy or explore the files below.

Public
The app’s code and revision history are public.
Owned app · rev 93fdaafeda16
Run it yourself
Take the whole tree and run it on your own machine. The download needs no account. Cloning needs a sign-in.
Download .zip
bounded clone 788ba0fa01083b300ad50913
A copy you download is not bounded. The guarantees this app runs under - declared egress, proven invariants, writes that fail closed - live in the runtime serving this page, not in the files. What you get is auditable source you can read and run, with no runtime guarantees attached.
← All files
functions/lib/canvas.ts
// Canvas's shared logic: what a new board starts with (a template, a graph or columns of sticky notes),
// the checks on that input, and the plain summary of a board the API hands to assistants.
// Used by the web app, the public API and the build (template pages). No imports: functions can only
// import inside functions/.

export const LIMITS = {
  title: 120,
  nodes: 150,
  edges: 300,
  label: 200,
  columns: 12,
  notes: 60,
  note: 300,
  /** One element's JSON as stored. A long freehand stroke is the largest common element. */
  element: 100_000,
  /** Image files, after Canvas shrinks them. */
  imageBytes: 2 * 1024 * 1024,
  /** The API reads at most this many elements of a board, and this much of each text, so a huge board cannot sink it. */
  readElements: 5_000,
  readText: 1_000,
};

/** UTF-8 bytes: what the policy's length checks count. */
export const byteLength = (s: string) => new TextEncoder().encode(s).length;

export const IMAGE_TYPES = ["image/png", "image/jpeg", "image/webp"];

/** Fill colors for notes and shapes, from Excalidraw's palette. */
export const COLORS = {
  yellow: "#ffec99",
  green: "#b2f2bb",
  blue: "#a5d8ff",
  red: "#ffc9c9",
  violet: "#d0bfff",
  orange: "#ffd8a8",
  gray: "#e9ecef",
  white: "#ffffff",
} as const;
export type Color = keyof typeof COLORS;
export const COLOR_NAMES = Object.keys(COLORS) as Color[];

export type Shape = "rectangle" | "ellipse" | "diamond";
export const SHAPES: Shape[] = ["rectangle", "ellipse", "diamond"];

export interface GraphNode { id: string; label: string; shape?: Shape; color?: Color }
export interface GraphEdge { from: string; to: string; label?: string }
export interface Graph { nodes: GraphNode[]; edges: GraphEdge[]; direction: "down" | "right" }
export interface Column { name: string; color: Color; notes: string[] }

/** What a new board starts with. Stored on the board as JSON and drawn by the first editor who opens it. */
export type Seed =
  | { template: TemplateId }
  | { graph: Graph; heading?: string }
  | { columns: Column[]; heading?: string };

export type TemplateId = "brainstorm" | "retro" | "user-flow" | "weekly-plan";

export interface Template {
  id: TemplateId;
  name: string;
  summary: string;
  title: string;
  heading?: string;
  columns?: Column[];
  graph?: Graph;
}

export const TEMPLATES: Template[] = [
  {
    id: "brainstorm",
    name: "Brainstorm",
    summary: "Ideas on sticky notes, grouped into themes, then next steps.",
    title: "Brainstorm",
    heading: "What should we try next?",
    columns: [
      { name: "Ideas", color: "yellow", notes: ["One idea per note", "Go for quantity first, judge later", "Build on someone else's idea", "", "", ""] },
      { name: "Themes", color: "blue", notes: ["Drag similar ideas next to each other and name the group"] },
      { name: "Top picks", color: "violet", notes: ["Draw a dot on the ideas you like best"] },
      { name: "Next steps", color: "green", notes: ["Who does what, by when"] },
    ],
  },
  {
    id: "retro",
    name: "Retro",
    summary: "What went well, what could be better, and action items.",
    title: "Team retro",
    heading: "Retro: looking back on the last two weeks",
    columns: [
      { name: "Went well", color: "green", notes: ["Shipped the new sign-up flow", ""] },
      { name: "Could be better", color: "red", notes: ["Too many meetings on Tuesdays", ""] },
      { name: "Ideas to try", color: "yellow", notes: ["Pair on the hardest bug each week", ""] },
      { name: "Action items", color: "blue", notes: ["Each with an owner and a date", ""] },
    ],
  },
  {
    id: "user-flow",
    name: "User flow",
    summary: "A sign-up flow with steps, a decision and arrows that stay attached.",
    title: "User flow",
    graph: {
      direction: "down",
      nodes: [
        { id: "start", label: "Opens the app", shape: "ellipse", color: "green" },
        { id: "landing", label: "Landing page", color: "white" },
        { id: "account", label: "Has an account?", shape: "diamond", color: "yellow" },
        { id: "signin", label: "Sign in", color: "white" },
        { id: "signup", label: "Sign up", color: "white" },
        { id: "confirm", label: "Confirm email", color: "white" },
        { id: "home", label: "Home screen", color: "blue" },
        { id: "done", label: "Starts first task", shape: "ellipse", color: "green" },
      ],
      edges: [
        { from: "start", to: "landing" },
        { from: "landing", to: "account" },
        { from: "account", to: "signin", label: "Yes" },
        { from: "account", to: "signup", label: "No" },
        { from: "signup", to: "confirm" },
        { from: "signin", to: "home" },
        { from: "confirm", to: "home" },
        { from: "home", to: "done" },
      ],
    },
  },
  {
    id: "weekly-plan",
    name: "Weekly plan",
    summary: "Goals for the week and a column for each day.",
    title: "Weekly plan",
    heading: "This week",
    columns: [
      { name: "Goals", color: "green", notes: ["The three things that matter most this week", "", ""] },
      { name: "Monday", color: "yellow", notes: [""] },
      { name: "Tuesday", color: "yellow", notes: [""] },
      { name: "Wednesday", color: "yellow", notes: [""] },
      { name: "Thursday", color: "yellow", notes: [""] },
      { name: "Friday", color: "yellow", notes: [""] },
      { name: "Weekend", color: "orange", notes: [""] },
    ],
  },
];

export const templateById = (id: string) => TEMPLATES.find((t) => t.id === id);

/** A template, graph or columns seed, with the template expanded. */
export function expandSeed(seed: Seed): { graph?: Graph; columns?: Column[]; heading?: string } {
  if ("template" in seed) {
    const t = templateById(seed.template);
    return t ? { graph: t.graph, columns: t.columns, heading: t.heading } : {};
  }
  return seed;
}

/** A stored seed, checked again with the API's limits: a board made straight through the database can hold any seed. */
export function parseSeed(raw: string | null | undefined): Seed | null {
  if (!raw) return null;
  try {
    const s = JSON.parse(raw);
    if (!s || typeof s !== "object" || Array.isArray(s)) return null;
    const made = seedFromInput("graph" in s ? { ...s.graph, heading: s.heading } : s);
    return "seed" in made ? made.seed : null;
  } catch {
    return null;
  }
}

const text = (v: unknown, max: number) => String(v ?? "").replace(/\s+/g, " ").trim().slice(0, max);

/**
 * Checks what an assistant sent to start a board and returns the seed to store, or an error sentence.
 * Accepts {template}, {nodes, edges, direction?} or {columns: [{name, color?, notes}]}.
 */
export function seedFromInput(input: Record<string, any>): { seed: Seed | null } | { error: string } {
  const kinds = ["template", "nodes", "columns"].filter((k) => input[k] != null);
  if (kinds.length > 1) return { error: "Send one of template, nodes (with edges) or columns, not several." };
  const heading = input.heading == null ? undefined : text(input.heading, LIMITS.label) || undefined;

  if (input.template != null) {
    if (!templateById(String(input.template))) return { error: `template must be one of ${TEMPLATES.map((t) => t.id).join(", ")}.` };
    return { seed: { template: String(input.template) as TemplateId } };
  }

  if (input.nodes != null) {
    if (!Array.isArray(input.nodes) || !input.nodes.length) return { error: "nodes must be a non-empty list of {id, label}." };
    if (input.nodes.length > LIMITS.nodes) return { error: `A board can start with up to ${LIMITS.nodes} nodes.` };
    const nodes: GraphNode[] = [];
    const ids = new Set<string>();
    for (const [i, n] of input.nodes.entries()) {
      if (!n || typeof n !== "object") return { error: `nodes[${i}] must be an object like {"id": "a", "label": "Start"}.` };
      const id = text(n.id ?? n.label, 80);
      const label = text(n.label ?? n.id, LIMITS.label);
      if (!id) return { error: `nodes[${i}] needs an id or a label.` };
      if (ids.has(id)) return { error: `Two nodes have the id "${id}". Give each node its own id.` };
      if (n.shape != null && !SHAPES.includes(n.shape)) return { error: `nodes[${i}].shape must be one of ${SHAPES.join(", ")}.` };
      if (n.color != null && !COLOR_NAMES.includes(n.color)) return { error: `nodes[${i}].color must be one of ${COLOR_NAMES.join(", ")}.` };
      ids.add(id);
      nodes.push({ id, label, ...(n.shape ? { shape: n.shape } : {}), ...(n.color ? { color: n.color } : {}) });
    }
    const rawEdges = input.edges ?? [];
    if (!Array.isArray(rawEdges)) return { error: "edges must be a list of {from, to}." };
    if (rawEdges.length > LIMITS.edges) return { error: `A board can start with up to ${LIMITS.edges} edges.` };
    const edges: GraphEdge[] = [];
    for (const [i, e] of rawEdges.entries()) {
      const from = text(e?.from, 80);
      const to = text(e?.to, 80);
      if (!ids.has(from) || !ids.has(to)) return { error: `edges[${i}] must connect two node ids from nodes (got "${from}" to "${to}").` };
      const label = e?.label == null ? "" : text(e.label, LIMITS.label);
      edges.push({ from, to, ...(label ? { label } : {}) });
    }
    const direction = input.direction == null ? "down" : String(input.direction);
    if (direction !== "down" && direction !== "right") return { error: 'direction must be "down" or "right".' };
    return { seed: { graph: { nodes, edges, direction }, ...(heading ? { heading } : {}) } };
  }

  if (input.columns != null) {
    if (!Array.isArray(input.columns) || !input.columns.length) return { error: "columns must be a non-empty list of {name, notes}." };
    if (input.columns.length > LIMITS.columns) return { error: `A board can start with up to ${LIMITS.columns} columns.` };
    const columns: Column[] = [];
    for (const [i, c] of input.columns.entries()) {
      const name = text(c?.name, LIMITS.label);
      const color = c?.color == null ? "yellow" : c.color;
      if (!COLOR_NAMES.includes(color)) return { error: `columns[${i}].color must be one of ${COLOR_NAMES.join(", ")}.` };
      const notes = c?.notes == null ? [] : c.notes;
      if (!Array.isArray(notes)) return { error: `columns[${i}].notes must be a list of strings.` };
      if (notes.length > LIMITS.notes) return { error: `A column can start with up to ${LIMITS.notes} notes.` };
      columns.push({ name, color, notes: notes.map((n: unknown) => String(n ?? "").trim().slice(0, LIMITS.note)) });
    }
    return { seed: { columns, ...(heading ? { heading } : {}) } };
  }

  return { seed: null };
}

/** Excalidraw element fields the summary reads. */
interface El {
  id: string; type: string; x: number; y: number; width: number; height: number; isDeleted?: boolean;
  text?: string; containerId?: string | null; name?: string | null; frameId?: string | null; backgroundColor?: string;
  startBinding?: { elementId: string } | null; endBinding?: { elementId: string } | null; boundElements?: { id: string; type: string }[] | null;
}

// Anyone with the edit link can store any JSON as an element, and Excalidraw trusts what it draws: a
// field of the wrong type stops the board from opening for everyone. Only well-formed elements are drawn.
// Excalidraw's "iframe" elements (pages its AI features make) run their HTML in a frame on every screen,
// so they are never drawn.
type Check = (v: unknown) => boolean;
const str: Check = (v) => typeof v === "string";
const num: Check = (v) => typeof v === "number" && Number.isFinite(v);
const bool: Check = (v) => typeof v === "boolean";
const obj: Check = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
const list = (item: Check): Check => (v) => Array.isArray(v) && v.every(item);
const point: Check = (v) => Array.isArray(v) && v.length === 2 && v.every(num);
const orNull = (check: Check): Check => (v) => v === null || check(v);
const ELEMENT_TYPES = ["rectangle", "diamond", "ellipse", "arrow", "line", "freedraw", "text", "image", "frame", "magicframe", "embeddable"];
const ELEMENT_FIELDS: Record<string, Check> = {
  id: str, type: str, version: num, versionNonce: num, seed: num, updated: num, index: orNull(str),
  x: num, y: num, width: num, height: num, angle: num, strokeWidth: num, roughness: num, opacity: num,
  strokeColor: str, backgroundColor: str, fillStyle: str, strokeStyle: str, isDeleted: bool, locked: bool,
  groupIds: list(str), frameId: orNull(str), link: orNull(str), roundness: orNull(obj),
  boundElements: orNull(list((b) => obj(b) && str((b as El).id) && str((b as El).type))),
  text: str, originalText: str, fontSize: num, fontFamily: num, lineHeight: num, textAlign: str, verticalAlign: str,
  containerId: orNull(str), autoResize: bool,
  points: list(point), pressures: list(num), simulatePressure: bool, lastCommittedPoint: orNull(point),
  startBinding: orNull(obj), endBinding: orNull(obj), startArrowhead: orNull(str), endArrowhead: orNull(str),
  elbowed: bool, fixedSegments: orNull(list(obj)),
  fileId: orNull(str), status: str, scale: point, crop: orNull(obj), name: orNull(str),
};

/** True for an element Excalidraw can draw safely: a known type, and every known field of the right type. */
export function isElement(v: unknown): boolean {
  if (!obj(v)) return false;
  const e = v as Record<string, unknown>;
  if (!str(e.id) || !num(e.version) || !ELEMENT_TYPES.includes(e.type as string)) return false;
  return Object.entries(ELEMENT_FIELDS).every(([field, check]) => e[field] === undefined || check(e[field]));
}

const colorName = (hex?: string) => (COLOR_NAMES.find((c) => COLORS[c] === hex?.toLowerCase()) ?? (hex && hex !== "transparent" ? hex : undefined));

/** A plain description of a board's contents: shapes with their text, arrows between them, frames, loose text. */
export function summarize(elements: El[]) {
  const live = elements.filter((e) => e && !e.isDeleted);
  const byId = new Map(live.map((e) => [e.id, e]));
  const labelOf = (e: El) => {
    const t = (e.boundElements || []).map((b) => byId.get(b.id)).find((b) => b?.type === "text");
    return t?.text?.trim() || "";
  };
  const round = (n: number) => Math.round(n);
  const frames = live.filter((e) => e.type === "frame" || e.type === "magicframe");
  const shapes = live.filter((e) => ["rectangle", "ellipse", "diamond"].includes(e.type));
  const arrows = live.filter((e) => e.type === "arrow" || e.type === "line");
  const texts = live.filter((e) => e.type === "text" && !e.containerId);
  const nameOf = (id?: string | null) => {
    const e = id ? byId.get(id) : undefined;
    return e ? labelOf(e) || e.text || e.name || e.type : undefined;
  };
  return {
    frames: frames.map((f) => ({ id: f.id, name: f.name || "Frame", items: live.filter((e) => e.frameId === f.id && !e.containerId).length })),
    shapes: shapes.map((s) => ({
      id: s.id, shape: s.type, text: labelOf(s), color: colorName(s.backgroundColor),
      frame: s.frameId ? (byId.get(s.frameId)?.name || "Frame") : undefined, x: round(s.x), y: round(s.y), width: round(s.width), height: round(s.height),
    })),
    arrows: arrows.map((a) => ({
      id: a.id, from: nameOf(a.startBinding?.elementId), to: nameOf(a.endBinding?.elementId), text: labelOf(a) || undefined,
    })),
    texts: texts.map((t) => ({ id: t.id, text: t.text || "", x: round(t.x), y: round(t.y) })),
    drawings: live.filter((e) => e.type === "freedraw").length,
    images: live.filter((e) => e.type === "image").length,
    elements: live.length,
  };
}

const ALPHABET = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789";
/** A random id from an unambiguous alphabet; 20 characters is about 116 bits. */
export function randomId(length = 20): string {
  const bytes = crypto.getRandomValues(new Uint8Array(length));
  let out = "";
  for (const b of bytes) out += ALPHABET[b % ALPHABET.length];
  return out;
}

Change history

Point OpenApps links to openapps.free
93fdaafeda · 18 hours ago
Bind AGENT_ADDRESS in policy constants
bf8f43b096 · 20 hours ago
Use functions.openapps.xyz alias, add OpenApp contribution strip
56f1a381b2 · 20 hours ago
Bounded source sync
9ec707aeb8 · 1 day ago
Bounded source sync
5645481c23 · 1 day ago
Bounded source sync
c38fe71874 · 1 day ago
Bounded source sync
28c0350b40 · 1 day ago