@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.
- package/README.md +51 -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/chat-outbox.d.ts +18 -0
- package/dist/chat-outbox.js +12 -0
- package/dist/file-tree-view.d.ts +29 -1
- package/dist/file-tree-view.js +74 -7
- package/dist/mark-store.d.ts +38 -0
- package/dist/mark-store.js +84 -0
- package/dist/markdown-editor.d.ts +6 -0
- package/dist/markdown-editor.js +17 -2
- package/dist/markdown-highlight-layer.d.ts +26 -0
- package/dist/markdown-highlight-layer.js +107 -0
- package/dist/markdown-quote.d.ts +23 -0
- package/dist/markdown-quote.js +39 -0
- package/dist/native-chat.d.ts +21 -0
- package/dist/native-chat.js +111 -7
- package/dist/remote-chat.js +16 -14
- package/dist/shared-chat/error.d.ts +10 -0
- package/dist/shared-chat/error.js +13 -0
- package/dist/shared-chat/fake.d.ts +39 -0
- package/dist/shared-chat/fake.js +90 -0
- package/dist/shared-chat/index.d.ts +51 -0
- package/dist/shared-chat/index.js +255 -0
- package/dist/shared-chat/socket.d.ts +20 -0
- package/dist/shared-chat/socket.js +114 -0
- package/dist/shared-chat/source.d.ts +117 -0
- package/dist/shared-chat/source.js +6 -0
- package/package.json +15 -3
- package/r/agent-target.json +36 -0
- package/r/app-shell.json +26 -11
- 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/highlights.json +29 -0
- 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 +7 -7
- package/r/pi-app.json +21 -18
- package/r/pi-chat.json +28 -20
- package/r/pi-workspace.json +4 -4
- package/r/provider-setup.json +2 -2
- package/r/record-viewer.json +40 -0
- package/r/registry.json +449 -83
- package/r/requests-board.json +41 -0
- package/r/shared-chat.json +79 -0
- package/r/task-list-viewer.json +2 -2
- package/r/theme.json +15 -0
- package/r/version-history.json +3 -3
- 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.
|
|
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.
|
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);
|
|
@@ -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
|
+
}
|
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;
|
|
@@ -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;
|