@hachej/boring-ui-kit 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/README.md +51 -1
  2. package/bin/boring-ui-blocks.mjs +29 -0
  3. package/dist/canvas-editor-native.d.ts +8 -1
  4. package/dist/canvas-editor-native.js +13 -7
  5. package/dist/canvas-editor.d.ts +4 -2
  6. package/dist/canvas-editor.js +5 -0
  7. package/dist/chat-outbox.d.ts +18 -0
  8. package/dist/chat-outbox.js +12 -0
  9. package/dist/file-tree-view.d.ts +29 -1
  10. package/dist/file-tree-view.js +74 -7
  11. package/dist/mark-store.d.ts +38 -0
  12. package/dist/mark-store.js +84 -0
  13. package/dist/markdown-editor.d.ts +6 -0
  14. package/dist/markdown-editor.js +17 -2
  15. package/dist/markdown-highlight-layer.d.ts +26 -0
  16. package/dist/markdown-highlight-layer.js +107 -0
  17. package/dist/markdown-quote.d.ts +23 -0
  18. package/dist/markdown-quote.js +39 -0
  19. package/dist/native-chat.d.ts +21 -0
  20. package/dist/native-chat.js +111 -7
  21. package/dist/remote-chat.js +16 -14
  22. package/dist/shared-chat/error.d.ts +10 -0
  23. package/dist/shared-chat/error.js +13 -0
  24. package/dist/shared-chat/fake.d.ts +39 -0
  25. package/dist/shared-chat/fake.js +90 -0
  26. package/dist/shared-chat/index.d.ts +51 -0
  27. package/dist/shared-chat/index.js +255 -0
  28. package/dist/shared-chat/socket.d.ts +20 -0
  29. package/dist/shared-chat/socket.js +114 -0
  30. package/dist/shared-chat/source.d.ts +117 -0
  31. package/dist/shared-chat/source.js +6 -0
  32. package/package.json +15 -3
  33. package/r/agent-target.json +36 -0
  34. package/r/app-shell.json +26 -11
  35. package/r/board-viewer.json +49 -0
  36. package/r/button.json +1 -1
  37. package/r/diagram-viewer.json +42 -0
  38. package/r/dock.json +1 -1
  39. package/r/documents.json +11 -11
  40. package/r/feedback-preview.json +1 -1
  41. package/r/feedback.json +6 -6
  42. package/r/file-tree.json +7 -6
  43. package/r/highlights.json +29 -0
  44. package/r/html-viewer.json +3 -3
  45. package/r/maquette-viewer.json +10 -13
  46. package/r/markdown-editor.json +3 -3
  47. package/r/mockup-viewer.json +45 -0
  48. package/r/pi-ambient.json +7 -7
  49. package/r/pi-app.json +21 -18
  50. package/r/pi-chat.json +28 -20
  51. package/r/pi-workspace.json +4 -4
  52. package/r/provider-setup.json +2 -2
  53. package/r/record-viewer.json +40 -0
  54. package/r/registry.json +449 -83
  55. package/r/requests-board.json +41 -0
  56. package/r/shared-chat.json +79 -0
  57. package/r/task-list-viewer.json +2 -2
  58. package/r/theme.json +15 -0
  59. package/r/version-history.json +3 -3
  60. package/r/viewers.json +16 -10
package/README.md CHANGED
@@ -68,8 +68,50 @@ The default schema includes headings, lists, task lists, quotes, code and text m
68
68
 
69
69
  `test/packages/ui-markdown-editor.test.mjs`, `test/packages/ui-markdown-navigation.test.mjs` and `test/packages/ui-markdown-mounted.test.mjs` and `test/packages/ui-markdown-proposals.test.mjs` drive the real React/Tiptap renderer, DOM selections and SQLite provider. `npm run test:editor-consumer` requires `npm_config_cache` pointing to a writable cache with the pinned archives; it installs actual package tarballs, checks declarations with `skipLibCheck: false`, drives the controls and bundles for browsers without Pi or server filesystem imports. These checks do not establish browser layout, caret, IME or accessibility qualification. Chromium journeys remain pending.
70
70
 
71
+ ## Document marks
72
+
73
+ `@hachej/boring-ui-kit/markdown-quote` exports `captureMarkdownQuote` and
74
+ `markdownQuoteResolution`: pure exact-only anchors over canonical Markdown source.
75
+ `@hachej/boring-ui-kit/mark-store` exports the kit's sidecar store, independently of
76
+ React, Tiptap and Pi execution:
77
+
78
+ ```ts
79
+ import { createMarkStore } from '@hachej/boring-ui-kit/mark-store';
80
+ import { captureMarkdownQuote } from '@hachej/boring-ui-kit/markdown-quote';
81
+
82
+ const store = await createMarkStore({ client, identity }, locator);
83
+ const capture = captureMarkdownQuote(source, { start: 0, end: 12 });
84
+ if (capture.kind === 'captured') {
85
+ await store.add({ id: 'mark-1', anchor: capture.anchor, colour: 'accent',
86
+ note: 'A fictional comment.', author: identity.principalId, time: new Date().toISOString() });
87
+ }
88
+ await store.edit('mark-1', { note: 'Revised fictional comment.' });
89
+ await store.remove('mark-1');
90
+ store.dispose(); // Does not close the borrowed resource client/provider.
91
+ ```
92
+
93
+ Use the same workspace `client` as the document. `identity` is expected receipt
94
+ metadata, never browser authentication. The store reads `<doc>.marks.json` in the
95
+ same view and writes it only through the existing conditional publication path.
96
+ `getSnapshot`/`subscribe` provide immutable marks, dirty/lifecycle and save state;
97
+ mutations return the exact existing `SaveResult`. A stale tab retains its local
98
+ marks and returns conflict; `reload()` explicitly discards them. Lost replies
99
+ require `reconcile()` on the original operation. No auto-merge or blind retry is
100
+ performed. The host owns policy, attribution and recovery after missing receipts.
101
+ The [owner](../../docs/architecture/FEEDBACK.md#markdown-quotes-and-document-marks)
102
+ defines quote/context matching and conservative rich-projection limits.
103
+
104
+ The existing `MarkdownEditor` accepts `highlights`, `onQuoteSelection` and
105
+ `onHighlightActivate`. Native decorations leave the document buffer clean. Rich
106
+ capture refuses anything outside one proven literal text leaf; source selection
107
+ remains available for syntax, escapes or normalization. The `highlights` registry
108
+ block supplies context actions, comments and an unplaced-mark tray, creating a
109
+ store by default or borrowing the same `MarkStore` interface from an app.
110
+
71
111
  ## Optional native chat
72
112
 
113
+ The `/native-chat` entry also owns the neutral `ChatController`/`ChatSnapshot` renderer types. Concrete native controllers retain their full APIs. Optional multi-party data (`ChannelSource`, `createSharedChat`, socket/fake sources and state types) stays exclusively at `/shared-chat`, not the headless root. Its presentation is the optional [`shared-chat` registry item](../../registry/README.md#regular-chat-or-shared-chat); regular `pi-chat` has no imports from it.
114
+
73
115
  `createNativeChatController` from `@hachej/boring-ui-kit/native-chat` borrows an actual native `Conversation` and host-bound `Context`. Supply explicit runtime, scope and principal identity. These labels select a presentation instance and grant no permission. The concrete `controller.conversation` keeps the full native API available. `connect()` owns one native watch, and `dispose()` asynchronously closes that watch without aborting tasks or closing the borrowed Harness.
74
116
 
75
117
  `setText`, `setAttachments` and `send` retain an immutable selected draft. `beforeSubmit` validates that selection before the first submission and every retry. A refused retry retains the original unknown attempt. Native authorization remains the host boundary's responsibility. A thrown admission stays unknown unless the host can prove it was not admitted. `reconcile()` looks up the original native request. `retrySameRequest()` uses its original request ID and content, including when the user has since edited the composer. `send()` empties the composer at once, like any chat app: the sent text is held by the controller as the pending submission, so text typed before the host confirms is a new draft, never appended to the sent one, and an acknowledgement never clears it. Sending again while a submission is still being confirmed queues the new message in `outbox` and submits it, in order and with its own request ID, once the previous one is admitted. A refused message (validation, or a submission the host proves was not admitted) and any messages waiting behind it go back into the composer, one per line, ahead of the text typed since. A submission whose outcome is unknown stays held for `reconcile()`/`retrySameRequest()`, and further sends are refused until it is resolved. `send(whenBusy, { text, attachments, restore })` sends explicit content without touching the composer (`restore` is what comes back on refusal). This controller retains uncertainty in memory; durable reload of an unacknowledged local draft is not implemented.
@@ -80,6 +122,8 @@ A submit the host refused before admission (status 402 `submission-refused`, for
80
122
 
81
123
  `@hachej/boring-ui-kit/remote-chat` releases its watch stream while the page is hidden (`document.visibilityState`) and reopens it when the page is visible again, because browsers cap concurrent HTTP/1.1 connections per host and one idle stream per background tab starves every other request. The reopened watch starts with a complete view, so the controller keeps reading `connected`; submissions, answers and running turns are separate requests and are unaffected. The watch also survives the network: a stream that ends without an end frame (a proxy or load balancer closed it), or stays silent for 2.5 of the server's announced heartbeat intervals, is reopened with exponential backoff (`reconnect: { baseDelayMs: 1000, maxDelayMs: 30000 }`, with jitter) until the controller stops it. Meanwhile `remote.link` reports reconnecting and a controller given it (spreading the `RemoteChat` into its options does) reads `connection.kind === 'reconnecting'`; the reopened stream starts with a complete view, so nothing is duplicated. A refusal (4xx) or a final end (`retired`, `stopped`, revoked) ends the watch instead. Pass `pauseWhenHidden: false` to keep the stream open, and always call `close()` on a `RemoteChat` the controller never claimed. Node and workers have no `document` and are unaffected.
82
124
 
125
+ `submissionStatus(submission)` reads that exact native handle using the controller's host-bound Context, verifies its conversation and rejects after disposal. It adds no lifecycle or retry: use native `done.answer` to attribute a response, rather than matching text or assuming the next assistant row belongs to a send. The optional [point-and-dictate block](../../docs/guides/agent-target.md) uses it while chat stays closed.
126
+
83
127
  `test/packages/ui-native-chat.test.mjs` uses a real native Harness and a fictional local streaming provider.
84
128
 
85
129
  ### Native conversation history
@@ -126,7 +170,9 @@ Native document author records are preserved. They do not grant access or select
126
170
 
127
171
  ## Optional React canvas editor
128
172
 
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. Load `tldraw/tldraw.css` when selecting the canvas route; the native JavaScript loads lazily. Asset URLs must cover all pinned fonts, icons, translations and embed icons, so omitted values cannot silently select default CDN assets.
173
+ 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.
174
+
175
+ **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
176
 
131
177
  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
178
 
@@ -350,3 +396,7 @@ publication and retained history without separate file callbacks. Its built-in
350
396
  text editor navigation asks before discarding unsaved content. The legacy
351
397
  `resources` binding remains available for existing hosts; use one binding at a
352
398
  time. History previews never replace the editable document.
399
+
400
+ ## Staged answers in native chat
401
+
402
+ The [fictional triage-note recipe](../../examples/staged-chat/README.md) composes three sequential native Pi tasks with validation and inline stage entries, two independent chat controllers, error restoration, historical offers, host-owned unread indicators and a 24-row initial display window. It owns the exact API semantics and limitations for `hideLine`, `historicalMessageActions`, `unread`, `replyError`, `displayLimit`, stage-card `status` and `createNativeChatController({ restoreOnError: true })`. These are optional presentation/draft seams, not a kit workflow, retention service or additional transcript store.
@@ -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(Tldraw, { store: controller.store, hideUi: true, locale: "en", autoFocus: false, assetUrls: assets, ...(licenseKey === undefined ? {} : { licenseKey }), onMount: attach, children: [_jsx(CanvasTools, { components: components, controller: controller }), _jsx(MountedCommands, { controller: controller, revokeTools: revokeTools, ...(onMountedTools ? { onMountedTools } : {}) })] }) })] });
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 = [
@@ -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
@@ -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);
@@ -0,0 +1,18 @@
1
+ /** Unanswered transport calls retain their identity and payload across reconnects. The host must deduplicate mutating calls. */
2
+ export declare function createChatOutbox<Input, Output>(): {
3
+ enqueue: (id: string, input: Input) => Promise<Output>;
4
+ get: (id: string) => {
5
+ readonly input: Input;
6
+ readonly resolve: (output: Output) => void;
7
+ readonly reject: (error: unknown) => void;
8
+ } | undefined;
9
+ values: () => {
10
+ readonly input: Input;
11
+ readonly resolve: (output: Output) => void;
12
+ readonly reject: (error: unknown) => void;
13
+ }[];
14
+ resolve: (id: string, output: Output) => void;
15
+ reject: (id: string, error: unknown) => void;
16
+ clear: () => void;
17
+ };
18
+ //# sourceMappingURL=chat-outbox.d.ts.map
@@ -0,0 +1,12 @@
1
+ /** Unanswered transport calls retain their identity and payload across reconnects. The host must deduplicate mutating calls. */
2
+ export function createChatOutbox() {
3
+ const pending = new Map();
4
+ return {
5
+ enqueue: (id, input) => new Promise((resolve, reject) => { pending.set(id, { input, resolve, reject }); }),
6
+ get: (id) => pending.get(id),
7
+ values: () => [...pending.values()],
8
+ resolve: (id, output) => { const call = pending.get(id); pending.delete(id); call?.resolve(output); },
9
+ reject: (id, error) => { const call = pending.get(id); pending.delete(id); call?.reject(error); },
10
+ clear: () => pending.clear(),
11
+ };
12
+ }
@@ -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
@@ -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
- const name = entry.path.split('/').at(-1);
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 }), _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)] });
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;
@@ -0,0 +1,38 @@
1
+ import type { ResourceAccess, ResourceClient, ResourceLocator } from '@hachej/boring-files';
2
+ import type { TextBufferState } from './text-buffer-state.js';
3
+ import type { SaveResult } from './resources.js';
4
+ import { type MarkdownQuoteAnchor } from './markdown-quote.js';
5
+ export type MarkColour = 'accent' | 'primary' | 'muted';
6
+ export interface DocumentMark {
7
+ readonly id: string;
8
+ readonly anchor: MarkdownQuoteAnchor;
9
+ readonly colour: MarkColour;
10
+ readonly note: string;
11
+ readonly author: string;
12
+ readonly time: string;
13
+ }
14
+ export interface MarkStoreSnapshot {
15
+ readonly marks: readonly DocumentMark[];
16
+ readonly save: TextBufferState['save'];
17
+ readonly dirty: boolean;
18
+ readonly lifecycle: 'active' | 'disposed';
19
+ }
20
+ export interface MarkStore {
21
+ readonly getSnapshot: () => MarkStoreSnapshot;
22
+ readonly subscribe: (notify: () => void) => () => void;
23
+ readonly add: (mark: DocumentMark) => Promise<SaveResult>;
24
+ readonly edit: (id: string, change: Partial<Pick<DocumentMark, 'anchor' | 'colour' | 'note'>>) => Promise<SaveResult>;
25
+ readonly remove: (id: string) => Promise<SaveResult>;
26
+ readonly reconcile: () => Promise<SaveResult>;
27
+ /** Explicitly discard a refused local change and load current marks. Unknown writes must be reconciled first. */
28
+ readonly reload: () => Promise<void>;
29
+ readonly dispose: () => void;
30
+ }
31
+ export interface MarkResource {
32
+ readonly client: ResourceClient;
33
+ /** Expected receipt identity, never authentication authority. */
34
+ readonly identity: Pick<ResourceAccess, 'scopeId' | 'principalId' | 'initiatorId'>;
35
+ }
36
+ /** Host data/presentation concern; uses the workspace's existing resource publication seam, not Pi execution state. */
37
+ export declare function createMarkStore(resource: MarkResource, locator: ResourceLocator): Promise<MarkStore>;
38
+ //# sourceMappingURL=mark-store.d.ts.map
@@ -0,0 +1,84 @@
1
+ import { randomUUID } from '@hachej/boring-files/platform';
2
+ import { createTextBuffer, freeze } from './text-buffer.js';
3
+ import { markdownQuoteResolution } from './markdown-quote.js';
4
+ function mark(value) {
5
+ if (!value || typeof value !== 'object')
6
+ throw new TypeError('Invalid document mark');
7
+ const m = value;
8
+ if (typeof m['id'] !== 'string' || !m['id'] || !['accent', 'primary', 'muted'].includes(String(m['colour']))
9
+ || typeof m['note'] !== 'string' || typeof m['author'] !== 'string' || !m['author'] || typeof m['time'] !== 'string' || !Number.isFinite(Date.parse(m['time'])))
10
+ throw new TypeError('Invalid document mark');
11
+ return { id: m['id'], anchor: markdownQuoteResolution.schema.parse(m['anchor']), colour: m['colour'], note: m['note'], author: m['author'], time: m['time'] };
12
+ }
13
+ function decode(text) {
14
+ const data = JSON.parse(text);
15
+ if (!data || typeof data !== 'object' || !('format' in data) || data.format !== 'document.marks@1' || !('marks' in data) || !Array.isArray(data.marks))
16
+ throw new TypeError('Unsupported marks file');
17
+ const marks = data.marks.map(mark);
18
+ if (new Set(marks.map(m => m.id)).size !== marks.length)
19
+ throw new TypeError('Duplicate mark id');
20
+ return marks;
21
+ }
22
+ const encode = (marks) => JSON.stringify({ format: 'document.marks@1', marks }, null, 2) + '\n';
23
+ const readText = (snapshot) => {
24
+ const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(snapshot.bytes);
25
+ decode(text);
26
+ return text;
27
+ };
28
+ /** Host data/presentation concern; uses the workspace's existing resource publication seam, not Pi execution state. */
29
+ export async function createMarkStore(resource, locator) {
30
+ const client = resource.client;
31
+ const identity = structuredClone(resource.identity);
32
+ const target = { resource: { ...locator.resource, path: `${locator.resource.path}.marks.json` }, view: structuredClone(locator.view) };
33
+ const read = await client.read({ target, revision: { kind: 'latest' } });
34
+ if (read.kind !== 'available' && read.kind !== 'missing')
35
+ throw new Error('Marks are unavailable');
36
+ if (read.kind === 'available' && (read.snapshot.ref.resource.providerId !== target.resource.providerId || read.snapshot.ref.resource.path !== target.resource.path
37
+ || JSON.stringify(read.snapshot.ref.view) !== JSON.stringify(target.view)))
38
+ throw new Error('Marks read returned another resource');
39
+ const buffer = createTextBuffer({ client, identity, instanceId: randomUUID(), epoch: randomUUID(),
40
+ source: read.kind === 'available' ? { kind: 'saved', snapshot: read.snapshot } : { kind: 'new', target, text: encode([]) },
41
+ mediaType: 'application/json', emptyText: encode([]), readText });
42
+ let snapshot;
43
+ const update = () => { const s = buffer.getSnapshot(); snapshot = freeze({ marks: decode(s.text), save: s.save, dirty: s.dirty, lifecycle: s.lifecycle }); };
44
+ update();
45
+ const listeners = new Set();
46
+ const unsubscribe = buffer.subscribe(() => { update(); for (const notify of [...listeners])
47
+ notify(); });
48
+ let preparing = false;
49
+ const mutate = (transform) => {
50
+ const s = buffer.getSnapshot();
51
+ if (s.lifecycle === 'disposed')
52
+ return Promise.resolve({ kind: 'unavailable', reason: 'Marks store is disposed' });
53
+ if (preparing || s.save.kind === 'pending' || s.save.kind === 'settled' && s.save.result.kind !== 'saved')
54
+ return Promise.resolve({ kind: 'unavailable', reason: 'Reconcile or reload marks before another change' });
55
+ preparing = true;
56
+ try {
57
+ buffer.edit(encode(transform(snapshot.marks)));
58
+ return buffer.flush(buffer.selection());
59
+ }
60
+ finally {
61
+ preparing = false;
62
+ }
63
+ };
64
+ return {
65
+ getSnapshot: () => snapshot,
66
+ subscribe: notify => { listeners.add(notify); return () => { listeners.delete(notify); }; },
67
+ add: input => { const captured = mark(input); return mutate(marks => { if (marks.some(m => m.id === captured.id))
68
+ throw new TypeError('Duplicate mark id'); return [...marks, captured]; }); },
69
+ edit: (id, change) => { const captured = structuredClone(change); return mutate(marks => { if (!marks.some(m => m.id === id))
70
+ throw new TypeError('Unknown mark id'); return marks.map(m => m.id === id ? mark({ ...m, ...captured }) : m); }); },
71
+ remove: id => mutate(marks => { if (!marks.some(m => m.id === id))
72
+ throw new TypeError('Unknown mark id'); return marks.filter(m => m.id !== id); }),
73
+ reconcile: buffer.reconcile,
74
+ reload: async () => {
75
+ const s = buffer.getSnapshot();
76
+ if (s.save.kind === 'pending' || s.save.kind === 'settled' && s.save.result.kind === 'unknown')
77
+ throw new Error('Reconcile the unconfirmed save first');
78
+ const result = await buffer.refresh(true);
79
+ if (result.kind !== 'available' && result.kind !== 'missing')
80
+ throw new Error('Marks reload failed');
81
+ },
82
+ dispose: () => { buffer.dispose(); unsubscribe(); listeners.clear(); },
83
+ };
84
+ }
@@ -1,3 +1,6 @@
1
+ import { type MarkdownDecoration, type MarkdownQuoteCapture } from './markdown-highlight-layer.js';
2
+ export type { MarkdownDecoration, MarkdownQuoteCapture } from './markdown-highlight-layer.js';
3
+ export { markdownMarkerBackground } from './markdown-highlight-layer.js';
1
4
  import type { ViewerComponents } from './viewer-components.js';
2
5
  export type { ViewerComponents } from './viewer-components.js';
3
6
  import type { MarkdownAutosave, MarkdownFlush } from './markdown-autosave.js';
@@ -12,6 +15,9 @@ export type { MarkdownRichSafety } from './markdown-safety.js';
12
15
  export type { MarkdownImageResolver } from './markdown-extensions.js';
13
16
  export type { MarkdownMountedTools, MarkdownMountedSubject, MarkdownMountedSelection, MarkdownMountedInspection } from './markdown-mounted.js';
14
17
  export interface MarkdownEditorProps {
18
+ readonly highlights?: readonly MarkdownDecoration[];
19
+ readonly onQuoteSelection?: (capture: MarkdownQuoteCapture) => void;
20
+ readonly onHighlightActivate?: (id: string) => void;
15
21
  /** Installed recipes supply the host's shadcn controls. Omit only for unstyled native HTML rendering. */
16
22
  readonly components?: ViewerComponents;
17
23
  readonly controller: MarkdownController;