@hachej/boring-ui-kit 0.3.2 → 0.4.1

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 (57) hide show
  1. package/README.md +46 -18
  2. package/THIRD_PARTY_NOTICES.md +35 -0
  3. package/bin/boring-ui-blocks.mjs +286 -0
  4. package/dist/canvas-editor-native.d.ts +4 -1
  5. package/dist/canvas-editor-native.js +8 -6
  6. package/dist/canvas-editor.js +1 -1
  7. package/dist/canvas-proposals.d.ts +3 -1
  8. package/dist/canvas-proposals.js +4 -3
  9. package/dist/experience-document-viewer.d.ts +3 -0
  10. package/dist/experience-document-viewer.js +6 -5
  11. package/dist/experience.d.ts +3 -0
  12. package/dist/experience.js +2 -1
  13. package/dist/file-tree-view.d.ts +76 -3
  14. package/dist/file-tree-view.js +172 -39
  15. package/dist/file-tree.d.ts +3 -0
  16. package/dist/file-tree.js +17 -2
  17. package/dist/html-viewer.d.ts +4 -0
  18. package/dist/html-viewer.js +5 -3
  19. package/dist/markdown-autosave.d.ts +13 -0
  20. package/dist/markdown-autosave.js +94 -0
  21. package/dist/markdown-editor.d.ts +16 -0
  22. package/dist/markdown-editor.js +81 -17
  23. package/dist/markdown-extensions.js +9 -1
  24. package/dist/markdown-proposal-diff.d.ts +2 -1
  25. package/dist/markdown-proposal-diff.js +3 -2
  26. package/dist/markdown-safety.js +3 -1
  27. package/dist/native-chat.js +1 -1
  28. package/dist/remote-chat.d.ts +18 -0
  29. package/dist/remote-chat.js +149 -7
  30. package/dist/text-buffer.js +3 -2
  31. package/dist/text-draft-controls.d.ts +3 -1
  32. package/dist/text-draft-controls.js +3 -2
  33. package/dist/viewer-components.d.ts +14 -0
  34. package/dist/viewer-components.js +1 -0
  35. package/package.json +8 -4
  36. package/r/app-shell.json +39 -20
  37. package/r/button.json +19 -11
  38. package/r/dock.json +2 -2
  39. package/r/documents.json +17 -14
  40. package/r/feedback-preview.json +8 -2
  41. package/r/feedback.json +46 -325
  42. package/r/file-tree.json +14 -6
  43. package/r/html-viewer.json +8 -42
  44. package/r/markdown-editor.json +38 -87
  45. package/r/pi-ambient.json +15 -8
  46. package/r/pi-app.json +30 -24
  47. package/r/pi-chat.json +101 -70
  48. package/r/pi-workspace.json +7 -5
  49. package/r/provider-setup.json +9 -4
  50. package/r/registry.json +471 -893
  51. package/r/task-list-viewer.json +11 -21
  52. package/r/theme.json +23 -4
  53. package/r/version-history.json +24 -0
  54. package/r/viewers.json +139 -320
  55. package/styles/file-tree.css +41 -42
  56. package/dist/file-tree-shadcn.d.ts +0 -8
  57. package/dist/file-tree-shadcn.js +0 -14
@@ -1,5 +1,9 @@
1
+ import type { ViewerComponents } from './viewer-components.js';
2
+ export type { ViewerComponents } from './viewer-components.js';
1
3
  import type { HtmlController } from './html.js';
2
4
  export interface HtmlViewerProps {
5
+ /** Installed recipes supply the host's shadcn controls. Omit only for unstyled native HTML rendering. */
6
+ readonly components?: ViewerComponents;
3
7
  readonly controller: HtmlController;
4
8
  readonly title?: string;
5
9
  readonly className?: string;
@@ -33,7 +33,9 @@ function HtmlPreview({ text }) {
33
33
  : status === 'failed' ? 'Preview unavailable. Source viewing and saving remain available.'
34
34
  : 'Preview unavailable until this viewer is mounted in a browser.' }), _jsx("div", { ref: container })] });
35
35
  }
36
- function MountedHtmlViewer({ controller, title = 'HTML document', className, header = true, mode: controlledMode, onModeChange }) {
36
+ function MountedHtmlViewer({ components, controller, title = 'HTML document', className, header = true, mode: controlledMode, onModeChange }) {
37
+ const Control = components?.button ?? 'button';
38
+ const Source = components?.textarea ?? 'textarea';
37
39
  const snapshot = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
38
40
  const [ownMode, setOwnMode] = useState('preview');
39
41
  const mode = controlledMode ?? ownMode;
@@ -56,8 +58,8 @@ function MountedHtmlViewer({ controller, title = 'HTML document', className, hea
56
58
  const status = disposed ? 'Viewer disposed' : snapshot.readOnly ? 'Read only' : pending ? 'Saving selected source' : unknown ? 'Save unconfirmed. Reconcile before saving again.' : snapshot.save.kind === 'settled' && snapshot.save.result.kind !== 'saved'
57
59
  ? snapshot.save.result.kind === 'partial' ? 'Save unconfirmed' : snapshot.save.result.reason
58
60
  : snapshot.dirty ? 'Unsaved changes' : 'Saved';
59
- return _jsxs("section", { "data-boring": "html-viewer", className: ['boring-html-viewer', className].filter(Boolean).join(' '), "aria-label": title, children: [header && _jsxs("header", { children: [_jsx("strong", { children: title }), _jsx("span", { role: "status", children: status })] }), header && _jsxs("div", { role: "toolbar", "aria-label": "HTML document controls", children: [_jsx("button", { type: "button", "aria-pressed": mode === 'preview', onClick: () => setMode('preview'), children: "HTML preview" }), _jsx("button", { type: "button", "aria-pressed": mode === 'source', onClick: () => setMode('source'), children: "HTML source" }), _jsx("button", { type: "button", disabled: disposed || snapshot.readOnly || !snapshot.dirty || pending || unknown, onClick: () => { const selection = controller.actions.selection(); perform(() => controller.flush(selection)); }, children: "Save" }), _jsx("button", { type: "button", disabled: disposed || pending || unknown, onClick: () => perform(controller.actions.refresh), children: "Refresh" }), _jsx("button", { type: "button", disabled: disposed || !unknown, onClick: () => perform(controller.actions.reconcile), children: "Reconcile save" }), _jsx("button", { type: "button", disabled: disposed || !unknown, onClick: () => perform(controller.actions.abandon), children: "Abandon and refresh, keeping my draft" }), _jsx("button", { type: "button", disabled: disposed || pending || unknown || snapshot.remote === null, onClick: () => perform(controller.actions.discardToRemote), children: "Discard local edits" })] }), _jsx(TextDraftControls, { recovery: snapshot.recovery, actions: controller.actions, readOnly: snapshot.readOnly, blocked: snapshot.lifecycle !== 'active' || snapshot.save.kind === 'pending' || (snapshot.save.kind === 'settled' && snapshot.save.result.kind === 'unknown') }), error && _jsx("p", { role: "alert", children: error }), mode === 'source'
60
- ? _jsx("textarea", { className: "boring-html-viewer__source", "aria-label": "HTML source", readOnly: snapshot.readOnly || disposed, value: snapshot.text, onChange: event => { if (!snapshot.readOnly && !disposed)
61
+ return _jsxs("section", { "data-boring": "html-viewer", className: ['boring-html-viewer', className].filter(Boolean).join(' '), "aria-label": title, children: [header && _jsxs("header", { children: [_jsx("strong", { children: title }), _jsx("span", { role: "status", children: status })] }), header && _jsxs("div", { role: "toolbar", "aria-label": "HTML document controls", children: [_jsx(Control, { type: "button", "aria-pressed": mode === 'preview', onClick: () => setMode('preview'), children: "HTML preview" }), _jsx(Control, { type: "button", "aria-pressed": mode === 'source', onClick: () => setMode('source'), children: "HTML source" }), _jsx(Control, { type: "button", disabled: disposed || snapshot.readOnly || !snapshot.dirty || pending || unknown, onClick: () => { const selection = controller.actions.selection(); perform(() => controller.flush(selection)); }, children: "Save" }), _jsx(Control, { type: "button", disabled: disposed || pending || unknown, onClick: () => perform(controller.actions.refresh), children: "Refresh" }), _jsx(Control, { type: "button", disabled: disposed || !unknown, onClick: () => perform(controller.actions.reconcile), children: "Reconcile save" }), _jsx(Control, { type: "button", disabled: disposed || !unknown, onClick: () => perform(controller.actions.abandon), children: "Abandon and refresh, keeping my draft" }), _jsx(Control, { type: "button", disabled: disposed || pending || unknown || snapshot.remote === null, onClick: () => perform(controller.actions.discardToRemote), children: "Discard local edits" })] }), _jsx(TextDraftControls, { components: components, recovery: snapshot.recovery, actions: controller.actions, readOnly: snapshot.readOnly, blocked: snapshot.lifecycle !== 'active' || snapshot.save.kind === 'pending' || (snapshot.save.kind === 'settled' && snapshot.save.result.kind === 'unknown') }), error && _jsx("p", { role: "alert", children: error }), mode === 'source'
62
+ ? _jsx(Source, { className: "boring-html-viewer__source", "aria-label": "HTML source", readOnly: snapshot.readOnly || disposed, value: snapshot.text, onChange: event => { if (!snapshot.readOnly && !disposed)
61
63
  controller.actions.edit(event.currentTarget.value); } })
62
64
  : _jsx(HtmlPreview, { text: snapshot.text })] });
63
65
  }
@@ -0,0 +1,13 @@
1
+ import type { MarkdownController } from './markdown.js';
2
+ import type { SaveResult } from './resources.js';
3
+ export type MarkdownAutosave = boolean | {
4
+ readonly delayMs?: number;
5
+ };
6
+ export type MarkdownFlush = () => Promise<boolean>;
7
+ /** Browser editing policy only. All writes still use the controller's exact conditional flush. */
8
+ export declare function useMarkdownAutosave(controller: MarkdownController, autosave: MarkdownAutosave, report: (result: SaveResult) => void): {
9
+ enabled: boolean;
10
+ immediate: () => Promise<boolean>;
11
+ manual: () => Promise<boolean>;
12
+ };
13
+ //# sourceMappingURL=markdown-autosave.d.ts.map
@@ -0,0 +1,94 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+ /** Browser editing policy only. All writes still use the controller's exact conditional flush. */
3
+ export function useMarkdownAutosave(controller, autosave, report) {
4
+ const enabled = autosave !== false;
5
+ const delayMs = typeof autosave === 'object' ? autosave.delayMs ?? 1200 : 1200;
6
+ const enabledRef = useRef(enabled);
7
+ enabledRef.current = enabled;
8
+ const reporting = useRef(report);
9
+ reporting.current = report;
10
+ const running = useRef(null);
11
+ const paused = useRef(null);
12
+ const timer = useRef(undefined);
13
+ const cancel = useCallback(() => { clearTimeout(timer.current); timer.current = undefined; }, []);
14
+ const flush = useCallback((manual = false) => {
15
+ cancel();
16
+ if (running.current)
17
+ return running.current;
18
+ const drain = async () => {
19
+ for (;;) {
20
+ const state = controller.getSnapshot();
21
+ if (state.lifecycle !== 'active' || state.readOnly)
22
+ return !state.dirty;
23
+ if (state.save.kind === 'pending') {
24
+ await new Promise(resolve => {
25
+ const stop = controller.subscribe(() => { if (controller.getSnapshot().save.kind !== 'pending' || controller.getSnapshot().lifecycle !== 'active') {
26
+ stop();
27
+ resolve();
28
+ } });
29
+ });
30
+ continue;
31
+ }
32
+ if (state.save.kind === 'settled' && state.save.result.kind === 'unknown')
33
+ return false;
34
+ if (!state.dirty || !manual && state.bufferVersion === 0)
35
+ return true;
36
+ if (!manual && (paused.current || state.remote !== null))
37
+ return false;
38
+ const result = await controller.flush(controller.actions.selection());
39
+ reporting.current(result);
40
+ if (result.kind !== 'saved') {
41
+ paused.current = controller.getSnapshot().save;
42
+ return false;
43
+ }
44
+ // A late acknowledgement only cleans its captured text. Select any newer edit after it settles.
45
+ }
46
+ };
47
+ const promise = Promise.resolve().then(drain).catch(() => {
48
+ paused.current = controller.getSnapshot().save;
49
+ reporting.current({ kind: 'unavailable', reason: 'The save could not be completed. Your text is still available.' });
50
+ return false;
51
+ }).finally(() => { if (running.current === promise)
52
+ running.current = null; });
53
+ running.current = promise;
54
+ return promise;
55
+ }, [cancel, controller]);
56
+ useEffect(() => {
57
+ if (!enabled)
58
+ return;
59
+ const changed = () => {
60
+ const state = controller.getSnapshot();
61
+ if (paused.current && state.save !== paused.current && (state.save.kind === 'idle' || state.save.kind === 'settled' && state.save.result.kind === 'saved'))
62
+ paused.current = null;
63
+ cancel();
64
+ if (state.lifecycle !== 'active' || state.readOnly || !state.dirty || state.bufferVersion === 0 || paused.current || state.remote !== null || state.save.kind === 'pending')
65
+ return;
66
+ if (state.save.kind === 'settled' && state.save.result.kind !== 'saved') {
67
+ paused.current = state.save;
68
+ return;
69
+ }
70
+ timer.current = setTimeout(() => { void flush(); }, delayMs);
71
+ };
72
+ const hidden = () => { if (document.visibilityState === 'hidden')
73
+ void flush(); };
74
+ const pagehide = () => { void flush(); };
75
+ const stop = controller.subscribe(changed);
76
+ document.addEventListener('visibilitychange', hidden);
77
+ window.addEventListener('pagehide', pagehide);
78
+ changed();
79
+ return () => {
80
+ stop();
81
+ cancel();
82
+ document.removeEventListener('visibilitychange', hidden);
83
+ window.removeEventListener('pagehide', pagehide);
84
+ };
85
+ }, [cancel, controller, delayMs, enabled, flush]);
86
+ useEffect(() => () => {
87
+ const state = controller.getSnapshot();
88
+ if (enabledRef.current && state.dirty && state.bufferVersion > 0)
89
+ void flush();
90
+ }, [controller, flush]);
91
+ const immediate = useCallback(() => flush(), [flush]);
92
+ const manual = useCallback(() => flush(true), [flush]);
93
+ return { enabled, immediate, manual };
94
+ }
@@ -1,3 +1,8 @@
1
+ import type { ViewerComponents } from './viewer-components.js';
2
+ export type { ViewerComponents } from './viewer-components.js';
3
+ import type { MarkdownAutosave, MarkdownFlush } from './markdown-autosave.js';
4
+ import type { SaveResult } from './resources.js';
5
+ export type { MarkdownAutosave, MarkdownFlush } from './markdown-autosave.js';
1
6
  import type { MarkdownController } from './markdown.js';
2
7
  import { type MarkdownImageResolver } from './markdown-extensions.js';
3
8
  import { type MarkdownRichSafety } from './markdown-safety.js';
@@ -7,6 +12,8 @@ export type { MarkdownRichSafety } from './markdown-safety.js';
7
12
  export type { MarkdownImageResolver } from './markdown-extensions.js';
8
13
  export type { MarkdownMountedTools, MarkdownMountedSubject, MarkdownMountedSelection, MarkdownMountedInspection } from './markdown-mounted.js';
9
14
  export interface MarkdownEditorProps {
15
+ /** Installed recipes supply the host's shadcn controls. Omit only for unstyled native HTML rendering. */
16
+ readonly components?: ViewerComponents;
10
17
  readonly controller: MarkdownController;
11
18
  readonly title?: string;
12
19
  readonly initialMode?: 'rich' | 'source';
@@ -15,6 +22,8 @@ export interface MarkdownEditorProps {
15
22
  readonly onMountedTools?: (tools: MarkdownMountedTools | null) => void;
16
23
  /** `false` omits the built-in header (title, status, source toggle, Save) so a host bar such as the viewer frame supplies them. */
17
24
  readonly header?: boolean;
25
+ /** Show local heading navigation. Defaults to the built-in header's visibility. */
26
+ readonly showOutline?: boolean;
18
27
  /** Controlled mode. The editor still falls back to `source` for a document that rich editing cannot keep exactly. */
19
28
  readonly mode?: 'rich' | 'source';
20
29
  readonly onModeChange?: (mode: 'rich' | 'source') => void;
@@ -22,6 +31,13 @@ export interface MarkdownEditorProps {
22
31
  readonly onRichSafety?: (safety: MarkdownRichSafety) => void;
23
32
  /** Images never load unless this maps their address to a URL (inline `data:` images load without it). */
24
33
  readonly resolveImage?: MarkdownImageResolver;
34
+ /** Autosave defaults to 1200ms idle, blur, hide and removal. false keeps explicit saving. */
35
+ readonly autosave?: MarkdownAutosave;
36
+ /** Registers the async navigation barrier. Null while autosave is disabled or the editor is unmounted. */
37
+ readonly onAutosaveChange?: (flush: MarkdownFlush | null) => void;
38
+ readonly onSaveResult?: (result: SaveResult) => void;
39
+ /** The complete formatting row is pinned by default on desktop; phones show it above the keyboard while editing, plus the selection bubble. */
40
+ readonly formattingToolbar?: 'selection' | 'pinned';
25
41
  }
26
42
  /** Borrows the controller. The host owns document selection, access and disposal. */
27
43
  export declare function MarkdownEditor(props: MarkdownEditorProps): import("react").JSX.Element;
@@ -1,8 +1,11 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { TextDraftControls } from './text-draft-controls.js';
4
- import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
4
+ import { useMarkdownAutosave } from './markdown-autosave.js';
5
+ import { Fragment, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
6
+ import { createPortal } from 'react-dom';
5
7
  import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
8
+ import { BubbleMenu } from '@tiptap/react/menus';
6
9
  import { createMarkdownExtensions } from './markdown-extensions.js';
7
10
  import { checkMarkdownRichSafety, describeUnsafe, finishRichMarkdown } from './markdown-safety.js';
8
11
  import { MarkdownIcon } from './markdown-icons.js';
@@ -18,7 +21,55 @@ export function MarkdownEditor(props) {
18
21
  setMount({ controller: props.controller, sequence: mount.sequence + 1 });
19
22
  return _jsx(MarkdownEditorSession, { ...props, activeController: active }, mount.sequence);
20
23
  }
21
- function MarkdownEditorSession({ controller, title = 'Document', initialMode = 'rich', placeholder = 'Start writing', className, onMountedTools, header = true, mode: controlledMode, onModeChange, onRichSafety, resolveImage, activeController: active }) {
24
+ const PHONE_QUERY = '(max-width: 640px)';
25
+ const phoneSnapshot = () => globalThis.matchMedia?.(PHONE_QUERY).matches === true;
26
+ const desktopSnapshot = () => false;
27
+ const subscribePhone = (notify) => {
28
+ const media = globalThis.matchMedia?.(PHONE_QUERY);
29
+ media?.addEventListener('change', notify);
30
+ return () => media?.removeEventListener('change', notify);
31
+ };
32
+ const BUBBLE_OPTIONS = { strategy: 'fixed', placement: 'top', offset: 8, shift: { padding: 12 }, flip: true };
33
+ const bubbleContainer = () => document.body;
34
+ function useEditorFocus(editor) {
35
+ const [focused, setFocused] = useState(false);
36
+ useEffect(() => {
37
+ if (!editor || editor.isDestroyed)
38
+ return;
39
+ const update = () => setFocused(editor.isFocused);
40
+ update();
41
+ editor.on('focus', update);
42
+ editor.on('blur', update);
43
+ return () => { editor.off('focus', update); editor.off('blur', update); };
44
+ }, [editor]);
45
+ return focused;
46
+ }
47
+ /** Height the soft keyboard covers at the bottom of the layout viewport, so a fixed bar can sit directly above it. */
48
+ function useKeyboardInset(active) {
49
+ const [inset, setInset] = useState(0);
50
+ useEffect(() => {
51
+ const viewport = globalThis.visualViewport;
52
+ if (!active || !viewport) {
53
+ setInset(0);
54
+ return;
55
+ }
56
+ const update = () => setInset(Math.max(0, Math.round(globalThis.innerHeight - viewport.offsetTop - viewport.height)));
57
+ update();
58
+ viewport.addEventListener('resize', update);
59
+ viewport.addEventListener('scroll', update);
60
+ return () => { viewport.removeEventListener('resize', update); viewport.removeEventListener('scroll', update); };
61
+ }, [active]);
62
+ return inset;
63
+ }
64
+ function MarkdownEditorSession({ components, controller, title = 'Document', initialMode = 'rich', placeholder = 'Start writing', className, onMountedTools, header = true, showOutline = header, mode: controlledMode, onModeChange, onRichSafety, resolveImage, formattingToolbar = 'pinned', autosave = true, onAutosaveChange, onSaveResult, activeController: active }) {
65
+ const Control = components?.button ?? 'button';
66
+ const Bubble = components?.bubble ?? Fragment;
67
+ const Source = components?.textarea ?? 'textarea';
68
+ const LinkInput = components?.input ?? 'input';
69
+ const phone = useSyncExternalStore(subscribePhone, phoneSnapshot, desktopSnapshot);
70
+ const [pinned, setPinned] = useState(formattingToolbar === 'pinned');
71
+ useEffect(() => { setPinned(formattingToolbar === 'pinned'); }, [formattingToolbar]);
72
+ const showPinned = pinned && !phone;
22
73
  const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
23
74
  const projected = useRef(null);
24
75
  const emitted = useRef(null);
@@ -160,20 +211,26 @@ function MarkdownEditorSession({ controller, title = 'Document', initialMode = '
160
211
  const outcome = state.save.kind === 'settled' ? state.save.result : null;
161
212
  const uncertain = outcome?.kind === 'unknown';
162
213
  const conflict = outcome?.kind === 'conflict' || state.remote !== null;
163
- const save = () => {
164
- const selected = controller.actions.selection();
165
- setError(null);
166
- void controller.flush(selected).then(report, () => { if (active.current === controller)
167
- setError('The save could not be completed. Your text is still available.'); });
168
- };
169
214
  /** Shows a refused save or accept; the buffer keeps the text. */
170
215
  const report = (result) => {
171
216
  if (active.current !== controller || !result || typeof result !== 'object')
172
217
  return;
173
218
  const { kind, reason } = result;
219
+ if (kind === 'saved')
220
+ setError(null);
174
221
  if (kind === 'denied' || kind === 'conflict' || kind === 'unavailable' || kind === 'file_too_large')
175
222
  setError(typeof reason === 'string' && reason ? reason : kind === 'conflict' ? 'The document changed elsewhere. Your text is still available.' : 'The document could not be saved. Your text is still available.');
176
223
  };
224
+ const saving = useMarkdownAutosave(controller, autosave, result => { report(result); onSaveResult?.(result); });
225
+ useEffect(() => { onAutosaveChange?.(saving.enabled ? saving.immediate : null); return () => onAutosaveChange?.(null); }, [onAutosaveChange, saving.enabled, saving.immediate]);
226
+ const save = () => {
227
+ setError(null);
228
+ if (saving.enabled)
229
+ void saving.manual();
230
+ else
231
+ void controller.flush(controller.actions.selection()).then(report, () => { if (active.current === controller)
232
+ setError('The save could not be completed. Your text is still available.'); });
233
+ };
177
234
  const run = (operation) => {
178
235
  setError(null);
179
236
  void operation().then(report, () => { if (active.current === controller)
@@ -181,14 +238,19 @@ function MarkdownEditorSession({ controller, title = 'Document', initialMode = '
181
238
  };
182
239
  const richOnly = unavailable || mode === 'source' || !editor;
183
240
  const [linkDraft, setLinkDraft] = useState(null);
241
+ // On a phone the full formatting row docks above the soft keyboard while the rich surface has focus.
242
+ const editing = useEditorFocus(editor);
243
+ const dockVisible = phone && !unavailable && !!editor && (editing || linkDraft !== null);
244
+ const keyboardInset = useKeyboardInset(dockVisible);
184
245
  const command = (operation) => { interaction.current = true; try {
185
246
  operation();
186
247
  }
187
248
  finally {
188
249
  interaction.current = false;
189
250
  } };
190
- const tool = (label, icon, pressed, operation, extra = {}) => (_jsx("button", { type: "button", className: "boring-markdown-tool", "aria-label": label, title: label, "aria-pressed": pressed === true, "data-active": pressed === true || undefined, disabled: richOnly || extra.disabled, onClick: () => command(operation), children: _jsx(MarkdownIcon, { name: icon }) }));
191
- const separator = _jsx("span", { role: "separator", "aria-orientation": "vertical", className: "boring-markdown-separator" });
251
+ const tool = (label, icon, pressed, operation, extra = {}) => (_jsx(Control, { type: "button", className: "boring-markdown-tool", "aria-label": label, title: label, "aria-pressed": pressed === true, "data-active": pressed === true || undefined, disabled: richOnly || extra.disabled, onClick: () => command(operation), children: _jsx(MarkdownIcon, { name: icon }) }));
252
+ const Rule = components?.separator;
253
+ const separator = Rule ? _jsx(Rule, { orientation: "vertical", className: "boring-markdown-separator" }) : _jsx("span", { role: "separator", "aria-orientation": "vertical", className: "boring-markdown-separator" });
192
254
  const chain = () => editor.chain().focus();
193
255
  const openLink = () => setLinkDraft(marks?.href ?? '');
194
256
  const applyLink = () => {
@@ -198,17 +260,19 @@ function MarkdownEditorSession({ controller, title = 'Document', initialMode = '
198
260
  command(() => href === '' ? chain().extendMarkRange('link').unsetLink().run() : chain().extendMarkRange('link').setLink({ href }).run());
199
261
  setLinkDraft(null);
200
262
  };
201
- return _jsxs("section", { className: className, "data-boring": "markdown-editor", "data-mode": mode, "data-dirty": state.dirty || undefined, onKeyDownCapture: event => {
263
+ const formatTools = _jsxs(_Fragment, { children: [tool('Bold', 'Bold', marks?.bold, () => chain().toggleBold().run()), tool('Italic', 'Italic', marks?.italic, () => chain().toggleItalic().run()), tool('Strikethrough', 'Strike', marks?.strike, () => chain().toggleStrike().run()), tool('Highlight', 'Highlight', marks?.highlight, () => chain().toggleHighlight().run()), tool('Code', 'Code', marks?.code, () => chain().toggleCode().run()), separator, tool('Heading 1', 'Heading1', marks?.h1, () => chain().toggleHeading({ level: 1 }).run()), tool('Heading 2', 'Heading2', marks?.h2, () => chain().toggleHeading({ level: 2 }).run()), tool('Heading 3', 'Heading3', marks?.h3, () => chain().toggleHeading({ level: 3 }).run()), separator, tool('Bullet list', 'BulletList', marks?.bullet, () => chain().toggleBulletList().run()), tool('Numbered list', 'OrderedList', marks?.ordered, () => chain().toggleOrderedList().run()), tool('Task list', 'TaskList', marks?.task, () => chain().toggleTaskList().run()), separator, tool('Quote', 'Quote', marks?.quote, () => chain().toggleBlockquote().run()), tool('Code block', 'CodeBlock', marks?.codeBlock, () => chain().toggleCodeBlock().run()), tool('Link', 'Link', marks?.link || linkDraft !== null, openLink), tool('Table', 'Table', marks?.table, () => marks?.table ? chain().deleteTable().run() : chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()), tool('Horizontal rule', 'Rule', false, () => chain().setHorizontalRule().run()), marks?.table && _jsxs(_Fragment, { children: [separator, tool('Add table row', 'Rows', false, () => chain().addRowAfter().run()), tool('Add table column', 'Columns', false, () => chain().addColumnAfter().run()), tool('Delete table', 'Trash', false, () => chain().deleteTable().run())] })] });
264
+ return _jsxs("section", { className: className, "data-boring": "markdown-editor", "data-mode": mode, "data-dock": (mode === 'rich' && dockVisible) || undefined, "data-dirty": state.dirty || undefined, onBlurCapture: event => { if (saving.enabled && !event.currentTarget.contains(event.relatedTarget))
265
+ void saving.immediate(); }, onKeyDownCapture: event => {
202
266
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
203
267
  event.preventDefault();
204
- if (!unavailable && !pending && !uncertain)
268
+ if (!unavailable && !uncertain)
205
269
  save();
206
270
  }
207
- }, children: [header && _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("button", { type: "button", "aria-pressed": mode === 'source', disabled: !richOffered, onClick: switchMode, children: "Markdown source" }), _jsx("button", { type: "button", disabled: unavailable || !state.dirty || pending || uncertain, onClick: save, children: "Save" })] }), _jsx(TextDraftControls, { recovery: state.recovery, actions: controller.actions, readOnly: state.readOnly, blocked: state.lifecycle !== 'active' || state.save.kind === 'pending' || (state.save.kind === 'settled' && state.save.result.kind === 'unknown') }), mode === 'rich' && _jsxs("div", { role: "toolbar", "aria-label": "Text formatting", className: "boring-markdown-toolbar", children: [tool('Bold', 'Bold', marks?.bold, () => chain().toggleBold().run()), tool('Italic', 'Italic', marks?.italic, () => chain().toggleItalic().run()), tool('Strikethrough', 'Strike', marks?.strike, () => chain().toggleStrike().run()), tool('Highlight', 'Highlight', marks?.highlight, () => chain().toggleHighlight().run()), tool('Code', 'Code', marks?.code, () => chain().toggleCode().run()), separator, tool('Heading 1', 'Heading1', marks?.h1, () => chain().toggleHeading({ level: 1 }).run()), tool('Heading 2', 'Heading2', marks?.h2, () => chain().toggleHeading({ level: 2 }).run()), tool('Heading 3', 'Heading3', marks?.h3, () => chain().toggleHeading({ level: 3 }).run()), separator, tool('Bullet list', 'BulletList', marks?.bullet, () => chain().toggleBulletList().run()), tool('Numbered list', 'OrderedList', marks?.ordered, () => chain().toggleOrderedList().run()), tool('Task list', 'TaskList', marks?.task, () => chain().toggleTaskList().run()), separator, tool('Quote', 'Quote', marks?.quote, () => chain().toggleBlockquote().run()), tool('Code block', 'CodeBlock', marks?.codeBlock, () => chain().toggleCodeBlock().run()), tool('Link', 'Link', marks?.link || linkDraft !== null, openLink), tool('Table', 'Table', marks?.table, () => marks?.table ? chain().deleteTable().run() : chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()), tool('Horizontal rule', 'Rule', false, () => chain().setHorizontalRule().run()), marks?.table && _jsxs(_Fragment, { children: [separator, tool('Add table row', 'Rows', false, () => chain().addRowAfter().run()), tool('Add table column', 'Columns', false, () => chain().addColumnAfter().run()), tool('Delete table', 'Trash', false, () => chain().deleteTable().run())] })] }), mode === 'rich' && linkDraft !== null && _jsxs("form", { className: "boring-markdown-link", "aria-label": "Link address", onSubmit: event => { event.preventDefault(); applyLink(); }, children: [_jsx("input", { type: "text", inputMode: "url", autoFocus: true, "aria-label": "Link address", placeholder: "https://", value: linkDraft, onChange: event => setLinkDraft(event.currentTarget.value), onKeyDown: event => { if (event.key === 'Escape') {
271
+ }, children: [header && _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' }), mode === 'rich' && !phone && !unavailable && _jsx(Control, { type: "button", className: "boring-markdown-pin", "aria-label": "Pin formatting toolbar", title: "Pin formatting toolbar", "aria-pressed": pinned, onClick: () => setPinned(value => !value), children: "Aa" }), _jsx(Control, { type: "button", "aria-pressed": mode === 'source', disabled: !richOffered, onClick: switchMode, children: "Markdown source" }), !saving.enabled && _jsx(Control, { type: "button", disabled: unavailable || !state.dirty || pending || uncertain, onClick: save, children: "Save" })] }), _jsx(TextDraftControls, { components: components, recovery: state.recovery, actions: controller.actions, readOnly: state.readOnly, blocked: state.lifecycle !== 'active' || state.save.kind === 'pending' || (state.save.kind === 'settled' && state.save.result.kind === 'unknown') }), mode === 'rich' && showPinned && _jsx("div", { role: "toolbar", "aria-label": "Text formatting", className: "boring-markdown-toolbar", children: formatTools }), mode === 'rich' && dockVisible && createPortal(_jsxs("div", { role: "toolbar", "aria-label": "Text formatting", className: "boring-viewer boring-markdown-dock", "data-testid": "markdown-dock", "data-keyboard": keyboardInset > 0 || undefined, style: { bottom: keyboardInset }, onPointerDown: event => event.preventDefault(), children: [Rule && _jsx(Rule, { className: "boring-markdown-dock-rule" }), formatTools] }), document.body), mode === 'rich' && editor && !showPinned && !unavailable && _jsx(BubbleMenu, { editor: editor, updateDelay: 0, options: BUBBLE_OPTIONS, appendTo: bubbleContainer, role: "toolbar", "aria-label": "Text formatting", className: "boring-viewer boring-markdown-bubble", children: _jsxs(Bubble, { children: [tool('Bold', 'Bold', marks?.bold, () => chain().toggleBold().run()), tool('Italic', 'Italic', marks?.italic, () => chain().toggleItalic().run()), tool('Highlight', 'Highlight', marks?.highlight, () => chain().toggleHighlight().run()), tool('Code', 'Code', marks?.code, () => chain().toggleCode().run()), tool('Link', 'Link', marks?.link || linkDraft !== null, openLink)] }) }), mode === 'rich' && linkDraft !== null && _jsxs("form", { className: "boring-markdown-link", "aria-label": "Link address", onSubmit: event => { event.preventDefault(); applyLink(); }, children: [_jsx(LinkInput, { type: "text", inputMode: "url", autoFocus: true, "aria-label": "Link address", placeholder: "https://", value: linkDraft, onChange: event => setLinkDraft(event.currentTarget.value), onKeyDown: event => { if (event.key === 'Escape') {
208
272
  event.preventDefault();
209
273
  setLinkDraft(null);
210
274
  editor?.commands.focus();
211
- } } }), _jsx("button", { type: "submit", children: linkDraft.trim() === '' && marks?.link ? 'Remove link' : 'Apply link' }), _jsx("button", { type: "button", onClick: () => { setLinkDraft(null); editor?.commands.focus(); }, children: "Cancel" })] }), !richOffered && _jsx("p", { role: "note", className: "boring-markdown-notice", "data-testid": "markdown-source-only", children: describeUnsafe(safety) }), mode === 'rich' && outline && outline.headings.length > 0 && _jsx("nav", { "aria-label": "Document outline", children: _jsx("ol", { children: outline.headings.map((heading, index) => _jsx("li", { "data-heading-level": heading.level, children: _jsx("button", { type: "button", "aria-label": `Go to heading: ${heading.text || 'Untitled heading'}`, disabled: state.lifecycle !== 'active', onClick: () => {
275
+ } } }), _jsx(Control, { type: "submit", children: linkDraft.trim() === '' && marks?.link ? 'Remove link' : 'Apply link' }), _jsx(Control, { type: "button", onClick: () => { setLinkDraft(null); editor?.commands.focus(); }, children: "Cancel" })] }), !richOffered && _jsx("p", { role: "note", className: "boring-markdown-notice", "data-testid": "markdown-source-only", children: describeUnsafe(safety) }), showOutline && mode === 'rich' && outline && outline.headings.length > 0 && _jsx("nav", { "aria-label": "Document outline", children: _jsx("ol", { children: outline.headings.map((heading, index) => _jsx("li", { "data-heading-level": heading.level, children: _jsx(Control, { type: "button", "aria-label": `Go to heading: ${heading.text || 'Untitled heading'}`, disabled: state.lifecycle !== 'active', onClick: () => {
212
276
  if (!editor || editor.isDestroyed || active.current !== controller || controller.getSnapshot().lifecycle !== 'active'
213
277
  || controller.getSnapshot().bufferVersion !== state.bufferVersion || editor.state.doc !== outline.document)
214
278
  return;
@@ -222,8 +286,8 @@ function MarkdownEditorSession({ controller, title = 'Document', initialMode = '
222
286
  setError(result.reason);
223
287
  }).catch(() => { if (active.current === controller)
224
288
  setError('The heading could not be selected'); });
225
- }, children: heading.text || 'Untitled heading' }) }, heading.position)) }) }), uncertain && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["Save acknowledgement was lost. Keep editing while its outcome is checked.", _jsx("button", { type: "button", onClick: () => run(controller.actions.reconcile), children: "Check save outcome" }), _jsx("button", { type: "button", onClick: () => run(controller.actions.abandon), children: "Stop checking and refresh, keeping my draft" })] }), conflict && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["The saved document changed. Your local text has been kept.", _jsx("button", { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.refresh), children: "Check saved version" }), _jsx("button", { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.discardToRemote), children: "Discard local changes and reload" })] }), (outcome?.kind === 'denied' || outcome?.kind === 'unavailable' || outcome?.kind === 'file_too_large') && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: outcome.reason }), error && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: error }), state.proposals.map(proposal => _jsxs("article", { "data-boring": "proposal", className: "boring-markdown-proposal", children: [_jsx("h3", { children: proposal.summary || 'Proposed edit' }), _jsxs("details", { children: [_jsx("summary", { children: "Review text changes" }), _jsx(MarkdownProposalDiff, { proposal: proposal }), _jsxs("details", { children: [_jsx("summary", { children: "Exact before and after" }), _jsx("h4", { children: "Before" }), _jsx("pre", { children: proposal.before }), _jsx("h4", { children: "After" }), _jsx("pre", { children: proposal.after })] })] }), _jsx("button", { type: "button", disabled: unavailable || pending || uncertain || proposal.adopted, onClick: () => run(() => controller.actions.accept(proposal.id)), children: proposal.adopted ? 'Adopted' : 'Accept and save' }), _jsx("button", { type: "button", onClick: () => controller.actions.reject(proposal.id), children: "Dismiss proposal" })] }, proposal.id)), mode === 'source'
226
- ? _jsx("textarea", { ref: sourceInput, className: "boring-markdown-source", "aria-label": `${title} Markdown source`, value: state.text, readOnly: unavailable, spellCheck: false, onChange: event => controller.actions.edit(event.currentTarget.value) })
289
+ }, children: heading.text || 'Untitled heading' }) }, heading.position)) }) }), uncertain && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["Save acknowledgement was lost. Keep editing while its outcome is checked.", _jsx(Control, { type: "button", onClick: () => run(controller.actions.reconcile), children: "Check save outcome" }), _jsx(Control, { type: "button", onClick: () => run(controller.actions.abandon), children: "Stop checking and refresh, keeping my draft" })] }), conflict && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["The saved document changed. Your local text has been kept.", _jsx(Control, { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.refresh), children: "Check saved version" }), _jsx(Control, { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.discardToRemote), children: "Discard local changes and reload" })] }), (outcome?.kind === 'denied' || outcome?.kind === 'unavailable' || outcome?.kind === 'file_too_large') && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: outcome.reason }), error && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: error }), state.proposals.map(proposal => _jsxs("article", { "data-boring": "proposal", className: "boring-markdown-proposal", children: [_jsx("h3", { children: proposal.summary || 'Proposed edit' }), _jsxs("details", { children: [_jsx("summary", { children: "Review text changes" }), _jsx(MarkdownProposalDiff, { components: components, proposal: proposal }), _jsxs("details", { children: [_jsx("summary", { children: "Exact before and after" }), _jsx("h4", { children: "Before" }), _jsx("pre", { children: proposal.before }), _jsx("h4", { children: "After" }), _jsx("pre", { children: proposal.after })] })] }), _jsx(Control, { type: "button", disabled: unavailable || pending || uncertain || proposal.adopted, onClick: () => run(() => controller.actions.accept(proposal.id)), children: proposal.adopted ? 'Adopted' : 'Accept and save' }), _jsx(Control, { type: "button", onClick: () => controller.actions.reject(proposal.id), children: "Dismiss proposal" })] }, proposal.id)), mode === 'source'
290
+ ? _jsx(Source, { ref: sourceInput, className: "boring-markdown-source", "aria-label": `${title} Markdown source`, value: state.text, readOnly: unavailable, spellCheck: false, onChange: event => controller.actions.edit(event.currentTarget.value) })
227
291
  : _jsx("div", { className: "boring-markdown-surface", onKeyDownCapture: markInteraction, onBeforeInputCapture: markInteraction, onInputCapture: markInteraction, onPasteCapture: markInteraction, onDropCapture: markInteraction, onChangeCapture: markInteraction, onClickCapture: event => { markInteraction(); if (event.target instanceof Element && event.target.closest('a'))
228
292
  event.preventDefault(); }, onAuxClickCapture: event => { if (event.target instanceof Element && event.target.closest('a'))
229
293
  event.preventDefault(); }, children: _jsx(EditorContent, { editor: editor }) })] });
@@ -5,6 +5,7 @@ import { TaskItem, TaskList } from '@tiptap/extension-list';
5
5
  import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table';
6
6
  import Image from '@tiptap/extension-image';
7
7
  import Highlight from '@tiptap/extension-highlight';
8
+ import { Node as TiptapNode } from '@tiptap/core';
8
9
  import { createMarkdownParser } from './markdown-parser.js';
9
10
  /**
10
11
  * The serialiser HTML-encodes text (`&` becomes `&amp;`), which rewrites ordinary prose such as "Notes & Highlights". The parser
@@ -24,6 +25,13 @@ const PlainTextMarkdown = Markdown.extend({
24
25
  };
25
26
  },
26
27
  });
28
+ const RichStarterKit = StarterKit.extend({
29
+ addExtensions() {
30
+ return (this.parent?.() ?? []).map(extension => extension instanceof TiptapNode && extension.name === 'paragraph'
31
+ ? extension.extend({ renderMarkdown: (node, helpers) => helpers.renderChildren(node.content ?? []) })
32
+ : extension);
33
+ },
34
+ });
27
35
  /**
28
36
  * A GFM table keeps the exact text it was written in (unpadded `|a|b|`, `:---:` alignment rows, `_emphasis_`, escaped pipes, empty
29
37
  * cells) while its content is unchanged: parsing records the source and the canonical JSON of the rows it produced, and rendering
@@ -85,7 +93,7 @@ export function createMarkdownExtensions({ placeholder, images } = {}) {
85
93
  onBeforeCreate() { this.parent?.(); schema = this.editor.schema; },
86
94
  });
87
95
  return [
88
- StarterKit.configure({ link: { openOnClick: false, autolink: false, linkOnPaste: false }, underline: false }),
96
+ RichStarterKit.configure({ link: { openOnClick: false, autolink: false, linkOnPaste: false }, underline: false }),
89
97
  TaskList, TaskItem.configure({ nested: true }),
90
98
  SourceTable.configure({ resizable: false }), TableRow, TableHeader, TableCell,
91
99
  InertImage.configure({ inline: false, allowBase64: true }),
@@ -1,5 +1,6 @@
1
1
  import type { MarkdownProposal } from './markdown.js';
2
- export declare function MarkdownProposalDiff({ proposal }: {
2
+ export declare function MarkdownProposalDiff({ components, proposal }: {
3
+ readonly components?: import('./viewer-components.js').ViewerComponents | undefined;
3
4
  readonly proposal: MarkdownProposal;
4
5
  }): import("react").JSX.Element;
5
6
  //# sourceMappingURL=markdown-proposal-diff.d.ts.map
@@ -107,11 +107,12 @@ function DiffRow({ line }) {
107
107
  const display = terminated ? line.value.slice(0, -1) : line.value;
108
108
  return _jsxs("div", { "data-diff": line.kind, "data-line-ending": terminated ? 'lf' : 'none', children: [_jsx("span", { children: line.kind === 'add' ? '+ ' : line.kind === 'remove' ? '- ' : ' ' }), _jsx("span", { "data-diff-text": "", children: display }), line.kind !== 'same' && line.value.endsWith('\r\n') && _jsx("span", { "data-diff-crlf": "", children: " CRLF line ending" }), !terminated && _jsx("span", { "data-diff-no-newline": "", children: " No newline at end" })] });
109
109
  }
110
- export function MarkdownProposalDiff({ proposal }) {
110
+ export function MarkdownProposalDiff({ components, proposal }) {
111
+ const Control = components?.button ?? 'button';
111
112
  const diff = useMemo(() => review(proposal.before, proposal.after), [proposal.before, proposal.after]);
112
113
  const shown = useMemo(() => entries(diff.lines), [diff]);
113
114
  const [expanded, setExpanded] = useState(() => new Set());
114
- return _jsxs("div", { "data-boring": "proposal-diff", role: "group", "aria-label": "Proposed line changes", children: [diff.fallback && _jsx("p", { role: "status", "data-diff-fallback": "", children: "Detailed line alignment unavailable; showing changed block." }), proposal.before === proposal.after && _jsx("p", { children: "No text changes" }), _jsx("div", { style: { overflowX: 'auto', whiteSpace: 'pre-wrap' }, children: shown.map((entry, index) => entry.kind === 'fold' ? _jsxs("div", { children: [_jsxs("button", { type: "button", "data-diff-fold": "", "aria-expanded": expanded.has(entry.index), onClick: () => setExpanded(current => {
115
+ return _jsxs("div", { "data-boring": "proposal-diff", role: "group", "aria-label": "Proposed line changes", children: [diff.fallback && _jsx("p", { role: "status", "data-diff-fallback": "", children: "Detailed line alignment unavailable; showing changed block." }), proposal.before === proposal.after && _jsx("p", { children: "No text changes" }), _jsx("div", { style: { overflowX: 'auto', whiteSpace: 'pre-wrap' }, children: shown.map((entry, index) => entry.kind === 'fold' ? _jsxs("div", { children: [_jsxs(Control, { type: "button", "data-diff-fold": "", "aria-expanded": expanded.has(entry.index), onClick: () => setExpanded(current => {
115
116
  const next = new Set(current);
116
117
  if (next.has(entry.index))
117
118
  next.delete(entry.index);
@@ -7,10 +7,12 @@ export function normalizeMarkdownWhitespace(text) {
7
7
  const lines = text.split('\n');
8
8
  const out = [];
9
9
  let fence;
10
- for (const line of lines) {
10
+ for (let line of lines) {
11
11
  const opened = FENCE.exec(line);
12
12
  if (opened && (!fence || opened[1] === fence))
13
13
  fence = fence ? undefined : opened[1];
14
+ if (!fence && !opened && !/^ {4}|^\t/.test(line) && /^(?:[ \t]*&nbsp;[ \t]*)+$/.test(line))
15
+ line = '';
14
16
  if (!fence && !opened && line.trim() === '' && out.length > 0 && out[out.length - 1].trim() === '')
15
17
  continue;
16
18
  out.push(line);
@@ -202,7 +202,7 @@ export function createNativeChatController(options) {
202
202
  }
203
203
  const cursor = page.next === undefined ? undefined : structuredClone(page.next);
204
204
  historyScan = { maxEntryId: scan.maxEntryId, ...(cursor === undefined ? {} : { cursor }), ...(previousId === undefined ? {} : { lastId: previousId }) };
205
- publish({ history: { kind: 'ready', entries: Object.freeze([...page.items].reverse()), hasMore: cursor !== undefined } });
205
+ publish({ history: { kind: 'ready', entries: Object.freeze([...page.items].reverse().concat(entries)), hasMore: cursor !== undefined } });
206
206
  }
207
207
  catch (error) {
208
208
  if (current())
@@ -29,6 +29,24 @@ export interface RemoteChatOptions {
29
29
  readonly baseDelayMs?: number;
30
30
  readonly maxDelayMs?: number;
31
31
  };
32
+ /**
33
+ * `websocket`: carry the watch and every call over one WebSocket to the same endpoint (`ws:`/`wss:` from the page), served by
34
+ * `createConversationSocketHandler` of `@hachej/boring-agent/chat-transport`. A Durable Object can then hibernate while the page
35
+ * is idle. Calls not yet answered when the socket drops are sent again unchanged (same request ID) on the next socket, and every
36
+ * socket begins with a complete view. If the first socket cannot open, the chat uses HTTP (`fetch`) instead. Default `http`.
37
+ */
38
+ readonly transport?: 'http' | 'websocket';
39
+ /**
40
+ * WebSocket details. `open` creates the socket (default `new WebSocket(url, 'boring.chat')`); a browser cannot set headers on a
41
+ * WebSocket, so a host with bearer tokens offers its credential as another subprotocol here. `pingMs` (default 15000): a `ping`
42
+ * every interval; a socket silent for 2.5 intervals is treated as lost and reopened.
43
+ */
44
+ readonly webSocket?: {
45
+ readonly open?: (url: URL) => WebSocket;
46
+ readonly pingMs?: number;
47
+ };
48
+ /** Host events the socket carries beside the chat (`broadcast` on the server, for example presence or the conversation list). */
49
+ readonly onEvent?: (event: unknown) => void;
32
50
  }
33
51
  export interface RemoteChat {
34
52
  /** The members of the native conversation the chat controller uses, served by the remote host. Nothing else is pretended. */