@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.
- package/LICENSE +21 -0
- package/README.md +74 -0
- package/annotate.ts +94 -0
- package/daemon.ts +46 -0
- package/index.ts +84 -0
- package/package.json +74 -0
- package/src/client/AnnotationDraftFooter.tsx +76 -0
- package/src/client/AnnotationEditor.tsx +320 -0
- package/src/client/AnnotationList.tsx +190 -0
- package/src/client/AnnotationProvider.tsx +294 -0
- package/src/client/AnnotationSidebar.tsx +374 -0
- package/src/client/FileIcon.tsx +76 -0
- package/src/client/PlanRenderer.tsx +587 -0
- package/src/client/Popover.tsx +361 -0
- package/src/client/RenderErrorContext.ts +11 -0
- package/src/client/ResponsePreview.tsx +186 -0
- package/src/client/SessionContext.tsx +3 -0
- package/src/client/annotationContext.ts +130 -0
- package/src/client/annotationDom.ts +115 -0
- package/src/client/annotationDraft.ts +130 -0
- package/src/client/annotationMarkdown.ts +102 -0
- package/src/client/annotationTarget.ts +73 -0
- package/src/client/appContext.tsx +61 -0
- package/src/client/blockTarget.ts +127 -0
- package/src/client/components/Callout.tsx +27 -0
- package/src/client/components/Checklist.tsx +37 -0
- package/src/client/components/Choice.tsx +178 -0
- package/src/client/components/CodeBlock.tsx +35 -0
- package/src/client/components/Diff.tsx +36 -0
- package/src/client/components/FilePreview.tsx +88 -0
- package/src/client/components/Image.tsx +48 -0
- package/src/client/components/Markdown.tsx +44 -0
- package/src/client/components/Mermaid.tsx +576 -0
- package/src/client/components/Note.tsx +14 -0
- package/src/client/components/Priority.tsx +19 -0
- package/src/client/components/ResponseNote.tsx +19 -0
- package/src/client/components/Section.tsx +33 -0
- package/src/client/components/Table.tsx +35 -0
- package/src/client/components/Task.tsx +76 -0
- package/src/client/components/UserInput.tsx +176 -0
- package/src/client/components/index.ts +18 -0
- package/src/client/components/renderMarkdownChildren.tsx +100 -0
- package/src/client/diffLines.ts +50 -0
- package/src/client/generateMarkdown.ts +250 -0
- package/src/client/highlightRange.ts +262 -0
- package/src/client/hostContext.tsx +55 -0
- package/src/client/index.ts +128 -0
- package/src/client/popoverPosition.ts +46 -0
- package/src/client/preact-all.ts +4 -0
- package/src/client/regionTarget.ts +180 -0
- package/src/client/runtime.ts +149 -0
- package/src/client/styles.css +492 -0
- package/src/client/testing/dom.ts +58 -0
- package/src/client/textTarget.ts +31 -0
- package/src/client/theme.css +66 -0
- package/src/client/useRegionAnnotation.tsx +188 -0
- package/src/client/useTextAnnotation.tsx +194 -0
- package/src/client/utils.ts +33 -0
- package/src/engine/channel.ts +278 -0
- package/src/engine/compiler.ts +415 -0
- package/src/engine/http-utils.ts +14 -0
- package/src/engine/lifecycle.ts +302 -0
- package/src/engine/paths.ts +63 -0
- package/src/engine/router.ts +16 -0
- package/src/engine/session.ts +678 -0
- package/src/engine/waiter.ts +188 -0
- package/src/engine/watcher.ts +84 -0
- 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,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
|
+
});
|