@glw907/cairn-cms 0.59.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 +60 -0
- package/dist/components/AdminLayout.svelte +130 -229
- package/dist/components/CairnAdmin.svelte +12 -41
- package/dist/components/CairnLogo.svelte +1 -6
- package/dist/components/CairnMediaLibrary.svelte +821 -1210
- package/dist/components/CairnTidySettings.svelte +486 -0
- package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
- 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 +786 -918
- 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 +688 -789
- package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
- 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 +355 -0
- package/dist/components/TidyReview.svelte.d.ts +47 -0
- package/dist/components/WebLinkDialog.svelte +19 -40
- package/dist/components/cairn-admin.css +768 -0
- package/dist/components/editor-tidy.d.ts +31 -0
- package/dist/components/editor-tidy.js +199 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +16 -0
- package/dist/components/markdown-directives.js +34 -0
- package/dist/components/objective-errors.d.ts +30 -0
- package/dist/components/objective-errors.js +113 -0
- package/dist/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
- package/dist/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
- package/dist/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
- package/dist/components/spellcheck-worker.d.ts +80 -0
- package/dist/components/spellcheck-worker.js +161 -0
- package/dist/components/spellcheck.d.ts +148 -0
- package/dist/components/spellcheck.js +553 -0
- package/dist/components/tidy-categorize.d.ts +67 -0
- package/dist/components/tidy-categorize.js +392 -0
- package/dist/components/tidy-diff.d.ts +60 -0
- package/dist/components/tidy-diff.js +147 -0
- package/dist/components/tidy-validate.d.ts +37 -0
- package/dist/components/tidy-validate.js +174 -0
- package/dist/content/compose.d.ts +1 -1
- package/dist/content/compose.js +11 -0
- package/dist/content/site-dictionary.d.ts +31 -0
- package/dist/content/site-dictionary.js +82 -0
- package/dist/content/types.d.ts +25 -0
- package/dist/delivery/CairnHead.svelte +8 -11
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +55 -6
- package/dist/doctor/index.js +2 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/nav/site-config.d.ts +98 -0
- package/dist/nav/site-config.js +132 -0
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +6 -2
- package/dist/sveltekit/cairn-admin.d.ts +13 -1
- package/dist/sveltekit/cairn-admin.js +22 -3
- package/dist/sveltekit/content-routes.d.ts +135 -1
- package/dist/sveltekit/content-routes.js +351 -3
- package/dist/sveltekit/tidy-prompt.d.ts +11 -0
- package/dist/sveltekit/tidy-prompt.js +118 -0
- package/package.json +11 -2
- package/src/lib/components/CairnAdmin.svelte +3 -0
- package/src/lib/components/CairnTidySettings.svelte +553 -0
- package/src/lib/components/EditPage.svelte +371 -2
- package/src/lib/components/MarkdownEditor.svelte +168 -1
- package/src/lib/components/TidyReview.svelte +463 -0
- package/src/lib/components/cairn-admin.css +25 -0
- package/src/lib/components/editor-tidy.ts +241 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +35 -0
- package/src/lib/components/objective-errors.ts +155 -0
- package/src/lib/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
- package/src/lib/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
- package/src/lib/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
- package/src/lib/components/spellcheck-worker.ts +279 -0
- package/src/lib/components/spellcheck.ts +693 -0
- package/src/lib/components/tidy-categorize.ts +460 -0
- package/src/lib/components/tidy-diff.ts +196 -0
- package/src/lib/components/tidy-validate.ts +202 -0
- package/src/lib/content/compose.ts +11 -1
- package/src/lib/content/site-dictionary.ts +84 -0
- package/src/lib/content/types.ts +25 -0
- package/src/lib/doctor/checks-local.ts +59 -5
- package/src/lib/doctor/index.ts +2 -0
- package/src/lib/log/events.ts +7 -1
- package/src/lib/nav/site-config.ts +197 -0
- package/src/lib/sveltekit/admin-dispatch.ts +7 -3
- package/src/lib/sveltekit/cairn-admin.ts +32 -4
- package/src/lib/sveltekit/content-routes.ts +504 -4
- package/src/lib/sveltekit/tidy-prompt.ts +153 -0
|
@@ -16,953 +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
|
-
};
|
|
104
|
-
});
|
|
105
|
-
|
|
106
|
-
// `body` is local editor state seeded once; it diverges as the user types. A blocked save returns
|
|
107
|
-
// the author's edited markdown as form.body, so seed from that when present to keep the edits and
|
|
108
|
-
// the broken link they were told to fix. On the success and delete-refused paths form carries no
|
|
109
|
-
// body, so it falls back to the committed data.body. untrack() captures the initial value without
|
|
110
|
-
// subscribing to future prop changes.
|
|
111
|
-
let body = $state(untrack(() => form?.body ?? data.body));
|
|
112
|
-
// True from the moment the save form submits until the navigation it triggers replaces the page,
|
|
113
|
-
// so the Save button shows a calm "Saving…" state instead of looking inert.
|
|
114
|
-
let saving = $state(false);
|
|
115
|
-
// The same working state for the Publish button, which rides the edit form via formaction. The
|
|
116
|
-
// submit handler reads the submitter to flip the right one, so Save never reads "Saving…" while
|
|
117
|
-
// a publish is in flight.
|
|
118
|
-
let publishing = $state(false);
|
|
119
|
-
function onEditSubmit(e: SubmitEvent) {
|
|
120
|
-
const formaction = (e.submitter as HTMLButtonElement | null)?.getAttribute('formaction');
|
|
121
|
-
if (formaction === '?/publish') publishing = true;
|
|
122
|
-
else saving = true;
|
|
123
|
-
}
|
|
124
|
-
// Either in-flight submit disables both buttons, so a second click cannot fire a second POST
|
|
125
|
-
// while the first navigation is still pending.
|
|
126
|
-
const busy = $derived(saving || publishing);
|
|
127
|
-
// True once a non-edit POST (discard, delete, rename) submits. Those forms navigate the
|
|
128
|
-
// document without flipping busy, so without this the leave guard would fire mid-discard while
|
|
129
|
-
// the page is still dirty, which is the primary discard scenario.
|
|
130
|
-
let leaving = $state(false);
|
|
131
|
-
|
|
132
|
-
// Dirty tracking. The body compares against the text the page loaded with (or the edited body a
|
|
133
|
-
// blocked save returned, which seeded the editor); the uncontrolled sidebar fields flip a flag
|
|
134
|
-
// on any input event, and the navigation a save triggers reloads the page, which resets both.
|
|
135
|
-
const bodyDirty = $derived(body !== (form?.body ?? data.body));
|
|
136
|
-
let fieldsDirty = $state(false);
|
|
137
|
-
const dirty = $derived(bodyDirty || fieldsDirty);
|
|
138
|
-
// What the header's save-state indicator says.
|
|
139
|
-
const saveState = $derived(dirty ? 'Unsaved changes' : data.saved ? 'Saved' : '');
|
|
140
|
-
function onFormInput(e: Event) {
|
|
141
|
-
const target = e.target as Element | null;
|
|
142
|
-
// Two kinds of input event bubble through the form without being frontmatter edits: the link
|
|
143
|
-
// picker's search box (its dialog sits in the toolbar snippet) and the editing surface's
|
|
144
|
-
// contenteditable. Skipping the surface keeps body edits owned by bodyDirty, so undoing back
|
|
145
|
-
// to the committed text reads clean again.
|
|
146
|
-
if (target?.closest('dialog, #cairn-pane-write')) return;
|
|
147
|
-
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;
|
|
148
103
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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;
|
|
154
111
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
function onFormInvalid(e: Event) {
|
|
170
|
-
const target = e.target as Element | null;
|
|
171
|
-
if (target?.closest('aside')) {
|
|
172
|
-
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);
|
|
173
126
|
return;
|
|
174
127
|
}
|
|
175
|
-
if (
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
// leaving does.
|
|
183
|
-
beforeNavigate((navigation) => {
|
|
184
|
-
// A full-page unload (refresh, tab close, external link): per SvelteKit semantics, cancel()
|
|
185
|
-
// on a leave navigation is what asks the browser for its native dialog, so no confirm()
|
|
186
|
-
// here or two prompts would stack. The beforeunload listener below is deliberate
|
|
187
|
-
// belt-and-braces, not the dialog's source.
|
|
188
|
-
if (navigation.willUnload) {
|
|
189
|
-
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();
|
|
190
135
|
return;
|
|
191
136
|
}
|
|
192
|
-
if (
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
const onBeforeUnload = (e: BeforeUnloadEvent) => {
|
|
200
|
-
if (dirty && !busy && !leaving) e.preventDefault();
|
|
201
|
-
};
|
|
202
|
-
// Guard-clause style on purpose: svelte 5.56.1 misprints `(a || b) && c` by dropping the
|
|
203
|
-
// parentheses, and consumers compile this source with their own svelte.
|
|
204
|
-
const onWindowKeydown = (e: KeyboardEvent) => {
|
|
205
|
-
// Escape precedence, top to bottom: an open dialog claims Escape natively, so step aside
|
|
206
|
-
// when one is up. Otherwise the details slide-over closes first (Task 8: it is a region, not
|
|
207
|
-
// a dialog, so it has no native light-dismiss), and only when no panel is open does Escape
|
|
208
|
-
// exit zen. So under zen with the panel open, the first Escape closes the panel and the
|
|
209
|
-
// second exits zen, which keeps the two affordances independent.
|
|
210
|
-
if (e.key === 'Escape' && (detailsOpen || zen)) {
|
|
211
|
-
const inDialog = !!(e.target as Element | null)?.closest?.('dialog');
|
|
212
|
-
if (inDialog) return;
|
|
213
|
-
e.preventDefault();
|
|
214
|
-
if (detailsOpen) closeDetails();
|
|
215
|
-
else setZen(false);
|
|
216
|
-
return;
|
|
217
|
-
}
|
|
218
|
-
if (!(e.ctrlKey || e.metaKey)) return;
|
|
219
|
-
const key = e.key.toLowerCase();
|
|
220
|
-
// The page-wide chords never act on a surface the author cannot see: a save, publish, mode
|
|
221
|
-
// flip, or focus toggle from inside an open modal is suppressed the same way.
|
|
222
|
-
const inDialog = !!(e.target as Element | null)?.closest?.('dialog');
|
|
223
|
-
// Ctrl+/ opens the shortcuts sheet, the third discoverability surface. It reads off e.key so
|
|
224
|
-
// it survives the shifted glyph differences across layouts, and it stays clear of dialogs the
|
|
225
|
-
// same way the other chords do (the sheet is itself a dialog, so opening from inside one would
|
|
226
|
-
// stack modals over a surface the author cannot see).
|
|
227
|
-
if (!e.shiftKey && !e.altKey && e.key === '/') {
|
|
228
|
-
e.preventDefault();
|
|
229
|
-
if (inDialog) return;
|
|
230
|
-
shortcutsDialog?.open();
|
|
231
|
-
return;
|
|
232
|
-
}
|
|
233
|
-
if (e.shiftKey && key === 's') {
|
|
234
|
-
// Publish rides the header's Publish submitter so the ?/publish formaction and the busy
|
|
235
|
-
// flags follow the existing submit path; it exists only while pending, so this no-ops
|
|
236
|
-
// otherwise.
|
|
237
|
-
e.preventDefault();
|
|
238
|
-
if (busy || inDialog || !data.pending) return;
|
|
239
|
-
editForm?.requestSubmit(publishButton);
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
if (e.altKey && key === 'p') {
|
|
243
|
-
e.preventDefault();
|
|
244
|
-
if (inDialog) return;
|
|
245
|
-
setMode(mode === 'write' ? 'preview' : 'write');
|
|
246
|
-
return;
|
|
247
|
-
}
|
|
248
|
-
if (e.shiftKey && key === 'f') {
|
|
249
|
-
e.preventDefault();
|
|
250
|
-
if (inDialog) return;
|
|
251
|
-
setFocusMode(!focusMode);
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
// Ctrl+Shift+. toggles zen (the bindings' zen key); the period reads off e.key. This sits
|
|
255
|
-
// before the Ctrl+. panel block so the shifted chord is not mistaken for the panel toggle.
|
|
256
|
-
if (e.shiftKey && !e.altKey && e.key === '.') {
|
|
257
|
-
e.preventDefault();
|
|
258
|
-
if (inDialog) return;
|
|
259
|
-
setZen(!zen);
|
|
260
|
-
return;
|
|
261
|
-
}
|
|
262
|
-
// Ctrl+. toggles the details slide-over (the bindings' panel key); the period reads off
|
|
263
|
-
// e.key with no shift or alt.
|
|
264
|
-
if (!e.shiftKey && !e.altKey && e.key === '.') {
|
|
265
|
-
e.preventDefault();
|
|
266
|
-
if (inDialog) return;
|
|
267
|
-
toggleDetails();
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
if (e.shiftKey || e.altKey) return;
|
|
271
|
-
if (key !== 's') return;
|
|
272
|
-
// 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") {
|
|
273
144
|
e.preventDefault();
|
|
274
|
-
// Gate the submit itself: an in-flight POST must not race a second one, a clean page has
|
|
275
|
-
// nothing to save (a no-op save would still cut a pending branch), and a save from inside
|
|
276
|
-
// an open modal would act on a surface the author cannot see.
|
|
277
|
-
if (busy) return;
|
|
278
|
-
if (!dirty && !data.isNew) return;
|
|
279
145
|
if (inDialog) return;
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
window.addEventListener('beforeunload', onBeforeUnload);
|
|
283
|
-
window.addEventListener('keydown', onWindowKeydown);
|
|
284
|
-
return () => {
|
|
285
|
-
window.removeEventListener('beforeunload', onBeforeUnload);
|
|
286
|
-
window.removeEventListener('keydown', onWindowKeydown);
|
|
287
|
-
};
|
|
288
|
-
});
|
|
289
|
-
// The discard confirm, on the DeleteDialog pattern: a native <dialog> holding the POST form.
|
|
290
|
-
let discardDialog = $state<HTMLDialogElement | null>(null);
|
|
291
|
-
// Which pane the editor card shows. The toolbar's tablist drives it; Write is always the
|
|
292
|
-
// landing tab.
|
|
293
|
-
let mode = $state<'write' | 'preview'>('write');
|
|
294
|
-
// Preview is read-only, so the insert controls the page renders into the toolbar disable with the
|
|
295
|
-
// strip's own format buttons. Declared here (above the Edit-block derivations that read it) so it
|
|
296
|
-
// is in scope before its first use.
|
|
297
|
-
const insertDisabled = $derived(mode === 'preview');
|
|
298
|
-
let previewHtml = $state('');
|
|
299
|
-
// True after a render call threw, so the preview pane can say so instead of going blank.
|
|
300
|
-
let previewFailed = $state(false);
|
|
301
|
-
// The preview frame's device width, a per-browser preference under its own key (the legacy
|
|
302
|
-
// 'cairn-admin:preview' key from the removed split-pane preview stays untouched). Desktop is
|
|
303
|
-
// the default; the storage read sits in an effect so it never runs during SSR, and it tracks
|
|
304
|
-
// nothing reactive, so it runs once.
|
|
305
|
-
const deviceStorageKey = 'cairn-editor-preview-device';
|
|
306
|
-
let device = $state<PreviewDeviceId>('desktop');
|
|
307
|
-
$effect(() => {
|
|
308
|
-
const stored = localStorage.getItem(deviceStorageKey);
|
|
309
|
-
if (previewDevices.some((d) => d.id === stored)) device = stored as PreviewDeviceId;
|
|
310
|
-
});
|
|
311
|
-
function setDevice(id: PreviewDeviceId) {
|
|
312
|
-
device = id;
|
|
313
|
-
localStorage.setItem(deviceStorageKey, id);
|
|
314
|
-
}
|
|
315
|
-
// The writing modes (focus, typewriter) and the surface posture, per-browser preferences on
|
|
316
|
-
// the device pick's pattern: read in an effect so SSR never touches localStorage, written by
|
|
317
|
-
// the card footer's toggles. The effect tracks nothing reactive, so it runs once.
|
|
318
|
-
const focusStorageKey = 'cairn-editor-focus-mode';
|
|
319
|
-
const typewriterStorageKey = 'cairn-editor-typewriter';
|
|
320
|
-
const surfaceStorageKey = 'cairn-editor-surface';
|
|
321
|
-
const zenStorageKey = 'cairn-editor-zen';
|
|
322
|
-
let focusMode = $state(false);
|
|
323
|
-
let typewriter = $state(false);
|
|
324
|
-
// Zen: the manuscript alone on the recessed ground. The band, the document title, the toolbar
|
|
325
|
-
// strip, and the footer go; the editing surface stays. It joins the editor-preference family on
|
|
326
|
-
// the same pattern (a localStorage key, read once below, written by the setter), and composes
|
|
327
|
-
// with focus mode and the postures rather than resetting them.
|
|
328
|
-
let zen = $state(false);
|
|
329
|
-
// The surface posture: prose (the writing instrument) by default; markup is the dense
|
|
330
|
-
// working surface.
|
|
331
|
-
let surface = $state<'prose' | 'markup'>('prose');
|
|
332
|
-
$effect(() => {
|
|
333
|
-
focusMode = localStorage.getItem(focusStorageKey) === 'true';
|
|
334
|
-
typewriter = localStorage.getItem(typewriterStorageKey) === 'true';
|
|
335
|
-
zen = localStorage.getItem(zenStorageKey) === 'true';
|
|
336
|
-
if (localStorage.getItem(surfaceStorageKey) === 'markup') surface = 'markup';
|
|
337
|
-
});
|
|
338
|
-
function setFocusMode(on: boolean) {
|
|
339
|
-
focusMode = on;
|
|
340
|
-
localStorage.setItem(focusStorageKey, String(on));
|
|
341
|
-
}
|
|
342
|
-
function setTypewriter(on: boolean) {
|
|
343
|
-
typewriter = on;
|
|
344
|
-
localStorage.setItem(typewriterStorageKey, String(on));
|
|
345
|
-
}
|
|
346
|
-
function setSurface(posture: 'prose' | 'markup') {
|
|
347
|
-
surface = posture;
|
|
348
|
-
localStorage.setItem(surfaceStorageKey, posture);
|
|
349
|
-
}
|
|
350
|
-
function setZen(on: boolean) {
|
|
351
|
-
// Entering zen hides the band, the document title, the toolbar strip, and the footer. Focus on
|
|
352
|
-
// any of those (a band action like Publish, a strip button, the title input, a footer toggle)
|
|
353
|
-
// would strand on a detached node when its host leaves the DOM, so move focus into the editing
|
|
354
|
-
// surface first. The surface (.cm-editor) and the exit chip are all that survive, so any focus
|
|
355
|
-
// outside the surface is about to hide. Reading activeElement before the DOM updates is what
|
|
356
|
-
// tells a hiding control from the surviving one.
|
|
357
|
-
const surface = editorCard?.querySelector('.cm-editor');
|
|
358
|
-
const focusHides = on && !surface?.contains(document.activeElement);
|
|
359
|
-
zen = on;
|
|
360
|
-
localStorage.setItem(zenStorageKey, String(on));
|
|
361
|
-
if (focusHides) {
|
|
362
|
-
// flushSync applies the zen layout (the strip and footer leave the DOM) before we reach for
|
|
363
|
-
// the surface, so the focus call lands on the now-sole interactive region.
|
|
364
|
-
flushSync();
|
|
365
|
-
(editorCard?.querySelector('.cm-content') as HTMLElement | null)?.focus();
|
|
146
|
+
setMode(mode === "write" ? "preview" : "write");
|
|
147
|
+
return;
|
|
366
148
|
}
|
|
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;
|
|
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 {
|
|
367
248
|
}
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
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)]'}`;
|
|
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();
|
|
382
262
|
}
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
let
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
// body scanner (findMediaImagesNeedingAlt), and its remediation focuses the alt input, never a
|
|
439
|
-
// source range (selectRange). The records are keyed by field name; `data.fields` is static for the
|
|
440
|
-
// page's lifetime, so a key never goes stale (no per-key cleanup on unmount is needed).
|
|
441
|
-
let heroFieldRefs = $state<Record<string, MediaHeroField>>({});
|
|
442
|
-
let heroNeedsAlt = $state<Record<string, boolean>>({});
|
|
443
|
-
|
|
444
|
-
// The server-owned records from each successful upload this session. They ride the save form as
|
|
445
|
-
// the hidden `media` field, so the save action merges them into media.json.
|
|
446
|
-
let uploadedRecords = $state<MediaEntry[]>([]);
|
|
447
|
-
// A headless dialog instance, typed structurally over its exported open() (the linkPicker idiom).
|
|
448
|
-
type DialogHandle = { open: () => void };
|
|
449
|
-
// The toolbar's insert dialogs. Each holds its own <form>, so they mount outside the edit form
|
|
450
|
-
// (a form nested in a form is invalid HTML the parser repairs by dropping the outer tag, which
|
|
451
|
-
// breaks SSR and hydration); the toolbar snippet renders plain triggers that open them here.
|
|
452
|
-
let webLinkDialog = $state<DialogHandle | null>(null);
|
|
453
|
-
let linkPicker = $state<DialogHandle | null>(null);
|
|
454
|
-
// The insert dialog binds the full instance, not the bare DialogHandle: the Edit-block control
|
|
455
|
-
// drives editComponent(def, values, range) on it, beyond the shared open().
|
|
456
|
-
let insertDialog = $state<ComponentInsertDialog | null>(null);
|
|
457
|
-
// The lifecycle dialogs, opened from the header's overflow menu.
|
|
458
|
-
let deleteDialog = $state<DialogHandle | null>(null);
|
|
459
|
-
let renameDialog = $state<DialogHandle | null>(null);
|
|
460
|
-
// The Markdown cheat sheet, opened from the editor card's footer.
|
|
461
|
-
let helpDialog = $state<DialogHandle | null>(null);
|
|
462
|
-
// The keyboard shortcuts sheet, opened from anywhere on the desk by Ctrl+/.
|
|
463
|
-
let shortcutsDialog = $state<DialogHandle | null>(null);
|
|
464
|
-
|
|
465
|
-
// Whether the registry offers anything insertable, the same condition the insert dialog lists
|
|
466
|
-
// by, so the toolbar trigger and the dialog appear and disappear together.
|
|
467
|
-
const hasComponents = $derived(insertableDefs(registry).length > 0);
|
|
468
|
-
|
|
469
|
-
// The directive container at the editor caret, reported by MarkdownEditor whenever it changes
|
|
470
|
-
// (null outside any container). The Edit-block control resolves it against the registry and the
|
|
471
|
-
// round-trip safety gate below; its identity is the key the async gate guards against a stale
|
|
472
|
-
// result. The reporter's name+markdown+from+to shape; declared locally because MarkdownEditor's
|
|
473
|
-
// matching interface is not exported.
|
|
474
|
-
type CaretComponent = { name: string | null; markdown: string; from: number; to: number };
|
|
475
|
-
let caretComponent = $state<CaretComponent | null>(null);
|
|
476
|
-
|
|
477
|
-
// The media image at the editor caret, reported by MarkdownEditor whenever it changes (null off any
|
|
478
|
-
// media image). The Figure control reads it to wrap a bare image or edit an existing figure; it
|
|
479
|
-
// writes source through the replaceRange seam. The figure dialog is mounted headless below.
|
|
480
|
-
let mediaAtCaret = $state<FigureAtImage | null>(null);
|
|
481
|
-
// The figure control's host <dialog>, opened by the toolbar control. Mounted outside the edit form
|
|
482
|
-
// (a form nested in a form is invalid HTML), the Edit-block dialog pattern.
|
|
483
|
-
let figureDialog = $state<HTMLDialogElement | null>(null);
|
|
484
|
-
// Whether the Figure control is available: a media image sits at the caret and Preview is not
|
|
485
|
-
// showing (the insert controls disable together with the Write surface). The control is always
|
|
486
|
-
// rendered (it never mounts on caret move); only its enabled state changes.
|
|
487
|
-
const figureAvailable = $derived(mediaAtCaret != null && !insertDisabled);
|
|
488
|
-
const figureLabel = $derived(
|
|
489
|
-
figureAvailable
|
|
490
|
-
? mediaAtCaret?.figure
|
|
491
|
-
? 'Edit the figure at the cursor'
|
|
492
|
-
: 'Wrap the image at the cursor in a figure'
|
|
493
|
-
: 'Place the cursor on an image to add a figure',
|
|
494
|
-
);
|
|
495
|
-
// Whether the image at the caret is decorative (empty or whitespace-only alt). The token came from
|
|
496
|
-
// a parsed image node, so the alt is the source between `![` and the closing `]` before `](`. An
|
|
497
|
-
// empty alt is the needs-alt signal; the figure control surfaces it and the decorative-plus-caption
|
|
498
|
-
// warning. Derived from the reported token so it tracks the caret.
|
|
499
|
-
const figureDecorative = $derived.by(() => {
|
|
500
|
-
if (!mediaAtCaret) return false;
|
|
501
|
-
const token = body.slice(mediaAtCaret.imageFrom, mediaAtCaret.imageTo);
|
|
502
|
-
const match = /^!\[([\s\S]*?)\]\(/.exec(token);
|
|
503
|
-
return (match?.[1] ?? '').trim() === '';
|
|
504
|
-
});
|
|
505
|
-
// A def is actionable for guided edit when it has a schema (the same notion the insert catalog
|
|
506
|
-
// lists by): a template-only def has no form to re-open into. Reuses the dialog's exported
|
|
507
|
-
// hasSchema so the two surfaces can never drift on what counts as editable.
|
|
508
|
-
function editableDef(name: string | null): ComponentDef | undefined {
|
|
509
|
-
if (!name) return undefined;
|
|
510
|
-
const def = registry?.get(name);
|
|
511
|
-
if (!def) return undefined;
|
|
512
|
-
return hasSchema(def) ? def : undefined;
|
|
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);
|
|
513
318
|
}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
// current caret never enables edit.
|
|
529
|
-
$effect(() => {
|
|
530
|
-
const current = caretComponent;
|
|
531
|
-
const def = editableDef(current?.name ?? null);
|
|
532
|
-
if (!current || !def) {
|
|
533
|
-
editable = null;
|
|
534
|
-
editReason = 'none';
|
|
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.";
|
|
535
333
|
return;
|
|
536
334
|
}
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
.
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
// this state (aria-disabled, not the native disabled attribute), so its reason reaches assistive
|
|
572
|
-
// technology; the dead click is made inert in editBlock().
|
|
573
|
-
const editBlockUnavailable = $derived(insertDisabled || !editable);
|
|
574
|
-
// Activate edit: parse the block into form values, then open the dialog in edit mode over the
|
|
575
|
-
// stored source range. Guarded by editable AND the preview-mode disable, so the control is inert
|
|
576
|
-
// unless the gate passed and the editor is on the Write tab. The def, range, and markdown all come
|
|
577
|
-
// from the one editable snapshot, so a newer caret markdown is never paired with an older range.
|
|
578
|
-
async function editBlock() {
|
|
579
|
-
if (insertDisabled || !editable) return;
|
|
580
|
-
const values = await parseComponent(editable.markdown, editable.def);
|
|
581
|
-
insertDialog?.editComponent(editable.def, values, editable.range);
|
|
582
|
-
}
|
|
583
|
-
|
|
584
|
-
// The figure dialog's pre-fill, snapshotted when the control opens so the form never mixes a newer
|
|
585
|
-
// caret with the values it opened on. Captured from mediaAtCaret at open time: edit mode with the
|
|
586
|
-
// figure's caption/role when a figure wraps the image, else wrap mode with empty caption and the
|
|
587
|
-
// measure default. decorative rides the snapshot too. Null while the dialog is closed.
|
|
588
|
-
let figurePrefill = $state<{
|
|
589
|
-
mode: 'wrap' | 'edit';
|
|
590
|
-
caption: string;
|
|
591
|
-
role: FigureRole | null;
|
|
592
|
-
decorative: boolean;
|
|
593
|
-
image: { from: number; to: number };
|
|
594
|
-
figureRange: { from: number; to: number } | null;
|
|
595
|
-
} | null>(null);
|
|
596
|
-
|
|
597
|
-
// Open the figure control over the media image at the caret. Inert unless a media image sits there
|
|
598
|
-
// and the Write surface is up, the same gate the toolbar control shows. The snapshot is the source
|
|
599
|
-
// of truth for the apply handlers, so a caret move while the dialog is open never re-targets it.
|
|
600
|
-
function openFigure() {
|
|
601
|
-
if (!figureAvailable || !mediaAtCaret) return;
|
|
602
|
-
const at = mediaAtCaret;
|
|
603
|
-
figurePrefill = {
|
|
604
|
-
mode: at.figure ? 'edit' : 'wrap',
|
|
605
|
-
caption: at.figure?.caption ?? '',
|
|
606
|
-
role: at.figure?.role ?? null,
|
|
607
|
-
decorative: figureDecorative,
|
|
608
|
-
image: { from: at.imageFrom, to: at.imageTo },
|
|
609
|
-
figureRange: at.figure ? { from: at.figure.from, to: at.figure.to } : null,
|
|
610
|
-
};
|
|
611
|
-
figureDialog?.showModal();
|
|
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;
|
|
339
|
+
}
|
|
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;
|
|
368
|
+
tidyBusy = false;
|
|
612
369
|
}
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
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) {
|
|
386
|
+
tidyApplied = false;
|
|
387
|
+
tidyAppliedBody = null;
|
|
625
388
|
}
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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: () => {
|
|
633
410
|
}
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
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;
|
|
644
458
|
}
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
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 {
|
|
466
|
+
editable = null;
|
|
467
|
+
editReason = "unsafe";
|
|
468
|
+
}
|
|
469
|
+
}).catch(() => {
|
|
470
|
+
if (stale || caretComponent !== current) return;
|
|
471
|
+
editable = null;
|
|
472
|
+
editReason = "none";
|
|
656
473
|
});
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
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];
|
|
677
555
|
}
|
|
678
|
-
|
|
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";
|
|
679
580
|
detailsOpen = false;
|
|
680
|
-
|
|
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.`;
|
|
681
607
|
}
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
608
|
+
if (visibleBrokenLinks.length) {
|
|
609
|
+
const count = visibleBrokenLinks.length;
|
|
610
|
+
return `This page links to ${count} missing ${count === 1 ? "page" : "pages"}.`;
|
|
685
611
|
}
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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;
|
|
693
620
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
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();
|
|
709
668
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
// The declared image (hero) fields, for labelling the needs-alt notice's frontmatter rows.
|
|
717
|
-
const imageFields = $derived(
|
|
718
|
-
data.fields.filter((f) => f.type === 'image').map((f) => ({ name: f.name, label: f.label })),
|
|
719
|
-
);
|
|
720
|
-
// The frontmatter-hero needs-alt rows: each image field whose hero reports a needs-alt signal. The
|
|
721
|
-
// row's action focuses the field's alt input (the body scanner and its source-range jump cannot
|
|
722
|
-
// reach a frontmatter value). The headline count sums these with the body scanner's hits.
|
|
723
|
-
const heroRows = $derived(imageFields.filter((f) => heroNeedsAlt[f.name]));
|
|
724
|
-
const needsAltCount = $derived(needsAlt.length + heroRows.length);
|
|
725
|
-
|
|
726
|
-
// The delete guard's inbound linkers, from a refused delete (fail 409). Empty when the delete was
|
|
727
|
-
// not refused. When set, a delete was blocked by a link that appeared since the page loaded.
|
|
728
|
-
const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
|
|
729
|
-
|
|
730
|
-
// The shared failure summary, rendered only when no richer banner claims the failure: the save
|
|
731
|
-
// and delete guards get their own banners from brokenLinks and inboundLinks below, so this
|
|
732
|
-
// surfaces the rest (a rename refusal, today).
|
|
733
|
-
const formError = $derived(
|
|
734
|
-
form?.error && !form.brokenLinks?.length && !form.inboundLinks?.length ? form.error : '',
|
|
735
|
-
);
|
|
736
|
-
|
|
737
|
-
// The entry this surface is editing. SvelteKit reuses the page component across a same-route
|
|
738
|
-
// navigation (the delete-refused and broken-link banners link entry to entry), so the per-entry
|
|
739
|
-
// state seeded at init would survive the hop and show entry A's body over entry B's data with
|
|
740
|
-
// the dirty indicator armed. When the identity changes, re-seed the state here; the {#key}
|
|
741
|
-
// block around the template remounts the DOM to match (CodeMirror with its undo history, the
|
|
742
|
-
// uncontrolled sidebar fields, any open dialog). The leave guard still protects the hop:
|
|
743
|
-
// beforeNavigate runs before the navigation completes, so it reads the old dirty value.
|
|
744
|
-
const entryKey = $derived(data.conceptId + '/' + data.id);
|
|
745
|
-
let seededKey = untrack(() => entryKey);
|
|
746
|
-
$effect.pre(() => {
|
|
747
|
-
const key = entryKey;
|
|
748
|
-
if (key === seededKey) return;
|
|
749
|
-
seededKey = key;
|
|
750
|
-
untrack(() => {
|
|
751
|
-
body = form?.body ?? data.body;
|
|
752
|
-
saving = false;
|
|
753
|
-
publishing = false;
|
|
754
|
-
leaving = false;
|
|
755
|
-
fieldsDirty = false;
|
|
756
|
-
mode = 'write';
|
|
757
|
-
detailsOpen = false;
|
|
758
|
-
previewHtml = '';
|
|
759
|
-
previewFailed = false;
|
|
760
|
-
removedLinks = [];
|
|
761
|
-
});
|
|
762
|
-
});
|
|
763
|
-
|
|
764
|
-
// After a save that links to a draft target, the redirect carries ?drafts=<tokens>. page.url
|
|
765
|
-
// is reactive kit state, so a client-side navigation that swaps the search string re-derives
|
|
766
|
-
// this, and the read is SSR-safe.
|
|
767
|
-
const draftWarning = $derived.by(() => {
|
|
768
|
-
const drafts = page.url.searchParams.get('drafts');
|
|
769
|
-
return drafts ? drafts.split(',').filter(Boolean).join(', ') : '';
|
|
770
|
-
});
|
|
771
|
-
|
|
772
|
-
// The one transient feedback strip under the sticky header. The redirect flags are mutually
|
|
773
|
-
// exclusive in practice; the chain picks one so a surprise overlap still renders a single strip.
|
|
774
|
-
// A saved flash with a draft warning yields to the warning alert below, the prior behavior.
|
|
775
|
-
const flash = $derived.by(() => {
|
|
776
|
-
if (data.saved && !draftWarning)
|
|
777
|
-
return 'Saved. Your site keeps showing the published version until you publish.';
|
|
778
|
-
if (data.publishedFlash) return 'Published. The live site is rebuilding.';
|
|
779
|
-
if (data.discardedFlash) return 'Changes discarded.';
|
|
780
|
-
if (data.renamed) return `The URL is now ${data.slug}.`;
|
|
781
|
-
return '';
|
|
782
|
-
});
|
|
783
|
-
|
|
784
|
-
// One persistent live region announces the current message, since a {#if}-gated role element
|
|
785
|
-
// inserted fresh is announced inconsistently. A polite region carries the success and draft
|
|
786
|
-
// notices (the flash, plus the draft notice the strip yields to); an assertive region carries
|
|
787
|
-
// the errors. The visible banners below keep their styling but drop their roles, so a message
|
|
788
|
-
// is announced once.
|
|
789
|
-
const politeMessage = $derived(
|
|
790
|
-
draftWarning ? `Saved. This page links to unpublished pages: ${draftWarning}.` : flash,
|
|
791
|
-
);
|
|
792
|
-
const assertiveMessage = $derived.by(() => {
|
|
793
|
-
if (data.error) return data.error;
|
|
794
|
-
if (formError) return formError;
|
|
795
|
-
if (deleteRefusedLinks.length) {
|
|
796
|
-
const count = deleteRefusedLinks.length;
|
|
797
|
-
return `This ${data.label.toLowerCase()} could not be deleted. ${count} ${count === 1 ? 'page links' : 'pages link'} to it.`;
|
|
798
|
-
}
|
|
799
|
-
if (visibleBrokenLinks.length) {
|
|
800
|
-
const count = visibleBrokenLinks.length;
|
|
801
|
-
return `This page links to ${count} missing ${count === 1 ? 'page' : 'pages'}.`;
|
|
802
|
-
}
|
|
803
|
-
return '';
|
|
804
|
-
});
|
|
805
|
-
|
|
806
|
-
// One line of body text reduced to its prose: inline directives drop wholesale, then the
|
|
807
|
-
// markdown marker characters become spaces. Spacing rather than deleting keeps "[text](url)"
|
|
808
|
-
// as two words instead of mashing the link text into its destination, so a link counts its
|
|
809
|
-
// text plus its URL and the count never undercounts prose.
|
|
810
|
-
function proseOnly(line: string): string {
|
|
811
|
-
let out = '';
|
|
812
|
-
let cursor = 0;
|
|
813
|
-
for (const { from, to } of findInlineDirectives(line)) {
|
|
814
|
-
out += line.slice(cursor, from);
|
|
815
|
-
cursor = to;
|
|
816
|
-
}
|
|
817
|
-
out += line.slice(cursor);
|
|
818
|
-
return out.replace(/[*_~`[\]()#]/g, ' ');
|
|
819
|
-
}
|
|
820
|
-
|
|
821
|
-
// The editor footer's word count, over the local body so it tracks every keystroke. Directive
|
|
822
|
-
// machinery lines and table rows are dropped first and the inline syntax stripped, so the
|
|
823
|
-
// count reads as the author's prose.
|
|
824
|
-
const countedBody = $derived(
|
|
825
|
-
body
|
|
826
|
-
.split('\n')
|
|
827
|
-
.filter((line) => directiveLineKind(line) === null && !/^\s*\|/.test(line))
|
|
828
|
-
.map(proseOnly)
|
|
829
|
-
.join('\n'),
|
|
830
|
-
);
|
|
831
|
-
const wordCount = $derived(countedBody.trim() ? countedBody.trim().split(/\s+/).length : 0);
|
|
832
|
-
const wordLabel = $derived(wordCount === 1 ? '1 word' : `${wordCount} words`);
|
|
833
|
-
|
|
834
|
-
// The manifest-backed resolver turns a cairn: link into its live permalink in the preview, and
|
|
835
|
-
// returns undefined for a missing target so the render step marks it cairn-broken-link.
|
|
836
|
-
const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
|
|
837
|
-
|
|
838
|
-
// The media analog: it turns a media: reference into its /media delivery path in the preview, and
|
|
839
|
-
// returns undefined for a missing target so the render step marks it cairn-broken-media. The
|
|
840
|
-
// committed mediaTargets projection is merged with this session's uploaded records (the same
|
|
841
|
-
// override the picker's library does), so a just-uploaded image renders its thumbnail in the live
|
|
842
|
-
// preview before the next save commits it, rather than reading as a broken reference.
|
|
843
|
-
const resolveMediaTargets = $derived({
|
|
844
|
-
...data.mediaTargets,
|
|
845
|
-
...Object.fromEntries(
|
|
846
|
-
uploadedRecords.map((r) => [r.hash, { slug: r.slug, ext: r.ext, contentType: r.contentType }]),
|
|
847
|
-
),
|
|
848
|
-
});
|
|
849
|
-
const resolveMedia = $derived(manifestMediaResolver(resolveMediaTargets));
|
|
850
|
-
|
|
851
|
-
// The picker's library, the committed projection merged with this session's uploaded records,
|
|
852
|
-
// keyed by content hash. An uploaded record overrides a committed entry on a hash match (the same
|
|
853
|
-
// hash is the same bytes, so the override is harmless). This is what the editor decorates with, so
|
|
854
|
-
// a just-uploaded image carries its source chip before the next save commits it.
|
|
855
|
-
const mediaLibrary = $derived({
|
|
856
|
-
...data.mediaLibrary,
|
|
857
|
-
...Object.fromEntries(uploadedRecords.map((r) => [r.hash, mediaLibraryEntry(r)])),
|
|
858
|
-
});
|
|
859
|
-
|
|
860
|
-
// The [[ autocomplete source over the same link targets, handed to the editor's generic seam.
|
|
861
|
-
const completionSources = $derived([cairnLinkCompletionSource(data.linkTargets)]);
|
|
862
|
-
|
|
863
|
-
function setMode(m: 'write' | 'preview') {
|
|
864
|
-
mode = m;
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
// The editor card's keyboard shortcuts. Bound to the card so they fire wherever focus sits in the
|
|
868
|
-
// strip or the surface, without claiming the keys page-wide. The listener attaches
|
|
869
|
-
// programmatically: it is event delegation, not an interaction affordance, which Svelte's a11y
|
|
870
|
-
// rule cannot tell apart on a declarative handler.
|
|
871
|
-
let editorCard = $state<HTMLDivElement | null>(null);
|
|
872
|
-
$effect(() => {
|
|
873
|
-
const card = editorCard;
|
|
874
|
-
if (!card) return;
|
|
875
|
-
card.addEventListener('keydown', onEditorKeydown);
|
|
876
|
-
return () => card.removeEventListener('keydown', onEditorKeydown);
|
|
877
|
-
});
|
|
878
|
-
function onEditorKeydown(e: KeyboardEvent) {
|
|
879
|
-
if (!(e.ctrlKey || e.metaKey)) return;
|
|
880
|
-
const key = e.key.toLowerCase();
|
|
881
|
-
// The shifted-digit list trio (Ctrl+Shift+9/8/7) arrives as '('/'*'/'&' for e.key on US
|
|
882
|
-
// layouts, so the digit identity comes from e.code; the heading pair rides Ctrl+Alt+2/3.
|
|
883
|
-
const fmt = formatForKeydown(e);
|
|
884
|
-
if (fmt) {
|
|
885
|
-
e.preventDefault();
|
|
886
|
-
format(fmt);
|
|
887
|
-
} else if (key === 'b') {
|
|
888
|
-
e.preventDefault();
|
|
889
|
-
format('bold');
|
|
890
|
-
} else if (key === 'i') {
|
|
891
|
-
e.preventDefault();
|
|
892
|
-
format('italic');
|
|
893
|
-
} else if (key === 'k') {
|
|
894
|
-
e.preventDefault();
|
|
895
|
-
webLinkDialog?.open();
|
|
896
|
-
}
|
|
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;
|
|
897
675
|
}
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
if (e.altKey) {
|
|
903
|
-
if (e.key === '2') return 'h2';
|
|
904
|
-
if (e.key === '3') return 'h3';
|
|
905
|
-
return null;
|
|
906
|
-
}
|
|
907
|
-
if (e.shiftKey) {
|
|
908
|
-
if (e.code === 'Digit9') return 'quote';
|
|
909
|
-
if (e.code === 'Digit8') return 'ul';
|
|
910
|
-
if (e.code === 'Digit7') return 'ol';
|
|
911
|
-
return null;
|
|
912
|
-
}
|
|
913
|
-
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";
|
|
914
680
|
return null;
|
|
915
681
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
previewHtml = html;
|
|
933
|
-
previewFailed = false;
|
|
934
|
-
}
|
|
935
|
-
} catch {
|
|
936
|
-
if (run === previewRun) {
|
|
937
|
-
previewHtml = '';
|
|
938
|
-
previewFailed = true;
|
|
939
|
-
}
|
|
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;
|
|
940
698
|
}
|
|
941
|
-
}
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
// The sidebar's grouping. The title field hoists above the editor card as the document title,
|
|
961
|
-
// and a boolean named draft becomes the Visibility group's Hidden toggle (both production
|
|
962
|
-
// adapters use that name); everything else is a Details field.
|
|
963
|
-
const titleField = $derived(data.fields.find((f) => f.name === 'title'));
|
|
964
|
-
const draftField = $derived(data.fields.find((f) => f.type === 'boolean' && f.name === 'draft'));
|
|
965
|
-
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));
|
|
966
718
|
</script>
|
|
967
719
|
|
|
968
720
|
<!-- The desk controls live in the one header band: AdminLayout renders this snippet through the
|
|
@@ -988,6 +740,15 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
988
740
|
{#if dirty}<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" aria-hidden="true"></span>{/if}
|
|
989
741
|
{saveState}
|
|
990
742
|
</span>
|
|
743
|
+
{#if tidyApplied}
|
|
744
|
+
<!-- The session-level Undo tidy (graft 6): surfaced right after Apply, dismissed on the next
|
|
745
|
+
edit. Ordinary editor Undo covers it mechanically (the apply is one history entry); this
|
|
746
|
+
chip names it so the author knows the whole tidy is one move back. -->
|
|
747
|
+
<span class="flex items-center gap-2 border-l border-[var(--cairn-card-border)] pl-3 text-xs text-[var(--color-muted)]" data-testid="tidy-undo-chip">
|
|
748
|
+
<span class="inline-flex items-center gap-1 font-semibold text-[var(--color-positive-ink)]">Tidy applied</span>
|
|
749
|
+
<button type="button" class="underline decoration-[color-mix(in_oklab,currentColor_40%,transparent)] underline-offset-2 hover:text-primary" onclick={undoTidy}>Undo tidy</button>
|
|
750
|
+
</span>
|
|
751
|
+
{/if}
|
|
991
752
|
</div>
|
|
992
753
|
|
|
993
754
|
<div class="ml-auto flex items-center gap-2 border-l border-[var(--cairn-card-border)] pl-3">
|
|
@@ -1295,6 +1056,21 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1295
1056
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
1296
1057
|
</svg>
|
|
1297
1058
|
</button>
|
|
1059
|
+
{#if tidyEnabled}
|
|
1060
|
+
<!-- Tidy (spec 2.1): the single desk entry point for the light copy-edit. A labelled
|
|
1061
|
+
accent-quiet action (something you invoke, not a format you toggle). Disabled in
|
|
1062
|
+
Preview, while a review is open, and while a request is in flight. -->
|
|
1063
|
+
<button
|
|
1064
|
+
type="button"
|
|
1065
|
+
class="btn btn-sm btn-ghost gap-1.5"
|
|
1066
|
+
aria-label="Tidy"
|
|
1067
|
+
title="Tidy: a light copy-edit you review before it lands"
|
|
1068
|
+
disabled={insertDisabled || tidyBusy}
|
|
1069
|
+
onclick={runTidy}
|
|
1070
|
+
>
|
|
1071
|
+
<SparklesIcon class="h-4 w-4" aria-hidden="true" />Tidy
|
|
1072
|
+
</button>
|
|
1073
|
+
{/if}
|
|
1298
1074
|
<!-- The Figure control: always rendered, enabled only when the caret sits on a media image
|
|
1299
1075
|
(and the Write surface is up). It never mounts or unmounts on caret movement; only its
|
|
1300
1076
|
enabled state changes (the Edit-block pattern). The unavailable state uses aria-disabled,
|
|
@@ -1332,7 +1108,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1332
1108
|
registerSelectRange={(fn) => (selectRange = fn)}
|
|
1333
1109
|
registerInsertLink={(fn) => (insertLink = fn)}
|
|
1334
1110
|
registerGetSelection={(fn) => (getSelection = fn)}
|
|
1111
|
+
registerGetSelectionRange={(fn) => (getSelectionRange = fn)}
|
|
1335
1112
|
registerFormat={(fn) => (format = fn)}
|
|
1113
|
+
registerTidy={(api) => (tidyApi = api)}
|
|
1114
|
+
registerUndo={(fn) => (undoEditor = fn)}
|
|
1115
|
+
{tidyMode}
|
|
1336
1116
|
registerCaretCoords={(fn) => (caretCoords = fn)}
|
|
1337
1117
|
registerFocusEditor={(fn) => (focusEditor = fn)}
|
|
1338
1118
|
registerImagePlaceholders={(api) => (placeholders = api)}
|
|
@@ -1342,6 +1122,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1342
1122
|
{mediaLibrary}
|
|
1343
1123
|
{focusMode}
|
|
1344
1124
|
{typewriter}
|
|
1125
|
+
{spellcheck}
|
|
1126
|
+
spellcheckDictionary={data.spellcheckDictionary}
|
|
1127
|
+
siteDictionary={data.siteDictionary}
|
|
1128
|
+
{pendingAdditions}
|
|
1345
1129
|
/>
|
|
1346
1130
|
<!-- The accumulated uploaded records ride the save form alongside the body. The save action
|
|
1347
1131
|
reads `media` and merges these records into media.json (publish submits the same form). -->
|
|
@@ -1454,6 +1238,17 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1454
1238
|
{#if typewriter}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
|
|
1455
1239
|
Typewriter
|
|
1456
1240
|
</button>
|
|
1241
|
+
<!-- Spellcheck: the markdown-aware lint underlines. Off reconfigures the lint compartment
|
|
1242
|
+
to empty and idles the Worker. Same check-and-tint grammar as the modes beside it. -->
|
|
1243
|
+
<button
|
|
1244
|
+
type="button"
|
|
1245
|
+
class={ftrToggleClass(spellcheck)}
|
|
1246
|
+
aria-pressed={spellcheck}
|
|
1247
|
+
onclick={() => setSpellcheck(!spellcheck)}
|
|
1248
|
+
>
|
|
1249
|
+
{#if spellcheck}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
|
|
1250
|
+
Spellcheck
|
|
1251
|
+
</button>
|
|
1457
1252
|
<!-- Zen enters from the footer (and Ctrl+Shift+.); it reads as a peer writing-mode
|
|
1458
1253
|
toggle here, but once on it hides the whole footer, so the chip carries the way out. -->
|
|
1459
1254
|
<button
|
|
@@ -1719,6 +1514,79 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1719
1514
|
/>
|
|
1720
1515
|
<MarkdownHelpDialog bind:this={helpDialog} />
|
|
1721
1516
|
<ShortcutsDialog bind:this={shortcutsDialog} />
|
|
1517
|
+
|
|
1518
|
+
<!-- The tidy review surface (spec 2.5). Mounted only while a review is open, keyed by the validated
|
|
1519
|
+
change set so a fresh review remounts. It drives the in-buffer decorations and the batched apply
|
|
1520
|
+
through tidyApi; the host clears tidy mode on close. -->
|
|
1521
|
+
{#if tidyReview && tidyApi}
|
|
1522
|
+
<TidyReview
|
|
1523
|
+
changes={tidyReview.changes}
|
|
1524
|
+
original={tidyReview.original}
|
|
1525
|
+
conventions={data.tidy.conventions}
|
|
1526
|
+
model={tidyReview.model}
|
|
1527
|
+
title={data.title}
|
|
1528
|
+
api={tidyApi}
|
|
1529
|
+
onclose={closeTidyReview}
|
|
1530
|
+
onshow={(from, to) => selectRange(from, to)}
|
|
1531
|
+
/>
|
|
1532
|
+
{/if}
|
|
1533
|
+
|
|
1534
|
+
<!-- The tidy working state: a cancelable dialog wired to the real abort (Task 11's AbortController
|
|
1535
|
+
plus the bounded client timeout). Shown while the model call is in flight. -->
|
|
1536
|
+
{#if tidyBusy}
|
|
1537
|
+
<dialog
|
|
1538
|
+
class="modal"
|
|
1539
|
+
aria-labelledby="cairn-tidy-working-title"
|
|
1540
|
+
data-testid="tidy-working"
|
|
1541
|
+
bind:this={tidyWorkingDialog}
|
|
1542
|
+
onclose={cancelTidy}
|
|
1543
|
+
>
|
|
1544
|
+
<div class="modal-box flex flex-col items-center gap-3 text-center">
|
|
1545
|
+
<span class="loading loading-spinner loading-lg text-primary" aria-hidden="true"></span>
|
|
1546
|
+
<h2 id="cairn-tidy-working-title" class="text-base font-semibold">Tidying your text</h2>
|
|
1547
|
+
<p class="max-w-prose text-sm text-[var(--color-muted)]">
|
|
1548
|
+
Claude is reading your draft for a light copy-edit. You will review every change before it lands.
|
|
1549
|
+
</p>
|
|
1550
|
+
<button type="button" class="btn btn-sm" onclick={() => tidyWorkingDialog?.close()}>Cancel</button>
|
|
1551
|
+
</div>
|
|
1552
|
+
</dialog>
|
|
1553
|
+
{/if}
|
|
1554
|
+
|
|
1555
|
+
<!-- The no-op confirmation: tidy found nothing to fix. Quiet, never an empty review. -->
|
|
1556
|
+
{#if tidyNoop}
|
|
1557
|
+
<dialog
|
|
1558
|
+
class="modal"
|
|
1559
|
+
aria-labelledby="cairn-tidy-noop-title"
|
|
1560
|
+
data-testid="tidy-noop"
|
|
1561
|
+
bind:this={tidyNoopDialog}
|
|
1562
|
+
onclose={() => (tidyNoop = false)}
|
|
1563
|
+
>
|
|
1564
|
+
<div class="modal-box flex flex-col items-center gap-3 text-center">
|
|
1565
|
+
<h2 id="cairn-tidy-noop-title" class="text-base font-semibold">Nothing to fix</h2>
|
|
1566
|
+
<p class="max-w-prose text-sm text-[var(--color-muted)]">Tidy read your text and found nothing to change.</p>
|
|
1567
|
+
<button type="button" class="btn btn-sm btn-primary" onclick={() => tidyNoopDialog?.close()}>Close</button>
|
|
1568
|
+
</div>
|
|
1569
|
+
</dialog>
|
|
1570
|
+
{/if}
|
|
1571
|
+
|
|
1572
|
+
<!-- A refused, failed, or rejected tidy: the honest message; the document is unchanged. -->
|
|
1573
|
+
{#if tidyMessage}
|
|
1574
|
+
<dialog
|
|
1575
|
+
class="modal"
|
|
1576
|
+
aria-labelledby="cairn-tidy-message-title"
|
|
1577
|
+
data-testid="tidy-message"
|
|
1578
|
+
bind:this={tidyMessageDialog}
|
|
1579
|
+
onclose={() => (tidyMessage = null)}
|
|
1580
|
+
>
|
|
1581
|
+
<div class="modal-box flex flex-col gap-3">
|
|
1582
|
+
<h2 id="cairn-tidy-message-title" class="text-base font-semibold">Tidy could not run</h2>
|
|
1583
|
+
<p class="text-sm text-[var(--color-muted)]">{tidyMessage}</p>
|
|
1584
|
+
<div class="flex justify-end">
|
|
1585
|
+
<button type="button" class="btn btn-sm btn-primary" onclick={() => tidyMessageDialog?.close()}>Close</button>
|
|
1586
|
+
</div>
|
|
1587
|
+
</div>
|
|
1588
|
+
</dialog>
|
|
1589
|
+
{/if}
|
|
1722
1590
|
<DeleteDialog
|
|
1723
1591
|
bind:this={deleteDialog}
|
|
1724
1592
|
trigger={false}
|