@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.
- package/README.md +46 -18
- package/THIRD_PARTY_NOTICES.md +35 -0
- package/bin/boring-ui-blocks.mjs +286 -0
- package/dist/canvas-editor-native.d.ts +4 -1
- package/dist/canvas-editor-native.js +8 -6
- package/dist/canvas-editor.js +1 -1
- package/dist/canvas-proposals.d.ts +3 -1
- package/dist/canvas-proposals.js +4 -3
- package/dist/experience-document-viewer.d.ts +3 -0
- package/dist/experience-document-viewer.js +6 -5
- package/dist/experience.d.ts +3 -0
- package/dist/experience.js +2 -1
- package/dist/file-tree-view.d.ts +76 -3
- package/dist/file-tree-view.js +172 -39
- package/dist/file-tree.d.ts +3 -0
- package/dist/file-tree.js +17 -2
- package/dist/html-viewer.d.ts +4 -0
- package/dist/html-viewer.js +5 -3
- package/dist/markdown-autosave.d.ts +13 -0
- package/dist/markdown-autosave.js +94 -0
- package/dist/markdown-editor.d.ts +16 -0
- package/dist/markdown-editor.js +81 -17
- package/dist/markdown-extensions.js +9 -1
- package/dist/markdown-proposal-diff.d.ts +2 -1
- package/dist/markdown-proposal-diff.js +3 -2
- package/dist/markdown-safety.js +3 -1
- package/dist/native-chat.js +1 -1
- package/dist/remote-chat.d.ts +18 -0
- package/dist/remote-chat.js +149 -7
- package/dist/text-buffer.js +3 -2
- package/dist/text-draft-controls.d.ts +3 -1
- package/dist/text-draft-controls.js +3 -2
- package/dist/viewer-components.d.ts +14 -0
- package/dist/viewer-components.js +1 -0
- package/package.json +8 -4
- package/r/app-shell.json +39 -20
- package/r/button.json +19 -11
- package/r/dock.json +2 -2
- package/r/documents.json +17 -14
- package/r/feedback-preview.json +8 -2
- package/r/feedback.json +46 -325
- package/r/file-tree.json +14 -6
- package/r/html-viewer.json +8 -42
- package/r/markdown-editor.json +38 -87
- package/r/pi-ambient.json +15 -8
- package/r/pi-app.json +30 -24
- package/r/pi-chat.json +101 -70
- package/r/pi-workspace.json +7 -5
- package/r/provider-setup.json +9 -4
- package/r/registry.json +471 -893
- package/r/task-list-viewer.json +11 -21
- package/r/theme.json +23 -4
- package/r/version-history.json +24 -0
- package/r/viewers.json +139 -320
- package/styles/file-tree.css +41 -42
- package/dist/file-tree-shadcn.d.ts +0 -8
- package/dist/file-tree-shadcn.js +0 -14
package/dist/html-viewer.d.ts
CHANGED
|
@@ -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;
|
package/dist/html-viewer.js
CHANGED
|
@@ -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(
|
|
60
|
-
? _jsx(
|
|
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;
|
package/dist/markdown-editor.js
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
|
-
import { jsx as _jsx,
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
191
|
-
const
|
|
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
|
-
|
|
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 && !
|
|
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(
|
|
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(
|
|
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(
|
|
226
|
-
? _jsx(
|
|
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 `&`), 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
|
-
|
|
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(
|
|
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);
|
package/dist/markdown-safety.js
CHANGED
|
@@ -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 (
|
|
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]* [ \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);
|
package/dist/native-chat.js
CHANGED
|
@@ -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())
|
package/dist/remote-chat.d.ts
CHANGED
|
@@ -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. */
|