@fabrika/canvas-kernel 0.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +74 -0
  3. package/annotate.ts +94 -0
  4. package/daemon.ts +46 -0
  5. package/index.ts +84 -0
  6. package/package.json +74 -0
  7. package/src/client/AnnotationDraftFooter.tsx +76 -0
  8. package/src/client/AnnotationEditor.tsx +320 -0
  9. package/src/client/AnnotationList.tsx +190 -0
  10. package/src/client/AnnotationProvider.tsx +294 -0
  11. package/src/client/AnnotationSidebar.tsx +374 -0
  12. package/src/client/FileIcon.tsx +76 -0
  13. package/src/client/PlanRenderer.tsx +587 -0
  14. package/src/client/Popover.tsx +361 -0
  15. package/src/client/RenderErrorContext.ts +11 -0
  16. package/src/client/ResponsePreview.tsx +186 -0
  17. package/src/client/SessionContext.tsx +3 -0
  18. package/src/client/annotationContext.ts +130 -0
  19. package/src/client/annotationDom.ts +115 -0
  20. package/src/client/annotationDraft.ts +130 -0
  21. package/src/client/annotationMarkdown.ts +102 -0
  22. package/src/client/annotationTarget.ts +73 -0
  23. package/src/client/appContext.tsx +61 -0
  24. package/src/client/blockTarget.ts +127 -0
  25. package/src/client/components/Callout.tsx +27 -0
  26. package/src/client/components/Checklist.tsx +37 -0
  27. package/src/client/components/Choice.tsx +178 -0
  28. package/src/client/components/CodeBlock.tsx +35 -0
  29. package/src/client/components/Diff.tsx +36 -0
  30. package/src/client/components/FilePreview.tsx +88 -0
  31. package/src/client/components/Image.tsx +48 -0
  32. package/src/client/components/Markdown.tsx +44 -0
  33. package/src/client/components/Mermaid.tsx +576 -0
  34. package/src/client/components/Note.tsx +14 -0
  35. package/src/client/components/Priority.tsx +19 -0
  36. package/src/client/components/ResponseNote.tsx +19 -0
  37. package/src/client/components/Section.tsx +33 -0
  38. package/src/client/components/Table.tsx +35 -0
  39. package/src/client/components/Task.tsx +76 -0
  40. package/src/client/components/UserInput.tsx +176 -0
  41. package/src/client/components/index.ts +18 -0
  42. package/src/client/components/renderMarkdownChildren.tsx +100 -0
  43. package/src/client/diffLines.ts +50 -0
  44. package/src/client/generateMarkdown.ts +250 -0
  45. package/src/client/highlightRange.ts +262 -0
  46. package/src/client/hostContext.tsx +55 -0
  47. package/src/client/index.ts +128 -0
  48. package/src/client/popoverPosition.ts +46 -0
  49. package/src/client/preact-all.ts +4 -0
  50. package/src/client/regionTarget.ts +180 -0
  51. package/src/client/runtime.ts +149 -0
  52. package/src/client/styles.css +492 -0
  53. package/src/client/testing/dom.ts +58 -0
  54. package/src/client/textTarget.ts +31 -0
  55. package/src/client/theme.css +66 -0
  56. package/src/client/useRegionAnnotation.tsx +188 -0
  57. package/src/client/useTextAnnotation.tsx +194 -0
  58. package/src/client/utils.ts +33 -0
  59. package/src/engine/channel.ts +278 -0
  60. package/src/engine/compiler.ts +415 -0
  61. package/src/engine/http-utils.ts +14 -0
  62. package/src/engine/lifecycle.ts +302 -0
  63. package/src/engine/paths.ts +63 -0
  64. package/src/engine/router.ts +16 -0
  65. package/src/engine/session.ts +678 -0
  66. package/src/engine/waiter.ts +188 -0
  67. package/src/engine/watcher.ts +84 -0
  68. package/src/engine/websocket.ts +159 -0
@@ -0,0 +1,46 @@
1
+ /** Width assumed when the caller does not say — the narrow popover. */
2
+ const DEFAULT_POPOVER_WIDTH = 280;
3
+
4
+ /**
5
+ * Read a CSS width the popover will be styled with. Only px is a width this
6
+ * placement can reason about; anything else falls back to the default rather
7
+ * than clamping against a number that means something different.
8
+ */
9
+ export function pxWidth(css: string): number | undefined {
10
+ const match = /^(\d+(?:\.\d+)?)px$/.exec(css.trim());
11
+ return match ? Number(match[1]) : undefined;
12
+ }
13
+
14
+ /**
15
+ * Calculate popover position relative to an anchor element.
16
+ * Returns absolute position within the scroll container (or body).
17
+ */
18
+ export function getPopoverPosition(
19
+ anchor: HTMLElement,
20
+ scrollContainer?: HTMLElement | null,
21
+ popoverWidth: number = DEFAULT_POPOVER_WIDTH,
22
+ ): { style: Record<string, string>; parent: HTMLElement } {
23
+ const gap = 8;
24
+ const parent = scrollContainer || document.body;
25
+
26
+ // Use getBoundingClientRect for both anchor and parent,
27
+ // then add scroll offsets to get absolute position within parent
28
+ const anchorRect = anchor.getBoundingClientRect();
29
+ const parentRect = parent.getBoundingClientRect();
30
+
31
+ // Both rects are viewport-relative, so the subtraction has already cancelled
32
+ // page scroll. Only a scrolling parent adds to that — the body is not what
33
+ // scrolls a document, and counting window.scrollY here counted it twice.
34
+ const scrollTop = parent === document.body ? 0 : parent.scrollTop;
35
+ const top = anchorRect.bottom - parentRect.top + scrollTop + gap;
36
+
37
+ // Horizontal: clamp to parent width
38
+ const left = Math.max(0, Math.min(anchorRect.left - parentRect.left, parentRect.width - popoverWidth - 12));
39
+
40
+ parent.style.position = "relative";
41
+
42
+ return {
43
+ style: { position: "absolute", top: `${top}px`, left: `${left}px` },
44
+ parent,
45
+ };
46
+ }
@@ -0,0 +1,4 @@
1
+ export * from "preact/compat";
2
+ export { default } from "preact/compat";
3
+ export { jsx, jsxs } from "preact/jsx-runtime";
4
+ export { createRoot, hydrateRoot } from "preact/compat/client";
@@ -0,0 +1,180 @@
1
+ import type { AnnotationTarget } from "./annotationTarget";
2
+
3
+ /**
4
+ * A rectangle or ellipse drawn inside an image block, so a reader can point at
5
+ * one corner of a screenshot instead of the whole picture.
6
+ *
7
+ * Coordinates are normalized against the image box, which is a uniform scale of
8
+ * the image's natural size — that is what lets a region survive a resize, a
9
+ * zoom or a different screen. They are stored on a fixed integer grid rather
10
+ * than as floats: a float printed into the snippet and read back is not
11
+ * guaranteed to be the same float, and a locator that shifts by one ulp names
12
+ * a region nobody drew.
13
+ */
14
+
15
+ /** The grid a region snaps to: hundredths of a percent of the image box. */
16
+ export const REGION_UNITS = 10000;
17
+
18
+ /** Under this a drag is a stray click, not a region — 0.3% of the image. */
19
+ const MIN_REGION_SIZE = 30;
20
+
21
+ export type RegionShape = "rect" | "ellipse";
22
+
23
+ export interface RegionPoint {
24
+ x: number;
25
+ y: number;
26
+ }
27
+
28
+ /** Left, top, width and height, all in REGION_UNITS. */
29
+ export interface RegionBox extends RegionPoint {
30
+ w: number;
31
+ h: number;
32
+ }
33
+
34
+ export interface RegionLocator extends RegionBox {
35
+ /** The image block the region sits in — the same `src` `[Image]` is keyed by. */
36
+ src: string;
37
+ shape: RegionShape;
38
+ }
39
+
40
+ /** Marks the element a region is measured against: the image's own box, not the
41
+ * `<figure>`, which also holds the caption and would skew every percentage. */
42
+ export const REGION_HOST_ATTR = "data-annotation-image";
43
+
44
+ // The coordinates are anchored at the end and strictly numeric, so the greedy
45
+ // `src` group can hold anything at all — including a literal " @1,2,3,4" — and
46
+ // still hand the real coordinates to the tail.
47
+ const REGION_PATTERN = /^\[Region:(rect|ellipse)\] ([\s\S]+) @(0|[1-9]\d*),(0|[1-9]\d*),(0|[1-9]\d*),(0|[1-9]\d*)$/;
48
+
49
+ function readShape(value: string | undefined): RegionShape | null {
50
+ if (value === "rect") return "rect";
51
+ if (value === "ellipse") return "ellipse";
52
+ return null;
53
+ }
54
+
55
+ /** A coordinate as `format` would have written it, or null. */
56
+ function readUnit(value: string | undefined): number | null {
57
+ if (value === undefined) return null;
58
+ const n = Number(value);
59
+ if (!Number.isInteger(n) || n < 0 || n > REGION_UNITS) return null;
60
+ return n;
61
+ }
62
+
63
+ export function clampUnit(value: number): number {
64
+ return Math.min(REGION_UNITS, Math.max(0, value));
65
+ }
66
+
67
+ /** Where a pointer landed inside a box, on the region grid. Null for a box with
68
+ * no extent — happy-dom and a not-yet-laid-out image both report zero. */
69
+ export function regionPointIn(box: { width: number; height: number }, offsetX: number, offsetY: number): RegionPoint | null {
70
+ if (!(box.width > 0) || !(box.height > 0)) return null;
71
+ return {
72
+ x: clampUnit(Math.round((offsetX / box.width) * REGION_UNITS)),
73
+ y: clampUnit(Math.round((offsetY / box.height) * REGION_UNITS)),
74
+ };
75
+ }
76
+
77
+ /** The box two drag corners span, kept inside the image. */
78
+ export function regionBetween(a: RegionPoint, b: RegionPoint): RegionBox {
79
+ const x = Math.min(a.x, b.x);
80
+ const y = Math.min(a.y, b.y);
81
+ return {
82
+ x,
83
+ y,
84
+ w: Math.min(REGION_UNITS - x, Math.abs(a.x - b.x)),
85
+ h: Math.min(REGION_UNITS - y, Math.abs(a.y - b.y)),
86
+ };
87
+ }
88
+
89
+ export function isDrawableRegion(box: RegionBox): boolean {
90
+ return box.w >= MIN_REGION_SIZE && box.h >= MIN_REGION_SIZE;
91
+ }
92
+
93
+ /** CSS geometry for the overlay. REGION_UNITS is percent × 100, so every value
94
+ * the grid holds divides out exactly. */
95
+ export function regionStyle(box: RegionBox): { left: string; top: string; width: string; height: string } {
96
+ return {
97
+ left: `${box.x / 100}%`,
98
+ top: `${box.y / 100}%`,
99
+ width: `${box.w / 100}%`,
100
+ height: `${box.h / 100}%`,
101
+ };
102
+ }
103
+
104
+ function pct(units: number): string {
105
+ return `${(units / 100).toFixed(1).replace(/\.0$/, "")}%`;
106
+ }
107
+
108
+ export function describeRegion(locator: RegionLocator): string {
109
+ const x = `${pct(locator.x)}-${pct(locator.x + locator.w)}`;
110
+ const y = `${pct(locator.y)}-${pct(locator.y + locator.h)}`;
111
+ return `[Region] ${locator.src} — ${locator.shape} x ${x}, y ${y} of the image`;
112
+ }
113
+
114
+ /** The image box a region belongs to. Compared attribute by attribute rather
115
+ * than through a selector, because an image src is a path and may carry any
116
+ * character a selector would have to escape. */
117
+ export function findRegionHost(src: string, root: ParentNode): HTMLElement | null {
118
+ for (const figure of root.querySelectorAll("[data-md='image']")) {
119
+ if (!(figure instanceof HTMLElement)) continue;
120
+ if (figure.getAttribute("data-md-src") !== src) continue;
121
+ const host = figure.querySelector(`[${REGION_HOST_ATTR}]`);
122
+ if (host instanceof HTMLElement) return host;
123
+ }
124
+ return null;
125
+ }
126
+
127
+ export function applyRegionStyle(el: HTMLElement, box: RegionBox): void {
128
+ const style = regionStyle(box);
129
+ el.style.left = style.left;
130
+ el.style.top = style.top;
131
+ el.style.width = style.width;
132
+ el.style.height = style.height;
133
+ }
134
+
135
+ /** The overlay box for one region. It carries `data-annotation-id`, so the
136
+ * hover, active-state and delete plumbing that already drives inline marks
137
+ * drives these too. */
138
+ export function createRegionOverlay(box: RegionBox, shape: RegionShape, annotationId: string): HTMLElement {
139
+ const el = document.createElement("div");
140
+ el.className = "ann-region";
141
+ el.setAttribute("data-annotation-id", annotationId);
142
+ el.setAttribute("data-annotation-region", shape);
143
+ applyRegionStyle(el, box);
144
+ return el;
145
+ }
146
+
147
+ export const regionTarget: AnnotationTarget<RegionLocator> = {
148
+ kind: "region",
149
+
150
+ parse: (snippet) => {
151
+ const match = REGION_PATTERN.exec(snippet);
152
+ if (!match) return null;
153
+ const shape = readShape(match[1]);
154
+ const src = match[2];
155
+ const x = readUnit(match[3]);
156
+ const y = readUnit(match[4]);
157
+ const w = readUnit(match[5]);
158
+ const h = readUnit(match[6]);
159
+ if (shape === null || src === undefined) return null;
160
+ if (x === null || y === null || w === null || h === null) return null;
161
+ if (w < 1 || h < 1) return null;
162
+ return { src, shape, x, y, w, h };
163
+ },
164
+
165
+ format: (locator) =>
166
+ `[Region:${locator.shape}] ${locator.src} @${locator.x},${locator.y},${locator.w},${locator.h}`,
167
+
168
+ // The overlay is found by annotation id like any other decoration; this is
169
+ // the fallback for before it is drawn, and it lands on the right image.
170
+ find: (locator, root) => findRegionHost(locator.src, root),
171
+
172
+ restore: (locator, root, ann) => {
173
+ if (root.querySelector(`[data-annotation-id="${ann.id}"]`)) return;
174
+ const host = findRegionHost(locator.src, root);
175
+ if (!host) return;
176
+ host.appendChild(createRegionOverlay(locator, locator.shape, ann.id));
177
+ },
178
+
179
+ describe: describeRegion,
180
+ };
@@ -0,0 +1,149 @@
1
+ // #canvas/runtime — shared context between client app and components bundle
2
+ // The React.createContext call MUST live here (not in AnnotationProvider)
3
+ // so that both bundles reference the same context object.
4
+
5
+ import { createContext, useContext, useEffect } from "react";
6
+
7
+ export interface AnnotationContext {
8
+ before: string;
9
+ after: string;
10
+ hierarchy: string[];
11
+ lineStart?: number;
12
+ lineEnd?: number;
13
+ }
14
+
15
+ /** Identifies the person who created an annotation. For local annotations
16
+ * this is the daemon owner (unset). For remote annotations it carries the
17
+ * reviewer's self-reported name + stable anonymous id assigned by the worker. */
18
+ export interface AnnotationAuthor {
19
+ id: string;
20
+ name: string;
21
+ }
22
+
23
+ /** An image or other file attached to an annotation. The richer sibling of
24
+ * `images: string[]`, carrying a mime type alongside a URL that can point at
25
+ * either a local upload (daemon) or a remote blob (CF Worker R2). */
26
+ export interface AnnotationAttachment {
27
+ url: string;
28
+ mime?: string;
29
+ }
30
+
31
+ export interface Annotation {
32
+ id: string;
33
+ snippet: string;
34
+ note: string;
35
+ createdAt: string;
36
+ filePath?: string;
37
+ canvasFile?: string;
38
+ context?: AnnotationContext;
39
+ /** Attachments written by the local editor, as bare upload paths/URLs. Still
40
+ * the only field it writes, and what persisted drafts hold. */
41
+ images?: string[];
42
+ /** Attachments that arrived over the wire, from a reviewer on a shared
43
+ * canvas. Both fields render — see `renderAnnotation`. */
44
+ attachments?: AnnotationAttachment[];
45
+ /** "remote" annotations come from a shared view and are read-only locally. */
46
+ source?: "local" | "remote";
47
+ /** Only set for source === "remote". */
48
+ author?: AnnotationAuthor;
49
+ }
50
+
51
+ export interface PlanResponse {
52
+ id: string;
53
+ type: "select" | "radio" | "checkbox" | "text" | "range";
54
+ label: string;
55
+ value: any;
56
+ options?: string[];
57
+ required?: boolean;
58
+ note?: string;
59
+ }
60
+
61
+ export interface FeedbackEntry {
62
+ id: string;
63
+ markdown: string;
64
+ label?: string;
65
+ required?: boolean;
66
+ }
67
+
68
+ export interface AnnotationContextValue {
69
+ annotations: Annotation[];
70
+ addAnnotation: (snippet: string, note: string, filePath?: string) => void;
71
+ addAnnotationWithId: (id: string, snippet: string, note: string, filePath?: string, context?: AnnotationContext, images?: string[], canvasFile?: string) => void;
72
+ updateAnnotation: (id: string, note: string) => void;
73
+ removeAnnotation: (id: string) => void;
74
+ addAnnotationImage: (id: string, imagePath: string) => void;
75
+ removeAnnotationImage: (id: string, imagePath: string) => void;
76
+ generalNote: string;
77
+ setGeneralNote: (text: string) => void;
78
+ clearAll: () => void;
79
+ activeAnnotationId: string | null;
80
+ setActiveAnnotationId: (id: string | null) => void;
81
+ /** Every answer held for this draft, including ones carried in from an
82
+ * earlier revision. Controls read and write this one. */
83
+ responses: Map<string, PlanResponse>;
84
+ setResponse: (id: string, response: PlanResponse) => void;
85
+ /** `responses` narrowed to the questions this revision actually asks.
86
+ * Everything that reports feedback — markdown, validation, "has content" —
87
+ * reads this instead, so a carried-over answer to a since-removed question
88
+ * is not submitted. Identical to `responses` until the host supplies the
89
+ * canvas file list and every one of them has rendered. */
90
+ submittableResponses: Map<string, PlanResponse>;
91
+ registerResponse: (id: string) => void;
92
+ registerCanvasRendered: (filename: string) => void;
93
+ feedbackEntries: Map<string, FeedbackEntry>;
94
+ setFeedbackEntry: (id: string, entry: FeedbackEntry) => void;
95
+ removeFeedbackEntry: (id: string) => void;
96
+ /** Canvas is rendered read-only (reviewing history). */
97
+ isReadOnly: boolean;
98
+ }
99
+
100
+ export const AnnotationCtx = createContext<AnnotationContextValue>(null!);
101
+
102
+ export function useAnnotations(): AnnotationContextValue {
103
+ return useContext(AnnotationCtx);
104
+ }
105
+
106
+ /** Record that this revision asks the question, so submission can tell it from
107
+ * an answer carried in for a question that is gone. Deliberately has no
108
+ * cleanup: unmounting means the reader navigated away, not that the question
109
+ * disappeared. */
110
+ export function useResponseRegistration(id: string): void {
111
+ const { registerResponse } = useAnnotations();
112
+ useEffect(() => {
113
+ registerResponse(id);
114
+ }, [id, registerResponse]);
115
+ }
116
+
117
+ export function useFeedback(
118
+ id: string,
119
+ markdown: string,
120
+ options?: { label?: string; required?: boolean },
121
+ ): void {
122
+ const { setFeedbackEntry, removeFeedbackEntry } = useAnnotations();
123
+ useEffect(() => {
124
+ setFeedbackEntry(id, {
125
+ id,
126
+ markdown,
127
+ label: options?.label,
128
+ required: options?.required,
129
+ });
130
+ return () => removeFeedbackEntry(id);
131
+ }, [id, markdown, options?.label, options?.required]);
132
+ }
133
+
134
+ export { SessionContext } from "./SessionContext";
135
+
136
+ // CanvasFile context — tells components which canvas file they belong to
137
+ export const CanvasFileCtx = createContext<string>("");
138
+ export function useCanvasFile(): string {
139
+ return useContext(CanvasFileCtx);
140
+ }
141
+
142
+ // ActiveView navigation — allows components (e.g. FilePreview) to open files
143
+ export type ActiveView = { type: "overview" } | { type: "canvas"; filename: string } | { type: "file"; path: string };
144
+
145
+ export const ActiveViewCtx = createContext<{
146
+ setActiveView: (v: ActiveView) => void;
147
+ }>({
148
+ setActiveView: () => {},
149
+ });