@hachej/boring-ui-kit 0.4.2 → 0.4.3
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/README.md +3 -1
- package/bin/boring-ui-blocks.mjs +29 -0
- package/dist/canvas-editor-native.d.ts +8 -1
- package/dist/canvas-editor-native.js +13 -7
- package/dist/canvas-editor.d.ts +4 -2
- package/dist/canvas-editor.js +5 -0
- package/dist/file-tree-view.d.ts +29 -1
- package/dist/file-tree-view.js +74 -7
- package/package.json +2 -2
- package/r/app-shell.json +7 -7
- package/r/board-viewer.json +49 -0
- package/r/button.json +1 -1
- package/r/diagram-viewer.json +42 -0
- package/r/dock.json +1 -1
- package/r/documents.json +11 -11
- package/r/feedback-preview.json +1 -1
- package/r/feedback.json +6 -6
- package/r/file-tree.json +7 -6
- package/r/html-viewer.json +3 -3
- package/r/maquette-viewer.json +10 -13
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +45 -0
- package/r/pi-ambient.json +6 -6
- package/r/pi-app.json +20 -18
- package/r/pi-chat.json +5 -5
- package/r/pi-workspace.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/registry.json +203 -81
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +3 -3
- package/r/viewers.json +7 -7
package/README.md
CHANGED
|
@@ -126,7 +126,9 @@ Native document author records are preserved. They do not grant access or select
|
|
|
126
126
|
|
|
127
127
|
## Optional React canvas editor
|
|
128
128
|
|
|
129
|
-
Import `CanvasEditor` and `CanvasAssetUrls` from `@hachej/boring-ui-kit/canvas-editor`. Install the pinned `tldraw` peer in addition to the canvas controller peers. Supply `controller`, a complete host-owned `assetUrls` map, and optionally `title`, `height`, `className`, `licenseKey` and the native `onMount` callback.
|
|
129
|
+
Import `CanvasEditor` and `CanvasAssetUrls` from `@hachej/boring-ui-kit/canvas-editor`. Install the pinned `tldraw` peer in addition to the canvas controller peers. Supply `controller`, a complete host-owned `assetUrls` map, and optionally `title`, `height`, `className`, `licenseKey` and the native `onMount` callback. The native JavaScript and `tldraw/tldraw.css` load lazily, only when a canvas mounts, so the host does not import the stylesheet itself. Asset URLs must cover all pinned fonts, icons, translations and embed icons, so omitted values cannot silently select default CDN assets.
|
|
130
|
+
|
|
131
|
+
**Self-hosted assets (host recipe).** Install `@tldraw/assets` at the same pin as `tldraw`, serve its package directory at one origin path (for example `/tldraw-assets`), and build `assetUrls` from `tldraw`'s own defaults with that origin in place of `https://cdn.tldraw.com/<version>`. The working example is [`examples/canvas-assets`](../../examples/canvas-assets): `self-hosted.mjs` is the map, `route.mjs` the route, and `journey.mjs` drives Chromium to prove that the canvas makes no request off the host origin and records no Content-Security-Policy violation (`npm run canvas-assets:journey`). Pass `licenseKey` from the host's own configuration, never a literal in source.
|
|
130
132
|
|
|
131
133
|
The native toolbar and Save/Check saved version controls use the concrete controller. Lost acknowledgements expose Check save outcome; conflicts preserve local edits and expose explicit discard. Paste/import/upload handlers are disabled until asset/content policy is qualified. Mounting may create native author metadata, but never publishes. Unmount preserves the controller and the native session read-only flag. The host explicitly resets or derives that flag when selecting a later writable session. Use one native editor per store; existing host read-only state remains effective.
|
|
132
134
|
|
package/bin/boring-ui-blocks.mjs
CHANGED
|
@@ -234,10 +234,39 @@ function parse(command, rest) {
|
|
|
234
234
|
return flags;
|
|
235
235
|
}
|
|
236
236
|
|
|
237
|
+
const DEPENDENCY_FIELDS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
|
|
238
|
+
const readJson = path => { try { return JSON.parse(readFileSync(path, 'utf8')); } catch { return undefined; } };
|
|
239
|
+
|
|
240
|
+
/** The version specifiers of an app's package.json (and the root entry of its npm lockfile), as written before the CLI runs. */
|
|
241
|
+
function specifiers(app) {
|
|
242
|
+
const read = name => { const path = join(app, name), text = existsSync(path) ? readFileSync(path, 'utf8') : undefined; return { path, text, json: text === undefined ? undefined : readJson(path) }; };
|
|
243
|
+
return [read('package.json'), read('package-lock.json')].map((file, index) => ({ ...file, root: index === 0 ? file.json : file.json?.packages?.[''] }));
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The shadcn CLI installs a block's dependencies by name (`npm install <name>`), and npm then saves `^<version>` over an exact pin the app
|
|
248
|
+
* already holds. The app owns its specifiers (AGENTS.md: pin dependencies), so after the CLI ran every dependency that was there before
|
|
249
|
+
* gets its old specifier back, in package.json and in the lockfile's root entry; dependencies the CLI added keep what it wrote.
|
|
250
|
+
*/
|
|
251
|
+
function restoreSpecifiers(before, app, log) {
|
|
252
|
+
const restored = new Set();
|
|
253
|
+
for (const [index, name] of ['package.json', 'package-lock.json'].entries()) {
|
|
254
|
+
const old = before[index], now = specifiers(app)[index];
|
|
255
|
+
if (!old.root || !now.root) continue;
|
|
256
|
+
for (const field of DEPENDENCY_FIELDS) for (const [dependency, range] of Object.entries(old.root[field] ?? {})) {
|
|
257
|
+
if (now.root[field]?.[dependency] !== undefined && now.root[field][dependency] !== range) { now.root[field][dependency] = range; restored.add(dependency); now.changed = true; }
|
|
258
|
+
}
|
|
259
|
+
if (now.changed) writeFileSync(now.path, `${JSON.stringify(now.json, null, /^\{\n([ \t]+)"/.exec(now.text)?.[1] ?? 2)}${now.text.endsWith('\n') ? '\n' : ''}`);
|
|
260
|
+
}
|
|
261
|
+
if (restored.size) log(`kept the version specifiers of ${[...restored].sort().join(', ')} (the CLI had rewritten them)`);
|
|
262
|
+
}
|
|
263
|
+
|
|
237
264
|
function shadcnAdd(app, args, log) {
|
|
238
265
|
const argv = ['-y', SHADCN, 'add', ...args];
|
|
239
266
|
log(`npx ${argv.join(' ')}`);
|
|
267
|
+
const before = specifiers(app);
|
|
240
268
|
const result = spawnSync('npx', argv, { cwd: app, stdio: 'inherit', shell: process.platform === 'win32' });
|
|
269
|
+
restoreSpecifiers(before, app, log);
|
|
241
270
|
return result.status ?? 1;
|
|
242
271
|
}
|
|
243
272
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import 'tldraw/tldraw.css';
|
|
1
2
|
import type { CSSProperties } from 'react';
|
|
2
3
|
import type { TLOnMountHandler } from '@tldraw/editor';
|
|
3
|
-
import type { TLDefaultFonts, TLUiAssetUrls } from 'tldraw';
|
|
4
|
+
import type { TLDefaultFonts, TLUiAssetUrls, TldrawProps } from 'tldraw';
|
|
4
5
|
import type { CanvasController } from './canvas.js';
|
|
5
6
|
import type { CanvasMountedTools } from './canvas-mounted.js';
|
|
6
7
|
import type { ViewerComponents } from './viewer-components.js';
|
|
@@ -21,5 +22,11 @@ export interface CanvasEditorProps {
|
|
|
21
22
|
/** Host presentation for the canvas controls; without it the package renders unstyled native buttons. */
|
|
22
23
|
readonly components?: ViewerComponents;
|
|
23
24
|
}
|
|
25
|
+
/** A borrowed native renderer for a host-owned document projection. It never saves the native store. */
|
|
26
|
+
export type CanvasProjectionProps = Pick<TldrawProps, 'store' | 'shapeUtils' | 'hideUi' | 'children' | 'onMount' | 'onUiEvent'> & {
|
|
27
|
+
readonly assetUrls: CanvasAssetUrls;
|
|
28
|
+
readonly licenseKey?: string | undefined;
|
|
29
|
+
};
|
|
30
|
+
export declare function NativeCanvasProjection({ assetUrls, licenseKey, onMount, ...props }: CanvasProjectionProps): import("react").JSX.Element;
|
|
24
31
|
export default function NativeCanvas({ controller, assetUrls, title, className, height, licenseKey, onMount, onMountedTools, components }: CanvasEditorProps): import("react").JSX.Element;
|
|
25
32
|
//# sourceMappingURL=canvas-editor-native.d.ts.map
|
|
@@ -1,10 +1,22 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import 'tldraw/tldraw.css';
|
|
3
4
|
import { useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
4
5
|
import { useEditor, useValue, GeoShapeGeoStyle, LANGUAGES } from '@tldraw/editor';
|
|
5
6
|
import { Tldraw, iconTypes, DEFAULT_EMBED_DEFINITIONS } from 'tldraw';
|
|
6
7
|
import { createMountedCanvasTools } from './canvas-mounted.js';
|
|
7
8
|
import { CanvasProposals } from './canvas-proposals.js';
|
|
9
|
+
export function NativeCanvasProjection({ assetUrls, licenseKey, onMount, ...props }) {
|
|
10
|
+
const signature = useMemo(() => assetSignature(assetUrls), [assetUrls]);
|
|
11
|
+
const assets = useMemo(() => ({ fonts: { ...assetUrls.fonts }, icons: { ...assetUrls.icons }, translations: { ...assetUrls.translations }, embedIcons: { ...assetUrls.embedIcons } }), [signature]);
|
|
12
|
+
return _jsx(Tldraw, { ...props, autoFocus: false, locale: "en", assetUrls: assets, ...(licenseKey === undefined ? {} : { licenseKey }), onMount: editor => {
|
|
13
|
+
editor.registerExternalAssetHandler('file', null);
|
|
14
|
+
editor.registerExternalAssetHandler('url', null);
|
|
15
|
+
for (const type of externalContentTypes)
|
|
16
|
+
editor.registerExternalContentHandler(type, null);
|
|
17
|
+
return onMount?.(editor);
|
|
18
|
+
} });
|
|
19
|
+
}
|
|
8
20
|
const fontKeys = [
|
|
9
21
|
'tldraw_draw', 'tldraw_draw_italic', 'tldraw_draw_bold', 'tldraw_draw_italic_bold',
|
|
10
22
|
'tldraw_sans', 'tldraw_sans_italic', 'tldraw_sans_bold', 'tldraw_sans_italic_bold',
|
|
@@ -32,8 +44,6 @@ export default function NativeCanvas({ controller, assetUrls, title = 'Canvas',
|
|
|
32
44
|
const mounted = useRef(true);
|
|
33
45
|
const revokeTools = useRef(null);
|
|
34
46
|
useLayoutEffect(() => { mounted.current = true; return () => { mounted.current = false; }; }, []);
|
|
35
|
-
const signature = useMemo(() => assetSignature(assetUrls), [assetUrls]);
|
|
36
|
-
const assets = useMemo(() => ({ fonts: { ...assetUrls.fonts }, icons: { ...assetUrls.icons }, translations: { ...assetUrls.translations }, embedIcons: { ...assetUrls.embedIcons } }), [signature]);
|
|
37
47
|
const outcome = state.save.kind === 'settled' ? state.save.result : undefined;
|
|
38
48
|
const pending = state.save.kind === 'pending';
|
|
39
49
|
const uncertain = outcome?.kind === 'unknown';
|
|
@@ -62,10 +72,6 @@ export default function NativeCanvas({ controller, assetUrls, title = 'Canvas',
|
|
|
62
72
|
const attach = (editor) => {
|
|
63
73
|
if (controller.getSnapshot().readOnly || controller.getSnapshot().lifecycle === 'disposed')
|
|
64
74
|
editor.updateInstanceState({ isReadonly: true });
|
|
65
|
-
editor.registerExternalAssetHandler('file', null);
|
|
66
|
-
editor.registerExternalAssetHandler('url', null);
|
|
67
|
-
for (const type of externalContentTypes)
|
|
68
|
-
editor.registerExternalContentHandler(type, null);
|
|
69
75
|
const cleanup = onMount?.(editor);
|
|
70
76
|
setNativeEditor(editor);
|
|
71
77
|
return () => { revokeTools.current?.(); setNativeEditor(current => current === editor ? null : current); cleanup?.(); };
|
|
@@ -78,7 +84,7 @@ export default function NativeCanvas({ controller, assetUrls, title = 'Canvas',
|
|
|
78
84
|
}
|
|
79
85
|
}, children: [_jsxs("header", { children: [_jsx("strong", { children: title }), state.readOnly && _jsx("span", { children: "Read only" }), _jsx("span", { role: "status", children: state.lifecycle === 'disposed' ? 'Closed' : pending ? 'Saving' : uncertain ? 'Save unconfirmed' : conflict ? 'Changed elsewhere' : state.dirty ? 'Unsaved changes' : 'Saved' }), _jsx(Control, { type: "button", disabled: unavailable || !state.dirty || pending || uncertain || busy || state.problem !== null, onClick: () => void save(), children: "Save" }), _jsx(Control, { type: "button", disabled: state.lifecycle === 'disposed' || pending || uncertain || busy, onClick: () => void run(controller.actions.refresh), children: "Check saved version" })] }), uncertain && _jsxs("div", { role: "alert", children: ["Save acknowledgement was lost. Your current canvas is retained.", _jsx(Control, { type: "button", disabled: busy, onClick: () => void run(controller.actions.reconcile), children: "Check save outcome" }), _jsx(Control, { type: "button", disabled: busy || state.lifecycle === 'disposed', onClick: () => void run(controller.actions.abandon), children: "Keep draft and refresh" })] }), conflict && _jsxs("div", { role: "alert", children: ["The saved canvas changed. Your local edits have been kept.", _jsx(Control, { type: "button", disabled: pending || uncertain || busy, onClick: () => void run(controller.actions.discardToRemote), children: "Discard local changes and reload" })] }), state.problem && _jsx("p", { role: "alert", children: state.problem }), (outcome?.kind === 'denied' || outcome?.kind === 'unavailable' || outcome?.kind === 'file_too_large') && _jsx("p", { role: "alert", children: outcome.reason }), error && _jsx("p", { role: "alert", children: error }), _jsx(CanvasProposals, { components: components, controller: controller, state: state, editor: nativeEditor, busy: busy, run: run }), state.lifecycle === 'active' && _jsx("div", { "data-boring": "canvas-stage", style: { position: 'relative', height }, onClickCapture: event => { if (event.target instanceof Element && event.target.closest('a'))
|
|
80
86
|
event.preventDefault(); }, onAuxClickCapture: event => { if (event.target instanceof Element && event.target.closest('a'))
|
|
81
|
-
event.preventDefault(); }, children: _jsxs(
|
|
87
|
+
event.preventDefault(); }, children: _jsxs(NativeCanvasProjection, { store: controller.store, hideUi: true, assetUrls: assetUrls, licenseKey: licenseKey, onMount: attach, children: [_jsx(CanvasTools, { components: components, controller: controller }), _jsx(MountedCommands, { controller: controller, revokeTools: revokeTools, ...(onMountedTools ? { onMountedTools } : {}) })] }) })] });
|
|
82
88
|
}
|
|
83
89
|
const externalContentTypes = ['text', 'files', 'file-replace', 'embed', 'svg-text', 'url', 'tldraw', 'excalidraw'];
|
|
84
90
|
const choices = [
|
package/dist/canvas-editor.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
export type { CanvasMountedSubject, CanvasMountedInspection, CanvasMountedTools } from './canvas-mounted.js';
|
|
2
|
-
import type { CanvasEditorProps } from './canvas-editor-native.js';
|
|
3
|
-
export type { CanvasAssetUrls, CanvasEditorProps, CanvasFontUrls } from './canvas-editor-native.js';
|
|
2
|
+
import type { CanvasEditorProps, CanvasProjectionProps } from './canvas-editor-native.js';
|
|
3
|
+
export type { CanvasAssetUrls, CanvasEditorProps, CanvasFontUrls, CanvasProjectionProps } from './canvas-editor-native.js';
|
|
4
|
+
/** Native tldraw presentation for a host format such as board@1. No document controller or native persistence. */
|
|
5
|
+
export declare function CanvasProjection(props: CanvasProjectionProps): import("react").JSX.Element;
|
|
4
6
|
/** Borrows one controller/store. The host supplies native CSS, fonts and any production license. */
|
|
5
7
|
export declare function CanvasEditor(props: CanvasEditorProps): import("react").JSX.Element;
|
|
6
8
|
//# sourceMappingURL=canvas-editor.d.ts.map
|
package/dist/canvas-editor.js
CHANGED
|
@@ -3,6 +3,11 @@ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-run
|
|
|
3
3
|
import { lazy, Suspense, useState, useSyncExternalStore } from 'react';
|
|
4
4
|
import { TextDraftControls } from './text-draft-controls.js';
|
|
5
5
|
const NativeCanvas = lazy(() => import('./canvas-editor-native.js'));
|
|
6
|
+
const NativeProjection = lazy(() => import('./canvas-editor-native.js').then(module => ({ default: module.NativeCanvasProjection })));
|
|
7
|
+
/** Native tldraw presentation for a host format such as board@1. No document controller or native persistence. */
|
|
8
|
+
export function CanvasProjection(props) {
|
|
9
|
+
return _jsx(Suspense, { fallback: _jsx("p", { role: "status", children: "Loading canvas" }), children: _jsx(NativeProjection, { ...props }) });
|
|
10
|
+
}
|
|
6
11
|
/** Borrows one controller/store. The host supplies native CSS, fonts and any production license. */
|
|
7
12
|
export function CanvasEditor(props) {
|
|
8
13
|
const state = useSyncExternalStore(props.controller.subscribe, props.controller.getSnapshot, props.controller.getSnapshot);
|
package/dist/file-tree-view.d.ts
CHANGED
|
@@ -47,6 +47,9 @@ export declare const defaultFileTreeLabels: {
|
|
|
47
47
|
checkUpload: string;
|
|
48
48
|
uploadStatusUnavailable: string;
|
|
49
49
|
uploadFailed: (name: string) => string;
|
|
50
|
+
tooLarge: string;
|
|
51
|
+
uploadProgress: (name: string) => string;
|
|
52
|
+
cancelUpload: (name: string) => string;
|
|
50
53
|
};
|
|
51
54
|
export type FileTreeLabels = typeof defaultFileTreeLabels;
|
|
52
55
|
/** The tree's words in French (`locale="fr"`). */
|
|
@@ -100,7 +103,26 @@ export interface FileTreeComponents {
|
|
|
100
103
|
readonly className?: string;
|
|
101
104
|
readonly children?: ReactNode;
|
|
102
105
|
}) => ReactNode;
|
|
106
|
+
/** The host's shadcn `Progress` (`value` 0–100, `null` while the total is unknown), for uploads through `uploadBlob`. */
|
|
107
|
+
readonly Progress?: (props: {
|
|
108
|
+
readonly value: number | null;
|
|
109
|
+
readonly 'aria-label': string;
|
|
110
|
+
}) => ReactNode;
|
|
111
|
+
}
|
|
112
|
+
/** How far an upload through `uploadBlob` is. */
|
|
113
|
+
export interface UploadProgress {
|
|
114
|
+
readonly loaded: number;
|
|
115
|
+
readonly total: number;
|
|
103
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* The host's upload of a large file: it stores the bytes and writes the workspace's pointer file itself, then resolves (with the
|
|
119
|
+
* pointer it wrote). The tree never writes anything for it; it reloads its listing. `signal` aborts it (the row's cancel button);
|
|
120
|
+
* a refusal rejects with `{ error: { code, message } }`, whose `message` the tree shows.
|
|
121
|
+
*/
|
|
122
|
+
export type UploadBlob = (file: globalThis.File, options: {
|
|
123
|
+
readonly signal: AbortSignal;
|
|
124
|
+
readonly onProgress: (progress: UploadProgress) => void;
|
|
125
|
+
}) => Promise<unknown>;
|
|
104
126
|
export interface FileTreeProps {
|
|
105
127
|
readonly components: FileTreeComponents;
|
|
106
128
|
readonly revisionProvider: RevisionProvider;
|
|
@@ -128,6 +150,12 @@ export interface FileTreeProps {
|
|
|
128
150
|
readonly refreshKey?: unknown;
|
|
129
151
|
/** Reload the open history panel whenever this value changes (for example the seq of the workspace's latest change event). */
|
|
130
152
|
readonly historyKey?: unknown;
|
|
153
|
+
/** The inline cap in bytes: a larger upload goes to `uploadBlob`, or is refused with `tooLarge` without it. No cap when omitted. */
|
|
154
|
+
readonly maxBytes?: number;
|
|
155
|
+
/** The refusal shown for a file above `maxBytes` when there is no `uploadBlob`. */
|
|
156
|
+
readonly tooLarge?: string;
|
|
157
|
+
/** The host's large-file upload (see `UploadBlob`), used above `maxBytes`. */
|
|
158
|
+
readonly uploadBlob?: UploadBlob;
|
|
131
159
|
}
|
|
132
160
|
/** One borrowed binding powers browsing, search, upload and history. Historical previews never replace an editor's draft. */
|
|
133
161
|
export declare function FileTree(props: FileTreeProps): import("react").JSX.Element;
|
|
@@ -135,5 +163,5 @@ export interface FileTreeViewProps extends Omit<FileTreeProps, 'revisionProvider
|
|
|
135
163
|
readonly controller: FileTreeController;
|
|
136
164
|
}
|
|
137
165
|
/** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
|
|
138
|
-
export declare function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory, locale, labels: given, upload, history, refresh, variant, title, onClose, refreshKey, historyKey }: FileTreeViewProps): import("react").JSX.Element;
|
|
166
|
+
export declare function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory, locale, labels: given, upload, history, refresh, variant, title, onClose, refreshKey, historyKey, maxBytes, tooLarge, uploadBlob }: FileTreeViewProps): import("react").JSX.Element;
|
|
139
167
|
//# sourceMappingURL=file-tree-view.d.ts.map
|
package/dist/file-tree-view.js
CHANGED
|
@@ -48,6 +48,9 @@ export const defaultFileTreeLabels = {
|
|
|
48
48
|
checkUpload: 'Check upload status',
|
|
49
49
|
uploadStatusUnavailable: 'Upload status unavailable',
|
|
50
50
|
uploadFailed: (name) => `${name}: upload failed`,
|
|
51
|
+
tooLarge: 'The file is too large to upload.',
|
|
52
|
+
uploadProgress: (name) => `Uploading ${name}`,
|
|
53
|
+
cancelUpload: (name) => `Cancel uploading ${name}`,
|
|
51
54
|
};
|
|
52
55
|
/** The tree's words in French (`locale="fr"`). */
|
|
53
56
|
export const frenchFileTreeLabels = {
|
|
@@ -94,6 +97,9 @@ export const frenchFileTreeLabels = {
|
|
|
94
97
|
checkUpload: 'Vérifier l’import',
|
|
95
98
|
uploadStatusUnavailable: 'État de l’import indisponible',
|
|
96
99
|
uploadFailed: name => `${name} : échec de l’import`,
|
|
100
|
+
tooLarge: 'Le fichier est trop volumineux pour être importé.',
|
|
101
|
+
uploadProgress: name => `Import de ${name}`,
|
|
102
|
+
cancelUpload: name => `Annuler l’import de ${name}`,
|
|
97
103
|
};
|
|
98
104
|
const LOCALES = { en: defaultFileTreeLabels, fr: frenchFileTreeLabels };
|
|
99
105
|
function fileKind(path, labels) {
|
|
@@ -129,8 +135,8 @@ function BoundFileTree(props) {
|
|
|
129
135
|
return owned?.provider === props.revisionProvider ? _jsx(FileTreeView, { ...props, controller: owned.controller }) : _jsx("p", { role: "status", children: props.labels?.loading ?? LOCALES[props.locale ?? 'en'].loading });
|
|
130
136
|
}
|
|
131
137
|
/** Renders a borrowed controller, for a workspace whose chat and tree share upload state. */
|
|
132
|
-
export function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refresh = true, variant = 'list', title, onClose, refreshKey, historyKey }) {
|
|
133
|
-
const { Button, Actions, Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Collapsible: CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, Empty } = components;
|
|
138
|
+
export function FileTreeView({ components, controller, onOpen, selectedPath, className, uploadDirectory = 'uploads', locale = 'en', labels: given, upload = true, history = true, refresh = true, variant = 'list', title, onClose, refreshKey, historyKey, maxBytes, tooLarge, uploadBlob }) {
|
|
139
|
+
const { Button, Actions, Menu: SidebarMenu, MenuItem: SidebarMenuItem, MenuSub: SidebarMenuSub, Input, Collapsible: CollapsibleRoot, CollapsibleTrigger, CollapsibleContent, Empty, Progress } = components;
|
|
134
140
|
const list = variant === 'list';
|
|
135
141
|
const labels = { ...LOCALES[locale], ...given };
|
|
136
142
|
const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
|
|
@@ -154,9 +160,49 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
154
160
|
const [uploadError, setUploadError] = useState('');
|
|
155
161
|
const [historyPath, setHistoryPath] = useState();
|
|
156
162
|
const [localFailures, setLocalFailures] = useState(new Map());
|
|
163
|
+
// Uploads through the host's `uploadBlob`, by a key of their own: the name, the progress and the abort behind the cancel button.
|
|
164
|
+
const [blobUploads, setBlobUploads] = useState(new Map());
|
|
165
|
+
const blobAborts = useRef(new Set());
|
|
166
|
+
useEffect(() => () => { for (const abort of blobAborts.current)
|
|
167
|
+
abort.abort(); }, []);
|
|
168
|
+
const uploadLarge = async (file, send) => {
|
|
169
|
+
const key = `${file.name}:${Math.random()}`, abort = new AbortController();
|
|
170
|
+
const set = (progress) => setBlobUploads(previous => new Map(previous).set(key, { name: file.name, progress, abort }));
|
|
171
|
+
blobAborts.current.add(abort);
|
|
172
|
+
set(undefined);
|
|
173
|
+
try {
|
|
174
|
+
await send(file, { signal: abort.signal, onProgress: progress => { if (!abort.signal.aborted)
|
|
175
|
+
set(progress); } });
|
|
176
|
+
if (abort.signal.aborted)
|
|
177
|
+
return;
|
|
178
|
+
// The host wrote the pointer file: the listing is read again, never written.
|
|
179
|
+
const { query, expanded } = controller.getSnapshot();
|
|
180
|
+
if (query)
|
|
181
|
+
void controller.search(query);
|
|
182
|
+
else
|
|
183
|
+
for (const directory of ['', ...expanded])
|
|
184
|
+
void controller.refresh(directory);
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
if (!abort.signal.aborted)
|
|
188
|
+
setUploadError(refusalOf(error) ?? labels.uploadFailed(file.name));
|
|
189
|
+
}
|
|
190
|
+
finally {
|
|
191
|
+
blobAborts.current.delete(abort);
|
|
192
|
+
setBlobUploads(previous => { const next = new Map(previous); next.delete(key); return next; });
|
|
193
|
+
}
|
|
194
|
+
};
|
|
157
195
|
const uploadFiles = async (files) => {
|
|
196
|
+
setUploadError('');
|
|
158
197
|
for (const file of files) {
|
|
159
198
|
const path = uploadDirectory ? `${uploadDirectory}/${file.name}` : file.name;
|
|
199
|
+
if (maxBytes !== undefined && file.size > maxBytes) {
|
|
200
|
+
if (uploadBlob)
|
|
201
|
+
void uploadLarge(file, uploadBlob);
|
|
202
|
+
else
|
|
203
|
+
setLocalFailures(previous => new Map(previous).set(path, { file, reason: tooLarge ?? labels.tooLarge }));
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
160
206
|
setLocalFailures(previous => { const next = new Map(previous); next.delete(path); return next; });
|
|
161
207
|
try {
|
|
162
208
|
await controller.upload({ path, bytes: new Uint8Array(await file.arrayBuffer()), mediaType: file.type || 'application/octet-stream' });
|
|
@@ -269,15 +315,17 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
269
315
|
const folder = entry.kind === 'directory';
|
|
270
316
|
const temporary = temporaryPaths.has(entry.path);
|
|
271
317
|
const status = uploadRows.get(entry.path);
|
|
272
|
-
|
|
318
|
+
// A pointer file shows the file it stands for: its name, kind and size (`withBlobPointers` resolved it where the listing was read).
|
|
319
|
+
const blob = entry.blob && 'url' in entry.blob ? entry.blob : undefined;
|
|
320
|
+
const name = blob?.name ?? entry.path.split('/').at(-1);
|
|
273
321
|
const parent = (state.query || temporary) && entry.path.includes('/') ? entry.path.slice(0, entry.path.lastIndexOf('/')) : '';
|
|
274
322
|
const children = folder ? state.directories.get(entry.path) : undefined;
|
|
275
323
|
const childCount = children?.kind === 'ready' && children.cursor === undefined ? labels.childCount(children.entries.length) : undefined;
|
|
276
|
-
const kind = folder ? { icon: Folder, label: labels.folder } : fileKind(entry.path, labels);
|
|
324
|
+
const kind = folder ? { icon: Folder, label: labels.folder } : fileKind(blob?.name ?? entry.path, labels);
|
|
277
325
|
const Icon = kind.icon;
|
|
278
326
|
const button = _jsxs(Button, { type: "button", variant: selectedPath === entry.path ? 'secondary' : 'ghost', "data-active": selectedPath === entry.path, tabIndex: -1, title: entry.path, disabled: temporary, className: "file-tree-row-button", onClick: () => { focus(entry.path); if (!folder)
|
|
279
|
-
onOpen?.(entry.path); }, children: [folder ? _jsx(ChevronRight, { "aria-hidden": "true" }) : list && _jsx("span", { className: "file-tree-chevron-space", "aria-hidden": "true" }), _jsx(Icon, { "aria-hidden": "true" }), _jsxs("span", { className: "file-tree-copy", children: [_jsx("span", { className: "file-tree-name", children: name }), list ? folder ? childCount !== undefined && _jsx("span", { "data-file-meta": true, children: childCount }) : _jsxs("span", { "data-file-meta": true, children: [kind.label, " \u00B7 ", fileSize(entry.size, locale), parent && ` · ${parent}`] }) : parent && _jsx("span", { className: "file-tree-parent", "data-file-meta": true, children: parent })] })] });
|
|
280
|
-
const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "data-upload-state": status?.kind, "aria-busy": status?.kind === 'pending' || undefined, "aria-level": level, "aria-selected": selectedPath === entry.path, "aria-expanded": folder ? state.expanded.has(entry.path) : undefined, tabIndex: focusPath === entry.path ? 0 : -1, onFocus: event => { if (event.target === event.currentTarget)
|
|
327
|
+
onOpen?.(entry.path); }, children: [folder ? _jsx(ChevronRight, { "aria-hidden": "true" }) : list && _jsx("span", { className: "file-tree-chevron-space", "aria-hidden": "true" }), _jsx(Icon, { "aria-hidden": "true" }), _jsxs("span", { className: "file-tree-copy", children: [_jsx("span", { className: "file-tree-name", children: name }), list ? folder ? childCount !== undefined && _jsx("span", { "data-file-meta": true, children: childCount }) : _jsxs("span", { "data-file-meta": true, children: [kind.label, " \u00B7 ", fileSize(blob?.size ?? entry.size, locale), parent && ` · ${parent}`] }) : parent && _jsx("span", { className: "file-tree-parent", "data-file-meta": true, children: parent })] })] });
|
|
328
|
+
const item = _jsxs(SidebarMenuItem, { role: "treeitem", "data-path": entry.path, "data-blob-type": blob?.type, "data-upload-state": status?.kind, "aria-busy": status?.kind === 'pending' || undefined, "aria-level": level, "aria-selected": selectedPath === entry.path, "aria-expanded": folder ? state.expanded.has(entry.path) : undefined, tabIndex: focusPath === entry.path ? 0 : -1, onFocus: event => { if (event.target === event.currentTarget)
|
|
281
329
|
setFocused(entry.path); }, onKeyDown: event => { if (event.target === event.currentTarget)
|
|
282
330
|
keyboard(event, entry); }, className: "file-tree-item", children: [_jsxs("div", { className: "flex items-center gap-1", children: [folder ? _jsx(CollapsibleTrigger, { render: button }) : button, status?.kind === 'pending' && _jsx(LoaderCircle, { "data-upload-spinner": true, className: "animate-spin motion-reduce:animate-none", "aria-hidden": "true" }), status?.kind === 'failed' && _jsx(Button, { type: "button", variant: "ghost", "data-upload-retry": true, "aria-label": status.retryLabel, onClick: status.retry, children: status.retryLabel }), !folder && !temporary && !status && _jsx(FileActions, { Actions: Actions, controller: controller, entry: entry, labels: labels, onOpen: onOpen ? () => onOpen(entry.path) : undefined, onHistory: history ? () => setHistoryPath(entry.path) : undefined, onError: setUploadError })] }), status?.kind === 'failed' && _jsx("span", { "data-upload-error": true, children: status.reason }), folder && !state.query && _jsx(CollapsibleContent, { render: _jsx(SidebarMenuSub, { role: "group" }), children: renderEntries(entry.path, level + 1) })] }, entry.path);
|
|
283
331
|
return folder ? _jsx(CollapsibleRoot, { render: item, open: state.expanded.has(entry.path), onOpenChange: () => { void controller.toggle(entry.path); } }, entry.path) : item;
|
|
@@ -304,13 +352,32 @@ export function FileTreeView({ components, controller, onOpen, selectedPath, cla
|
|
|
304
352
|
const files = Array.from(event.currentTarget.files ?? []);
|
|
305
353
|
event.currentTarget.value = '';
|
|
306
354
|
void uploadFiles(files);
|
|
307
|
-
} }), dropping && _jsx("div", { className: "file-tree-drop", role: "status", children: _jsx(Empty, { icon: _jsx(Upload, { "aria-hidden": "true" }), title: labels.dropFiles }) }), uploadError && _jsx("p", { role: "alert", children: uploadError }),
|
|
355
|
+
} }), dropping && _jsx("div", { className: "file-tree-drop", role: "status", children: _jsx(Empty, { icon: _jsx(Upload, { "aria-hidden": "true" }), title: labels.dropFiles }) }), uploadError && _jsx("p", { role: "alert", children: uploadError }), blobUploads.size > 0 && _jsx("ul", { "aria-label": labels.uploads, className: "file-tree-blob-uploads", children: [...blobUploads].map(([key, item]) => {
|
|
356
|
+
const percent = item.progress && item.progress.total > 0 ? Math.min(100, Math.round(item.progress.loaded / item.progress.total * 100)) : null;
|
|
357
|
+
return _jsxs("li", { "data-blob-upload": item.name, "data-progress": percent ?? undefined, className: "flex items-center gap-2", children: [_jsx("span", { className: "min-w-0 flex-1 truncate", children: item.name }), Progress ? _jsx("span", { className: "w-24 shrink-0", children: _jsx(Progress, { value: percent, "aria-label": labels.uploadProgress(item.name) }) }) : _jsx("span", { role: "status", children: percent === null ? labels.uploading : `${percent}%` }), _jsx(Button, { type: "button", variant: "ghost", size: "icon", "data-blob-upload-cancel": true, "aria-label": labels.cancelUpload(item.name), title: labels.cancelUpload(item.name), onClick: () => item.abort.abort(), children: _jsx(X, { "aria-hidden": "true" }) })] }, key);
|
|
358
|
+
}) }), _jsxs(SidebarMenu, { ref: tree, role: "tree", "aria-label": labels.tree, className: "file-tree-menu", children: [state.query ? _jsxs(_Fragment, { children: [state.search.entries.map(entry => renderEntry(entry, 1)), listingStatus(state.search, '', true)] }) : renderEntries('', 1), temporaryEntries.map(entry => renderEntry(entry, 1))] }), _jsxs("ul", { "aria-label": labels.uploads, "aria-live": "polite", "aria-relevant": "additions text", className: "file-tree-announcements", children: [state.uploads.map(upload => _jsxs("li", { "data-operation": upload.operationId, children: [upload.path, ": ", upload.state.kind === 'pending' ? labels.uploading : upload.state.result.kind === 'committed' ? labels.saved : upload.state.result.kind === 'partial' ? labels.unconfirmed : upload.state.result.reason] }, upload.operationId)), [...localFailures].map(([path, failure]) => _jsxs("li", { children: [path, ": ", failure.reason] }, path))] }), history && historyPath !== undefined && _jsx(FileHistory, { components: components, locale: locale, controller: controller, path: historyPath, labels: labels, reload: historyKey, onClose: () => setHistoryPath(undefined) }, historyPath)] });
|
|
359
|
+
}
|
|
360
|
+
/** The message of a host refusal `{ error: { code, message } }` (or an Error's), if any. */
|
|
361
|
+
function refusalOf(error) {
|
|
362
|
+
const message = typeof error === 'object' && error !== null && 'error' in error && typeof error.error === 'object' && error.error !== null && 'message' in error.error ? error.error.message : error instanceof Error ? error.message : undefined;
|
|
363
|
+
return typeof message === 'string' && message ? message : undefined;
|
|
308
364
|
}
|
|
309
365
|
function FileActions({ Actions, controller, entry, labels, onOpen, onHistory, onError }) {
|
|
310
366
|
const lifetime = useRef(undefined);
|
|
311
367
|
useEffect(() => () => lifetime.current?.abort(), []);
|
|
368
|
+
const blob = entry.blob && 'url' in entry.blob ? entry.blob : undefined;
|
|
312
369
|
const download = async () => {
|
|
313
370
|
onError('');
|
|
371
|
+
if (blob) {
|
|
372
|
+
// The host serves a blob's bytes at its URL, with its own disposition; nothing is read into the page.
|
|
373
|
+
const link = document.createElement('a');
|
|
374
|
+
link.href = blob.url;
|
|
375
|
+
link.download = blob.name;
|
|
376
|
+
document.body.append(link);
|
|
377
|
+
link.click();
|
|
378
|
+
link.remove();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
314
381
|
lifetime.current?.abort();
|
|
315
382
|
const abort = new AbortController();
|
|
316
383
|
lifetime.current = abort;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hachej/boring-ui-kit",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.3",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"private": false,
|
|
6
6
|
"type": "module",
|
|
@@ -114,7 +114,7 @@
|
|
|
114
114
|
"./file-tree.css": "./styles/file-tree.css"
|
|
115
115
|
},
|
|
116
116
|
"peerDependencies": {
|
|
117
|
-
"@hachej/boring-files": "^0.4.
|
|
117
|
+
"@hachej/boring-files": "^0.4.3",
|
|
118
118
|
"@earendil-works/pi-durable": "~1.2.0",
|
|
119
119
|
"react": "^19.0.0",
|
|
120
120
|
"react-dom": "^19.0.0",
|
package/r/app-shell.json
CHANGED
|
@@ -4,19 +4,19 @@
|
|
|
4
4
|
"title": "App shell: layouts, list/detail and agent layer",
|
|
5
5
|
"description": "AppShell with three layouts. B \"workspace\" puts a ListDetail on a screen (phone: the list, then the detail as a stack step with Back; wide: a 20rem list and the detail side by side, sidebar as an icon rail). C \"chat\" wraps the app's own AgentWorkspace (untouched, passed as the nav item's render; install the pi-app item yourself) in the same sidebar, account menu and header; it takes no shell agent. A \"screen\": one screen at a time, every screen kept mounted. On a phone bottom tabs (at most four; more give three plus a « Plus » sheet) and an avatar button that opens the account sheet; on a wide screen the shadcn sidebar (icon rail, NavUser). One header per screen through useScreenHeader (title, subtitle, back, up to two actions, the rest behind « … »). The account menu holds « Améliorer l'app » (the improve agent), the app's own entries and sign out. AppSheet is the one bottom sheet (one open at a time, Back closes it). It mounts the AgentLayer: one agent's PiChat above the app (phone: a drawer with peek, half and full snap points; wide: a right panel), the improve agent in the same layer, optional push-to-talk and point mode. Also useAgentContext and readAgentContext (the screen on show travels as `context` with each submit) and useBackLayer, the one Back manager. Built on explicitly pinned Base UI drawer and dropdown-menu recipes, plus the sidebar and avatar of the host's own shadcn style.",
|
|
6
6
|
"dependencies": [
|
|
7
|
-
"@hachej/boring-feedback@^0.4.
|
|
8
|
-
"@hachej/boring-ui-kit@^0.4.
|
|
7
|
+
"@hachej/boring-feedback@^0.4.3",
|
|
8
|
+
"@hachej/boring-ui-kit@^0.4.3",
|
|
9
9
|
"@base-ui/react@^1.9.0",
|
|
10
10
|
"react@^19.0.0",
|
|
11
11
|
"react-dom@^19.0.0",
|
|
12
12
|
"lucide-react@^1.51.0"
|
|
13
13
|
],
|
|
14
14
|
"registryDependencies": [
|
|
15
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
16
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
17
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
18
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
19
|
-
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/pi-chat.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/dock.json",
|
|
17
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/feedback.json",
|
|
18
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/utils.json",
|
|
19
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/button.json",
|
|
20
20
|
"drawer",
|
|
21
21
|
"sidebar",
|
|
22
22
|
"avatar",
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
|
|
3
|
+
"name": "board-viewer",
|
|
4
|
+
"title": "Board viewer",
|
|
5
|
+
"description": "A board@1 artifact kind for *.board.json. Lazy native tldraw projection of maquette screens, sandboxed HTML mock-ups, goto arrows and notes. Conditional board-file autosave; phone viewing without accidental frame edits. Hosts supply their resource client, asset URLs and license key.",
|
|
6
|
+
"dependencies": [
|
|
7
|
+
"@hachej/boring-ui-kit@^0.4.3",
|
|
8
|
+
"@hachej/boring-files@^0.4.3",
|
|
9
|
+
"react@^19.0.0",
|
|
10
|
+
"lucide-react@1.51.0",
|
|
11
|
+
"tldraw@5.5.2"
|
|
12
|
+
],
|
|
13
|
+
"registryDependencies": [
|
|
14
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/maquette-viewer.json",
|
|
15
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/mockup-viewer.json",
|
|
16
|
+
"https://cdn.jsdelivr.net/npm/@hachej/boring-ui-kit@0.4.3/r/pi-chat.json",
|
|
17
|
+
"button",
|
|
18
|
+
"alert",
|
|
19
|
+
"card",
|
|
20
|
+
"tooltip"
|
|
21
|
+
],
|
|
22
|
+
"files": [
|
|
23
|
+
{
|
|
24
|
+
"path": "registry/board-viewer/board.ts",
|
|
25
|
+
"content": "/*\n * The board format (`board@1`): a PM agent writes `<name>.board.json` to lay out maquette and HTML mock-ups left to right, with arrows for\n * the maquette's goto links and sticky notes beside them. It is plain data that names other workspace files (paths relative to the board's\n * folder); nothing it names is embedded or run here. The board file is the only authority: a canvas is a projection of it, and people's\n * frame moves and notes come back through `applyCanvasEdits`, never as a canvas store. The format is documented in registry/README.md\n * (\"Board format\"). Model output is untrusted: `parseBoard` is strict and reports every problem as an issue, and never throws.\n */\nimport { linksOf, parseMaquette } from '../maquette-viewer/maquette';\nimport type { Maquette } from '../maquette-viewer/maquette';\n\n/** The format this module reads and writes. A higher `board@N` is reported as `newer`, not read. */\nexport const BOARD_FORMAT = 'board@1';\nexport const BOARD_FORMAT_VERSION = 1;\n\nexport type BoardDevice = 'phone' | 'desktop';\n\n/** One thing on the board: a maquette (one frame per screen), an HTML mock-up (one frame) or a sticky note. */\nexport type BoardItem =\n | { readonly id: string; readonly maquette: string; readonly title?: string }\n | { readonly id: string; readonly html: string; readonly title?: string; readonly device: BoardDevice; readonly width?: number; readonly height?: number }\n | { readonly id: string; readonly note: string; readonly near?: string };\n\n/** An arrow between two frames (`<item>/<screen>` for a maquette screen, the item id for an HTML frame). */\nexport interface BoardLink { readonly from: string; readonly to: string; readonly label?: string }\nexport interface BoardPoint { readonly x: number; readonly y: number }\n\nexport interface Board {\n readonly format: typeof BOARD_FORMAT;\n readonly title: string;\n readonly items: readonly BoardItem[];\n readonly links: readonly BoardLink[];\n /** Where people put frames and notes: frame or note id to its top-left corner. An id absent here gets the automatic layout. */\n readonly layout: Readonly<Record<string, BoardPoint>>;\n}\n\n/** A problem at a JSON path (`items[2].html`), in words an agent can act on. */\nexport interface BoardIssue { readonly path: string; readonly message: string }\n\n/**\n * The outcome of reading a board. `newer` is a valid `board@N` with N above this module's version: the host should say so, not guess.\n * `invalid` covers bad JSON, a missing or unknown `format` (unversioned files are invalid), and every shape error.\n */\nexport type BoardParse =\n | { readonly kind: 'ok'; readonly board: Board }\n | { readonly kind: 'newer'; readonly format: string; readonly version: number }\n | { readonly kind: 'invalid'; readonly issues: readonly BoardIssue[] };\n\nconst MAX_ITEMS = 40, MAX_LINKS = 80, MAX_LAYOUT = 200, MAX_NOTE = 1000;\nconst ITEM_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{0,59}$/;\n/** Frame ids are `<item>/<screen>`, so layout keys may hold one slash. */\nconst FRAME_ID = /^[A-Za-z0-9][\\w./-]{0,119}$/;\nconst COORDINATE = 1e6;\nconst FORMAT_LIKE = /^board@(.*)$/;\n\ntype Obj = Record<string, unknown>;\nconst isObject = (value: unknown): value is Obj => value !== null && typeof value === 'object' && !Array.isArray(value);\n\n/** A workspace path relative to the board's folder, ending in `ending`: no scheme, no absolute path, no `..`. */\nexport function boardPathProblem(value: unknown, ending: '.maquette.json' | '.mockup.html'): string | undefined {\n if (typeof value !== 'string' || !value) return 'must be a non-empty path string';\n if (value.length > 200) return 'path is longer than 200 characters';\n if (/^[a-z]+:/i.test(value) || value.startsWith('/') || value.includes('\\\\')) return 'must be a relative path inside the board folder';\n if (value.split('/').includes('..')) return 'must not contain \"..\"';\n if (!value.toLowerCase().endsWith(ending)) return `must end with ${ending}`;\n return undefined;\n}\n\nfunction optionalText(value: unknown, path: string, max: number, issues: BoardIssue[]): string | undefined {\n if (value === undefined) return undefined;\n if (typeof value !== 'string') { issues.push({ path, message: 'must be a string' }); return undefined; }\n if (value.length > max) issues.push({ path, message: `is longer than ${max} characters` });\n return value.slice(0, max);\n}\n\nfunction optionalNumber(value: unknown, path: string, min: number, max: number, issues: BoardIssue[]): number | undefined {\n if (value === undefined) return undefined;\n if (typeof value !== 'number' || !Number.isFinite(value) || value < min || value > max) {\n issues.push({ path, message: `must be a number from ${min} to ${max}` });\n return undefined;\n }\n return value;\n}\n\nfunction unknownFields(value: Obj, allowed: readonly string[], path: string, issues: BoardIssue[]) {\n for (const key of Object.keys(value)) if (!allowed.includes(key)) issues.push({ path: path ? `${path}.${key}` : key, message: `unknown field (allowed: ${allowed.join(', ')})` });\n}\n\nfunction readItem(value: unknown, path: string, issues: BoardIssue[]): BoardItem | undefined {\n if (!isObject(value)) { issues.push({ path, message: 'must be an object' }); return undefined; }\n const id = value['id'];\n if (typeof id !== 'string' || !ITEM_ID.test(id)) { issues.push({ path: `${path}.id`, message: 'must be 1 to 60 letters, digits, \"-\" or \"_\", starting with a letter or digit' }); return undefined; }\n const kinds = ['maquette', 'html', 'note'].filter(kind => kind in value);\n if (kinds.length !== 1) { issues.push({ path, message: 'needs exactly one of \"maquette\", \"html\" or \"note\"' }); return undefined; }\n const kind = kinds[0];\n const title = optionalText(value['title'], `${path}.title`, 120, issues);\n if (kind === 'maquette') {\n unknownFields(value, ['id', 'maquette', 'title'], path, issues);\n const problem = boardPathProblem(value['maquette'], '.maquette.json');\n if (problem) { issues.push({ path: `${path}.maquette`, message: problem }); return undefined; }\n return { id, maquette: value['maquette'] as string, ...(title ? { title } : {}) };\n }\n if (kind === 'html') {\n unknownFields(value, ['id', 'html', 'title', 'device', 'width', 'height'], path, issues);\n const problem = boardPathProblem(value['html'], '.mockup.html');\n if (problem) { issues.push({ path: `${path}.html`, message: problem }); return undefined; }\n const device = value['device'] === undefined ? 'phone' : value['device'];\n if (device !== 'phone' && device !== 'desktop') { issues.push({ path: `${path}.device`, message: 'must be \"phone\" or \"desktop\"' }); return undefined; }\n const width = optionalNumber(value['width'], `${path}.width`, 240, 1600, issues);\n const height = optionalNumber(value['height'], `${path}.height`, 240, 2400, issues);\n return { id, html: value['html'] as string, device, ...(title ? { title } : {}), ...(width !== undefined ? { width } : {}), ...(height !== undefined ? { height } : {}) };\n }\n unknownFields(value, ['id', 'note', 'near'], path, issues);\n const note = value['note'];\n if (typeof note !== 'string' || !note.trim() || note.length > MAX_NOTE) { issues.push({ path: `${path}.note`, message: `must be 1 to ${MAX_NOTE} characters` }); return undefined; }\n const near = value['near'];\n if (near !== undefined && (typeof near !== 'string' || !FRAME_ID.test(near))) { issues.push({ path: `${path}.near`, message: 'must name a frame id, such as \"booking/sessions\"' }); return undefined; }\n return { id, note, ...(near !== undefined ? { near } : {}) };\n}\n\nfunction readLink(value: unknown, path: string, issues: BoardIssue[]): BoardLink | undefined {\n if (!isObject(value)) { issues.push({ path, message: 'must be an object' }); return undefined; }\n unknownFields(value, ['from', 'to', 'label'], path, issues);\n const from = value['from'], to = value['to'];\n for (const [field, end] of [['from', from], ['to', to]] as const) {\n if (typeof end !== 'string' || !FRAME_ID.test(end)) { issues.push({ path: `${path}.${field}`, message: 'must name a frame id, such as \"booking/confirmed\"' }); return undefined; }\n }\n const label = optionalText(value['label'], `${path}.label`, 80, issues);\n return { from: from as string, to: to as string, ...(label ? { label } : {}) };\n}\n\n/** Reads a board (its JSON text or an already parsed value). Never throws. */\nexport function parseBoard(input: unknown): BoardParse {\n let raw: unknown = input;\n if (typeof raw === 'string') {\n try { raw = JSON.parse(raw); } catch { return { kind: 'invalid', issues: [{ path: '', message: 'is not valid JSON' }] }; }\n }\n if (!isObject(raw)) return { kind: 'invalid', issues: [{ path: '', message: 'must be a JSON object' }] };\n const format = raw['format'];\n if (format === undefined) return { kind: 'invalid', issues: [{ path: 'format', message: `is missing: an unversioned board is invalid, write \"format\": \"${BOARD_FORMAT}\"` }] };\n if (typeof format !== 'string') return { kind: 'invalid', issues: [{ path: 'format', message: `must be a string such as \"${BOARD_FORMAT}\"` }] };\n const versioned = FORMAT_LIKE.exec(format)?.[1];\n const version = versioned !== undefined && /^[1-9]\\d{0,3}$/.test(versioned) ? Number(versioned) : undefined;\n if (version === undefined) return { kind: 'invalid', issues: [{ path: 'format', message: `is \"${format.slice(0, 40)}\", not a board format such as \"${BOARD_FORMAT}\"` }] };\n if (version > BOARD_FORMAT_VERSION) return { kind: 'newer', format, version };\n if (version !== BOARD_FORMAT_VERSION) return { kind: 'invalid', issues: [{ path: 'format', message: `\"${format}\" is not a known board version` }] };\n\n const issues: BoardIssue[] = [];\n unknownFields(raw, ['format', 'title', 'items', 'links', 'layout'], '', issues);\n const title = optionalText(raw['title'], 'title', 120, issues) ?? '';\n\n const items: BoardItem[] = [];\n const seen = new Set<string>();\n const list = raw['items'];\n if (!Array.isArray(list)) issues.push({ path: 'items', message: 'must be a list (use [] for an empty board)' });\n else if (list.length > MAX_ITEMS) issues.push({ path: 'items', message: `has ${list.length} items; at most ${MAX_ITEMS}` });\n else list.forEach((value, index) => {\n const item = readItem(value, `items[${index}]`, issues);\n if (!item) return;\n if (seen.has(item.id)) { issues.push({ path: `items[${index}].id`, message: `\"${item.id}\" is already used by another item` }); return; }\n seen.add(item.id);\n items.push(item);\n });\n\n const links: BoardLink[] = [];\n const linkList = raw['links'] ?? [];\n if (!Array.isArray(linkList)) issues.push({ path: 'links', message: 'must be a list' });\n else if (linkList.length > MAX_LINKS) issues.push({ path: 'links', message: `has ${linkList.length} links; at most ${MAX_LINKS}` });\n else linkList.forEach((value, index) => { const link = readLink(value, `links[${index}]`, issues); if (link) links.push(link); });\n\n const layout: Record<string, BoardPoint> = {};\n const layoutSpec = raw['layout'] ?? {};\n if (!isObject(layoutSpec)) issues.push({ path: 'layout', message: 'must be an object from frame or note id to {\"x\", \"y\"}' });\n else if (Object.keys(layoutSpec).length > MAX_LAYOUT) issues.push({ path: 'layout', message: `has more than ${MAX_LAYOUT} entries` });\n else for (const [id, value] of Object.entries(layoutSpec)) {\n const path = `layout.${id}`;\n if (!FRAME_ID.test(id)) { issues.push({ path, message: 'is not a frame or note id' }); continue; }\n if (!isObject(value)) { issues.push({ path, message: 'must be {\"x\": number, \"y\": number}' }); continue; }\n const x = optionalNumber(value['x'], `${path}.x`, -COORDINATE, COORDINATE, issues);\n const y = optionalNumber(value['y'], `${path}.y`, -COORDINATE, COORDINATE, issues);\n if (x !== undefined && y !== undefined) layout[id] = { x, y };\n }\n\n if (issues.length) return { kind: 'invalid', issues };\n return { kind: 'ok', board: { format: BOARD_FORMAT, title, items, links, layout } };\n}\n\n/** What a canvas draws. Ids are stable: `<item>/<screen>` for maquette frames, the item id for HTML frames and notes. */\nexport interface PlannedFrame { readonly id: string; readonly kind: 'maquette' | 'html'; readonly src: string; readonly screen: string; readonly title: string; readonly x: number; readonly y: number; readonly w: number; readonly h: number }\nexport interface PlannedArrow { readonly id: string; readonly from: string; readonly to: string; readonly label: string }\nexport interface PlannedNote { readonly id: string; readonly text: string; readonly x: number; readonly y: number }\nexport interface BoardPlan {\n readonly frames: readonly PlannedFrame[];\n readonly arrows: readonly PlannedArrow[];\n readonly notes: readonly PlannedNote[];\n /** Referenced files that are missing or unreadable, and links that name no frame. The plan still draws everything else. */\n readonly problems: readonly BoardIssue[];\n}\n\nconst SIZE = { phone: { w: 390, h: 780 }, desktop: { w: 1024, h: 720 } } as const;\nconst GAP = 160;\n\n/**\n * Lays the board out left to right. `files` maps each referenced path to its text: a maquette is needed for its screens and goto links.\n * A frame with a layout entry keeps that position; the automatic frames go to the right of everything placed before them.\n */\nexport function planBoard(board: Board, files: ReadonlyMap<string, string>): BoardPlan {\n const frames: PlannedFrame[] = [], notes: PlannedNote[] = [], problems: BoardIssue[] = [];\n const maquettes = new Map<string, Maquette>();\n const arrowLinks: { from: string; to: string; label: string; source: string }[] = [];\n let cursor = 0;\n // Automatic frames go to the right of everything placed so far, so a frame a person moved never overlaps one the layout did not place.\n const place = (id: string, w: number): BoardPoint => {\n const at = board.layout[id] ?? { x: cursor, y: 0 };\n cursor = Math.max(cursor, at.x + w + GAP);\n return at;\n };\n\n for (const item of board.items) {\n if ('maquette' in item) {\n const text = files.get(item.maquette);\n const parsed = text === undefined ? undefined : parseMaquette(text);\n if (!parsed || parsed.kind !== 'ok') {\n problems.push({ path: `items.${item.id}`, message: text === undefined ? `${item.maquette} is missing` : `${item.maquette} is not a readable maquette` });\n continue;\n }\n const maquette = parsed.maquette;\n maquettes.set(item.id, maquette);\n const size = SIZE[maquette.device === 'desktop' ? 'desktop' : 'phone'];\n for (const screen of maquette.screens) {\n const id = `${item.id}/${screen.id}`;\n const at = place(id, size.w);\n frames.push({ id, kind: 'maquette', src: item.maquette, screen: screen.id, title: screen.title, x: at.x, y: at.y, ...size });\n }\n for (const screen of maquette.screens) for (const target of linksOf(screen.blocks))\n arrowLinks.push({ from: `${item.id}/${screen.id}`, to: `${item.id}/${target}`, label: '', source: `${item.id}/${screen.id}` });\n } else if ('html' in item) {\n const device = SIZE[item.device];\n const size = { w: item.width ?? device.w, h: item.height ?? device.h };\n const at = place(item.id, size.w);\n frames.push({ id: item.id, kind: 'html', src: item.html, screen: '', title: item.title ?? item.html, x: at.x, y: at.y, ...size });\n if (!files.has(item.html)) problems.push({ path: `items.${item.id}`, message: `${item.html} is missing` });\n }\n }\n\n const frameIds = new Set(frames.map(frame => frame.id));\n for (const link of board.links) arrowLinks.push({ from: link.from, to: link.to, label: link.label ?? '', source: `links.${link.from}->${link.to}` });\n const arrows: PlannedArrow[] = [];\n for (const link of arrowLinks) {\n if (frameIds.has(link.from) && frameIds.has(link.to)) arrows.push({ id: `${link.from}->${link.to}`, from: link.from, to: link.to, label: link.label });\n else problems.push({ path: link.source, message: `${frameIds.has(link.from) ? link.to : link.from} is not a frame on this board` });\n }\n\n for (const item of board.items) if ('note' in item) {\n const near = item.near === undefined ? undefined : frames.find(frame => frame.id === item.near);\n if (item.near !== undefined && !near) problems.push({ path: `items.${item.id}.near`, message: `${item.near} is not a frame on this board` });\n const at = board.layout[item.id] ?? (near ? { x: near.x + near.w - 120, y: near.y - 260 } : { x: cursor, y: 0 });\n notes.push({ id: item.id, text: item.note, x: at.x, y: at.y });\n }\n return { frames, arrows, notes, problems };\n}\n\n/** Frame and note positions, and the notes after people edited them on the canvas. */\nexport interface CanvasEdits {\n readonly positions: Readonly<Record<string, BoardPoint>>;\n /** Every note that remains. A note missing here was deleted. Its `near` is kept from the board. */\n readonly notes: readonly { readonly id: string; readonly text: string }[];\n}\n\nexport type BoardEditResult = { readonly kind: 'ok'; readonly board: Board } | { readonly kind: 'invalid'; readonly issues: readonly BoardIssue[] };\n\n/**\n * Writes people's canvas edits back into a board, as `board@1`: `layout` takes the new positions and the note items take the notes.\n * Maquette, HTML and link entries are never changed. The result is valid by construction and is checked as if it were read from a file.\n */\nexport function applyCanvasEdits(board: Board, edits: CanvasEdits): BoardEditResult {\n const issues: BoardIssue[] = [];\n const kept = board.items.filter(item => !('note' in item));\n const keptIds = new Set(kept.map(item => item.id));\n const near = new Map(board.items.flatMap(item => 'note' in item ? [[item.id, item.near] as const] : []));\n const notes: BoardItem[] = [];\n edits.notes.forEach((note, index) => {\n if (!ITEM_ID.test(note.id) || keptIds.has(note.id)) { issues.push({ path: `notes[${index}].id`, message: `\"${note.id}\" is not a free note id` }); return; }\n const text = note.text.trim();\n if (!text) { issues.push({ path: `notes[${index}].text`, message: 'is empty' }); return; }\n const attached = near.get(note.id);\n notes.push({ id: note.id, note: text.slice(0, MAX_NOTE), ...(attached !== undefined ? { near: attached } : {}) });\n });\n const layout: Record<string, BoardPoint> = {};\n for (const [id, at] of Object.entries(edits.positions)) {\n if (!FRAME_ID.test(id)) { issues.push({ path: `positions.${id}`, message: 'is not a frame or note id' }); continue; }\n if (!Number.isFinite(at.x) || !Number.isFinite(at.y) || Math.abs(at.x) > COORDINATE || Math.abs(at.y) > COORDINATE) { issues.push({ path: `positions.${id}`, message: 'is not a finite position' }); continue; }\n layout[id] = { x: Math.round(at.x), y: Math.round(at.y) };\n }\n if (Object.keys(layout).length > MAX_LAYOUT) issues.push({ path: 'positions', message: `has more than ${MAX_LAYOUT} entries` });\n if (kept.length + notes.length > MAX_ITEMS) issues.push({ path: 'notes', message: `would make more than ${MAX_ITEMS} items` });\n if (issues.length) return { kind: 'invalid', issues };\n return { kind: 'ok', board: { ...board, items: [...kept, ...notes], layout } };\n}\n\n/** The board's file text: `board@1` JSON, two-space indented, with a trailing newline. */\nexport const serializeBoard = (board: Board): string => `${JSON.stringify(board, null, 2)}\\n`;\n",
|
|
26
|
+
"type": "registry:lib",
|
|
27
|
+
"target": "components/board-viewer/board.ts"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"path": "registry/board-viewer/board-viewer.tsx",
|
|
31
|
+
"content": "'use client';\n\nimport { lazy, Suspense, useEffect, useMemo, useState, useSyncExternalStore } from 'react';\nimport { LayoutDashboardIcon } from 'lucide-react';\nimport type { ResourceClient, ResourceLocator } from '@hachej/boring-files';\nimport { createTextBuffer } from '@hachej/boring-ui-kit/text-buffer';\nimport type { TextBuffer } from '@hachej/boring-ui-kit/text-buffer';\nimport { Button } from '@/components/ui/button';\nimport { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';\nimport type { ArtifactKind } from '../pi-chat/artifact';\nimport type { BoardHostProps, BoardViewerProps } from './board-props';\nexport type { BoardHostProps, BoardViewerProps } from './board-props';\nimport { parseBoard } from './board';\n\nexport { parseBoard, planBoard, applyCanvasEdits, serializeBoard } from './board';\nexport type { Board, BoardPlan } from './board';\nconst Canvas = lazy(() => import('./board-canvas'));\n\nconst readText = (snapshot: Parameters<Parameters<typeof createTextBuffer>[0]['readText']>[0]) => {\n const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(snapshot.bytes);\n if (parseBoard(text).kind !== 'ok') throw new Error('Unreadable board');\n return text;\n};\n\n/** An opt-in artifact kind. Its client and authenticated receipt identity belong to the host's selected workspace. */\nexport function createBoardKind(host: BoardHostProps): ArtifactKind {\n return { match: target => /\\.board\\.json$/i.test(target.resource.path), label: 'Board', icon: LayoutDashboardIcon,\n renderer: ({ artifact, text, revision, pinned }) => <BoardViewer {...host} target={artifact.target} text={text} revision={revision} pinned={pinned} /> };\n}\n\nexport function BoardViewer(props: BoardViewerProps) {\n const parsed = useMemo(() => parseBoard(props.text), [props.text]);\n if (parsed.kind !== 'ok') return <Alert role=\"alert\"><AlertTitle>{parsed.kind === 'newer' ? 'Newer board format' : 'This board cannot be read'}</AlertTitle>\n <AlertDescription>{parsed.kind === 'newer' ? parsed.format : parsed.issues.map(issue => `${issue.path}: ${issue.message}`).join('; ')}</AlertDescription></Alert>;\n const key = JSON.stringify([props.target, props.identity, props.pinned]);\n return <BoardSession key={key} {...props} />;\n}\n\nfunction BoardSession({ text, revision, target, pinned, client, identity, ...host }: BoardViewerProps) {\n const [buffer, setBuffer] = useState<TextBuffer>();\n useEffect(() => {\n const next = createTextBuffer({ client, identity, instanceId: 'board', epoch: revision, readOnly: pinned || !client.publish,\n source: { kind: 'saved', snapshot: { ref: { ...target, revision }, bytes: new TextEncoder().encode(text), mediaType: 'application/json' } },\n mediaType: 'application/json', readText });\n setBuffer(next);\n return () => next.dispose();\n // A changed revision refreshes the existing buffer below; it must not replace a dirty canvas.\n }, [client, identity, target, pinned]); // eslint-disable-line react-hooks/exhaustive-deps\n useEffect(() => {\n if (!buffer || pinned) return;\n const state = buffer.getSnapshot();\n if (state.base.kind === 'revision' && state.base.target.revision !== revision) void buffer.refresh(false);\n }, [buffer, text, revision, pinned]);\n return buffer ? <BoardDocument {...host} buffer={buffer} client={client} target={target} pinned={pinned} /> : <p role=\"status\">Loading board…</p>;\n}\n\nfunction BoardDocument({ buffer, client, target, pinned, ...host }: Omit<BoardHostProps, 'identity'> & { readonly buffer: TextBuffer; readonly target: ResourceLocator; readonly pinned: boolean }) {\n const state = useSyncExternalStore(buffer.subscribe, buffer.getSnapshot, buffer.getSnapshot);\n const parsed = useMemo(() => parseBoard(state.text), [state.text]);\n // Layout-only edits must not fetch linked files or remount the projection.\n const sources = parsed.kind === 'ok' ? JSON.stringify([...new Set(parsed.board.items.flatMap(item => 'maquette' in item ? [item.maquette] : 'html' in item ? [item.html] : []))]) : '[]';\n const [loaded, setLoaded] = useState<{ readonly sources: string; readonly files: ReadonlyMap<string, string> }>();\n const [error, setError] = useState<string>();\n useEffect(() => {\n let live = true;\n setError(undefined);\n const paths: string[] = JSON.parse(sources);\n const folder = target.resource.path.slice(0, target.resource.path.lastIndexOf('/') + 1);\n void Promise.all(paths.map(async relative => {\n const result = await client.read({ target: { ...target, resource: { ...target.resource, path: folder + relative } }, revision: { kind: 'latest' } });\n if (result.kind !== 'available') throw new Error('Linked files are unavailable');\n return [relative, new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(result.snapshot.bytes)] as const;\n })).then(entries => { if (live) setLoaded({ sources, files: new Map(entries) }); }, () => { if (live) setError('Linked files are unavailable. Reload the board to retry.'); });\n return () => { live = false; };\n }, [client, target, sources]);\n const outcome = state.save.kind === 'settled' ? state.save.result : undefined;\n const conflict = state.remote !== null || outcome?.kind === 'conflict';\n const unknown = outcome?.kind === 'unknown';\n const pending = state.save.kind === 'pending';\n const run = (action: () => Promise<unknown>) => { void action().catch(() => setError('Board operation failed. Your local edits are retained.')); };\n return <section className=\"flex h-full min-h-0 flex-col\" data-boring=\"board-viewer\">\n <div role=\"status\" data-testid=\"board-save\" className=\"text-muted-foreground\">{pinned ? 'Read only' : pending ? 'Saving…' : unknown ? 'Save unconfirmed' : conflict ? 'Changed elsewhere' : outcome && outcome.kind !== 'saved' ? 'Save failed' : state.dirty ? 'Unsaved changes' : 'Saved'}</div>\n {conflict && <Alert role=\"alert\" data-testid=\"board-conflict\"><AlertTitle>Changed elsewhere</AlertTitle><AlertDescription>Your local edits have been kept.</AlertDescription>\n <Button disabled={pending || unknown} onClick={() => run(() => buffer.refresh(true))}>Discard local changes and reload</Button></Alert>}\n {unknown && <Alert role=\"alert\"><AlertTitle>Save unconfirmed</AlertTitle><AlertDescription>Your local edits have been kept. Do not repeat the save.</AlertDescription>\n <Button onClick={() => run(buffer.reconcile)}>Check save outcome</Button><Button onClick={() => run(buffer.abandon)}>Keep draft and refresh</Button></Alert>}\n {error && <Alert role=\"alert\"><AlertTitle>{error}</AlertTitle></Alert>}\n {!error && loaded?.sources === sources && parsed.kind === 'ok' && <Suspense fallback={<p role=\"status\">Loading board…</p>}>\n <Canvas {...host} buffer={buffer} files={loaded.files} />\n </Suspense>}\n </section>;\n}\n",
|
|
32
|
+
"type": "registry:component",
|
|
33
|
+
"target": "components/board-viewer/board-viewer.tsx"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"path": "registry/board-viewer/board-canvas.tsx",
|
|
37
|
+
"content": "'use client';\n\n// Ported from spike/canvas-board. Native shapes are a disposable projection of board@1, never a saved tldraw store.\nimport { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';\nimport { HTMLContainer, Rectangle2d, ShapeUtil, T, createShapeId, renderPlaintextFromRichText, toRichText, useValue } from 'tldraw';\nimport type { Editor, TLBaseShape } from 'tldraw';\nimport { CanvasProjection } from '@hachej/boring-ui-kit/canvas-editor';\nimport type { TextBuffer } from '@hachej/boring-ui-kit/text-buffer';\nimport { HandIcon, MousePointer2Icon, StickyNoteIcon, MaximizeIcon, Undo2Icon, Redo2Icon, MessageSquarePlusIcon } from 'lucide-react';\nimport { Button } from '@/components/ui/button';\nimport { Card, CardContent } from '@/components/ui/card';\nimport { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';\nimport { MockupViewer } from '../mockup-viewer/mockup-viewer';\nimport { parseMaquette } from '../maquette-viewer/maquette';\nimport { MaquetteScreenView } from '../maquette-viewer/maquette-blocks';\nimport { defaultMaquetteLabels } from '../maquette-viewer/maquette-labels';\nimport { applyCanvasEdits, parseBoard, planBoard, serializeBoard } from './board';\nimport type { Board } from './board';\nimport type { BoardHostProps } from './board-props';\n\nconst FRAME = 'board-frame';\ntype FrameProps = { w: number; h: number; kind: string; src: string; screen: string; title: string; frameId: string };\ntype BoardFrame = TLBaseShape<typeof FRAME, FrameProps>;\ndeclare module 'tldraw' { interface TLGlobalShapePropsMap { 'board-frame': FrameProps } }\ninterface BoardContext {\n readonly files: ReadonlyMap<string, string>;\n readonly comments: BoardHostProps['comments'];\n readonly path: string;\n readonly go: (frameId: string, screen: string) => void;\n}\nconst Context = createContext<BoardContext>({ files: new Map(), comments: undefined, path: '', go: () => {} });\nconst frameId = (id: string) => createShapeId(`frame-${id}`);\n\nfunction FrameContent({ shape, editor }: { readonly shape: BoardFrame; readonly editor: Editor }) {\n const board = useContext(Context);\n const editing = useValue('board frame editing', () => editor.getEditingShapeId() === shape.id, [editor, shape.id]);\n const { src, kind, screen, frameId: id, title } = shape.props;\n const text = board.files.get(src) ?? '';\n const parsed = useMemo(() => kind === 'maquette' ? parseMaquette(text) : undefined, [kind, text]);\n const shown = parsed?.kind === 'ok' ? parsed.maquette.screens.find(item => item.id === screen) : undefined;\n return <HTMLContainer className=\"flex flex-col\" style={{ width: shape.props.w, height: shape.props.h }}>\n <div className=\"pointer-events-none absolute -top-8 left-0 whitespace-nowrap text-muted-foreground\" data-testid=\"board-frame-title\">{title}</div>\n <Card className=\"min-h-0 flex-1 overflow-hidden\" data-testid=\"board-frame\" data-frame={id}>\n <CardContent className=\"min-h-0 flex-1 overflow-hidden\" style={{ pointerEvents: editing || board.comments?.active ? 'auto' : 'none' }}>\n {kind === 'html' ? <MockupViewer text={text} path={src} comments={board.comments} />\n : shown && parsed?.kind === 'ok' ? <MaquetteScreenView maquette={parsed.maquette} screen={shown} labels={defaultMaquetteLabels}\n links={{ go: target => board.go(id, target), hint: target => `Opens ${target}`,\n target: block => `board:${board.path}#frame=${id}&block=${block.id}`,\n pins: block => board.comments?.pins.filter(pin => pin.target === `board:${board.path}#frame=${id}&block=${block.id}`).map(pin => pin.number) ?? [] }} /> : <p role=\"alert\">Unreadable maquette {src}</p>}\n </CardContent>\n </Card>\n </HTMLContainer>;\n}\n\nclass BoardFrameUtil extends ShapeUtil<BoardFrame> {\n static override type = FRAME;\n static override props = { w: T.number, h: T.number, kind: T.string, src: T.string, screen: T.string, title: T.string, frameId: T.string };\n override getDefaultProps(): FrameProps { return { w: 390, h: 780, kind: 'maquette', src: '', screen: '', title: '', frameId: '' }; }\n override canEdit() { return !this.editor.getIsReadonly(); }\n override canResize() { return false; }\n override canBind() { return true; }\n override hideRotateHandle() { return true; }\n override getGeometry(shape: BoardFrame) { return new Rectangle2d({ width: shape.props.w, height: shape.props.h, isFilled: true }); }\n override component(shape: BoardFrame) { return <FrameContent shape={shape} editor={this.editor} />; }\n override getIndicatorPath(shape: BoardFrame) { const path = new Path2D(); path.rect(0, 0, shape.props.w, shape.props.h); return path; }\n}\nconst shapeUtils = [BoardFrameUtil];\n\nfunction project(editor: Editor, board: Board, files: ReadonlyMap<string, string>) {\n const plan = planBoard(board, files);\n const readOnly = editor.getIsReadonly();\n editor.updateInstanceState({ isReadonly: false });\n try { editor.store.mergeRemoteChanges(() => editor.run(() => {\n editor.deleteShapes([...editor.getCurrentPageShapeIds()]);\n editor.createShapes<BoardFrame>(plan.frames.map(frame => ({ id: frameId(frame.id), type: FRAME, x: frame.x, y: frame.y,\n props: { w: frame.w, h: frame.h, kind: frame.kind, src: frame.src, screen: frame.screen, title: frame.title, frameId: frame.id } })));\n const at = new Map(plan.frames.map(frame => [frame.id, frame]));\n // Duplicate goto targets produce one arrow per pair.\n for (const arrow of new Map(plan.arrows.map(arrow => [arrow.id, arrow])).values()) {\n const id = createShapeId(`arrow-${arrow.id}`);\n const backward = (at.get(arrow.from)?.x ?? 0) > (at.get(arrow.to)?.x ?? 0);\n editor.createShape({ id, type: 'arrow', isLocked: true, props: { start: { x: 0, y: 0 }, end: { x: 1, y: 0 }, bend: backward ? -260 : 0,\n richText: toRichText(arrow.label), dash: backward ? 'dashed' : 'solid' } });\n editor.createBindings(([['start', arrow.from], ['end', arrow.to]] as const).map(([terminal, frame]) => ({ type: 'arrow', fromId: id, toId: frameId(frame),\n props: { terminal, normalizedAnchor: { x: 0.5, y: 0.5 }, isExact: false, isPrecise: false, snap: 'none' } })));\n }\n editor.createShapes(plan.notes.map(note => ({ id: createShapeId(`note-${note.id}`), type: 'note', x: note.x, y: note.y, props: { richText: toRichText(note.text) } })));\n }, { history: 'ignore', ignoreShapeLock: true })); }\n finally { editor.updateInstanceState({ isReadonly: readOnly }); }\n return plan;\n}\n\nfunction capture(editor: Editor, board: Board): Board | undefined {\n const positions: Record<string, { x: number; y: number }> = {};\n const notes: { id: string; text: string }[] = [];\n for (const shape of editor.getCurrentPageShapes()) {\n if (editor.isShapeOfType<BoardFrame>(shape, FRAME)) positions[shape.props.frameId] = { x: shape.x, y: shape.y };\n if (editor.isShapeOfType(shape, 'note')) {\n const id = shape.id.startsWith('shape:note-') ? shape.id.slice('shape:note-'.length) : `note-${shape.id.slice(6)}`;\n positions[id] = { x: shape.x, y: shape.y };\n notes.push({ id, text: renderPlaintextFromRichText(editor, shape.props.richText) });\n }\n }\n const result = applyCanvasEdits(board, { positions, notes });\n return result.kind === 'ok' ? result.board : undefined;\n}\n\nexport default function BoardCanvas({ buffer, files, assetUrls, licenseKey, onEditor, comments }: Omit<BoardHostProps, 'identity' | 'client'> & { readonly buffer: TextBuffer; readonly files: ReadonlyMap<string, string> }) {\n const state = useSyncExternalStore(buffer.subscribe, buffer.getSnapshot, buffer.getSnapshot);\n const [phone, setPhone] = useState(() => typeof matchMedia === 'function' && matchMedia('(max-width: 639px), (pointer: coarse)').matches);\n useEffect(() => {\n const media = matchMedia('(max-width: 639px), (pointer: coarse)');\n const changed = () => setPhone(media.matches);\n media.addEventListener('change', changed);\n return () => media.removeEventListener('change', changed);\n }, []);\n const editorRef = useRef<Editor | null>(null);\n const projected = useRef(state.text);\n const [editor, setEditor] = useState<Editor | null>(null);\n const blocked = state.readOnly || phone;\n const mode = useRef(blocked); mode.current = blocked;\n const [problem, setProblem] = useState<string>();\n const attach = useCallback((native: Editor) => {\n editorRef.current = native;\n const parsed = parseBoard(buffer.getSnapshot().text);\n if (parsed.kind !== 'ok') return;\n projected.current = buffer.getSnapshot().text;\n const plan = project(native, parsed.board, files);\n native.updateInstanceState({ isReadonly: mode.current });\n native.setCurrentTool(mode.current ? 'hand' : 'select');\n const fit = () => {\n native.updateViewportScreenBounds(native.getContainer());\n const first = plan.frames[0] && native.getShapePageBounds(frameId(plan.frames[0].id));\n if (mode.current && first) native.zoomToBounds(first, { animation: { duration: 0 }, inset: 24 });\n else native.zoomToFit({ animation: { duration: 0 } });\n };\n fit();\n const timer = setTimeout(fit, 400);\n // Frame identity/content and goto arrows come only from board@1, never native delete/paste/rotate operations.\n const stopDelete = native.sideEffects.registerBeforeDeleteHandler('shape', (shape, source) => { if (source === 'user' && (shape.type === FRAME || shape.type === 'arrow')) return false; });\n const stopUpdate = native.sideEffects.registerBeforeChangeHandler('shape', (previous, next, source) => source === 'user' && previous.type === FRAME ? { ...previous, x: next.x, y: next.y } : next);\n const stop = native.store.listen(() => {\n if (mode.current) return;\n const current = parseBoard(buffer.getSnapshot().text);\n if (current.kind !== 'ok') return;\n const next = capture(native, current.board);\n if (!next) { setProblem('Finish the note before saving.'); return; }\n setProblem(undefined);\n projected.current = serializeBoard(next);\n buffer.edit(projected.current);\n }, { source: 'user', scope: 'document' });\n setEditor(native); onEditor?.(native);\n return () => { clearTimeout(timer); stop(); stopDelete(); stopUpdate(); editorRef.current = null; setEditor(null); onEditor?.(null); };\n }, [buffer, files, onEditor]);\n useEffect(() => {\n if (!editor) return;\n editor.updateInstanceState({ isReadonly: blocked });\n if (blocked) editor.setCurrentTool('hand');\n }, [editor, blocked]);\n useEffect(() => {\n if (!editor || state.text === projected.current) return;\n const parsed = parseBoard(state.text);\n if (parsed.kind === 'ok') { project(editor, parsed.board, files); projected.current = state.text; }\n }, [editor, state.text, files]);\n useEffect(() => {\n if (blocked || problem || !state.dirty || state.remote || state.save.kind === 'pending' || state.save.kind === 'settled' && state.save.result.kind !== 'saved') return;\n const timer = setTimeout(() => { void buffer.flush(buffer.selection()); }, 600);\n return () => clearTimeout(timer);\n }, [buffer, state, blocked, problem]);\n const context = useMemo<BoardContext>(() => ({ files, comments, path: state.base.target.resource.path, go: (id, screen) => {\n const native = editorRef.current;\n const target = native?.getShape(frameId(`${id.split('/')[0]}/${screen}`));\n const bounds = target && native?.getShapePageBounds(target);\n if (native && bounds) native.zoomToBounds(bounds, { animation: { duration: 200 }, inset: 24 });\n } }), [files, comments, state.base.target.resource.path]);\n return <Context.Provider value={context}><div data-testid=\"board\" data-phone={phone} className=\"relative min-h-0 flex-1 overscroll-contain\" style={{ touchAction: 'none' }}>\n <CanvasProjection assetUrls={assetUrls} licenseKey={licenseKey} hideUi shapeUtils={shapeUtils} onMount={attach} />\n {editor && <BoardTools editor={editor} blocked={blocked} comments={comments} />}\n {problem && <p role=\"alert\" className=\"absolute bottom-2 left-2 text-muted-foreground\">{problem}</p>}\n </div></Context.Provider>;\n}\n\nfunction BoardTools({ editor, blocked, comments }: { readonly editor: Editor; readonly blocked: boolean; readonly comments: BoardHostProps['comments'] }) {\n const native = useValue('board toolbar', () => ({ undo: editor.getCanUndo(), redo: editor.getCanRedo() }), [editor]);\n const tools = [\n ...(!blocked ? [{ label: 'Select frames', icon: MousePointer2Icon, action: () => editor.setCurrentTool('select') }, { label: 'Pan board', icon: HandIcon, action: () => editor.setCurrentTool('hand') },\n { label: 'Add note', icon: StickyNoteIcon, action: () => editor.setCurrentTool('note') }, { label: 'Undo', icon: Undo2Icon, action: () => editor.undo(), disabled: !native.undo },\n { label: 'Redo', icon: Redo2Icon, action: () => editor.redo(), disabled: !native.redo }] : []),\n { label: 'Fit board', icon: MaximizeIcon, action: () => editor.zoomToFit() },\n ...(comments ? [{ label: 'Comment on board', icon: MessageSquarePlusIcon, action: comments.point }] : []),\n ];\n return <nav aria-label=\"Board tools\" data-feedback-ignore=\"\" className=\"absolute top-2 left-2 z-[300] flex flex-wrap gap-1\">\n {tools.map(tool => <Tooltip key={tool.label}><TooltipTrigger render={<Button size=\"icon\" aria-label={tool.label} disabled={'disabled' in tool && tool.disabled} onClick={tool.action} />}><tool.icon aria-hidden=\"true\" /></TooltipTrigger><TooltipContent>{tool.label}</TooltipContent></Tooltip>)}\n </nav>;\n}\n",
|
|
38
|
+
"type": "registry:component",
|
|
39
|
+
"target": "components/board-viewer/board-canvas.tsx"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"path": "registry/board-viewer/board-props.ts",
|
|
43
|
+
"content": "import type { ResourceClient, ResourceLocator } from '@hachej/boring-files';\nimport type { TextBufferState } from '@hachej/boring-ui-kit/text-buffer';\nimport type { CanvasAssetUrls } from '@hachej/boring-ui-kit/canvas-editor';\nimport type { Editor } from 'tldraw';\nimport type { MockupComments } from '../mockup-viewer/mockup-viewer';\n\nexport interface BoardHostProps {\n readonly client: ResourceClient;\n readonly identity: TextBufferState['identity'];\n readonly assetUrls: CanvasAssetUrls;\n readonly licenseKey?: string | undefined;\n /** Existing host point-comments binding; structured board pin delivery is installed separately. */\n readonly comments?: MockupComments | undefined;\n /** The editor and its store are a disposable projection, never the saved document. */\n readonly onEditor?: ((editor: Editor | null) => void) | undefined;\n}\nexport interface BoardViewerProps extends BoardHostProps {\n readonly target: ResourceLocator;\n readonly text: string;\n readonly revision: string;\n readonly pinned: boolean;\n}\n",
|
|
44
|
+
"type": "registry:lib",
|
|
45
|
+
"target": "components/board-viewer/board-props.ts"
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
"type": "registry:block"
|
|
49
|
+
}
|
package/r/button.json
CHANGED