// deno-lint-ignore-file no-explicit-any /** * Annotation - A pattern for annotating existing cells/pieces. * * An annotation points at one or more existing cells (via targetPiece), * can reference other annotations it is "blocked by", and is discoverable * by agents via `wish({ query: "#annotation" })`. * * By including targetPiece in the `mentioned` output array, the annotated * piece automatically gains a backlink to this annotation — no special * wiring needed. * * Keywords: annotation, note, todo, wish, comment, blocker, backlink */ import { computed, type Default, handler, ifElse, NAME, pattern, UI, type VNode, wish, Writable, } from "commonfabric"; import type { MentionablePiece } from "./system/backlinks-index.tsx"; // ===== Types ===== export type AnnotationKind = "note" | "todo" | "wish"; export type AnnotationStatus = | "open" | "in-progress" | "resolved" | "dismissed"; export interface AnnotationPiece { [NAME]?: string; content?: string; kind?: AnnotationKind; status?: AnnotationStatus; targetPiece?: MentionablePiece | null; blockedBy?: AnnotationPiece[]; isAnnotation?: boolean; isHidden?: boolean; mentioned?: MentionablePiece[]; } export interface AnnotationInput { content: Writable>; kind: Writable>; status: Writable>; targetPiece: Writable>; blockedBy: Writable>; isAnnotation: boolean | Default; isHidden: boolean | Default; } /** An #annotation pointing at an existing cell, optionally blocked by other annotations. */ export interface AnnotationOutput extends AnnotationPiece { [NAME]: string; [UI]: VNode; mentioned: MentionablePiece[]; content: string; kind: AnnotationKind; status: AnnotationStatus; targetPiece: MentionablePiece | null; blockedBy: AnnotationPiece[]; isAnnotation: boolean; } // ===== Handlers ===== const setKind = handler }>((event, { kind }) => { const val = (event.target as { value?: string })?.value; if (val) kind.set(val as AnnotationKind); }); const setStatus = handler }>( (event, { status }) => { const val = (event.target as { value?: string })?.value; if (val) status.set(val as AnnotationStatus); }, ); const markResolved = handler }>( (_event, { status }) => { status.set("resolved"); }, ); const selectTarget = handler< unknown, { piece: MentionablePiece; targetPiece: Writable; targetSearch: Writable; } >((_event, { piece, targetPiece, targetSearch }) => { targetPiece.set(piece); targetSearch.set(""); }); const clearTarget = handler }>( (_event, { targetPiece }) => { targetPiece.set(null); }, ); const addBlocker = handler< unknown, { blocker: AnnotationPiece; blockedBy: Writable; blockerSearch: Writable; } >((_event, { blocker, blockedBy, blockerSearch }) => { const current = (blockedBy.get() ?? []) as AnnotationPiece[]; if (!current.some((b) => b === blocker)) { blockedBy.set([...current, blocker]); } blockerSearch.set(""); }); const removeBlocker = handler< unknown, { index: number; blockedBy: Writable } >((_event, { index, blockedBy }) => { const current = (blockedBy.get() as AnnotationPiece[]) ?? []; blockedBy.set(current.toSpliced(index, 1)); }); const toggleBlockerPicker = handler< unknown, { showBlockerPicker: Writable } >((_event, { showBlockerPicker }) => { showBlockerPicker.set(!showBlockerPicker.get()); }); // ===== The Pattern ===== export const Annotation = pattern( ({ content, kind, status, targetPiece, blockedBy, isAnnotation }) => { // Local UI state const targetSearch = new Writable(""); const blockerSearch = new Writable(""); const showBlockerPicker = new Writable(false); // Discover all mentionable pieces for target picker const mentionable = wish({ query: "#mentionable", }).result; // Discover other annotations for blocked-by picker const allAnnotations = wish({ query: "#annotation", }).result; // Filtered mentionable list for target picker const filteredMentionable = computed(() => { const query = targetSearch.get().toLowerCase(); const items = (mentionable ?? []).filter( (p) => !!p, ) as MentionablePiece[]; if (!query) return items.slice(0, 10); return items .filter((p) => { const name = (p?.[NAME] ?? "").toLowerCase(); return name.includes(query); }) .slice(0, 10); }); // Filtered annotations for blocker picker (exclude already-added ones) const filteredAnnotations = computed(() => { const query = blockerSearch.get().toLowerCase(); const current = blockedBy.get() ?? []; const items = (allAnnotations ?? []) .filter((a) => !!a) .filter((a) => !current.some((b) => b === a)) as AnnotationPiece[]; if (!query) return items.slice(0, 10); return items .filter((a) => { const name = (a?.[NAME] ?? "").toLowerCase(); const c = (a?.content ?? "").toLowerCase(); return name.includes(query) || c.includes(query); }) .slice(0, 10); }); // Derived state for UI const hasTarget = computed(() => targetPiece.get() != null); const isResolved = computed( () => status.get() === "resolved" || status.get() === "dismissed", ); const kindValue = computed(() => kind.get() ?? "note"); const statusValue = computed(() => status.get() ?? "open"); const blockedByList = computed(() => blockedBy.get() ?? []); const targetPieceValue = computed(() => targetPiece.get()); // The [NAME] for this annotation — emoji prefix + truncated content const annotationName = computed(() => { const k = kind.get() ?? "note"; const c = (content.get() ?? "").trim(); const prefix = k === "wish" ? "✨" : k === "todo" ? "☐" : "📌"; const label = c ? c.slice(0, 40) : "New annotation"; return `${prefix} ${label}`; }); // The `mentioned` array feeds the backlinks system for free const mentioned = computed(() => { const t = targetPiece.get(); return t ? [t] : []; }); return { [NAME]: annotationName, [UI]: ( {/* ── Header row: kind + status + resolve button ── */} {/* Kind selector */} {/* Status selector */} {/* Mark resolved shortcut */} {ifElse( isResolved, {statusValue} , Mark resolved , )} {/* ── Content textarea ── */} {/* ── Target piece section ── */} Annotating {ifElse( hasTarget, /* Target is set — show link + clear button */ Remove , /* No target — show search picker */ {filteredMentionable.map( (piece: MentionablePiece) => piece && ( ), )} , )} {/* ── Blocked by section ── */} Blocked by {/* Current blockers */} {blockedByList.map((blocker: AnnotationPiece, index: number) => ( Remove ))} {/* Add blocker toggle */} + Add blocker {/* Blocker search picker */} {ifElse( showBlockerPicker, {filteredAnnotations.map( (ann: AnnotationPiece) => ann && ( ), )} , , )} ), mentioned, content, kind, status, targetPiece, blockedBy, isAnnotation, }; }, ); export default Annotation;