@glw907/cairn-cms 0.60.0 → 0.60.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/CHANGELOG.md +13 -0
- package/dist/components/AdminLayout.svelte +130 -229
- package/dist/components/CairnAdmin.svelte +10 -42
- package/dist/components/CairnLogo.svelte +1 -6
- package/dist/components/CairnMediaLibrary.svelte +821 -1210
- package/dist/components/CairnTidySettings.svelte +192 -259
- package/dist/components/ComponentForm.svelte +110 -185
- package/dist/components/ComponentInsertDialog.svelte +163 -283
- package/dist/components/ConceptList.svelte +111 -191
- package/dist/components/ConfirmPage.svelte +5 -12
- package/dist/components/CsrfField.svelte +5 -11
- package/dist/components/DeleteDialog.svelte +15 -42
- package/dist/components/EditPage.svelte +665 -1166
- package/dist/components/EditorToolbar.svelte +108 -170
- package/dist/components/IconPicker.svelte +23 -53
- package/dist/components/LinkPicker.svelte +34 -58
- package/dist/components/LoginPage.svelte +14 -27
- package/dist/components/ManageEditors.svelte +3 -15
- package/dist/components/MarkdownEditor.svelte +689 -957
- package/dist/components/MarkdownHelpDialog.svelte +8 -12
- package/dist/components/MediaCaptureCard.svelte +18 -57
- package/dist/components/MediaFigureControl.svelte +32 -71
- package/dist/components/MediaHeroField.svelte +210 -329
- package/dist/components/MediaInsertPopover.svelte +156 -283
- package/dist/components/MediaPicker.svelte +67 -131
- package/dist/components/NavTree.svelte +46 -78
- package/dist/components/RenameDialog.svelte +16 -43
- package/dist/components/ShortcutsDialog.svelte +9 -13
- package/dist/components/ShortcutsGrid.svelte +1 -2
- package/dist/components/TidyReview.svelte +140 -248
- package/dist/components/WebLinkDialog.svelte +19 -40
- package/dist/components/cairn-admin.css +4 -0
- package/dist/components/spellcheck.d.ts +3 -1
- package/dist/components/spellcheck.js +14 -2
- package/dist/delivery/CairnHead.svelte +8 -11
- package/package.json +2 -2
- package/src/lib/components/spellcheck.ts +16 -2
|
@@ -16,1206 +16,705 @@ pending), and an overflow menu for Discard and Delete. One feedback strip under
|
|
|
16
16
|
transient flashes, and the editor card's footer is the writing-environment strip: the word
|
|
17
17
|
count, the Prose/Markup posture pair, the focus and typewriter toggles, and the Markdown help.
|
|
18
18
|
-->
|
|
19
|
-
<script lang="ts">
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
const topbar = useTopbar();
|
|
104
|
-
$effect(() => {
|
|
105
|
-
if (!topbar) return;
|
|
106
|
-
topbar.desk = desk;
|
|
107
|
-
// Zen drops the band: AdminLayout reads this flag to remove the whole topbar element, so the
|
|
108
|
-
// desk's clusters and AdminLayout's own chrome (the drawer toggle, the breadcrumb) all slide
|
|
109
|
-
// away together. The effect tracks `zen`, so a toggle reaches the band live.
|
|
110
|
-
topbar.zen = zen;
|
|
111
|
-
return () => {
|
|
112
|
-
topbar.desk = null;
|
|
113
|
-
topbar.zen = false;
|
|
114
|
-
};
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
// `body` is local editor state seeded once; it diverges as the user types. A blocked save returns
|
|
118
|
-
// the author's edited markdown as form.body, so seed from that when present to keep the edits and
|
|
119
|
-
// the broken link they were told to fix. On the success and delete-refused paths form carries no
|
|
120
|
-
// body, so it falls back to the committed data.body. untrack() captures the initial value without
|
|
121
|
-
// subscribing to future prop changes.
|
|
122
|
-
let body = $state(untrack(() => form?.body ?? data.body));
|
|
123
|
-
// True from the moment the save form submits until the navigation it triggers replaces the page,
|
|
124
|
-
// so the Save button shows a calm "Saving…" state instead of looking inert.
|
|
125
|
-
let saving = $state(false);
|
|
126
|
-
// The same working state for the Publish button, which rides the edit form via formaction. The
|
|
127
|
-
// submit handler reads the submitter to flip the right one, so Save never reads "Saving…" while
|
|
128
|
-
// a publish is in flight.
|
|
129
|
-
let publishing = $state(false);
|
|
130
|
-
function onEditSubmit(e: SubmitEvent) {
|
|
131
|
-
const formaction = (e.submitter as HTMLButtonElement | null)?.getAttribute('formaction');
|
|
132
|
-
if (formaction === '?/publish') publishing = true;
|
|
133
|
-
else saving = true;
|
|
134
|
-
// Commit any pending personal-dictionary additions alongside the save. Fire-and-forget: the words
|
|
135
|
-
// are already live in the Worker, so the in-flight commit never blocks the save navigation; an add
|
|
136
|
-
// that does not land stays pending for the next save (declared before the navigation reads it).
|
|
137
|
-
void commitPendingDictionary();
|
|
138
|
-
}
|
|
139
|
-
// Either in-flight submit disables both buttons, so a second click cannot fire a second POST
|
|
140
|
-
// while the first navigation is still pending.
|
|
141
|
-
const busy = $derived(saving || publishing);
|
|
142
|
-
// True once a non-edit POST (discard, delete, rename) submits. Those forms navigate the
|
|
143
|
-
// document without flipping busy, so without this the leave guard would fire mid-discard while
|
|
144
|
-
// the page is still dirty, which is the primary discard scenario.
|
|
145
|
-
let leaving = $state(false);
|
|
146
|
-
|
|
147
|
-
// Dirty tracking. The body compares against the text the page loaded with (or the edited body a
|
|
148
|
-
// blocked save returned, which seeded the editor); the uncontrolled sidebar fields flip a flag
|
|
149
|
-
// on any input event, and the navigation a save triggers reloads the page, which resets both.
|
|
150
|
-
const bodyDirty = $derived(body !== (form?.body ?? data.body));
|
|
151
|
-
let fieldsDirty = $state(false);
|
|
152
|
-
const dirty = $derived(bodyDirty || fieldsDirty);
|
|
153
|
-
// What the header's save-state indicator says.
|
|
154
|
-
const saveState = $derived(dirty ? 'Unsaved changes' : data.saved ? 'Saved' : '');
|
|
155
|
-
function onFormInput(e: Event) {
|
|
156
|
-
const target = e.target as Element | null;
|
|
157
|
-
// Two kinds of input event bubble through the form without being frontmatter edits: the link
|
|
158
|
-
// picker's search box (its dialog sits in the toolbar snippet) and the editing surface's
|
|
159
|
-
// contenteditable. Skipping the surface keeps body edits owned by bodyDirty, so undoing back
|
|
160
|
-
// to the committed text reads clean again.
|
|
161
|
-
if (target?.closest('dialog, #cairn-pane-write')) return;
|
|
162
|
-
fieldsDirty = true;
|
|
19
|
+
<script lang="ts">import { flushSync, untrack, getContext } from "svelte";
|
|
20
|
+
import { beforeNavigate } from "$app/navigation";
|
|
21
|
+
import { deserialize } from "$app/forms";
|
|
22
|
+
import { page } from "$app/state";
|
|
23
|
+
import BlocksIcon from "@lucide/svelte/icons/blocks";
|
|
24
|
+
import SquarePenIcon from "@lucide/svelte/icons/square-pen";
|
|
25
|
+
import LinkIcon from "@lucide/svelte/icons/link";
|
|
26
|
+
import FileSymlinkIcon from "@lucide/svelte/icons/file-symlink";
|
|
27
|
+
import PanelRightIcon from "@lucide/svelte/icons/panel-right";
|
|
28
|
+
import ImageIcon from "@lucide/svelte/icons/image";
|
|
29
|
+
import { useTopbar } from "./topbar-context.js";
|
|
30
|
+
import CsrfField from "./CsrfField.svelte";
|
|
31
|
+
import MarkdownEditor from "./MarkdownEditor.svelte";
|
|
32
|
+
import EditorToolbar from "./EditorToolbar.svelte";
|
|
33
|
+
import ComponentInsertDialog, { insertableDefs, hasSchema } from "./ComponentInsertDialog.svelte";
|
|
34
|
+
import LinkPicker from "./LinkPicker.svelte";
|
|
35
|
+
import WebLinkDialog from "./WebLinkDialog.svelte";
|
|
36
|
+
import MediaInsertPopover from "./MediaInsertPopover.svelte";
|
|
37
|
+
import MediaHeroField from "./MediaHeroField.svelte";
|
|
38
|
+
import MediaFigureControl from "./MediaFigureControl.svelte";
|
|
39
|
+
import DeleteDialog from "./DeleteDialog.svelte";
|
|
40
|
+
import RenameDialog from "./RenameDialog.svelte";
|
|
41
|
+
import MarkdownHelpDialog from "./MarkdownHelpDialog.svelte";
|
|
42
|
+
import ShortcutsDialog from "./ShortcutsDialog.svelte";
|
|
43
|
+
import TidyReview from "./TidyReview.svelte";
|
|
44
|
+
import SparklesIcon from "@lucide/svelte/icons/sparkles";
|
|
45
|
+
import { validateTidy, TIDY_REJECTION_MESSAGE } from "./tidy-validate.js";
|
|
46
|
+
import { cairnLinkCompletionSource } from "./link-completion.js";
|
|
47
|
+
import {
|
|
48
|
+
findMediaImagesNeedingAlt,
|
|
49
|
+
unwrapCairnLink,
|
|
50
|
+
unwrapFigure,
|
|
51
|
+
updateFigure,
|
|
52
|
+
wrapImageInFigure
|
|
53
|
+
} from "./markdown-format.js";
|
|
54
|
+
import { buildPreviewDoc, deviceLabel, previewDevice, previewDevices } from "./preview-doc.js";
|
|
55
|
+
import { directiveLineKind, findInlineDirectives } from "./markdown-directives.js";
|
|
56
|
+
import { parseComponent, componentRoundTripSafety } from "../render/component-grammar.js";
|
|
57
|
+
import { manifestLinkResolver } from "../content/manifest.js";
|
|
58
|
+
import { manifestMediaResolver } from "../render/resolve-media.js";
|
|
59
|
+
import { mediaLibraryEntry } from "../media/library-entry.js";
|
|
60
|
+
import { CSRF_CONTEXT_KEY } from "./csrf-context.js";
|
|
61
|
+
let { data, registry, render, icons, form } = $props();
|
|
62
|
+
const TIDY_CLIENT_TIMEOUT_MS = 45e3;
|
|
63
|
+
const topbar = useTopbar();
|
|
64
|
+
$effect(() => {
|
|
65
|
+
if (!topbar) return;
|
|
66
|
+
topbar.desk = desk;
|
|
67
|
+
topbar.zen = zen;
|
|
68
|
+
return () => {
|
|
69
|
+
topbar.desk = null;
|
|
70
|
+
topbar.zen = false;
|
|
71
|
+
};
|
|
72
|
+
});
|
|
73
|
+
let body = $state(untrack(() => form?.body ?? data.body));
|
|
74
|
+
let saving = $state(false);
|
|
75
|
+
let publishing = $state(false);
|
|
76
|
+
function onEditSubmit(e) {
|
|
77
|
+
const formaction = e.submitter?.getAttribute("formaction");
|
|
78
|
+
if (formaction === "?/publish") publishing = true;
|
|
79
|
+
else saving = true;
|
|
80
|
+
void commitPendingDictionary();
|
|
81
|
+
}
|
|
82
|
+
const busy = $derived(saving || publishing);
|
|
83
|
+
let leaving = $state(false);
|
|
84
|
+
const bodyDirty = $derived(body !== (form?.body ?? data.body));
|
|
85
|
+
let fieldsDirty = $state(false);
|
|
86
|
+
const dirty = $derived(bodyDirty || fieldsDirty);
|
|
87
|
+
const saveState = $derived(dirty ? "Unsaved changes" : data.saved ? "Saved" : "");
|
|
88
|
+
function onFormInput(e) {
|
|
89
|
+
const target = e.target;
|
|
90
|
+
if (target?.closest("dialog, #cairn-pane-write")) return;
|
|
91
|
+
fieldsDirty = true;
|
|
92
|
+
}
|
|
93
|
+
function markFieldsDirty() {
|
|
94
|
+
fieldsDirty = true;
|
|
95
|
+
}
|
|
96
|
+
let editForm = $state(null);
|
|
97
|
+
let publishButton = $state(null);
|
|
98
|
+
function onFormInvalid(e) {
|
|
99
|
+
const target = e.target;
|
|
100
|
+
if (target?.closest("aside")) {
|
|
101
|
+
if (!detailsOpen) flushSync(() => detailsOpen = true);
|
|
102
|
+
return;
|
|
163
103
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
104
|
+
if (mode === "write") return;
|
|
105
|
+
flushSync(() => mode = "write");
|
|
106
|
+
}
|
|
107
|
+
beforeNavigate((navigation) => {
|
|
108
|
+
if (navigation.willUnload) {
|
|
109
|
+
if (dirty && !busy && !leaving) navigation.cancel();
|
|
110
|
+
return;
|
|
169
111
|
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
function onFormInvalid(e: Event) {
|
|
185
|
-
const target = e.target as Element | null;
|
|
186
|
-
if (target?.closest('aside')) {
|
|
187
|
-
if (!detailsOpen) flushSync(() => (detailsOpen = true));
|
|
112
|
+
if (dirty && !busy && !leaving && !confirm("You have unsaved changes. Leave anyway?"))
|
|
113
|
+
navigation.cancel();
|
|
114
|
+
});
|
|
115
|
+
$effect(() => {
|
|
116
|
+
const onBeforeUnload = (e) => {
|
|
117
|
+
if (dirty && !busy && !leaving) e.preventDefault();
|
|
118
|
+
};
|
|
119
|
+
const onWindowKeydown = (e) => {
|
|
120
|
+
if (e.key === "Escape" && (detailsOpen || zen)) {
|
|
121
|
+
const inDialog2 = !!e.target?.closest?.("dialog");
|
|
122
|
+
if (inDialog2) return;
|
|
123
|
+
e.preventDefault();
|
|
124
|
+
if (detailsOpen) closeDetails();
|
|
125
|
+
else setZen(false);
|
|
188
126
|
return;
|
|
189
127
|
}
|
|
190
|
-
if (
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
// leaving does.
|
|
198
|
-
beforeNavigate((navigation) => {
|
|
199
|
-
// A full-page unload (refresh, tab close, external link): per SvelteKit semantics, cancel()
|
|
200
|
-
// on a leave navigation is what asks the browser for its native dialog, so no confirm()
|
|
201
|
-
// here or two prompts would stack. The beforeunload listener below is deliberate
|
|
202
|
-
// belt-and-braces, not the dialog's source.
|
|
203
|
-
if (navigation.willUnload) {
|
|
204
|
-
if (dirty && !busy && !leaving) navigation.cancel();
|
|
128
|
+
if (!(e.ctrlKey || e.metaKey)) return;
|
|
129
|
+
const key = e.key.toLowerCase();
|
|
130
|
+
const inDialog = !!e.target?.closest?.("dialog");
|
|
131
|
+
if (!e.shiftKey && !e.altKey && e.key === "/") {
|
|
132
|
+
e.preventDefault();
|
|
133
|
+
if (inDialog) return;
|
|
134
|
+
shortcutsDialog?.open();
|
|
205
135
|
return;
|
|
206
136
|
}
|
|
207
|
-
if (
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
215
|
-
if (dirty && !busy && !leaving) e.preventDefault();
|
|
216
|
-
};
|
|
217
|
-
// Guard-clause style on purpose: svelte 5.56.1 misprints `(a || b) && c` by dropping the
|
|
218
|
-
// parentheses, and consumers compile this source with their own svelte.
|
|
219
|
-
const onWindowKeydown = (e: KeyboardEvent) => {
|
|
220
|
-
// Escape precedence, top to bottom: an open dialog claims Escape natively, so step aside
|
|
221
|
-
// when one is up. Otherwise the details slide-over closes first (Task 8: it is a region, not
|
|
222
|
-
// a dialog, so it has no native light-dismiss), and only when no panel is open does Escape
|
|
223
|
-
// exit zen. So under zen with the panel open, the first Escape closes the panel and the
|
|
224
|
-
// second exits zen, which keeps the two affordances independent.
|
|
225
|
-
if (e.key === 'Escape' && (detailsOpen || zen)) {
|
|
226
|
-
const inDialog = !!(e.target as Element | null)?.closest?.('dialog');
|
|
227
|
-
if (inDialog) return;
|
|
228
|
-
e.preventDefault();
|
|
229
|
-
if (detailsOpen) closeDetails();
|
|
230
|
-
else setZen(false);
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
if (!(e.ctrlKey || e.metaKey)) return;
|
|
234
|
-
const key = e.key.toLowerCase();
|
|
235
|
-
// The page-wide chords never act on a surface the author cannot see: a save, publish, mode
|
|
236
|
-
// flip, or focus toggle from inside an open modal is suppressed the same way.
|
|
237
|
-
const inDialog = !!(e.target as Element | null)?.closest?.('dialog');
|
|
238
|
-
// Ctrl+/ opens the shortcuts sheet, the third discoverability surface. It reads off e.key so
|
|
239
|
-
// it survives the shifted glyph differences across layouts, and it stays clear of dialogs the
|
|
240
|
-
// same way the other chords do (the sheet is itself a dialog, so opening from inside one would
|
|
241
|
-
// stack modals over a surface the author cannot see).
|
|
242
|
-
if (!e.shiftKey && !e.altKey && e.key === '/') {
|
|
243
|
-
e.preventDefault();
|
|
244
|
-
if (inDialog) return;
|
|
245
|
-
shortcutsDialog?.open();
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
if (e.shiftKey && key === 's') {
|
|
249
|
-
// Publish rides the header's Publish submitter so the ?/publish formaction and the busy
|
|
250
|
-
// flags follow the existing submit path; it exists only while pending, so this no-ops
|
|
251
|
-
// otherwise.
|
|
252
|
-
e.preventDefault();
|
|
253
|
-
if (busy || inDialog || !data.pending) return;
|
|
254
|
-
editForm?.requestSubmit(publishButton);
|
|
255
|
-
return;
|
|
256
|
-
}
|
|
257
|
-
if (e.altKey && key === 'p') {
|
|
258
|
-
e.preventDefault();
|
|
259
|
-
if (inDialog) return;
|
|
260
|
-
setMode(mode === 'write' ? 'preview' : 'write');
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
if (e.shiftKey && key === 'f') {
|
|
264
|
-
e.preventDefault();
|
|
265
|
-
if (inDialog) return;
|
|
266
|
-
setFocusMode(!focusMode);
|
|
267
|
-
return;
|
|
268
|
-
}
|
|
269
|
-
// Ctrl+Shift+. toggles zen (the bindings' zen key); the period reads off e.key. This sits
|
|
270
|
-
// before the Ctrl+. panel block so the shifted chord is not mistaken for the panel toggle.
|
|
271
|
-
if (e.shiftKey && !e.altKey && e.key === '.') {
|
|
272
|
-
e.preventDefault();
|
|
273
|
-
if (inDialog) return;
|
|
274
|
-
setZen(!zen);
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
// Ctrl+. toggles the details slide-over (the bindings' panel key); the period reads off
|
|
278
|
-
// e.key with no shift or alt.
|
|
279
|
-
if (!e.shiftKey && !e.altKey && e.key === '.') {
|
|
280
|
-
e.preventDefault();
|
|
281
|
-
if (inDialog) return;
|
|
282
|
-
toggleDetails();
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
if (e.shiftKey || e.altKey) return;
|
|
286
|
-
if (key !== 's') return;
|
|
287
|
-
// Always claim the shortcut so the browser's save-page dialog never opens over the admin.
|
|
137
|
+
if (e.shiftKey && key === "s") {
|
|
138
|
+
e.preventDefault();
|
|
139
|
+
if (busy || inDialog || !data.pending) return;
|
|
140
|
+
editForm?.requestSubmit(publishButton);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
if (e.altKey && key === "p") {
|
|
288
144
|
e.preventDefault();
|
|
289
|
-
// Gate the submit itself: an in-flight POST must not race a second one, a clean page has
|
|
290
|
-
// nothing to save (a no-op save would still cut a pending branch), and a save from inside
|
|
291
|
-
// an open modal would act on a surface the author cannot see.
|
|
292
|
-
if (busy) return;
|
|
293
|
-
if (!dirty && !data.isNew) return;
|
|
294
145
|
if (inDialog) return;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
window.addEventListener('beforeunload', onBeforeUnload);
|
|
298
|
-
window.addEventListener('keydown', onWindowKeydown);
|
|
299
|
-
return () => {
|
|
300
|
-
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
301
|
-
window.removeEventListener('keydown', onWindowKeydown);
|
|
302
|
-
};
|
|
303
|
-
});
|
|
304
|
-
// The discard confirm, on the DeleteDialog pattern: a native <dialog> holding the POST form.
|
|
305
|
-
let discardDialog = $state<HTMLDialogElement | null>(null);
|
|
306
|
-
// Which pane the editor card shows. The toolbar's tablist drives it; Write is always the
|
|
307
|
-
// landing tab.
|
|
308
|
-
let mode = $state<'write' | 'preview'>('write');
|
|
309
|
-
// Preview is read-only, so the insert controls the page renders into the toolbar disable with the
|
|
310
|
-
// strip's own format buttons. Declared here (above the Edit-block derivations that read it) so it
|
|
311
|
-
// is in scope before its first use.
|
|
312
|
-
// Tidy mode disables the toolbar and makes the surface read-only while a review is open. The host
|
|
313
|
-
// sets it when the review opens and clears it on apply or cancel. Declared here so the insert-disable
|
|
314
|
-
// derivation below can read it.
|
|
315
|
-
let tidyMode = $state(false);
|
|
316
|
-
// The tidy request in-flight flag, so the Tidy control reads busy while a call runs.
|
|
317
|
-
let tidyBusy = $state(false);
|
|
318
|
-
// The insert controls disable in Preview (read-only) and while a tidy review is open (the author
|
|
319
|
-
// cannot edit underneath a pending review, the same posture Preview takes).
|
|
320
|
-
const insertDisabled = $derived(mode === 'preview' || tidyMode);
|
|
321
|
-
let previewHtml = $state('');
|
|
322
|
-
// True after a render call threw, so the preview pane can say so instead of going blank.
|
|
323
|
-
let previewFailed = $state(false);
|
|
324
|
-
// The preview frame's device width, a per-browser preference under its own key (the legacy
|
|
325
|
-
// 'cairn-admin:preview' key from the removed split-pane preview stays untouched). Desktop is
|
|
326
|
-
// the default; the storage read sits in an effect so it never runs during SSR, and it tracks
|
|
327
|
-
// nothing reactive, so it runs once.
|
|
328
|
-
const deviceStorageKey = 'cairn-editor-preview-device';
|
|
329
|
-
let device = $state<PreviewDeviceId>('desktop');
|
|
330
|
-
$effect(() => {
|
|
331
|
-
const stored = localStorage.getItem(deviceStorageKey);
|
|
332
|
-
if (previewDevices.some((d) => d.id === stored)) device = stored as PreviewDeviceId;
|
|
333
|
-
});
|
|
334
|
-
function setDevice(id: PreviewDeviceId) {
|
|
335
|
-
device = id;
|
|
336
|
-
localStorage.setItem(deviceStorageKey, id);
|
|
337
|
-
}
|
|
338
|
-
// The writing modes (focus, typewriter) and the surface posture, per-browser preferences on
|
|
339
|
-
// the device pick's pattern: read in an effect so SSR never touches localStorage, written by
|
|
340
|
-
// the card footer's toggles. The effect tracks nothing reactive, so it runs once.
|
|
341
|
-
const focusStorageKey = 'cairn-editor-focus-mode';
|
|
342
|
-
const typewriterStorageKey = 'cairn-editor-typewriter';
|
|
343
|
-
const surfaceStorageKey = 'cairn-editor-surface';
|
|
344
|
-
const zenStorageKey = 'cairn-editor-zen';
|
|
345
|
-
// Spellcheck (the markdown-aware lint underlines) defaults ON, so a fresh editor checks spelling
|
|
346
|
-
// without a choice. The toggle joins the editor-preference family on the same pattern: a localStorage
|
|
347
|
-
// key read once in the effect below, written by the footer setter. Stored as 'false' only when the
|
|
348
|
-
// author turns it off; any other value (including unset) reads as on.
|
|
349
|
-
const spellcheckStorageKey = 'cairn-editor-spellcheck';
|
|
350
|
-
let focusMode = $state(false);
|
|
351
|
-
let typewriter = $state(false);
|
|
352
|
-
let spellcheck = $state(true);
|
|
353
|
-
// Zen: the manuscript alone on the recessed ground. The band, the document title, the toolbar
|
|
354
|
-
// strip, and the footer go; the editing surface stays. It joins the editor-preference family on
|
|
355
|
-
// the same pattern (a localStorage key, read once below, written by the setter), and composes
|
|
356
|
-
// with focus mode and the postures rather than resetting them.
|
|
357
|
-
let zen = $state(false);
|
|
358
|
-
// The surface posture: prose (the writing instrument) by default; markup is the dense
|
|
359
|
-
// working surface.
|
|
360
|
-
let surface = $state<'prose' | 'markup'>('prose');
|
|
361
|
-
$effect(() => {
|
|
362
|
-
focusMode = localStorage.getItem(focusStorageKey) === 'true';
|
|
363
|
-
typewriter = localStorage.getItem(typewriterStorageKey) === 'true';
|
|
364
|
-
zen = localStorage.getItem(zenStorageKey) === 'true';
|
|
365
|
-
if (localStorage.getItem(surfaceStorageKey) === 'markup') surface = 'markup';
|
|
366
|
-
// Spellcheck is on unless the author explicitly stored it off.
|
|
367
|
-
spellcheck = localStorage.getItem(spellcheckStorageKey) !== 'false';
|
|
368
|
-
});
|
|
369
|
-
function setFocusMode(on: boolean) {
|
|
370
|
-
focusMode = on;
|
|
371
|
-
localStorage.setItem(focusStorageKey, String(on));
|
|
372
|
-
}
|
|
373
|
-
function setTypewriter(on: boolean) {
|
|
374
|
-
typewriter = on;
|
|
375
|
-
localStorage.setItem(typewriterStorageKey, String(on));
|
|
376
|
-
}
|
|
377
|
-
function setSpellcheck(on: boolean) {
|
|
378
|
-
spellcheck = on;
|
|
379
|
-
localStorage.setItem(spellcheckStorageKey, String(on));
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
// The personal-dictionary pending additions (spec 1.6), owned here and shared with MarkdownEditor's
|
|
383
|
-
// lint source: an add-to-dictionary choice records the lowercased word here (and clears the underline
|
|
384
|
-
// at once), and this host commits the set through the addDictionaryWord action at save time. An add
|
|
385
|
-
// that fails to commit stays here for the session and re-attempts on the next save, so the word is
|
|
386
|
-
// never silently dropped. A plain Set, not $state: the lint source mutates it, and nothing renders
|
|
387
|
-
// from it, so reactivity is unneeded.
|
|
388
|
-
const pendingAdditions = new Set<string>();
|
|
389
|
-
// The CSRF token getter from the admin layout context, for the raw-body dictionary commit.
|
|
390
|
-
const csrf = getContext<(() => string) | undefined>(CSRF_CONTEXT_KEY);
|
|
391
|
-
|
|
392
|
-
/** Commit the pending personal-dictionary additions through the addDictionaryWord action, then drop
|
|
393
|
-
* the words the server confirms from the pending set. Fire-and-forget at save time: the words are
|
|
394
|
-
* already live in the Worker's in-memory set, so a slow or failed commit never blocks the save. A
|
|
395
|
-
* failure leaves the words pending for the next save (never dropped). The transport mirrors the media
|
|
396
|
-
* raw-body actions: a text/plain POST, the CSRF token in X-Cairn-CSRF, a JSON `{ words }` body. */
|
|
397
|
-
async function commitPendingDictionary(): Promise<void> {
|
|
398
|
-
if (pendingAdditions.size === 0) return;
|
|
399
|
-
const words = [...pendingAdditions];
|
|
400
|
-
try {
|
|
401
|
-
const res = await fetch(`/admin/${data.conceptId}/${data.id}?/addDictionaryWord`, {
|
|
402
|
-
method: 'POST',
|
|
403
|
-
redirect: 'manual',
|
|
404
|
-
headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
|
|
405
|
-
body: JSON.stringify({ words }),
|
|
406
|
-
});
|
|
407
|
-
// The guard's expired-session 303 under redirect: 'manual' surfaces as an opaque, status-0
|
|
408
|
-
// response with no body: leave the words pending and bail.
|
|
409
|
-
if (res.type === 'opaqueredirect' || res.status === 0) return;
|
|
410
|
-
// deserialize turns the devalue-encoded form-action result back into an ActionResult; a 500 HTML
|
|
411
|
-
// error page is not devalue-encoded, so it throws into the catch below. Only a success carries
|
|
412
|
-
// the merged word list; a fail (csrf, 400, 409) leaves the words pending for the next save.
|
|
413
|
-
const result = deserialize(await res.text()) as
|
|
414
|
-
| { type: 'success'; data?: { words?: unknown } }
|
|
415
|
-
| { type: 'failure' | 'error' | 'redirect' };
|
|
416
|
-
if (result.type !== 'success') return;
|
|
417
|
-
const merged = result.data?.words;
|
|
418
|
-
if (!Array.isArray(merged)) return;
|
|
419
|
-
// Reconcile: drop every now-committed word (matched lowercased, the form the action stored) from
|
|
420
|
-
// the pending set so it is not re-sent. A word the server did not confirm stays pending.
|
|
421
|
-
const committed = new Set(merged.filter((w): w is string => typeof w === 'string').map((w) => w.toLowerCase()));
|
|
422
|
-
for (const w of words) if (committed.has(w.toLowerCase())) pendingAdditions.delete(w);
|
|
423
|
-
} catch {
|
|
424
|
-
// A network failure or an unparseable server response leaves the pending set intact for the next
|
|
425
|
-
// save; the words stay live in the Worker for the session, so the author sees no regression.
|
|
146
|
+
setMode(mode === "write" ? "preview" : "write");
|
|
147
|
+
return;
|
|
426
148
|
}
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// flushSync applies the zen layout (the strip and footer leave the DOM) before we reach for
|
|
445
|
-
// the surface, so the focus call lands on the now-sole interactive region.
|
|
446
|
-
flushSync();
|
|
447
|
-
(editorCard?.querySelector('.cm-content') as HTMLElement | null)?.focus();
|
|
149
|
+
if (e.shiftKey && key === "f") {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
if (inDialog) return;
|
|
152
|
+
setFocusMode(!focusMode);
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
if (e.shiftKey && !e.altKey && e.key === ".") {
|
|
156
|
+
e.preventDefault();
|
|
157
|
+
if (inDialog) return;
|
|
158
|
+
setZen(!zen);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
if (!e.shiftKey && !e.altKey && e.key === ".") {
|
|
162
|
+
e.preventDefault();
|
|
163
|
+
if (inDialog) return;
|
|
164
|
+
toggleDetails();
|
|
165
|
+
return;
|
|
448
166
|
}
|
|
167
|
+
if (e.shiftKey || e.altKey) return;
|
|
168
|
+
if (key !== "s") return;
|
|
169
|
+
e.preventDefault();
|
|
170
|
+
if (busy) return;
|
|
171
|
+
if (!dirty && !data.isNew) return;
|
|
172
|
+
if (inDialog) return;
|
|
173
|
+
editForm?.requestSubmit();
|
|
174
|
+
};
|
|
175
|
+
window.addEventListener("beforeunload", onBeforeUnload);
|
|
176
|
+
window.addEventListener("keydown", onWindowKeydown);
|
|
177
|
+
return () => {
|
|
178
|
+
window.removeEventListener("beforeunload", onBeforeUnload);
|
|
179
|
+
window.removeEventListener("keydown", onWindowKeydown);
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
let discardDialog = $state(null);
|
|
183
|
+
let mode = $state("write");
|
|
184
|
+
let tidyMode = $state(false);
|
|
185
|
+
let tidyBusy = $state(false);
|
|
186
|
+
const insertDisabled = $derived(mode === "preview" || tidyMode);
|
|
187
|
+
let previewHtml = $state("");
|
|
188
|
+
let previewFailed = $state(false);
|
|
189
|
+
const deviceStorageKey = "cairn-editor-preview-device";
|
|
190
|
+
let device = $state("desktop");
|
|
191
|
+
$effect(() => {
|
|
192
|
+
const stored = localStorage.getItem(deviceStorageKey);
|
|
193
|
+
if (previewDevices.some((d) => d.id === stored)) device = stored;
|
|
194
|
+
});
|
|
195
|
+
function setDevice(id) {
|
|
196
|
+
device = id;
|
|
197
|
+
localStorage.setItem(deviceStorageKey, id);
|
|
198
|
+
}
|
|
199
|
+
const focusStorageKey = "cairn-editor-focus-mode";
|
|
200
|
+
const typewriterStorageKey = "cairn-editor-typewriter";
|
|
201
|
+
const surfaceStorageKey = "cairn-editor-surface";
|
|
202
|
+
const zenStorageKey = "cairn-editor-zen";
|
|
203
|
+
const spellcheckStorageKey = "cairn-editor-spellcheck";
|
|
204
|
+
let focusMode = $state(false);
|
|
205
|
+
let typewriter = $state(false);
|
|
206
|
+
let spellcheck = $state(true);
|
|
207
|
+
let zen = $state(false);
|
|
208
|
+
let surface = $state("prose");
|
|
209
|
+
$effect(() => {
|
|
210
|
+
focusMode = localStorage.getItem(focusStorageKey) === "true";
|
|
211
|
+
typewriter = localStorage.getItem(typewriterStorageKey) === "true";
|
|
212
|
+
zen = localStorage.getItem(zenStorageKey) === "true";
|
|
213
|
+
if (localStorage.getItem(surfaceStorageKey) === "markup") surface = "markup";
|
|
214
|
+
spellcheck = localStorage.getItem(spellcheckStorageKey) !== "false";
|
|
215
|
+
});
|
|
216
|
+
function setFocusMode(on) {
|
|
217
|
+
focusMode = on;
|
|
218
|
+
localStorage.setItem(focusStorageKey, String(on));
|
|
219
|
+
}
|
|
220
|
+
function setTypewriter(on) {
|
|
221
|
+
typewriter = on;
|
|
222
|
+
localStorage.setItem(typewriterStorageKey, String(on));
|
|
223
|
+
}
|
|
224
|
+
function setSpellcheck(on) {
|
|
225
|
+
spellcheck = on;
|
|
226
|
+
localStorage.setItem(spellcheckStorageKey, String(on));
|
|
227
|
+
}
|
|
228
|
+
const pendingAdditions = /* @__PURE__ */ new Set();
|
|
229
|
+
const csrf = getContext(CSRF_CONTEXT_KEY);
|
|
230
|
+
async function commitPendingDictionary() {
|
|
231
|
+
if (pendingAdditions.size === 0) return;
|
|
232
|
+
const words = [...pendingAdditions];
|
|
233
|
+
try {
|
|
234
|
+
const res = await fetch(`/admin/${data.conceptId}/${data.id}?/addDictionaryWord`, {
|
|
235
|
+
method: "POST",
|
|
236
|
+
redirect: "manual",
|
|
237
|
+
headers: { "Content-Type": "text/plain", "X-Cairn-CSRF": csrf?.() ?? "" },
|
|
238
|
+
body: JSON.stringify({ words })
|
|
239
|
+
});
|
|
240
|
+
if (res.type === "opaqueredirect" || res.status === 0) return;
|
|
241
|
+
const result = deserialize(await res.text());
|
|
242
|
+
if (result.type !== "success") return;
|
|
243
|
+
const merged = result.data?.words;
|
|
244
|
+
if (!Array.isArray(merged)) return;
|
|
245
|
+
const committed = new Set(merged.filter((w) => typeof w === "string").map((w) => w.toLowerCase()));
|
|
246
|
+
for (const w of words) if (committed.has(w.toLowerCase())) pendingAdditions.delete(w);
|
|
247
|
+
} catch {
|
|
449
248
|
}
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
249
|
+
}
|
|
250
|
+
function setSurface(posture) {
|
|
251
|
+
surface = posture;
|
|
252
|
+
localStorage.setItem(surfaceStorageKey, posture);
|
|
253
|
+
}
|
|
254
|
+
function setZen(on) {
|
|
255
|
+
const surface2 = editorCard?.querySelector(".cm-editor");
|
|
256
|
+
const focusHides = on && !surface2?.contains(document.activeElement);
|
|
257
|
+
zen = on;
|
|
258
|
+
localStorage.setItem(zenStorageKey, String(on));
|
|
259
|
+
if (focusHides) {
|
|
260
|
+
flushSync();
|
|
261
|
+
editorCard?.querySelector(".cm-content")?.focus();
|
|
459
262
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
263
|
+
}
|
|
264
|
+
function segButtonClass(pressed) {
|
|
265
|
+
return `inline-flex items-center gap-1 px-2.5 py-1 text-xs font-normal ${pressed ? "bg-primary/10 text-primary font-medium" : "text-[var(--color-muted)]"}`;
|
|
266
|
+
}
|
|
267
|
+
function ftrToggleClass(pressed) {
|
|
268
|
+
return `ftr-toggle inline-flex items-center gap-1 rounded-lg px-2 py-1 text-xs font-normal hover:bg-base-content/[0.06] ${pressed ? "bg-primary/10 text-primary font-medium" : "text-[var(--color-muted)]"}`;
|
|
269
|
+
}
|
|
270
|
+
const activeDevice = $derived(previewDevice(device));
|
|
271
|
+
const previewDoc = $derived(buildPreviewDoc(previewHtml, data.preview));
|
|
272
|
+
let insert = $state.raw(() => {
|
|
273
|
+
});
|
|
274
|
+
let replaceRange = $state.raw(() => {
|
|
275
|
+
});
|
|
276
|
+
let selectRange = $state.raw(() => {
|
|
277
|
+
});
|
|
278
|
+
let insertLink = $state.raw(() => {
|
|
279
|
+
});
|
|
280
|
+
let getSelection = $state.raw(() => "");
|
|
281
|
+
let getSelectionRange = $state.raw(() => null);
|
|
282
|
+
let format = $state.raw(() => {
|
|
283
|
+
});
|
|
284
|
+
let tidyApi = $state.raw(null);
|
|
285
|
+
let undoEditor = $state.raw(() => {
|
|
286
|
+
});
|
|
287
|
+
let tidyReview = $state.raw(null);
|
|
288
|
+
let tidyMessage = $state(null);
|
|
289
|
+
let tidyNoop = $state(false);
|
|
290
|
+
let tidyApplied = $state(false);
|
|
291
|
+
let tidyController = null;
|
|
292
|
+
let tidyWorkingDialog = $state(null);
|
|
293
|
+
let tidyNoopDialog = $state(null);
|
|
294
|
+
let tidyMessageDialog = $state(null);
|
|
295
|
+
$effect(() => {
|
|
296
|
+
if (tidyBusy) tidyWorkingDialog?.showModal();
|
|
297
|
+
});
|
|
298
|
+
$effect(() => {
|
|
299
|
+
if (tidyNoop) tidyNoopDialog?.showModal();
|
|
300
|
+
});
|
|
301
|
+
$effect(() => {
|
|
302
|
+
if (tidyMessage) tidyMessageDialog?.showModal();
|
|
303
|
+
});
|
|
304
|
+
const tidyEnabled = $derived(data.tidy?.enabled ?? false);
|
|
305
|
+
async function runTidy() {
|
|
306
|
+
if (!tidyEnabled || tidyBusy || tidyMode) return;
|
|
307
|
+
tidyMessage = null;
|
|
308
|
+
tidyNoop = false;
|
|
309
|
+
tidyApplied = false;
|
|
310
|
+
const selected = getSelection();
|
|
311
|
+
const range = getSelectionRange();
|
|
312
|
+
const useSelection = selected.length > 0;
|
|
313
|
+
let offset = 0;
|
|
314
|
+
if (range) {
|
|
315
|
+
offset = range.from;
|
|
316
|
+
} else if (useSelection) {
|
|
317
|
+
offset = Math.max(body.indexOf(selected), 0);
|
|
464
318
|
}
|
|
465
|
-
const
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
// The editor's selection range, registered by MarkdownEditor on mount; tidy reads it for the exact
|
|
481
|
-
// selected span's offset so a selection tidy never maps onto an identical passage earlier in the
|
|
482
|
-
// document. Returns null when the selection is empty (a bare caret), which reads as document scope.
|
|
483
|
-
let getSelectionRange = $state.raw<() => { from: number; to: number } | null>(() => null);
|
|
484
|
-
// The editor's selection transform, registered by MarkdownEditor on mount; a no-op until then.
|
|
485
|
-
let format = $state.raw<(kind: FormatKind) => void>(() => {});
|
|
486
|
-
|
|
487
|
-
// The tidy apply seam, registered by MarkdownEditor on mount; the review surface drives the in-buffer
|
|
488
|
-
// decorations and the batched apply through it. Null until the editor mounts.
|
|
489
|
-
let tidyApi = $state.raw<import('./editor-tidy.js').TidyApi | null>(null);
|
|
490
|
-
// The editor's undo, registered on mount; the Undo-tidy chip calls it. A no-op until then.
|
|
491
|
-
let undoEditor = $state.raw<() => void>(() => {});
|
|
492
|
-
// The open review's data: the validated change set, the captured original it was diffed against, the
|
|
493
|
-
// scope, and the model. Null when no review is open. The diff positions index `tidyOriginal`, which
|
|
494
|
-
// for a selection tidy is the FULL document (the changes are offset back before they reach here).
|
|
495
|
-
let tidyReview = $state.raw<{ changes: Change[]; original: string; model: string } | null>(null);
|
|
496
|
-
// The error message a refused or failed tidy surfaces. The working state is cancelable through the
|
|
497
|
-
// AbortController; a validation rejection or an action failure lands here.
|
|
498
|
-
let tidyMessage = $state<string | null>(null);
|
|
499
|
-
// The no-op confirmation: a clean result (tidy found nothing to fix) shows "Nothing to fix" and never
|
|
500
|
-
// opens an empty review. Cleared on the next tidy run.
|
|
501
|
-
let tidyNoop = $state(false);
|
|
502
|
-
// The session-level "Undo tidy" affordance: surfaced right after Apply, dismissed on the next edit.
|
|
503
|
-
let tidyApplied = $state(false);
|
|
504
|
-
// The in-flight controller, for Cancel and the bounded client timeout.
|
|
505
|
-
let tidyController: AbortController | null = null;
|
|
506
|
-
|
|
507
|
-
// The three tidy status dialogs (working, no-op, message). Each is promoted to the top layer with
|
|
508
|
-
// showModal() the way TidyReview does, so the focus trap, Escape, and inert background come from the
|
|
509
|
-
// platform. The $effect below opens each when its flag flips and closes it when the flag clears; the
|
|
510
|
-
// {#if} mounts the element, so the ref is set before the effect reads it.
|
|
511
|
-
let tidyWorkingDialog = $state<HTMLDialogElement | null>(null);
|
|
512
|
-
let tidyNoopDialog = $state<HTMLDialogElement | null>(null);
|
|
513
|
-
let tidyMessageDialog = $state<HTMLDialogElement | null>(null);
|
|
514
|
-
$effect(() => {
|
|
515
|
-
if (tidyBusy) tidyWorkingDialog?.showModal();
|
|
516
|
-
});
|
|
517
|
-
$effect(() => {
|
|
518
|
-
if (tidyNoop) tidyNoopDialog?.showModal();
|
|
519
|
-
});
|
|
520
|
-
$effect(() => {
|
|
521
|
-
if (tidyMessage) tidyMessageDialog?.showModal();
|
|
522
|
-
});
|
|
523
|
-
|
|
524
|
-
// True when tidy is enabled for the site (the developer-tier master switch). Gates the Tidy control.
|
|
525
|
-
// The optional chain mirrors the component's tolerance of a partial data load: a degraded load that
|
|
526
|
-
// omits the tidy block simply reads disabled rather than throwing.
|
|
527
|
-
const tidyEnabled = $derived(data.tidy?.enabled ?? false);
|
|
528
|
-
|
|
529
|
-
/** Run tidy (spec 2.1, Task 11) over the whole document or the current selection. The action receives
|
|
530
|
-
* only the selected text plus a scope flag; the diff is computed against that text and the changes'
|
|
531
|
-
* ranges are offset back into the full document before they reach the apply seam. On success the
|
|
532
|
-
* result is validated as a proofread (Task 13); a rejection shows the honest message and writes
|
|
533
|
-
* nothing; a clean result shows "Nothing to fix"; otherwise the review opens. */
|
|
534
|
-
async function runTidy() {
|
|
535
|
-
if (!tidyEnabled || tidyBusy || tidyMode) return;
|
|
536
|
-
tidyMessage = null;
|
|
537
|
-
tidyNoop = false;
|
|
538
|
-
tidyApplied = false;
|
|
539
|
-
// Scope: a non-empty selection tidies that range; otherwise the whole body. The offset is where the
|
|
540
|
-
// selected text begins in the full document, so the diff positions map back. The range seam carries
|
|
541
|
-
// the exact selection offsets, so a passage that repeats earlier in the body still maps the
|
|
542
|
-
// corrections onto the actually-selected occurrence. Fall back to the first textual match only when
|
|
543
|
-
// no range is available (offset 0 keeps document-scope tidy unchanged).
|
|
544
|
-
const selected = getSelection();
|
|
545
|
-
const range = getSelectionRange();
|
|
546
|
-
const useSelection = selected.length > 0;
|
|
547
|
-
let offset = 0;
|
|
548
|
-
if (range) {
|
|
549
|
-
offset = range.from;
|
|
550
|
-
} else if (useSelection) {
|
|
551
|
-
offset = Math.max(body.indexOf(selected), 0);
|
|
319
|
+
const text = useSelection ? selected : body;
|
|
320
|
+
tidyBusy = true;
|
|
321
|
+
tidyController = new AbortController();
|
|
322
|
+
const timer = setTimeout(() => tidyController?.abort(), TIDY_CLIENT_TIMEOUT_MS);
|
|
323
|
+
try {
|
|
324
|
+
const res = await fetch(`/admin/${data.conceptId}/${data.id}?/tidy`, {
|
|
325
|
+
method: "POST",
|
|
326
|
+
redirect: "manual",
|
|
327
|
+
headers: { "Content-Type": "text/plain", "X-Cairn-CSRF": csrf?.() ?? "" },
|
|
328
|
+
body: JSON.stringify({ text, scope: useSelection ? "selection" : "document" }),
|
|
329
|
+
signal: tidyController.signal
|
|
330
|
+
});
|
|
331
|
+
if (res.type === "opaqueredirect" || res.status === 0) {
|
|
332
|
+
tidyMessage = "Your session expired. Sign in again to tidy.";
|
|
333
|
+
return;
|
|
552
334
|
}
|
|
553
|
-
const
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
// The bounded client timeout: a slow call becomes a cancel/retry rather than hanging the review.
|
|
558
|
-
const timer = setTimeout(() => tidyController?.abort(), TIDY_CLIENT_TIMEOUT_MS);
|
|
559
|
-
try {
|
|
560
|
-
const res = await fetch(`/admin/${data.conceptId}/${data.id}?/tidy`, {
|
|
561
|
-
method: 'POST',
|
|
562
|
-
redirect: 'manual',
|
|
563
|
-
headers: { 'Content-Type': 'text/plain', 'X-Cairn-CSRF': csrf?.() ?? '' },
|
|
564
|
-
body: JSON.stringify({ text, scope: useSelection ? 'selection' : 'document' }),
|
|
565
|
-
signal: tidyController.signal,
|
|
566
|
-
});
|
|
567
|
-
if (res.type === 'opaqueredirect' || res.status === 0) {
|
|
568
|
-
tidyMessage = 'Your session expired. Sign in again to tidy.';
|
|
569
|
-
return;
|
|
570
|
-
}
|
|
571
|
-
const result = deserialize(await res.text()) as
|
|
572
|
-
| { type: 'success'; data?: { corrected?: unknown; model?: unknown } }
|
|
573
|
-
| { type: 'failure'; data?: { error?: unknown } }
|
|
574
|
-
| { type: 'error' | 'redirect' };
|
|
575
|
-
if (result.type !== 'success') {
|
|
576
|
-
tidyMessage =
|
|
577
|
-
result.type === 'failure' && typeof result.data?.error === 'string' && result.data.error !== 'csrf'
|
|
578
|
-
? result.data.error
|
|
579
|
-
: 'Tidy could not finish. Try again.';
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
const corrected = typeof result.data?.corrected === 'string' ? result.data.corrected : '';
|
|
583
|
-
const model = typeof result.data?.model === 'string' ? result.data.model : data.tidy.model;
|
|
584
|
-
if (corrected.length === 0 || corrected === text) {
|
|
585
|
-
// A clean result: tidy found nothing to fix. Never open an empty review.
|
|
586
|
-
tidyNoop = true;
|
|
587
|
-
return;
|
|
588
|
-
}
|
|
589
|
-
// Validate the result as a proofread (Task 13). A rejection writes nothing and shows the message.
|
|
590
|
-
const validation = validateTidy(text, corrected);
|
|
591
|
-
if (!validation.ok) {
|
|
592
|
-
tidyMessage = TIDY_REJECTION_MESSAGE;
|
|
593
|
-
return;
|
|
594
|
-
}
|
|
595
|
-
if (validation.changes.length === 0) {
|
|
596
|
-
tidyNoop = true;
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
// Offset the changes back into the full document (a selection tidy diffs the selected text). The
|
|
600
|
-
// captured original handed to the review is the full body, so every line label and context row is
|
|
601
|
-
// computed against the real document.
|
|
602
|
-
const changes: Change[] = validation.changes.map((c) => ({
|
|
603
|
-
...c,
|
|
604
|
-
from: c.from + offset,
|
|
605
|
-
to: c.to + offset,
|
|
606
|
-
}));
|
|
607
|
-
tidyReview = { changes, original: body, model };
|
|
608
|
-
tidyMode = true;
|
|
609
|
-
tidyApi?.enter(changes);
|
|
610
|
-
} catch {
|
|
611
|
-
// An abort (Cancel or the client timeout) or a network/parse failure both map to the same
|
|
612
|
-
// retryable message; the buffer is untouched.
|
|
613
|
-
tidyMessage = tidyController?.signal.aborted ? null : 'Tidy could not finish. Try again.';
|
|
614
|
-
} finally {
|
|
615
|
-
clearTimeout(timer);
|
|
616
|
-
tidyController = null;
|
|
617
|
-
tidyBusy = false;
|
|
335
|
+
const result = deserialize(await res.text());
|
|
336
|
+
if (result.type !== "success") {
|
|
337
|
+
tidyMessage = result.type === "failure" && typeof result.data?.error === "string" && result.data.error !== "csrf" ? result.data.error : "Tidy could not finish. Try again.";
|
|
338
|
+
return;
|
|
618
339
|
}
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
340
|
+
const corrected = typeof result.data?.corrected === "string" ? result.data.corrected : "";
|
|
341
|
+
const model = typeof result.data?.model === "string" ? result.data.model : data.tidy.model;
|
|
342
|
+
if (corrected.length === 0 || corrected === text) {
|
|
343
|
+
tidyNoop = true;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
const validation = validateTidy(text, corrected);
|
|
347
|
+
if (!validation.ok) {
|
|
348
|
+
tidyMessage = TIDY_REJECTION_MESSAGE;
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
if (validation.changes.length === 0) {
|
|
352
|
+
tidyNoop = true;
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const changes = validation.changes.map((c) => ({
|
|
356
|
+
...c,
|
|
357
|
+
from: c.from + offset,
|
|
358
|
+
to: c.to + offset
|
|
359
|
+
}));
|
|
360
|
+
tidyReview = { changes, original: body, model };
|
|
361
|
+
tidyMode = true;
|
|
362
|
+
tidyApi?.enter(changes);
|
|
363
|
+
} catch {
|
|
364
|
+
tidyMessage = tidyController?.signal.aborted ? null : "Tidy could not finish. Try again.";
|
|
365
|
+
} finally {
|
|
366
|
+
clearTimeout(timer);
|
|
367
|
+
tidyController = null;
|
|
624
368
|
tidyBusy = false;
|
|
625
|
-
tidyMessage = null;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
/** Close the review: clear tidy mode and the review data. On apply the "Undo tidy" affordance shows
|
|
629
|
-
* until the next edit; on cancel nothing changed. */
|
|
630
|
-
function closeTidyReview(applied: boolean) {
|
|
631
|
-
tidyMode = false;
|
|
632
|
-
tidyReview = null;
|
|
633
|
-
tidyApplied = applied;
|
|
634
|
-
// Record the body the apply produced, so the next edit (a different body) dismisses the Undo chip.
|
|
635
|
-
tidyAppliedBody = applied ? body : null;
|
|
636
369
|
}
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
370
|
+
}
|
|
371
|
+
function cancelTidy() {
|
|
372
|
+
tidyController?.abort();
|
|
373
|
+
tidyBusy = false;
|
|
374
|
+
tidyMessage = null;
|
|
375
|
+
}
|
|
376
|
+
function closeTidyReview(applied) {
|
|
377
|
+
tidyMode = false;
|
|
378
|
+
tidyReview = null;
|
|
379
|
+
tidyApplied = applied;
|
|
380
|
+
tidyAppliedBody = applied ? body : null;
|
|
381
|
+
}
|
|
382
|
+
let tidyAppliedBody = $state(null);
|
|
383
|
+
$effect(() => {
|
|
384
|
+
const current = body;
|
|
385
|
+
if (tidyApplied && tidyAppliedBody !== null && current !== tidyAppliedBody) {
|
|
650
386
|
tidyApplied = false;
|
|
651
387
|
tidyAppliedBody = null;
|
|
652
388
|
}
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
}
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
// The directive container at the editor caret, reported by MarkdownEditor whenever it changes
|
|
723
|
-
// (null outside any container). The Edit-block control resolves it against the registry and the
|
|
724
|
-
// round-trip safety gate below; its identity is the key the async gate guards against a stale
|
|
725
|
-
// result. The reporter's name+markdown+from+to shape; declared locally because MarkdownEditor's
|
|
726
|
-
// matching interface is not exported.
|
|
727
|
-
type CaretComponent = { name: string | null; markdown: string; from: number; to: number };
|
|
728
|
-
let caretComponent = $state<CaretComponent | null>(null);
|
|
729
|
-
|
|
730
|
-
// The media image at the editor caret, reported by MarkdownEditor whenever it changes (null off any
|
|
731
|
-
// media image). The Figure control reads it to wrap a bare image or edit an existing figure; it
|
|
732
|
-
// writes source through the replaceRange seam. The figure dialog is mounted headless below.
|
|
733
|
-
let mediaAtCaret = $state<FigureAtImage | null>(null);
|
|
734
|
-
// The figure control's host <dialog>, opened by the toolbar control. Mounted outside the edit form
|
|
735
|
-
// (a form nested in a form is invalid HTML), the Edit-block dialog pattern.
|
|
736
|
-
let figureDialog = $state<HTMLDialogElement | null>(null);
|
|
737
|
-
// Whether the Figure control is available: a media image sits at the caret and Preview is not
|
|
738
|
-
// showing (the insert controls disable together with the Write surface). The control is always
|
|
739
|
-
// rendered (it never mounts on caret move); only its enabled state changes.
|
|
740
|
-
const figureAvailable = $derived(mediaAtCaret != null && !insertDisabled);
|
|
741
|
-
const figureLabel = $derived(
|
|
742
|
-
figureAvailable
|
|
743
|
-
? mediaAtCaret?.figure
|
|
744
|
-
? 'Edit the figure at the cursor'
|
|
745
|
-
: 'Wrap the image at the cursor in a figure'
|
|
746
|
-
: 'Place the cursor on an image to add a figure',
|
|
747
|
-
);
|
|
748
|
-
// Whether the image at the caret is decorative (empty or whitespace-only alt). The token came from
|
|
749
|
-
// a parsed image node, so the alt is the source between `![` and the closing `]` before `](`. An
|
|
750
|
-
// empty alt is the needs-alt signal; the figure control surfaces it and the decorative-plus-caption
|
|
751
|
-
// warning. Derived from the reported token so it tracks the caret.
|
|
752
|
-
const figureDecorative = $derived.by(() => {
|
|
753
|
-
if (!mediaAtCaret) return false;
|
|
754
|
-
const token = body.slice(mediaAtCaret.imageFrom, mediaAtCaret.imageTo);
|
|
755
|
-
const match = /^!\[([\s\S]*?)\]\(/.exec(token);
|
|
756
|
-
return (match?.[1] ?? '').trim() === '';
|
|
757
|
-
});
|
|
758
|
-
// A def is actionable for guided edit when it has a schema (the same notion the insert catalog
|
|
759
|
-
// lists by): a template-only def has no form to re-open into. Reuses the dialog's exported
|
|
760
|
-
// hasSchema so the two surfaces can never drift on what counts as editable.
|
|
761
|
-
function editableDef(name: string | null): ComponentDef | undefined {
|
|
762
|
-
if (!name) return undefined;
|
|
763
|
-
const def = registry?.get(name);
|
|
764
|
-
if (!def) return undefined;
|
|
765
|
-
return hasSchema(def) ? def : undefined;
|
|
389
|
+
});
|
|
390
|
+
function undoTidy() {
|
|
391
|
+
undoEditor();
|
|
392
|
+
tidyApplied = false;
|
|
393
|
+
tidyAppliedBody = null;
|
|
394
|
+
}
|
|
395
|
+
let caretCoords = $state.raw(
|
|
396
|
+
() => null
|
|
397
|
+
);
|
|
398
|
+
let focusEditor = $state.raw(() => {
|
|
399
|
+
});
|
|
400
|
+
let placeholders = $state.raw(null);
|
|
401
|
+
let insertImageFn = $state.raw(() => {
|
|
402
|
+
});
|
|
403
|
+
const noopPlaceholders = {
|
|
404
|
+
begin: () => 0,
|
|
405
|
+
progress: () => {
|
|
406
|
+
},
|
|
407
|
+
resolveTo: () => {
|
|
408
|
+
},
|
|
409
|
+
cancel: () => {
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
const editorApi = $derived({
|
|
413
|
+
caretCoords: () => caretCoords(),
|
|
414
|
+
focusEditor: () => focusEditor(),
|
|
415
|
+
placeholders: placeholders ?? noopPlaceholders,
|
|
416
|
+
insertImage: (alt, ref) => insertImageFn(alt, ref)
|
|
417
|
+
});
|
|
418
|
+
let mediaPopover = $state(null);
|
|
419
|
+
let heroFieldRefs = $state({});
|
|
420
|
+
let heroNeedsAlt = $state({});
|
|
421
|
+
let uploadedRecords = $state([]);
|
|
422
|
+
let webLinkDialog = $state(null);
|
|
423
|
+
let linkPicker = $state(null);
|
|
424
|
+
let insertDialog = $state(null);
|
|
425
|
+
let deleteDialog = $state(null);
|
|
426
|
+
let renameDialog = $state(null);
|
|
427
|
+
let helpDialog = $state(null);
|
|
428
|
+
let shortcutsDialog = $state(null);
|
|
429
|
+
const hasComponents = $derived(insertableDefs(registry).length > 0);
|
|
430
|
+
let caretComponent = $state(null);
|
|
431
|
+
let mediaAtCaret = $state(null);
|
|
432
|
+
let figureDialog = $state(null);
|
|
433
|
+
const figureAvailable = $derived(mediaAtCaret != null && !insertDisabled);
|
|
434
|
+
const figureLabel = $derived(
|
|
435
|
+
figureAvailable ? mediaAtCaret?.figure ? "Edit the figure at the cursor" : "Wrap the image at the cursor in a figure" : "Place the cursor on an image to add a figure"
|
|
436
|
+
);
|
|
437
|
+
const figureDecorative = $derived.by(() => {
|
|
438
|
+
if (!mediaAtCaret) return false;
|
|
439
|
+
const token = body.slice(mediaAtCaret.imageFrom, mediaAtCaret.imageTo);
|
|
440
|
+
const match = /^!\[([\s\S]*?)\]\(/.exec(token);
|
|
441
|
+
return (match?.[1] ?? "").trim() === "";
|
|
442
|
+
});
|
|
443
|
+
function editableDef(name) {
|
|
444
|
+
if (!name) return void 0;
|
|
445
|
+
const def = registry?.get(name);
|
|
446
|
+
if (!def) return void 0;
|
|
447
|
+
return hasSchema(def) ? def : void 0;
|
|
448
|
+
}
|
|
449
|
+
let editable = $state(null);
|
|
450
|
+
let editReason = $state("none");
|
|
451
|
+
$effect(() => {
|
|
452
|
+
const current = caretComponent;
|
|
453
|
+
const def = editableDef(current?.name ?? null);
|
|
454
|
+
if (!current || !def) {
|
|
455
|
+
editable = null;
|
|
456
|
+
editReason = "none";
|
|
457
|
+
return;
|
|
766
458
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
// disabled tooltip is honest. 'none' covers both no-caret-component and an unknown/template-only
|
|
775
|
-
// one (no guided form either way); 'unsafe' is a known component the safety gate refused.
|
|
776
|
-
let editReason = $state<'none' | 'unsafe'>('none');
|
|
777
|
-
// Resolve editability when the caret-component changes, async-safe. componentRoundTripSafety is
|
|
778
|
-
// async, so a slow check could resolve after a newer caret move; guard latest-wins on the
|
|
779
|
-
// caretComponent identity (the EditPage preview-debounce pattern), only applying a result when
|
|
780
|
-
// the caret has not moved since the check started. A block whose check did not pass for the
|
|
781
|
-
// current caret never enables edit.
|
|
782
|
-
$effect(() => {
|
|
783
|
-
const current = caretComponent;
|
|
784
|
-
const def = editableDef(current?.name ?? null);
|
|
785
|
-
if (!current || !def) {
|
|
459
|
+
let stale = false;
|
|
460
|
+
void componentRoundTripSafety(current.markdown, def).then((result) => {
|
|
461
|
+
if (stale || caretComponent !== current) return;
|
|
462
|
+
if (result.safe) {
|
|
463
|
+
editable = { def, range: { from: current.from, to: current.to }, markdown: current.markdown };
|
|
464
|
+
editReason = "none";
|
|
465
|
+
} else {
|
|
786
466
|
editable = null;
|
|
787
|
-
editReason =
|
|
788
|
-
return;
|
|
467
|
+
editReason = "unsafe";
|
|
789
468
|
}
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
if (result.safe) {
|
|
795
|
-
editable = { def, range: { from: current.from, to: current.to }, markdown: current.markdown };
|
|
796
|
-
editReason = 'none';
|
|
797
|
-
} else {
|
|
798
|
-
editable = null;
|
|
799
|
-
editReason = 'unsafe';
|
|
800
|
-
}
|
|
801
|
-
})
|
|
802
|
-
.catch(() => {
|
|
803
|
-
// A parse throw during the safety check must never leave a stale block enabled. Guarded by
|
|
804
|
-
// the same latest-wins identity, fall back to the safe default of no editable block.
|
|
805
|
-
if (stale || caretComponent !== current) return;
|
|
806
|
-
editable = null;
|
|
807
|
-
editReason = 'none';
|
|
808
|
-
});
|
|
809
|
-
return () => {
|
|
810
|
-
stale = true;
|
|
811
|
-
};
|
|
812
|
-
});
|
|
813
|
-
// The Edit-block control's accessible label and tooltip: a plain reason in each state. Enabled
|
|
814
|
-
// names the action; the two disabled reasons are honest about why.
|
|
815
|
-
const editBlockLabel = $derived(
|
|
816
|
-
editable
|
|
817
|
-
? 'Edit the component at the cursor'
|
|
818
|
-
: editReason === 'unsafe'
|
|
819
|
-
? "This block can't be edited in the form. Edit it as markdown."
|
|
820
|
-
: 'Place the cursor in a component to edit it',
|
|
821
|
-
);
|
|
822
|
-
// Whether the Edit-block control is unavailable: either Preview hides the Write surface, or the
|
|
823
|
-
// caret is not on a safe, schema-bearing component. The control stays focusable and announced in
|
|
824
|
-
// this state (aria-disabled, not the native disabled attribute), so its reason reaches assistive
|
|
825
|
-
// technology; the dead click is made inert in editBlock().
|
|
826
|
-
const editBlockUnavailable = $derived(insertDisabled || !editable);
|
|
827
|
-
// Activate edit: parse the block into form values, then open the dialog in edit mode over the
|
|
828
|
-
// stored source range. Guarded by editable AND the preview-mode disable, so the control is inert
|
|
829
|
-
// unless the gate passed and the editor is on the Write tab. The def, range, and markdown all come
|
|
830
|
-
// from the one editable snapshot, so a newer caret markdown is never paired with an older range.
|
|
831
|
-
async function editBlock() {
|
|
832
|
-
if (insertDisabled || !editable) return;
|
|
833
|
-
const values = await parseComponent(editable.markdown, editable.def);
|
|
834
|
-
insertDialog?.editComponent(editable.def, values, editable.range);
|
|
835
|
-
}
|
|
836
|
-
|
|
837
|
-
// The figure dialog's pre-fill, snapshotted when the control opens so the form never mixes a newer
|
|
838
|
-
// caret with the values it opened on. Captured from mediaAtCaret at open time: edit mode with the
|
|
839
|
-
// figure's caption/role when a figure wraps the image, else wrap mode with empty caption and the
|
|
840
|
-
// measure default. decorative rides the snapshot too. Null while the dialog is closed.
|
|
841
|
-
let figurePrefill = $state<{
|
|
842
|
-
mode: 'wrap' | 'edit';
|
|
843
|
-
caption: string;
|
|
844
|
-
role: FigureRole | null;
|
|
845
|
-
decorative: boolean;
|
|
846
|
-
image: { from: number; to: number };
|
|
847
|
-
figureRange: { from: number; to: number } | null;
|
|
848
|
-
} | null>(null);
|
|
849
|
-
|
|
850
|
-
// Open the figure control over the media image at the caret. Inert unless a media image sits there
|
|
851
|
-
// and the Write surface is up, the same gate the toolbar control shows. The snapshot is the source
|
|
852
|
-
// of truth for the apply handlers, so a caret move while the dialog is open never re-targets it.
|
|
853
|
-
function openFigure() {
|
|
854
|
-
if (!figureAvailable || !mediaAtCaret) return;
|
|
855
|
-
const at = mediaAtCaret;
|
|
856
|
-
figurePrefill = {
|
|
857
|
-
mode: at.figure ? 'edit' : 'wrap',
|
|
858
|
-
caption: at.figure?.caption ?? '',
|
|
859
|
-
role: at.figure?.role ?? null,
|
|
860
|
-
decorative: figureDecorative,
|
|
861
|
-
image: { from: at.imageFrom, to: at.imageTo },
|
|
862
|
-
figureRange: at.figure ? { from: at.figure.from, to: at.figure.to } : null,
|
|
863
|
-
};
|
|
864
|
-
figureDialog?.showModal();
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
// Apply the control's choice through the replaceRange seam, then close. Wrap a bare image or update
|
|
868
|
-
// an existing figure, off the snapshot the dialog opened on. The pure transform owns the source
|
|
869
|
-
// shape and keeps the media token byte-intact; the preview stays read-only.
|
|
870
|
-
function applyFigure(choice: { caption: string; role: FigureRole | null }) {
|
|
871
|
-
const pre = figurePrefill;
|
|
872
|
-
if (!pre) return;
|
|
873
|
-
const result =
|
|
874
|
-
pre.mode === 'edit' && pre.figureRange
|
|
875
|
-
? updateFigure(body, pre.figureRange, choice.caption, choice.role)
|
|
876
|
-
: wrapImageInFigure(body, pre.image.from, pre.image.to, choice.caption, choice.role);
|
|
877
|
-
writeFigureResult(result);
|
|
878
|
-
}
|
|
879
|
-
|
|
880
|
-
// Unwrap the figure back to its bare image, then close. Edit mode only (the snapshot carries the
|
|
881
|
-
// figure range). The bare image token is restored verbatim by the pure transform.
|
|
882
|
-
function unwrapFigureAction() {
|
|
883
|
-
const pre = figurePrefill;
|
|
884
|
-
if (!pre || !pre.figureRange) return;
|
|
885
|
-
writeFigureResult(unwrapFigure(body, pre.figureRange));
|
|
886
|
-
}
|
|
887
|
-
|
|
888
|
-
// Write a figure transform's result back to the editor: overwrite the whole doc through the
|
|
889
|
-
// replaceRange seam, then place the selection the transform chose (the seam alone drops the caret
|
|
890
|
-
// at the end). replaceRange dispatches the doc change and focuses the surface; selectRange then
|
|
891
|
-
// dispatches a selection-only transaction, which CodeMirror's history does not record as its own
|
|
892
|
-
// undoable event, so one undo reverts the whole figure write. Close the dialog last.
|
|
893
|
-
function writeFigureResult(result: { doc: string; from: number; to: number }) {
|
|
894
|
-
replaceRange(0, body.length, result.doc);
|
|
895
|
-
selectRange(result.from, result.to);
|
|
896
|
-
figureDialog?.close();
|
|
897
|
-
}
|
|
898
|
-
|
|
899
|
-
// The header's status badge, in ConceptList's vocabulary: a pending entry reads Edited (or New
|
|
900
|
-
// when it has never been published); otherwise the live site matches and it reads Published.
|
|
901
|
-
const status = $derived.by(() => {
|
|
902
|
-
if (!data.pending) return 'Published';
|
|
903
|
-
return data.published ? 'Edited' : 'New';
|
|
904
|
-
});
|
|
905
|
-
const statusBadge = $derived.by(() => {
|
|
906
|
-
if (status === 'Edited') return 'badge-warning';
|
|
907
|
-
if (status === 'New') return 'badge-info';
|
|
908
|
-
return 'badge-ghost';
|
|
469
|
+
}).catch(() => {
|
|
470
|
+
if (stale || caretComponent !== current) return;
|
|
471
|
+
editable = null;
|
|
472
|
+
editReason = "none";
|
|
909
473
|
});
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
474
|
+
return () => {
|
|
475
|
+
stale = true;
|
|
476
|
+
};
|
|
477
|
+
});
|
|
478
|
+
const editBlockLabel = $derived(
|
|
479
|
+
editable ? "Edit the component at the cursor" : editReason === "unsafe" ? "This block can't be edited in the form. Edit it as markdown." : "Place the cursor in a component to edit it"
|
|
480
|
+
);
|
|
481
|
+
const editBlockUnavailable = $derived(insertDisabled || !editable);
|
|
482
|
+
async function editBlock() {
|
|
483
|
+
if (insertDisabled || !editable) return;
|
|
484
|
+
const values = await parseComponent(editable.markdown, editable.def);
|
|
485
|
+
insertDialog?.editComponent(editable.def, values, editable.range);
|
|
486
|
+
}
|
|
487
|
+
let figurePrefill = $state(null);
|
|
488
|
+
function openFigure() {
|
|
489
|
+
if (!figureAvailable || !mediaAtCaret) return;
|
|
490
|
+
const at = mediaAtCaret;
|
|
491
|
+
figurePrefill = {
|
|
492
|
+
mode: at.figure ? "edit" : "wrap",
|
|
493
|
+
caption: at.figure?.caption ?? "",
|
|
494
|
+
role: at.figure?.role ?? null,
|
|
495
|
+
decorative: figureDecorative,
|
|
496
|
+
image: { from: at.imageFrom, to: at.imageTo },
|
|
497
|
+
figureRange: at.figure ? { from: at.figure.from, to: at.figure.to } : null
|
|
498
|
+
};
|
|
499
|
+
figureDialog?.showModal();
|
|
500
|
+
}
|
|
501
|
+
function applyFigure(choice) {
|
|
502
|
+
const pre = figurePrefill;
|
|
503
|
+
if (!pre) return;
|
|
504
|
+
const result = pre.mode === "edit" && pre.figureRange ? updateFigure(body, pre.figureRange, choice.caption, choice.role) : wrapImageInFigure(body, pre.image.from, pre.image.to, choice.caption, choice.role);
|
|
505
|
+
writeFigureResult(result);
|
|
506
|
+
}
|
|
507
|
+
function unwrapFigureAction() {
|
|
508
|
+
const pre = figurePrefill;
|
|
509
|
+
if (!pre || !pre.figureRange) return;
|
|
510
|
+
writeFigureResult(unwrapFigure(body, pre.figureRange));
|
|
511
|
+
}
|
|
512
|
+
function writeFigureResult(result) {
|
|
513
|
+
replaceRange(0, body.length, result.doc);
|
|
514
|
+
selectRange(result.from, result.to);
|
|
515
|
+
figureDialog?.close();
|
|
516
|
+
}
|
|
517
|
+
const status = $derived.by(() => {
|
|
518
|
+
if (!data.pending) return "Published";
|
|
519
|
+
return data.published ? "Edited" : "New";
|
|
520
|
+
});
|
|
521
|
+
const statusBadge = $derived.by(() => {
|
|
522
|
+
if (status === "Edited") return "badge-warning";
|
|
523
|
+
if (status === "New") return "badge-info";
|
|
524
|
+
return "badge-ghost";
|
|
525
|
+
});
|
|
526
|
+
let actionsMenu = $state(null);
|
|
527
|
+
let actionsOpen = $state(false);
|
|
528
|
+
let detailsOpen = $state(false);
|
|
529
|
+
let detailsTrigger = $state(null);
|
|
530
|
+
let detailsClose = $state(null);
|
|
531
|
+
function openDetails() {
|
|
532
|
+
flushSync(() => detailsOpen = true);
|
|
533
|
+
detailsClose?.focus();
|
|
534
|
+
}
|
|
535
|
+
function closeDetails() {
|
|
536
|
+
detailsOpen = false;
|
|
537
|
+
detailsTrigger?.focus();
|
|
538
|
+
}
|
|
539
|
+
function toggleDetails() {
|
|
540
|
+
if (detailsOpen) closeDetails();
|
|
541
|
+
else openDetails();
|
|
542
|
+
}
|
|
543
|
+
function pickAction(action) {
|
|
544
|
+
action();
|
|
545
|
+
if (actionsMenu?.matches(":popover-open")) actionsMenu.hidePopover();
|
|
546
|
+
}
|
|
547
|
+
const brokenLinks = $derived(form?.brokenLinks ?? []);
|
|
548
|
+
let removedLinks = $state([]);
|
|
549
|
+
const visibleBrokenLinks = $derived(brokenLinks.filter((h) => !removedLinks.includes(h)));
|
|
550
|
+
function removeBrokenLink(href) {
|
|
551
|
+
const next = unwrapCairnLink(body, href);
|
|
552
|
+
if (next !== body) {
|
|
553
|
+
body = next;
|
|
554
|
+
removedLinks = [...removedLinks, href];
|
|
930
555
|
}
|
|
931
|
-
|
|
556
|
+
}
|
|
557
|
+
const needsAlt = $derived(findMediaImagesNeedingAlt(body));
|
|
558
|
+
const imageFields = $derived(
|
|
559
|
+
data.fields.filter((f) => f.type === "image").map((f) => ({ name: f.name, label: f.label }))
|
|
560
|
+
);
|
|
561
|
+
const heroRows = $derived(imageFields.filter((f) => heroNeedsAlt[f.name]));
|
|
562
|
+
const needsAltCount = $derived(needsAlt.length + heroRows.length);
|
|
563
|
+
const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
|
|
564
|
+
const formError = $derived(
|
|
565
|
+
form?.error && !form.brokenLinks?.length && !form.inboundLinks?.length ? form.error : ""
|
|
566
|
+
);
|
|
567
|
+
const entryKey = $derived(data.conceptId + "/" + data.id);
|
|
568
|
+
let seededKey = untrack(() => entryKey);
|
|
569
|
+
$effect.pre(() => {
|
|
570
|
+
const key = entryKey;
|
|
571
|
+
if (key === seededKey) return;
|
|
572
|
+
seededKey = key;
|
|
573
|
+
untrack(() => {
|
|
574
|
+
body = form?.body ?? data.body;
|
|
575
|
+
saving = false;
|
|
576
|
+
publishing = false;
|
|
577
|
+
leaving = false;
|
|
578
|
+
fieldsDirty = false;
|
|
579
|
+
mode = "write";
|
|
932
580
|
detailsOpen = false;
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
581
|
+
previewHtml = "";
|
|
582
|
+
previewFailed = false;
|
|
583
|
+
removedLinks = [];
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
const draftWarning = $derived.by(() => {
|
|
587
|
+
const drafts = page.url.searchParams.get("drafts");
|
|
588
|
+
return drafts ? drafts.split(",").filter(Boolean).join(", ") : "";
|
|
589
|
+
});
|
|
590
|
+
const flash = $derived.by(() => {
|
|
591
|
+
if (data.saved && !draftWarning)
|
|
592
|
+
return "Saved. Your site keeps showing the published version until you publish.";
|
|
593
|
+
if (data.publishedFlash) return "Published. The live site is rebuilding.";
|
|
594
|
+
if (data.discardedFlash) return "Changes discarded.";
|
|
595
|
+
if (data.renamed) return `The URL is now ${data.slug}.`;
|
|
596
|
+
return "";
|
|
597
|
+
});
|
|
598
|
+
const politeMessage = $derived(
|
|
599
|
+
draftWarning ? `Saved. This page links to unpublished pages: ${draftWarning}.` : flash
|
|
600
|
+
);
|
|
601
|
+
const assertiveMessage = $derived.by(() => {
|
|
602
|
+
if (data.error) return data.error;
|
|
603
|
+
if (formError) return formError;
|
|
604
|
+
if (deleteRefusedLinks.length) {
|
|
605
|
+
const count = deleteRefusedLinks.length;
|
|
606
|
+
return `This ${data.label.toLowerCase()} could not be deleted. ${count} ${count === 1 ? "page links" : "pages link"} to it.`;
|
|
938
607
|
}
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
// still up.
|
|
943
|
-
function pickAction(action: () => void) {
|
|
944
|
-
action();
|
|
945
|
-
if (actionsMenu?.matches(':popover-open')) actionsMenu.hidePopover();
|
|
608
|
+
if (visibleBrokenLinks.length) {
|
|
609
|
+
const count = visibleBrokenLinks.length;
|
|
610
|
+
return `This page links to ${count} missing ${count === 1 ? "page" : "pages"}.`;
|
|
946
611
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
function removeBrokenLink(href: string) {
|
|
956
|
-
// Hide the row only when the unwrap changed the body. A genuine no-op keeps the row honest.
|
|
957
|
-
const next = unwrapCairnLink(body, href);
|
|
958
|
-
if (next !== body) {
|
|
959
|
-
body = next;
|
|
960
|
-
removedLinks = [...removedLinks, href];
|
|
961
|
-
}
|
|
962
|
-
}
|
|
963
|
-
|
|
964
|
-
// The media images in the live body that carry no alt text, recomputed as the author types. Alt is
|
|
965
|
-
// accessibility debt, never a render or publish failure, so this drives a non-blocking warning the
|
|
966
|
-
// author can act on or leave; the count drops and the notice clears as each alt is filled.
|
|
967
|
-
const needsAlt = $derived(findMediaImagesNeedingAlt(body));
|
|
968
|
-
|
|
969
|
-
// The declared image (hero) fields, for labelling the needs-alt notice's frontmatter rows.
|
|
970
|
-
const imageFields = $derived(
|
|
971
|
-
data.fields.filter((f) => f.type === 'image').map((f) => ({ name: f.name, label: f.label })),
|
|
972
|
-
);
|
|
973
|
-
// The frontmatter-hero needs-alt rows: each image field whose hero reports a needs-alt signal. The
|
|
974
|
-
// row's action focuses the field's alt input (the body scanner and its source-range jump cannot
|
|
975
|
-
// reach a frontmatter value). The headline count sums these with the body scanner's hits.
|
|
976
|
-
const heroRows = $derived(imageFields.filter((f) => heroNeedsAlt[f.name]));
|
|
977
|
-
const needsAltCount = $derived(needsAlt.length + heroRows.length);
|
|
978
|
-
|
|
979
|
-
// The delete guard's inbound linkers, from a refused delete (fail 409). Empty when the delete was
|
|
980
|
-
// not refused. When set, a delete was blocked by a link that appeared since the page loaded.
|
|
981
|
-
const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
|
|
982
|
-
|
|
983
|
-
// The shared failure summary, rendered only when no richer banner claims the failure: the save
|
|
984
|
-
// and delete guards get their own banners from brokenLinks and inboundLinks below, so this
|
|
985
|
-
// surfaces the rest (a rename refusal, today).
|
|
986
|
-
const formError = $derived(
|
|
987
|
-
form?.error && !form.brokenLinks?.length && !form.inboundLinks?.length ? form.error : '',
|
|
988
|
-
);
|
|
989
|
-
|
|
990
|
-
// The entry this surface is editing. SvelteKit reuses the page component across a same-route
|
|
991
|
-
// navigation (the delete-refused and broken-link banners link entry to entry), so the per-entry
|
|
992
|
-
// state seeded at init would survive the hop and show entry A's body over entry B's data with
|
|
993
|
-
// the dirty indicator armed. When the identity changes, re-seed the state here; the {#key}
|
|
994
|
-
// block around the template remounts the DOM to match (CodeMirror with its undo history, the
|
|
995
|
-
// uncontrolled sidebar fields, any open dialog). The leave guard still protects the hop:
|
|
996
|
-
// beforeNavigate runs before the navigation completes, so it reads the old dirty value.
|
|
997
|
-
const entryKey = $derived(data.conceptId + '/' + data.id);
|
|
998
|
-
let seededKey = untrack(() => entryKey);
|
|
999
|
-
$effect.pre(() => {
|
|
1000
|
-
const key = entryKey;
|
|
1001
|
-
if (key === seededKey) return;
|
|
1002
|
-
seededKey = key;
|
|
1003
|
-
untrack(() => {
|
|
1004
|
-
body = form?.body ?? data.body;
|
|
1005
|
-
saving = false;
|
|
1006
|
-
publishing = false;
|
|
1007
|
-
leaving = false;
|
|
1008
|
-
fieldsDirty = false;
|
|
1009
|
-
mode = 'write';
|
|
1010
|
-
detailsOpen = false;
|
|
1011
|
-
previewHtml = '';
|
|
1012
|
-
previewFailed = false;
|
|
1013
|
-
removedLinks = [];
|
|
1014
|
-
});
|
|
1015
|
-
});
|
|
1016
|
-
|
|
1017
|
-
// After a save that links to a draft target, the redirect carries ?drafts=<tokens>. page.url
|
|
1018
|
-
// is reactive kit state, so a client-side navigation that swaps the search string re-derives
|
|
1019
|
-
// this, and the read is SSR-safe.
|
|
1020
|
-
const draftWarning = $derived.by(() => {
|
|
1021
|
-
const drafts = page.url.searchParams.get('drafts');
|
|
1022
|
-
return drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
|
|
1023
|
-
});
|
|
1024
|
-
|
|
1025
|
-
// The one transient feedback strip under the sticky header. The redirect flags are mutually
|
|
1026
|
-
// exclusive in practice; the chain picks one so a surprise overlap still renders a single strip.
|
|
1027
|
-
// A saved flash with a draft warning yields to the warning alert below, the prior behavior.
|
|
1028
|
-
const flash = $derived.by(() => {
|
|
1029
|
-
if (data.saved && !draftWarning)
|
|
1030
|
-
return 'Saved. Your site keeps showing the published version until you publish.';
|
|
1031
|
-
if (data.publishedFlash) return 'Published. The live site is rebuilding.';
|
|
1032
|
-
if (data.discardedFlash) return 'Changes discarded.';
|
|
1033
|
-
if (data.renamed) return `The URL is now ${data.slug}.`;
|
|
1034
|
-
return '';
|
|
1035
|
-
});
|
|
1036
|
-
|
|
1037
|
-
// One persistent live region announces the current message, since a {#if}-gated role element
|
|
1038
|
-
// inserted fresh is announced inconsistently. A polite region carries the success and draft
|
|
1039
|
-
// notices (the flash, plus the draft notice the strip yields to); an assertive region carries
|
|
1040
|
-
// the errors. The visible banners below keep their styling but drop their roles, so a message
|
|
1041
|
-
// is announced once.
|
|
1042
|
-
const politeMessage = $derived(
|
|
1043
|
-
draftWarning ? `Saved. This page links to unpublished pages: ${draftWarning}.` : flash,
|
|
1044
|
-
);
|
|
1045
|
-
const assertiveMessage = $derived.by(() => {
|
|
1046
|
-
if (data.error) return data.error;
|
|
1047
|
-
if (formError) return formError;
|
|
1048
|
-
if (deleteRefusedLinks.length) {
|
|
1049
|
-
const count = deleteRefusedLinks.length;
|
|
1050
|
-
return `This ${data.label.toLowerCase()} could not be deleted. ${count} ${count === 1 ? 'page links' : 'pages link'} to it.`;
|
|
1051
|
-
}
|
|
1052
|
-
if (visibleBrokenLinks.length) {
|
|
1053
|
-
const count = visibleBrokenLinks.length;
|
|
1054
|
-
return `This page links to ${count} missing ${count === 1 ? 'page' : 'pages'}.`;
|
|
1055
|
-
}
|
|
1056
|
-
return '';
|
|
1057
|
-
});
|
|
1058
|
-
|
|
1059
|
-
// One line of body text reduced to its prose: inline directives drop wholesale, then the
|
|
1060
|
-
// markdown marker characters become spaces. Spacing rather than deleting keeps "[text](url)"
|
|
1061
|
-
// as two words instead of mashing the link text into its destination, so a link counts its
|
|
1062
|
-
// text plus its URL and the count never undercounts prose.
|
|
1063
|
-
function proseOnly(line: string): string {
|
|
1064
|
-
let out = '';
|
|
1065
|
-
let cursor = 0;
|
|
1066
|
-
for (const { from, to } of findInlineDirectives(line)) {
|
|
1067
|
-
out += line.slice(cursor, from);
|
|
1068
|
-
cursor = to;
|
|
1069
|
-
}
|
|
1070
|
-
out += line.slice(cursor);
|
|
1071
|
-
return out.replace(/[*_~`[\]()#]/g, ' ');
|
|
612
|
+
return "";
|
|
613
|
+
});
|
|
614
|
+
function proseOnly(line) {
|
|
615
|
+
let out = "";
|
|
616
|
+
let cursor = 0;
|
|
617
|
+
for (const { from, to } of findInlineDirectives(line)) {
|
|
618
|
+
out += line.slice(cursor, from);
|
|
619
|
+
cursor = to;
|
|
1072
620
|
}
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
})
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
621
|
+
out += line.slice(cursor);
|
|
622
|
+
return out.replace(/[*_~`[\]()#]/g, " ");
|
|
623
|
+
}
|
|
624
|
+
const countedBody = $derived(
|
|
625
|
+
body.split("\n").filter((line) => directiveLineKind(line) === null && !/^\s*\|/.test(line)).map(proseOnly).join("\n")
|
|
626
|
+
);
|
|
627
|
+
const wordCount = $derived(countedBody.trim() ? countedBody.trim().split(/\s+/).length : 0);
|
|
628
|
+
const wordLabel = $derived(wordCount === 1 ? "1 word" : `${wordCount} words`);
|
|
629
|
+
const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
|
|
630
|
+
const resolveMediaTargets = $derived({
|
|
631
|
+
...data.mediaTargets,
|
|
632
|
+
...Object.fromEntries(
|
|
633
|
+
uploadedRecords.map((r) => [r.hash, { slug: r.slug, ext: r.ext, contentType: r.contentType }])
|
|
634
|
+
)
|
|
635
|
+
});
|
|
636
|
+
const resolveMedia = $derived(manifestMediaResolver(resolveMediaTargets));
|
|
637
|
+
const mediaLibrary = $derived({
|
|
638
|
+
...data.mediaLibrary,
|
|
639
|
+
...Object.fromEntries(uploadedRecords.map((r) => [r.hash, mediaLibraryEntry(r)]))
|
|
640
|
+
});
|
|
641
|
+
const completionSources = $derived([cairnLinkCompletionSource(data.linkTargets)]);
|
|
642
|
+
function setMode(m) {
|
|
643
|
+
mode = m;
|
|
644
|
+
}
|
|
645
|
+
let editorCard = $state(null);
|
|
646
|
+
$effect(() => {
|
|
647
|
+
const card = editorCard;
|
|
648
|
+
if (!card) return;
|
|
649
|
+
card.addEventListener("keydown", onEditorKeydown);
|
|
650
|
+
return () => card.removeEventListener("keydown", onEditorKeydown);
|
|
651
|
+
});
|
|
652
|
+
function onEditorKeydown(e) {
|
|
653
|
+
if (!(e.ctrlKey || e.metaKey)) return;
|
|
654
|
+
const key = e.key.toLowerCase();
|
|
655
|
+
const fmt = formatForKeydown(e);
|
|
656
|
+
if (fmt) {
|
|
657
|
+
e.preventDefault();
|
|
658
|
+
format(fmt);
|
|
659
|
+
} else if (key === "b") {
|
|
660
|
+
e.preventDefault();
|
|
661
|
+
format("bold");
|
|
662
|
+
} else if (key === "i") {
|
|
663
|
+
e.preventDefault();
|
|
664
|
+
format("italic");
|
|
665
|
+
} else if (key === "k") {
|
|
666
|
+
e.preventDefault();
|
|
667
|
+
webLinkDialog?.open();
|
|
1118
668
|
}
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
$effect(() => {
|
|
1126
|
-
const card = editorCard;
|
|
1127
|
-
if (!card) return;
|
|
1128
|
-
card.addEventListener('keydown', onEditorKeydown);
|
|
1129
|
-
return () => card.removeEventListener('keydown', onEditorKeydown);
|
|
1130
|
-
});
|
|
1131
|
-
function onEditorKeydown(e: KeyboardEvent) {
|
|
1132
|
-
if (!(e.ctrlKey || e.metaKey)) return;
|
|
1133
|
-
const key = e.key.toLowerCase();
|
|
1134
|
-
// The shifted-digit list trio (Ctrl+Shift+9/8/7) arrives as '('/'*'/'&' for e.key on US
|
|
1135
|
-
// layouts, so the digit identity comes from e.code; the heading pair rides Ctrl+Alt+2/3.
|
|
1136
|
-
const fmt = formatForKeydown(e);
|
|
1137
|
-
if (fmt) {
|
|
1138
|
-
e.preventDefault();
|
|
1139
|
-
format(fmt);
|
|
1140
|
-
} else if (key === 'b') {
|
|
1141
|
-
e.preventDefault();
|
|
1142
|
-
format('bold');
|
|
1143
|
-
} else if (key === 'i') {
|
|
1144
|
-
e.preventDefault();
|
|
1145
|
-
format('italic');
|
|
1146
|
-
} else if (key === 'k') {
|
|
1147
|
-
e.preventDefault();
|
|
1148
|
-
webLinkDialog?.open();
|
|
1149
|
-
}
|
|
669
|
+
}
|
|
670
|
+
function formatForKeydown(e) {
|
|
671
|
+
if (e.altKey) {
|
|
672
|
+
if (e.key === "2") return "h2";
|
|
673
|
+
if (e.key === "3") return "h3";
|
|
674
|
+
return null;
|
|
1150
675
|
}
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
if (e.altKey) {
|
|
1156
|
-
if (e.key === '2') return 'h2';
|
|
1157
|
-
if (e.key === '3') return 'h3';
|
|
1158
|
-
return null;
|
|
1159
|
-
}
|
|
1160
|
-
if (e.shiftKey) {
|
|
1161
|
-
if (e.code === 'Digit9') return 'quote';
|
|
1162
|
-
if (e.code === 'Digit8') return 'ul';
|
|
1163
|
-
if (e.code === 'Digit7') return 'ol';
|
|
1164
|
-
return null;
|
|
1165
|
-
}
|
|
1166
|
-
if (e.key.toLowerCase() === 'e') return 'code';
|
|
676
|
+
if (e.shiftKey) {
|
|
677
|
+
if (e.code === "Digit9") return "quote";
|
|
678
|
+
if (e.code === "Digit8") return "ul";
|
|
679
|
+
if (e.code === "Digit7") return "ol";
|
|
1167
680
|
return null;
|
|
1168
681
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
previewHtml = html;
|
|
1186
|
-
previewFailed = false;
|
|
1187
|
-
}
|
|
1188
|
-
} catch {
|
|
1189
|
-
if (run === previewRun) {
|
|
1190
|
-
previewHtml = '';
|
|
1191
|
-
previewFailed = true;
|
|
1192
|
-
}
|
|
682
|
+
if (e.key.toLowerCase() === "e") return "code";
|
|
683
|
+
return null;
|
|
684
|
+
}
|
|
685
|
+
let previewRun = 0;
|
|
686
|
+
$effect(() => {
|
|
687
|
+
if (mode !== "preview" || !render) return;
|
|
688
|
+
const md = body;
|
|
689
|
+
const resolve = resolveLink;
|
|
690
|
+
const resolveMediaRef = resolveMedia;
|
|
691
|
+
const run = ++previewRun;
|
|
692
|
+
const handle = setTimeout(async () => {
|
|
693
|
+
try {
|
|
694
|
+
const html = await render(md, { resolve, resolveMedia: resolveMediaRef });
|
|
695
|
+
if (run === previewRun) {
|
|
696
|
+
previewHtml = html;
|
|
697
|
+
previewFailed = false;
|
|
1193
698
|
}
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
// The sidebar's grouping. The title field hoists above the editor card as the document title,
|
|
1214
|
-
// and a boolean named draft becomes the Visibility group's Hidden toggle (both production
|
|
1215
|
-
// adapters use that name); everything else is a Details field.
|
|
1216
|
-
const titleField = $derived(data.fields.find((f) => f.name === 'title'));
|
|
1217
|
-
const draftField = $derived(data.fields.find((f) => f.type === 'boolean' && f.name === 'draft'));
|
|
1218
|
-
const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
|
|
699
|
+
} catch {
|
|
700
|
+
if (run === previewRun) {
|
|
701
|
+
previewHtml = "";
|
|
702
|
+
previewFailed = true;
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
}, 150);
|
|
706
|
+
return () => {
|
|
707
|
+
clearTimeout(handle);
|
|
708
|
+
previewRun++;
|
|
709
|
+
};
|
|
710
|
+
});
|
|
711
|
+
function str(v) {
|
|
712
|
+
return v == null ? "" : String(v);
|
|
713
|
+
}
|
|
714
|
+
const eyebrowClass = "mb-2 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]";
|
|
715
|
+
const titleField = $derived(data.fields.find((f) => f.name === "title"));
|
|
716
|
+
const draftField = $derived(data.fields.find((f) => f.type === "boolean" && f.name === "draft"));
|
|
717
|
+
const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
|
|
1219
718
|
</script>
|
|
1220
719
|
|
|
1221
720
|
<!-- The desk controls live in the one header band: AdminLayout renders this snippet through the
|