@hachej/boring-ui-kit 0.4.56 → 0.4.57
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 +2 -0
- package/dist/chat-react-conversations.d.ts +3 -7
- package/dist/chat-react-conversations.js +73 -9
- package/dist/markdown-conflict-choices.d.ts +17 -0
- package/dist/markdown-conflict-choices.js +47 -0
- package/dist/markdown-editor-labels.d.ts +15 -0
- package/dist/markdown-editor-labels.js +16 -2
- package/dist/markdown-editor.d.ts +5 -1
- package/dist/markdown-editor.js +23 -7
- package/dist/markdown-proposal-diff.d.ts +3 -2
- package/dist/markdown-proposal-diff.js +2 -2
- package/dist/markdown-safety.d.ts +7 -1
- package/dist/markdown-safety.js +17 -1
- package/dist/markdown-tab-poke.d.ts +7 -0
- package/dist/markdown-tab-poke.js +57 -0
- package/dist/markdown.d.ts +6 -0
- package/dist/markdown.js +1 -1
- package/dist/text-buffer.d.ts +12 -0
- package/dist/text-buffer.js +83 -1
- package/package.json +2 -2
- package/r/agent-target.json +4 -4
- package/r/app-shell.json +9 -9
- package/r/board-viewer.json +6 -6
- package/r/button.json +1 -1
- package/r/dock.json +1 -1
- package/r/documents.json +12 -12
- package/r/drawing-viewer.json +1 -1
- package/r/feedback.json +6 -6
- package/r/file-tree.json +4 -4
- package/r/highlights.json +3 -3
- package/r/html-viewer.json +3 -3
- package/r/looks-viewer.json +2 -2
- package/r/maquette-viewer.json +1 -1
- package/r/markdown-editor.json +3 -3
- package/r/mockup-viewer.json +1 -1
- package/r/pi-ambient.json +9 -9
- package/r/pi-app-kinds.json +10 -10
- package/r/pi-app.json +11 -11
- package/r/pi-artifacts.json +2 -2
- package/r/pi-chat-approval.json +2 -2
- package/r/pi-chat-attachments.json +1 -1
- package/r/pi-chat-markdown.json +2 -2
- package/r/pi-chat-mentions.json +1 -1
- package/r/pi-chat-pickers.json +1 -1
- package/r/pi-chat-plugins.json +11 -11
- package/r/pi-chat-question.json +1 -1
- package/r/pi-chat-slash.json +2 -2
- package/r/pi-chat-subagent.json +1 -1
- package/r/pi-chat-voice.json +3 -3
- package/r/pi-chat.json +8 -8
- package/r/pi-workspace.json +3 -3
- package/r/placed-agent.json +4 -4
- package/r/provider-setup.json +1 -1
- package/r/registry.json +162 -162
- package/r/shared-chat.json +6 -6
- package/r/spaces.json +1 -1
- package/r/task-list-viewer.json +2 -2
- package/r/version-history.json +2 -2
- package/r/viewer-code.json +2 -2
- package/r/viewer-frame.json +4 -4
- package/r/viewer-html.json +4 -4
- package/r/viewer-image.json +2 -2
- package/r/viewer-markdown.json +5 -5
- package/r/viewer-pdf.json +3 -3
- package/r/viewers.json +6 -6
- package/r/windows.json +1 -1
package/README.md
CHANGED
|
@@ -50,6 +50,8 @@ Autosave is enabled by default after 1200ms of idle editing and flushes on blur
|
|
|
50
50
|
|
|
51
51
|
Use `onAutosaveChange` to register the mounted flush barrier. Await that flush before closing, unmounting or disposing the controller. If it returns false, keep the editor mounted with its refusal/conflict UI unless a real host navigation guard obtains deliberate discard consent. Internal flush registration is not a discard guard. Unmount cleanup and browser page hide are best effort, not substitutes for awaiting the owner teardown barrier. See the [registry recipes](../../registry/README.md) for `MarkdownPane`, viewer-window policy and navigation integration.
|
|
52
52
|
|
|
53
|
+
A refused save shows Keep mine, Take theirs and Review diff. Keep mine rebases onto the revision the person was shown and saves through the same conditional write; a remote that moved since is a new conflict, never an overwrite. Each save shows its revision. `crossTabRefresh` (default on) tells other tabs of the note to run their existing refresh over BroadcastChannel, carrying only the revision; it is a nudge, not a guarantee, and it does nothing where the browser has no channel. A note over `richSizeLimit` bytes (default 512 KiB) opens in source mode and cannot switch to rich mode.
|
|
54
|
+
|
|
53
55
|
Proposal review shows added/removed lines with two lines of context and expandable unchanged runs. It preserves exact line endings and marks a missing final newline. An inner details panel retains exact before/after text. Accept and save delegates to the controller; reviewing or expanding context never publishes. Conflict and unknown outcomes expose explicit discard or reconciliation controls.
|
|
54
56
|
|
|
55
57
|
Detailed alignment uses a matrix of at most 250,000 cells after trimming equal prefix/suffix lines. Larger comparisons show all removed/added middle lines with an explicit notice. This bounds the comparison matrix, not total document size, string-comparison cost or rendered rows.
|
|
@@ -11,8 +11,10 @@ export interface ConversationsClient {
|
|
|
11
11
|
readonly activeId: string | undefined;
|
|
12
12
|
/** New or fork is on its way: the conversation on screen is about to be left, so it must take no message (`PiChat` disables its composer). */
|
|
13
13
|
readonly creating: boolean;
|
|
14
|
-
/** The conversation New or fork just opened, for
|
|
14
|
+
/** The conversation New or fork just opened, for up to three seconds: `PiChat` puts the cursor in its box once, then calls `openedSeen`, so a later remount does not. */
|
|
15
15
|
readonly opened: string | undefined;
|
|
16
|
+
/** Ends `opened` and returns what the person typed since New, for the new box. */
|
|
17
|
+
readonly openedSeen: () => string;
|
|
16
18
|
readonly reload: () => Promise<void>;
|
|
17
19
|
readonly onSelect: (id: string) => void;
|
|
18
20
|
readonly onNew: () => void;
|
|
@@ -34,12 +36,6 @@ export interface ConversationsClientOptions {
|
|
|
34
36
|
/** How long New or fork may hold the composer (`creating`) before the person gets it back and a late answer no longer moves them. Default 15 s. */
|
|
35
37
|
readonly openTimeoutMs?: number;
|
|
36
38
|
}
|
|
37
|
-
/**
|
|
38
|
-
* The sessions list over the host's conversations handler (`createConversationsHandler` of `@hachej/boring-agent/conversations`, mounted at
|
|
39
|
-
* `endpoint`): the active list (polled every `pollMs` while mounted), server-side search with the Archived filter, New, rename, archive,
|
|
40
|
-
* delete and fork. New and fork select the conversation they made through `onSelect`. `onChange` runs after every change the person made
|
|
41
|
-
* (the host may reload what depends on the list). `fetch` is the host's authenticated fetch. Returns `undefined` until the first list arrives.
|
|
42
|
-
*/
|
|
43
39
|
export declare function useConversationsClient({ endpoint, fetch, activeId, onSelect, onChange, pollMs, openTimeoutMs }: ConversationsClientOptions): ConversationsClient | undefined;
|
|
44
40
|
/** Compatibility spelling retained for one release. */
|
|
45
41
|
export declare const useConversations: typeof useConversationsClient;
|
|
@@ -7,10 +7,53 @@ const item = (served) => ({ id: String(served.id), title: served.title ?? undefi
|
|
|
7
7
|
* delete and fork. New and fork select the conversation they made through `onSelect`. `onChange` runs after every change the person made
|
|
8
8
|
* (the host may reload what depends on the list). `fetch` is the host's authenticated fetch. Returns `undefined` until the first list arrives.
|
|
9
9
|
*/
|
|
10
|
+
const OPENED_MS = 3000;
|
|
11
|
+
/**
|
|
12
|
+
* The clicked button keeps the focus until the new box is back, so a Space typed then would press New again, and the box is held, so the start of a message would be lost.
|
|
13
|
+
* The focus goes to a hidden field until then: it outlives the chat's remount, and dead keys, IME and Backspace work there. Not on a phone, where it would open the keyboard.
|
|
14
|
+
*/
|
|
15
|
+
function holdTyping() {
|
|
16
|
+
const page = globalThis.document, focused = page?.activeElement;
|
|
17
|
+
if (!page || focused?.matches('input, textarea, select, [contenteditable]'))
|
|
18
|
+
return undefined;
|
|
19
|
+
const from = focused?.matches('button, [role="button"]') ? focused : undefined;
|
|
20
|
+
focused?.blur();
|
|
21
|
+
if (globalThis.matchMedia?.('(pointer: coarse)').matches)
|
|
22
|
+
return { from, field: undefined };
|
|
23
|
+
const field = page.createElement('textarea');
|
|
24
|
+
field.dataset.testid = 'new-chat-typing';
|
|
25
|
+
field.tabIndex = -1;
|
|
26
|
+
field.setAttribute('aria-hidden', 'true');
|
|
27
|
+
field.style.cssText = 'position:fixed;top:0;left:0;width:1px;height:1px;padding:0;border:0;opacity:0;font-size:16px';
|
|
28
|
+
field.addEventListener('keydown', event => { if (event.key === 'Enter')
|
|
29
|
+
event.preventDefault(); });
|
|
30
|
+
page.body.append(field);
|
|
31
|
+
field.focus();
|
|
32
|
+
return { from, field };
|
|
33
|
+
}
|
|
10
34
|
export function useConversationsClient({ endpoint, fetch, activeId, onSelect, onChange, pollMs = 4000, openTimeoutMs = 15000 }) {
|
|
11
35
|
const [items, setItems] = useState();
|
|
12
36
|
const [creating, setCreating] = useState(false);
|
|
13
37
|
const [opened, setOpened] = useState();
|
|
38
|
+
const typing = useRef(undefined);
|
|
39
|
+
// What was typed goes nowhere else, so nothing reaches the conversation being left. The focus goes back unless the person moved.
|
|
40
|
+
const dropTyping = useCallback(() => {
|
|
41
|
+
const held = typing.current;
|
|
42
|
+
typing.current = undefined;
|
|
43
|
+
held?.field?.remove();
|
|
44
|
+
if (globalThis.document?.activeElement === globalThis.document?.body)
|
|
45
|
+
held?.from?.focus();
|
|
46
|
+
}, []);
|
|
47
|
+
useEffect(() => dropTyping, [dropTyping]);
|
|
48
|
+
const openedTimer = useRef(undefined);
|
|
49
|
+
const markOpened = useCallback((id) => {
|
|
50
|
+
clearTimeout(openedTimer.current);
|
|
51
|
+
if (id !== undefined)
|
|
52
|
+
openedTimer.current = setTimeout(() => { dropTyping(); setOpened(undefined); }, OPENED_MS);
|
|
53
|
+
else
|
|
54
|
+
dropTyping();
|
|
55
|
+
setOpened(id);
|
|
56
|
+
}, [dropTyping]);
|
|
14
57
|
// The New or fork on its way; forgetting it gives the composer back, and its late answer then moves nobody.
|
|
15
58
|
const opening = useRef(undefined);
|
|
16
59
|
const forget = useCallback(() => { opening.current = undefined; setCreating(false); }, []);
|
|
@@ -43,7 +86,7 @@ export function useConversationsClient({ endpoint, fetch, activeId, onSelect, on
|
|
|
43
86
|
const reload = useCallback(() => list(), [list]);
|
|
44
87
|
// Another endpoint (another owner's list) starts empty; switching conversations only reloads.
|
|
45
88
|
// eslint-disable-next-line react-hooks/set-state-in-effect -- a new endpoint clears the old owner's list once, after commit
|
|
46
|
-
useEffect(() => { setItems(undefined);
|
|
89
|
+
useEffect(() => { setItems(undefined); markOpened(undefined); forget(); }, [base, forget, markOpened]);
|
|
47
90
|
useEffect(() => {
|
|
48
91
|
if (base === undefined)
|
|
49
92
|
return;
|
|
@@ -65,27 +108,48 @@ export function useConversationsClient({ endpoint, fetch, activeId, onSelect, on
|
|
|
65
108
|
const run = {};
|
|
66
109
|
opening.current = run;
|
|
67
110
|
setCreating(true);
|
|
68
|
-
const timer = setTimeout(() => { if (opening.current === run)
|
|
69
|
-
forget();
|
|
111
|
+
const timer = setTimeout(() => { if (opening.current === run) {
|
|
112
|
+
forget();
|
|
113
|
+
dropTyping();
|
|
114
|
+
} }, latest.current.openTimeoutMs);
|
|
115
|
+
let selected = false;
|
|
70
116
|
try {
|
|
71
117
|
const { conversationId } = await op(name, body);
|
|
72
118
|
await changed();
|
|
73
119
|
if (conversationId !== undefined && opening.current === run) {
|
|
74
|
-
|
|
75
|
-
|
|
120
|
+
selected = true;
|
|
121
|
+
markOpened(String(conversationId));
|
|
76
122
|
latest.current.onSelect(String(conversationId));
|
|
77
123
|
}
|
|
78
124
|
}
|
|
79
125
|
finally {
|
|
80
126
|
clearTimeout(timer);
|
|
81
|
-
if (opening.current === run)
|
|
127
|
+
if (opening.current === run) {
|
|
82
128
|
forget();
|
|
129
|
+
if (!selected)
|
|
130
|
+
dropTyping();
|
|
131
|
+
}
|
|
83
132
|
}
|
|
84
133
|
};
|
|
85
134
|
return {
|
|
86
135
|
items, activeId, creating, opened, reload,
|
|
87
|
-
|
|
88
|
-
|
|
136
|
+
openedSeen: () => {
|
|
137
|
+
const typed = typing.current?.field?.value ?? '';
|
|
138
|
+
typing.current?.field?.remove();
|
|
139
|
+
typing.current = undefined;
|
|
140
|
+
markOpened(undefined);
|
|
141
|
+
return typed;
|
|
142
|
+
},
|
|
143
|
+
onSelect: (id) => { forget(); markOpened(undefined); latest.current.onSelect(id); },
|
|
144
|
+
onNew: () => {
|
|
145
|
+
if (!opening.current) {
|
|
146
|
+
markOpened(undefined);
|
|
147
|
+
typing.current = holdTyping();
|
|
148
|
+
}
|
|
149
|
+
else
|
|
150
|
+
typing.current?.field?.focus();
|
|
151
|
+
void open('create', {}).catch(() => { });
|
|
152
|
+
},
|
|
89
153
|
search: async (query, { archived }, signal) => ((await call({ ...(query ? { q: query } : {}), ...(archived ? { archived: '1' } : {}) }, undefined, signal)).conversations ?? []).map(item),
|
|
90
154
|
rename: async (id, title) => { await op('rename', { conversationId: id, title }); await changed(); },
|
|
91
155
|
archive: async (id, archived) => { await op('archive', { conversationId: id, archived }); await changed(); },
|
|
@@ -97,7 +161,7 @@ export function useConversationsClient({ endpoint, fetch, activeId, onSelect, on
|
|
|
97
161
|
await open('fork', { conversationId: from, at });
|
|
98
162
|
},
|
|
99
163
|
};
|
|
100
|
-
}, [items, activeId, creating, opened, call, reload, forget]);
|
|
164
|
+
}, [items, activeId, creating, opened, call, reload, forget, markOpened, dropTyping]);
|
|
101
165
|
}
|
|
102
166
|
/** Compatibility spelling retained for one release. */
|
|
103
167
|
export const useConversations = useConversationsClient;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { MarkdownController, MarkdownState } from './markdown.js';
|
|
2
|
+
import type { MarkdownEditorLabels } from './markdown-editor-labels.js';
|
|
3
|
+
import type { SaveResult } from './resources.js';
|
|
4
|
+
import type { ViewerComponents } from './viewer-components.js';
|
|
5
|
+
/** The three ways out of a conflict. Each goes through the controller; nothing here writes or compares revisions itself. */
|
|
6
|
+
export declare function MarkdownConflictChoices({ controller, state, labels, components, disabled, readOnly, setError, run, onSaveResult }: {
|
|
7
|
+
readonly controller: MarkdownController;
|
|
8
|
+
readonly state: MarkdownState;
|
|
9
|
+
readonly labels: MarkdownEditorLabels;
|
|
10
|
+
readonly components?: ViewerComponents | undefined;
|
|
11
|
+
readonly disabled: boolean;
|
|
12
|
+
readonly readOnly: boolean;
|
|
13
|
+
readonly setError: (message: string | null) => void;
|
|
14
|
+
readonly run: (operation: () => Promise<unknown>) => void;
|
|
15
|
+
readonly onSaveResult: (result: SaveResult) => void;
|
|
16
|
+
}): import("react").JSX.Element;
|
|
17
|
+
//# sourceMappingURL=markdown-conflict-choices.d.ts.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useRef, useState } from 'react';
|
|
3
|
+
import { MarkdownProposalDiff } from './markdown-proposal-diff.js';
|
|
4
|
+
/** The three ways out of a conflict. Each goes through the controller; nothing here writes or compares revisions itself. */
|
|
5
|
+
export function MarkdownConflictChoices({ controller, state, labels, components, disabled, readOnly, setError, run, onSaveResult }) {
|
|
6
|
+
const Control = components?.button ?? 'button';
|
|
7
|
+
const [busy, setBusy] = useState(false);
|
|
8
|
+
const [review, setReview] = useState(null);
|
|
9
|
+
const reviewing = useRef(0);
|
|
10
|
+
const deleted = state.remote?.kind === 'absent';
|
|
11
|
+
const remoteRevision = state.remote?.kind === 'revision' ? state.remote.target.revision : null;
|
|
12
|
+
// A diff against a revision that is no longer the known remote would mislead: it closes, and Review diff reads again.
|
|
13
|
+
const shown = review && (deleted ? review.revision === null : remoteRevision === null || review.revision === remoteRevision) ? review : null;
|
|
14
|
+
const keepMine = () => {
|
|
15
|
+
setBusy(true);
|
|
16
|
+
setError(null);
|
|
17
|
+
setReview(null);
|
|
18
|
+
reviewing.current++;
|
|
19
|
+
void controller.actions.keepLocal().then(result => {
|
|
20
|
+
// A conflict here always means the remote moved past what the person chose over; a provider's reason would not say so, or say it in their language.
|
|
21
|
+
if (result.kind === 'conflict')
|
|
22
|
+
setError(labels.conflictMovedAgain);
|
|
23
|
+
onSaveResult(result);
|
|
24
|
+
}, () => setError(labels.operationFailed)).finally(() => setBusy(false));
|
|
25
|
+
};
|
|
26
|
+
const toggleReview = () => {
|
|
27
|
+
const sequence = ++reviewing.current;
|
|
28
|
+
if (shown) {
|
|
29
|
+
setReview(null);
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
setBusy(true);
|
|
33
|
+
setError(null);
|
|
34
|
+
void controller.actions.readRemote().then(remote => {
|
|
35
|
+
if (sequence !== reviewing.current)
|
|
36
|
+
return;
|
|
37
|
+
if (remote.kind === 'available')
|
|
38
|
+
setReview({ remote: remote.text, revision: remote.revision });
|
|
39
|
+
else if (remote.kind === 'missing')
|
|
40
|
+
setReview({ remote: '', revision: null });
|
|
41
|
+
else
|
|
42
|
+
setError(remote.reason);
|
|
43
|
+
}, () => { if (sequence === reviewing.current)
|
|
44
|
+
setError(labels.operationFailed); }).finally(() => setBusy(false));
|
|
45
|
+
};
|
|
46
|
+
return _jsxs("div", { role: "alert", className: "boring-markdown-alert", "data-boring": "conflict-choices", children: [deleted ? labels.conflictRemoteDeleted : labels.conflictNotice, _jsx(Control, { type: "button", disabled: disabled || busy || readOnly, onClick: keepMine, children: labels.keepMine }), _jsx(Control, { type: "button", disabled: disabled || busy || deleted, onClick: () => run(controller.actions.discardToRemote), children: labels.takeTheirs }), _jsx(Control, { type: "button", disabled: disabled || busy, "aria-expanded": shown !== null, onClick: toggleReview, children: labels.reviewDiff }), shown && _jsxs("div", { style: { flex: '1 1 100%', minWidth: 0 }, children: [_jsx("p", { children: labels.conflictDiffLegend }), _jsx(MarkdownProposalDiff, { components: components, proposal: { before: shown.remote, after: state.text }, name: labels.conflictDiffName, labels: labels })] })] });
|
|
47
|
+
}
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { MarkdownEditorMenuLabels } from './viewer-components.js';
|
|
2
2
|
import { type TextDraftLabels } from './text-draft-labels.js';
|
|
3
|
+
/** The reason name `richOff` recognises: unlike the others, this is about the size and not about keeping the text exactly. */
|
|
4
|
+
export declare const MARKDOWN_TOO_LARGE_REASON = "more text than the rich editor can open";
|
|
3
5
|
/**
|
|
4
6
|
* Every word the markdown editor draws itself. `locale` picks the English or French set; `labels` overrides single keys
|
|
5
7
|
* (for example `{ saved: 'Enregistré', sourceName: title => title }`). Text a host supplies (`title`, `placeholder`, a save
|
|
@@ -51,7 +53,20 @@ export interface MarkdownEditorLabels extends MarkdownEditorMenuLabels, TextDraf
|
|
|
51
53
|
readonly checkSaveOutcome: string;
|
|
52
54
|
readonly stopChecking: string;
|
|
53
55
|
readonly conflictNotice: string;
|
|
56
|
+
readonly keepMine: string;
|
|
57
|
+
readonly takeTheirs: string;
|
|
58
|
+
readonly reviewDiff: string;
|
|
59
|
+
/** Keep mine found a revision the person had not seen yet. */
|
|
60
|
+
readonly conflictMovedAgain: string;
|
|
61
|
+
readonly conflictRemoteDeleted: string;
|
|
62
|
+
/** Accessible name of the Review diff panel. */
|
|
63
|
+
readonly conflictDiffName: string;
|
|
64
|
+
readonly conflictDiffLegend: string;
|
|
65
|
+
/** Short revision shown after a save; the full one is in its `title` and `data-revision`. */
|
|
66
|
+
readonly savedRevision: (revision: string) => string;
|
|
67
|
+
/** @deprecated No longer drawn: the conflict strip offers Keep mine, Take theirs and Review diff. Kept so existing overrides still type-check. */
|
|
54
68
|
readonly checkSavedVersion: string;
|
|
69
|
+
/** @deprecated No longer drawn: see `takeTheirs`. Kept so existing overrides still type-check. */
|
|
55
70
|
readonly discardLocal: string;
|
|
56
71
|
readonly conflictFallback: string;
|
|
57
72
|
readonly saveFallback: string;
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { textDraftLabels } from './text-draft-labels.js';
|
|
2
|
+
/** The reason name `richOff` recognises: unlike the others, this is about the size and not about keeping the text exactly. */
|
|
3
|
+
export const MARKDOWN_TOO_LARGE_REASON = 'more text than the rich editor can open';
|
|
2
4
|
const frenchBlocks = { 'Front matter': 'Métadonnées', Footnote: 'Note de bas de page', Reference: 'Référence' };
|
|
3
5
|
const frenchReasons = {
|
|
4
6
|
'bare carriage returns': 'des retours chariot isolés',
|
|
@@ -25,12 +27,18 @@ export const markdownEditorLabels = {
|
|
|
25
27
|
uncertainNotice: 'Save acknowledgement was lost. Keep editing while its outcome is checked.',
|
|
26
28
|
checkSaveOutcome: 'Check save outcome', stopChecking: 'Stop checking and refresh, keeping my draft',
|
|
27
29
|
conflictNotice: 'The saved document changed. Your local text has been kept.',
|
|
30
|
+
keepMine: 'Keep mine', takeTheirs: 'Take theirs', reviewDiff: 'Review diff',
|
|
31
|
+
conflictMovedAgain: 'The saved document changed again. Nothing was saved.',
|
|
32
|
+
conflictRemoteDeleted: 'The saved document was deleted. Keep mine creates it again.',
|
|
33
|
+
conflictDiffName: 'Differences between the saved version and your text',
|
|
34
|
+
conflictDiffLegend: 'Lines marked − are in the saved version. Lines marked + are in your text.',
|
|
35
|
+
savedRevision: revision => `Saved revision ${revision}`,
|
|
28
36
|
checkSavedVersion: 'Check saved version', discardLocal: 'Discard local changes and reload',
|
|
29
37
|
conflictFallback: 'The document changed elsewhere. Your text is still available.',
|
|
30
38
|
saveFallback: 'The document could not be saved. Your text is still available.',
|
|
31
39
|
saveFailed: 'The save could not be completed. Your text is still available.',
|
|
32
40
|
operationFailed: 'The operation could not be completed. Your text is still available.',
|
|
33
|
-
richOff: reasons => `Rich editing is off because this document contains ${reasons.join(', ')}, which it could not keep exactly. You can still edit the Markdown source.`,
|
|
41
|
+
richOff: reasons => reasons.includes(MARKDOWN_TOO_LARGE_REASON) ? 'Rich editing is off because this document is too large for it. You can still edit the Markdown source.' : `Rich editing is off because this document contains ${reasons.join(', ')}, which it could not keep exactly. You can still edit the Markdown source.`,
|
|
34
42
|
preservedBlockName: kind => kind, frontMatterCode: 'Front matter code',
|
|
35
43
|
proposedLineChanges: 'Proposed line changes', diffFallback: 'Detailed line alignment unavailable; showing changed block.',
|
|
36
44
|
noTextChanges: 'No text changes', crlfLineEnding: 'CRLF line ending', noNewline: 'No newline at end',
|
|
@@ -55,12 +63,18 @@ export const markdownEditorLabels = {
|
|
|
55
63
|
uncertainNotice: 'L’accusé d’enregistrement a été perdu. Continuez à écrire pendant que le résultat est vérifié.',
|
|
56
64
|
checkSaveOutcome: 'Vérifier l’enregistrement', stopChecking: 'Arrêter la vérification et actualiser, en gardant mon brouillon',
|
|
57
65
|
conflictNotice: 'Le document enregistré a changé. Votre texte local a été conservé.',
|
|
66
|
+
keepMine: 'Garder la mienne', takeTheirs: 'Prendre la leur', reviewDiff: 'Voir les différences',
|
|
67
|
+
conflictMovedAgain: 'Le document enregistré a encore changé. Rien n’a été enregistré.',
|
|
68
|
+
conflictRemoteDeleted: 'Le document enregistré a été supprimé. Garder la mienne le recrée.',
|
|
69
|
+
conflictDiffName: 'Différences entre la version enregistrée et votre texte',
|
|
70
|
+
conflictDiffLegend: 'Les lignes marquées − sont dans la version enregistrée. Les lignes marquées + sont dans votre texte.',
|
|
71
|
+
savedRevision: revision => `Révision enregistrée ${revision}`,
|
|
58
72
|
checkSavedVersion: 'Vérifier la version enregistrée', discardLocal: 'Abandonner mes modifications et recharger',
|
|
59
73
|
conflictFallback: 'Le document a été modifié ailleurs. Votre texte est toujours disponible.',
|
|
60
74
|
saveFallback: 'Le document n’a pas pu être enregistré. Votre texte est toujours disponible.',
|
|
61
75
|
saveFailed: 'L’enregistrement n’a pas pu aboutir. Votre texte est toujours disponible.',
|
|
62
76
|
operationFailed: 'L’opération n’a pas pu aboutir. Votre texte est toujours disponible.',
|
|
63
|
-
richOff: reasons => `La mise en forme est désactivée car ce document contient ${reasons.map(reason => frenchReasons[reason] ?? reason).join(', ')}, qu’elle ne saurait pas conserver à l’identique. Vous pouvez toujours modifier la source Markdown.`,
|
|
77
|
+
richOff: reasons => reasons.includes(MARKDOWN_TOO_LARGE_REASON) ? 'La mise en forme est désactivée car ce document est trop volumineux. Vous pouvez toujours modifier la source Markdown.' : `La mise en forme est désactivée car ce document contient ${reasons.map(reason => frenchReasons[reason] ?? reason).join(', ')}, qu’elle ne saurait pas conserver à l’identique. Vous pouvez toujours modifier la source Markdown.`,
|
|
64
78
|
preservedBlockName: kind => frenchBlocks[kind] ?? kind,
|
|
65
79
|
frontMatterCode: 'Code des métadonnées', proposedLineChanges: 'Changements de lignes proposés',
|
|
66
80
|
diffFallback: 'Alignement détaillé indisponible ; affichage du bloc modifié.', noTextChanges: 'Aucun changement de texte',
|
|
@@ -11,7 +11,7 @@ import { type MarkdownImageResolver } from './markdown-extensions.js';
|
|
|
11
11
|
import { type MarkdownRichSafety } from './markdown-safety.js';
|
|
12
12
|
import { type MarkdownEditorLabelOverrides } from './markdown-editor-labels.js';
|
|
13
13
|
import { type MarkdownMountedTools } from './markdown-mounted.js';
|
|
14
|
-
export { checkMarkdownRichSafety } from './markdown-safety.js';
|
|
14
|
+
export { checkMarkdownRichSafety, MARKDOWN_RICH_SIZE_LIMIT } from './markdown-safety.js';
|
|
15
15
|
export type { MarkdownRichSafety } from './markdown-safety.js';
|
|
16
16
|
export type { MarkdownImageResolver } from './markdown-extensions.js';
|
|
17
17
|
export { markdownEditorLabels } from './markdown-editor-labels.js';
|
|
@@ -46,6 +46,10 @@ export interface MarkdownEditorProps {
|
|
|
46
46
|
/** Registers the async navigation barrier. Null while autosave is disabled or the editor is unmounted. */
|
|
47
47
|
readonly onAutosaveChange?: (flush: MarkdownFlush | null) => void;
|
|
48
48
|
readonly onSaveResult?: (result: SaveResult) => void;
|
|
49
|
+
/** After a save, tells other tabs of this note in this browser to refresh, when the browser has BroadcastChannel. false keeps tabs apart. */
|
|
50
|
+
readonly crossTabRefresh?: boolean;
|
|
51
|
+
/** Above this many UTF-8 bytes the note opens as source and cannot switch to rich mode. Defaults to `MARKDOWN_RICH_SIZE_LIMIT`. */
|
|
52
|
+
readonly richSizeLimit?: number;
|
|
49
53
|
/** Selection formatting by default. Pinned shows the full row on desktop or above the phone keyboard while editing. */
|
|
50
54
|
readonly formattingToolbar?: 'selection' | 'pinned';
|
|
51
55
|
/** Language of everything the editor draws itself: status, alerts, buttons, accessible names. */
|
package/dist/markdown-editor.js
CHANGED
|
@@ -10,14 +10,16 @@ import { createPortal } from 'react-dom';
|
|
|
10
10
|
import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
|
|
11
11
|
import { BubbleMenu } from '@tiptap/react/menus';
|
|
12
12
|
import { createMarkdownExtensions } from './markdown-extensions.js';
|
|
13
|
-
import { checkMarkdownRichSafety, finishRichMarkdown } from './markdown-safety.js';
|
|
13
|
+
import { checkMarkdownRichSafety, finishRichMarkdown, markdownExceedsRichSize, MARKDOWN_RICH_SIZE_LIMIT } from './markdown-safety.js';
|
|
14
14
|
import { captureMarkdownSource, serializeMarkdownEdit } from './markdown-source.js';
|
|
15
15
|
import { MarkdownIcon } from './markdown-icons.js';
|
|
16
16
|
import { MarkdownProposalDiff } from './markdown-proposal-diff.js';
|
|
17
17
|
import { updatePreservedMarkdownLabels } from './markdown-preserved.js';
|
|
18
18
|
import { resolveMarkdownEditorLabels } from './markdown-editor-labels.js';
|
|
19
19
|
import { createMountedMarkdownTools } from './markdown-mounted.js';
|
|
20
|
-
|
|
20
|
+
import { MarkdownConflictChoices } from './markdown-conflict-choices.js';
|
|
21
|
+
import { useMarkdownTabPoke } from './markdown-tab-poke.js';
|
|
22
|
+
export { checkMarkdownRichSafety, MARKDOWN_RICH_SIZE_LIMIT } from './markdown-safety.js';
|
|
21
23
|
export { markdownEditorLabels } from './markdown-editor-labels.js';
|
|
22
24
|
/** Borrows the controller. The host owns document selection, access and disposal. */
|
|
23
25
|
export function MarkdownEditor(props) {
|
|
@@ -77,7 +79,7 @@ function NativeMarkdownViewOptions({ labels, mode, onModeChange, richDisabledRea
|
|
|
77
79
|
menu.current.open = false; };
|
|
78
80
|
return _jsxs("details", { ref: menu, "data-boring": "markdown-menu", children: [_jsx("summary", { "aria-label": labels.moreActions, children: _jsx(MarkdownIcon, { name: "More" }) }), _jsxs("fieldset", { children: [_jsx("legend", { children: labels.editorMode }), _jsxs("label", { title: richDisabledReason, children: [_jsx(InputControl, { type: "radio", name: group, "aria-label": labels.formatted, "aria-describedby": richDisabledReason ? `${group}-reason` : undefined, checked: mode === 'rich', disabled: !!richDisabledReason, onChange: () => choose(() => onModeChange('rich')) }), _jsx(MarkdownIcon, { name: "Heading1" }), labels.formatted, richDisabledReason && _jsx("span", { id: `${group}-reason`, children: richDisabledReason })] }), _jsxs("label", { children: [_jsx(InputControl, { type: "radio", name: group, "aria-label": labels.markdown, checked: mode === 'source', onChange: () => choose(() => onModeChange('source')) }), _jsx(MarkdownIcon, { name: "Code" }), labels.markdown] })] }), outline && _jsxs("label", { children: [_jsx(InputControl, { type: "checkbox", "aria-label": labels.showOutline, checked: outline.shown, onChange: event => choose(() => outline.onChange(event.currentTarget.checked)) }), _jsx(MarkdownIcon, { name: "BulletList" }), labels.showOutline] })] });
|
|
79
81
|
}
|
|
80
|
-
function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActivate, components, controller, title: titleProp, initialMode = 'rich', placeholder: placeholderProp, className, onMountedTools, header = true, showOutline = header, mode: controlledMode, onModeChange, onRichSafety, resolveImage, formattingToolbar = 'selection', locale = 'en', labels: labelOverrides, autosave = true, onAutosaveChange, onSaveResult, activeController: active }) {
|
|
82
|
+
function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActivate, components, controller, title: titleProp, initialMode = 'rich', placeholder: placeholderProp, className, onMountedTools, header = true, showOutline = header, mode: controlledMode, onModeChange, onRichSafety, resolveImage, formattingToolbar = 'selection', locale = 'en', labels: labelOverrides, autosave = true, onAutosaveChange, onSaveResult, crossTabRefresh = true, richSizeLimit = MARKDOWN_RICH_SIZE_LIMIT, activeController: active }) {
|
|
81
83
|
const text = useMemo(() => resolveMarkdownEditorLabels(locale, labelOverrides), [locale, labelOverrides]);
|
|
82
84
|
const title = titleProp ?? text.defaultTitle;
|
|
83
85
|
const placeholder = placeholderProp ?? text.defaultPlaceholder;
|
|
@@ -102,7 +104,12 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
102
104
|
const checked = useDeferredValue(state.text);
|
|
103
105
|
// The editor's own output is safe by construction; only text that arrived from elsewhere (or from the source box) is checked.
|
|
104
106
|
const ownText = state.text === emitted.current && source.current !== null;
|
|
105
|
-
const
|
|
107
|
+
const oversized = useMemo(() => markdownExceedsRichSize(state.text, richSizeLimit), [state.text, richSizeLimit]);
|
|
108
|
+
const safety = useMemo(() => {
|
|
109
|
+
if (oversized)
|
|
110
|
+
return checkMarkdownRichSafety(state.text, { maxBytes: richSizeLimit });
|
|
111
|
+
return ownText ? { safe: true } : checkMarkdownRichSafety(checked, { maxBytes: richSizeLimit });
|
|
112
|
+
}, [checked, ownText, oversized, richSizeLimit, state.text]);
|
|
106
113
|
const richOffered = safety.safe;
|
|
107
114
|
const mode = richOffered ? requestedMode : 'source';
|
|
108
115
|
const activeMode = useRef(mode);
|
|
@@ -149,7 +156,8 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
149
156
|
setError(null);
|
|
150
157
|
}, [controller]);
|
|
151
158
|
useEffect(() => {
|
|
152
|
-
if
|
|
159
|
+
// A note too large for rich mode is never parsed behind the source box; it projects if it comes back under the limit.
|
|
160
|
+
if (!editor || editor.isDestroyed || oversized || state.text === projected.current)
|
|
153
161
|
return;
|
|
154
162
|
// Text from outside (a load, a refresh, a discard, the source box) starts with the selection at the beginning, where the browser leaves
|
|
155
163
|
// its caret when the text is replaced. Replacing the whole document would otherwise leave the editor's selection at the end while the
|
|
@@ -158,7 +166,7 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
158
166
|
editor.chain().setContent(state.text.replace(/\r\n/g, '\n'), { contentType: 'markdown', emitUpdate: false }).setTextSelection(0).command(({ tr }) => { tr.setMeta('addToHistory', false); return true; }).run();
|
|
159
167
|
source.current = captureMarkdownSource(editor, state.text);
|
|
160
168
|
projected.current = state.text;
|
|
161
|
-
}, [editor, state.text]);
|
|
169
|
+
}, [editor, oversized, state.text]);
|
|
162
170
|
useEffect(() => { editor?.setEditable(!state.readOnly && state.lifecycle === 'active'); }, [editor, state.readOnly, state.lifecycle]);
|
|
163
171
|
useEffect(() => { if (editor && !editor.isDestroyed)
|
|
164
172
|
updatePreservedMarkdownLabels(editor.view.dom, text); }, [editor, text]);
|
|
@@ -264,6 +272,12 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
264
272
|
const outcome = state.save.kind === 'settled' ? state.save.result : null;
|
|
265
273
|
const uncertain = outcome?.kind === 'unknown';
|
|
266
274
|
const conflict = outcome?.kind === 'conflict' || state.remote !== null;
|
|
275
|
+
// The receipt stays while the next save is on its way, and goes once the text came from somewhere else.
|
|
276
|
+
const receipt = useRef(null);
|
|
277
|
+
if (outcome?.kind === 'saved')
|
|
278
|
+
receipt.current = outcome.ref.revision;
|
|
279
|
+
else if (state.save.kind !== 'pending')
|
|
280
|
+
receipt.current = null;
|
|
267
281
|
/** Shows a refused save or accept; the buffer keeps the text. */
|
|
268
282
|
const report = (result) => {
|
|
269
283
|
if (active.current !== controller || !result || typeof result !== 'object')
|
|
@@ -275,6 +289,7 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
275
289
|
setError(typeof reason === 'string' && reason ? reason : kind === 'conflict' ? text.conflictFallback : text.saveFallback);
|
|
276
290
|
};
|
|
277
291
|
const saving = useMarkdownAutosave(controller, autosave, result => { report(result); onSaveResult?.(result); });
|
|
292
|
+
useMarkdownTabPoke(controller, crossTabRefresh);
|
|
278
293
|
useEffect(() => { onAutosaveChange?.(saving.enabled ? saving.immediate : null); return () => onAutosaveChange?.(null); }, [onAutosaveChange, saving.enabled, saving.immediate]);
|
|
279
294
|
const save = () => {
|
|
280
295
|
setError(null);
|
|
@@ -349,7 +364,8 @@ function MarkdownEditorSession({ highlights, onQuoteSelection, onHighlightActiva
|
|
|
349
364
|
setError(result.reason);
|
|
350
365
|
}).catch(() => { if (active.current === controller)
|
|
351
366
|
setError(text.headingNotSelected); });
|
|
352
|
-
}, children: heading.text || text.untitledHeading }) }, heading.position)) }) }), uncertain && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: [text.uncertainNotice, _jsx(Control, { type: "button", onClick: () => run(controller.actions.reconcile), children: text.checkSaveOutcome }), _jsx(Control, { type: "button", onClick: () => run(controller.actions.abandon), children: text.stopChecking })] }), conflict &&
|
|
367
|
+
}, children: heading.text || text.untitledHeading }) }, heading.position)) }) }), uncertain && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: [text.uncertainNotice, _jsx(Control, { type: "button", onClick: () => run(controller.actions.reconcile), children: text.checkSaveOutcome }), _jsx(Control, { type: "button", onClick: () => run(controller.actions.abandon), children: text.stopChecking })] }), conflict && _jsx(MarkdownConflictChoices, { controller: controller, state: state, labels: text, components: components, disabled: pending || uncertain, readOnly: state.readOnly, setError: setError, run: run, onSaveResult: result => { if (result.kind !== 'conflict')
|
|
368
|
+
report(result); onSaveResult?.(result); } }), receipt.current !== null && !conflict && _jsx("p", { className: "boring-markdown-notice", "data-testid": "markdown-save-receipt", "data-revision": receipt.current, title: receipt.current, children: text.savedRevision(receipt.current.slice(0, 8)) }), (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 || text.proposedEdit }), _jsxs("details", { children: [_jsx("summary", { children: text.reviewChanges }), _jsx(MarkdownProposalDiff, { components: components, proposal: proposal, labels: text }), _jsxs("details", { children: [_jsx("summary", { children: text.exactBeforeAfter }), _jsx("h4", { children: text.before }), _jsx("pre", { children: proposal.before }), _jsx("h4", { children: text.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 ? text.adopted : text.acceptAndSave }), _jsx(Control, { type: "button", onClick: () => controller.actions.reject(proposal.id), children: text.dismissProposal })] }, proposal.id)), mode === 'source'
|
|
353
369
|
? _jsx(Source, { ref: sourceInput, className: "boring-markdown-source", "aria-label": text.sourceName(title), placeholder: placeholder, value: state.text, readOnly: unavailable, spellCheck: false, onSelect: event => onQuoteSelection?.(captureMarkdownQuote(state.text, { start: event.currentTarget.selectionStart, end: event.currentTarget.selectionEnd })), onChange: event => controller.actions.edit(event.currentTarget.value) })
|
|
354
370
|
: _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'))
|
|
355
371
|
event.preventDefault(); }, onAuxClickCapture: event => { if (event.target instanceof Element && event.target.closest('a'))
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { MarkdownProposal } from './markdown.js';
|
|
2
2
|
import { type MarkdownEditorLabels } from './markdown-editor-labels.js';
|
|
3
|
-
export declare function MarkdownProposalDiff({ components, proposal, labels }: {
|
|
3
|
+
export declare function MarkdownProposalDiff({ components, proposal, name, labels }: {
|
|
4
4
|
readonly components?: import('./viewer-components.js').ViewerComponents | undefined;
|
|
5
|
-
readonly proposal: MarkdownProposal
|
|
5
|
+
readonly proposal: Pick<MarkdownProposal, 'before' | 'after'>;
|
|
6
|
+
readonly name?: string | undefined;
|
|
6
7
|
readonly labels?: MarkdownEditorLabels;
|
|
7
8
|
}): import("react").JSX.Element;
|
|
8
9
|
//# sourceMappingURL=markdown-proposal-diff.d.ts.map
|
|
@@ -108,12 +108,12 @@ function DiffRow({ line, labels }) {
|
|
|
108
108
|
const display = terminated ? line.value.slice(0, -1) : line.value;
|
|
109
109
|
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') && _jsxs("span", { "data-diff-crlf": "", children: [" ", labels.crlfLineEnding] }), !terminated && _jsxs("span", { "data-diff-no-newline": "", children: [" ", labels.noNewline] })] });
|
|
110
110
|
}
|
|
111
|
-
export function MarkdownProposalDiff({ components, proposal, labels = markdownEditorLabels.en }) {
|
|
111
|
+
export function MarkdownProposalDiff({ components, proposal, name, labels = markdownEditorLabels.en }) {
|
|
112
112
|
const Control = components?.button ?? 'button';
|
|
113
113
|
const diff = useMemo(() => review(proposal.before, proposal.after), [proposal.before, proposal.after]);
|
|
114
114
|
const shown = useMemo(() => entries(diff.lines), [diff]);
|
|
115
115
|
const [expanded, setExpanded] = useState(() => new Set());
|
|
116
|
-
return _jsxs("div", { "data-boring": "proposal-diff", role: "group", "aria-label": labels.proposedLineChanges, children: [diff.fallback && _jsx("p", { role: "status", "data-diff-fallback": "", children: labels.diffFallback }), proposal.before === proposal.after && _jsx("p", { children: labels.noTextChanges }), _jsx("div", { style: { overflowX: 'auto', whiteSpace: 'pre-wrap' }, children: shown.map((entry, index) => entry.kind === 'fold' ? _jsxs("div", { children: [_jsx(Control, { type: "button", "data-diff-fold": "", "aria-expanded": expanded.has(entry.index), onClick: () => setExpanded(current => {
|
|
116
|
+
return _jsxs("div", { "data-boring": "proposal-diff", role: "group", "aria-label": name ?? labels.proposedLineChanges, children: [diff.fallback && _jsx("p", { role: "status", "data-diff-fallback": "", children: labels.diffFallback }), proposal.before === proposal.after && _jsx("p", { children: labels.noTextChanges }), _jsx("div", { style: { overflowX: 'auto', whiteSpace: 'pre-wrap' }, children: shown.map((entry, index) => entry.kind === 'fold' ? _jsxs("div", { children: [_jsx(Control, { type: "button", "data-diff-fold": "", "aria-expanded": expanded.has(entry.index), onClick: () => setExpanded(current => {
|
|
117
117
|
const next = new Set(current);
|
|
118
118
|
if (next.has(entry.index))
|
|
119
119
|
next.delete(entry.index);
|
|
@@ -10,10 +10,16 @@ export declare function normalizeMarkdownWhitespace(text: string): string;
|
|
|
10
10
|
export declare function trailingWhitespace(text: string): string;
|
|
11
11
|
/** The text a rich edit publishes: the serialised document, whitespace normalised, with the original's trailing newline. */
|
|
12
12
|
export declare function finishRichMarkdown(serialized: string, previous: string): string;
|
|
13
|
+
/** Above this many UTF-8 bytes a note opens as source: parsing it into the rich editor would stall the tab on every change. */
|
|
14
|
+
export declare const MARKDOWN_RICH_SIZE_LIMIT: number;
|
|
15
|
+
/** A UTF-16 unit is one to three UTF-8 bytes, so most lengths answer without encoding the text. */
|
|
16
|
+
export declare function markdownExceedsRichSize(text: string, maxBytes?: number): boolean;
|
|
13
17
|
/**
|
|
14
18
|
* Verifies a native projection and its exact source spans. Unchanged blocks retain their source, and unsupported constructs are
|
|
15
19
|
* inert atoms. The legacy whitespace comparison remains for empty/nbsp-only layout. Without a DOM only encoding can be checked.
|
|
16
20
|
*/
|
|
17
|
-
export declare function checkMarkdownRichSafety(text: string
|
|
21
|
+
export declare function checkMarkdownRichSafety(text: string, options?: {
|
|
22
|
+
readonly maxBytes?: number;
|
|
23
|
+
}): MarkdownRichSafety;
|
|
18
24
|
export declare function describeUnsafe(safety: MarkdownRichSafety): string;
|
|
19
25
|
//# sourceMappingURL=markdown-safety.d.ts.map
|
package/dist/markdown-safety.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Editor } from '@tiptap/core';
|
|
2
2
|
import { createMarkdownExtensions } from './markdown-extensions.js';
|
|
3
3
|
import { captureMarkdownSource } from './markdown-source.js';
|
|
4
|
+
import { MARKDOWN_TOO_LARGE_REASON } from './markdown-editor-labels.js';
|
|
4
5
|
const FENCE = /^ {0,3}(```|~~~)/;
|
|
5
6
|
/** Blank-line runs outside fenced code collapse to one blank line, and the ends are trimmed: the only whitespace the rich editor may normalise. */
|
|
6
7
|
export function normalizeMarkdownWhitespace(text) {
|
|
@@ -79,14 +80,27 @@ function namedReasons(text) {
|
|
|
79
80
|
reasons.push('NUL characters');
|
|
80
81
|
return reasons;
|
|
81
82
|
}
|
|
83
|
+
/** Above this many UTF-8 bytes a note opens as source: parsing it into the rich editor would stall the tab on every change. */
|
|
84
|
+
export const MARKDOWN_RICH_SIZE_LIMIT = 512 * 1024;
|
|
85
|
+
const TOO_LARGE = Object.freeze({ safe: false, reasons: Object.freeze([MARKDOWN_TOO_LARGE_REASON]) });
|
|
86
|
+
/** A UTF-16 unit is one to three UTF-8 bytes, so most lengths answer without encoding the text. */
|
|
87
|
+
export function markdownExceedsRichSize(text, maxBytes = MARKDOWN_RICH_SIZE_LIMIT) {
|
|
88
|
+
if (text.length > maxBytes)
|
|
89
|
+
return true;
|
|
90
|
+
if (text.length * 3 <= maxBytes)
|
|
91
|
+
return false;
|
|
92
|
+
return new TextEncoder().encode(text).byteLength > maxBytes;
|
|
93
|
+
}
|
|
82
94
|
const cache = new Map();
|
|
83
95
|
/**
|
|
84
96
|
* Verifies a native projection and its exact source spans. Unchanged blocks retain their source, and unsupported constructs are
|
|
85
97
|
* inert atoms. The legacy whitespace comparison remains for empty/nbsp-only layout. Without a DOM only encoding can be checked.
|
|
86
98
|
*/
|
|
87
|
-
export function checkMarkdownRichSafety(text) {
|
|
99
|
+
export function checkMarkdownRichSafety(text, options = {}) {
|
|
88
100
|
if (text === '')
|
|
89
101
|
return { safe: true };
|
|
102
|
+
if (markdownExceedsRichSize(text, options.maxBytes))
|
|
103
|
+
return TOO_LARGE;
|
|
90
104
|
const known = cache.get(text);
|
|
91
105
|
if (known)
|
|
92
106
|
return known;
|
|
@@ -116,5 +130,7 @@ export function checkMarkdownRichSafety(text) {
|
|
|
116
130
|
export function describeUnsafe(safety) {
|
|
117
131
|
if (safety.safe)
|
|
118
132
|
return '';
|
|
133
|
+
if (safety.reasons.includes(MARKDOWN_TOO_LARGE_REASON))
|
|
134
|
+
return 'Rich editing is off because this document is too large for it. You can still edit the Markdown source.';
|
|
119
135
|
return `Rich editing is off because this document contains ${safety.reasons.join(', ')}, which it could not keep exactly. You can still edit the Markdown source.`;
|
|
120
136
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { MarkdownController } from './markdown.js';
|
|
2
|
+
/**
|
|
3
|
+
* Optional nudge between tabs of one note in one browser. A saved revision goes out and the receiver only runs its existing refresh,
|
|
4
|
+
* which never overwrites unsaved text and never saves, so a poke cannot start another one. No document text crosses the channel.
|
|
5
|
+
*/
|
|
6
|
+
export declare function useMarkdownTabPoke(controller: MarkdownController, enabled: boolean): void;
|
|
7
|
+
//# sourceMappingURL=markdown-tab-poke.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { useEffect } from 'react';
|
|
2
|
+
function channelName({ base: { target: { resource, view } }, identity }) {
|
|
3
|
+
return `boring-markdown:${JSON.stringify([identity.scopeId, resource.providerId, resource.path, view.kind, view.kind === 'working' ? view.viewId : null])}`;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* Optional nudge between tabs of one note in one browser. A saved revision goes out and the receiver only runs its existing refresh,
|
|
7
|
+
* which never overwrites unsaved text and never saves, so a poke cannot start another one. No document text crosses the channel.
|
|
8
|
+
*/
|
|
9
|
+
export function useMarkdownTabPoke(controller, enabled) {
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const Channel = globalThis.BroadcastChannel;
|
|
12
|
+
if (!enabled || typeof Channel !== 'function')
|
|
13
|
+
return;
|
|
14
|
+
const opening = controller.getSnapshot();
|
|
15
|
+
if (opening.lifecycle !== 'active')
|
|
16
|
+
return;
|
|
17
|
+
let channel;
|
|
18
|
+
try {
|
|
19
|
+
channel = new Channel(channelName(opening));
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
// A listening channel must not hold a server-side process open.
|
|
25
|
+
channel.unref?.();
|
|
26
|
+
const receive = (event) => {
|
|
27
|
+
const revision = event.data?.revision;
|
|
28
|
+
const state = controller.getSnapshot();
|
|
29
|
+
if (typeof revision !== 'string' || state.lifecycle !== 'active')
|
|
30
|
+
return;
|
|
31
|
+
const known = (expectation) => expectation?.kind === 'revision' && expectation.target.revision === revision;
|
|
32
|
+
if (known(state.base) || known(state.remote))
|
|
33
|
+
return;
|
|
34
|
+
void controller.actions.refresh().catch(() => { });
|
|
35
|
+
};
|
|
36
|
+
channel.addEventListener('message', receive);
|
|
37
|
+
let announced = opening.save;
|
|
38
|
+
const stop = controller.subscribe(() => {
|
|
39
|
+
const { save } = controller.getSnapshot();
|
|
40
|
+
if (save === announced || save.kind !== 'settled' || save.result.kind !== 'saved')
|
|
41
|
+
return;
|
|
42
|
+
announced = save;
|
|
43
|
+
try {
|
|
44
|
+
channel.postMessage({ revision: save.result.ref.revision });
|
|
45
|
+
}
|
|
46
|
+
catch { /* A tab that cannot tell the others still saved. */ }
|
|
47
|
+
});
|
|
48
|
+
return () => {
|
|
49
|
+
stop();
|
|
50
|
+
channel.removeEventListener('message', receive);
|
|
51
|
+
try {
|
|
52
|
+
channel.close();
|
|
53
|
+
}
|
|
54
|
+
catch { /* Nothing is left to stop. */ }
|
|
55
|
+
};
|
|
56
|
+
}, [controller, enabled]);
|
|
57
|
+
}
|
package/dist/markdown.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { ReadResult, ResourceAccess, ResourceClient, ResourceExpectation, ResourceLocator, ResourceSnapshot } from '@hachej/boring-files';
|
|
2
2
|
import { type TextEdit } from '@hachej/boring-files/text';
|
|
3
3
|
import type { PresentationCommand, PresentationResult } from './contracts.js';
|
|
4
|
+
import { type TextBufferRemote } from './text-buffer.js';
|
|
4
5
|
import type { EditableViewerController, SaveResult, SaveSelection } from './resources.js';
|
|
5
6
|
import type { TextDraftActions, TextDraftOptions, TextDraftRecoveryState } from './text-draft-types.js';
|
|
6
7
|
export type MarkdownSource = {
|
|
@@ -68,7 +69,12 @@ export interface MarkdownActions extends TextDraftActions {
|
|
|
68
69
|
/** For an unconfirmed save: stop reconciling and refresh, keeping the draft. The save is never reported committed or replayed. */
|
|
69
70
|
readonly abandon: () => Promise<ReadResult>;
|
|
70
71
|
readonly discardToRemote: () => Promise<ReadResult>;
|
|
72
|
+
/** Keeps the local text over the remote revision already shown and saves it conditionally. A remote that moved since is a new conflict, never an overwrite. */
|
|
73
|
+
readonly keepLocal: () => Promise<SaveResult>;
|
|
74
|
+
/** Reads the current saved text for review. The local text and base stay; what the editor knows of the remote moves to the revision returned, so `keepLocal` acts on it. */
|
|
75
|
+
readonly readRemote: () => Promise<MarkdownRemote>;
|
|
71
76
|
}
|
|
77
|
+
export type MarkdownRemote = TextBufferRemote;
|
|
72
78
|
export interface MarkdownOptions {
|
|
73
79
|
readonly drafts?: TextDraftOptions;
|
|
74
80
|
/** Expected authenticated identity is comparison metadata, never a grant. */
|