@glw907/cairn-cms 0.60.0 → 0.62.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 +82 -0
- package/dist/components/AdminLayout.svelte +152 -229
- package/dist/components/CairnAdmin.svelte +13 -42
- package/dist/components/CairnLogo.svelte +1 -6
- package/dist/components/CairnMediaLibrary.svelte +821 -1210
- package/dist/components/CairnTidySettings.svelte +194 -261
- package/dist/components/CairnTidySettings.svelte.d.ts +1 -1
- 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 +781 -1205
- package/dist/components/EditorToolbar.svelte +108 -170
- package/dist/components/HelpHome.svelte +824 -0
- package/dist/components/HelpHome.svelte.d.ts +22 -0
- 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 +12 -27
- 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/client-ingest.d.ts +16 -8
- package/dist/components/client-ingest.js +12 -6
- package/dist/components/editor-media.js +16 -8
- package/dist/components/editor-placeholder.d.ts +4 -2
- package/dist/components/editor-tidy.d.ts +24 -12
- package/dist/components/editor-tidy.js +8 -4
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/link-completion.d.ts +12 -6
- package/dist/components/link-completion.js +12 -6
- package/dist/components/markdown-directives.d.ts +9 -6
- package/dist/components/markdown-directives.js +9 -6
- package/dist/components/markdown-format.d.ts +7 -2
- package/dist/components/markdown-format.js +59 -28
- package/dist/components/markdown-reference.d.ts +8 -0
- package/dist/components/markdown-reference.js +22 -0
- package/dist/components/media-upload-outcome.d.ts +12 -6
- package/dist/components/objective-errors.d.ts +8 -4
- package/dist/components/objective-errors.js +8 -4
- package/dist/components/preview-doc.d.ts +4 -2
- package/dist/components/preview-doc.js +4 -2
- package/dist/components/spellcheck.d.ts +57 -29
- package/dist/components/spellcheck.js +50 -20
- package/dist/components/tidy-categorize.d.ts +20 -10
- package/dist/components/tidy-categorize.js +16 -8
- package/dist/components/tidy-validate.d.ts +12 -6
- package/dist/components/tidy-validate.js +20 -10
- package/dist/components/topbar-context.d.ts +4 -2
- package/dist/content/advisories.d.ts +51 -0
- package/dist/content/advisories.js +79 -0
- package/dist/content/compose.d.ts +4 -2
- package/dist/content/compose.js +1 -0
- package/dist/content/excerpt.js +4 -2
- package/dist/content/getting-started.d.ts +18 -0
- package/dist/content/getting-started.js +12 -0
- package/dist/content/links.d.ts +16 -8
- package/dist/content/links.js +12 -6
- package/dist/content/manifest.d.ts +36 -18
- package/dist/content/manifest.js +32 -16
- package/dist/content/media-refs.d.ts +4 -2
- package/dist/content/media-refs.js +4 -2
- package/dist/content/media-rewrite.d.ts +8 -4
- package/dist/content/media-rewrite.js +76 -38
- package/dist/content/schema.d.ts +20 -10
- package/dist/content/site-dictionary.d.ts +4 -2
- package/dist/content/site-dictionary.js +8 -4
- package/dist/content/types.d.ts +97 -42
- package/dist/delivery/CairnHead.svelte +8 -11
- package/dist/delivery/content-index.d.ts +16 -8
- package/dist/delivery/feeds.js +4 -2
- package/dist/delivery/json-ld.d.ts +3 -0
- package/dist/delivery/json-ld.js +3 -0
- package/dist/delivery/manifest.d.ts +4 -2
- package/dist/delivery/manifest.js +4 -2
- package/dist/delivery/public-routes.d.ts +12 -6
- package/dist/delivery/public-routes.js +4 -2
- package/dist/delivery/seo-fields.d.ts +12 -6
- package/dist/delivery/seo-fields.js +8 -4
- package/dist/delivery/site-indexes.d.ts +4 -2
- package/dist/delivery/site-resolver.d.ts +4 -2
- package/dist/delivery/site-resolver.js +4 -2
- package/dist/doctor/cloudflare-api.d.ts +6 -0
- package/dist/doctor/cloudflare-api.js +6 -0
- package/dist/doctor/index.d.ts +12 -6
- package/dist/doctor/report.d.ts +3 -0
- package/dist/doctor/report.js +3 -0
- package/dist/doctor/run.d.ts +3 -0
- package/dist/doctor/run.js +3 -0
- package/dist/doctor/types.d.ts +10 -2
- package/dist/doctor/types.js +6 -0
- package/dist/doctor/wrangler-config.d.ts +7 -2
- package/dist/doctor/wrangler-config.js +3 -0
- package/dist/email.d.ts +4 -2
- package/dist/env.d.ts +0 -3
- package/dist/env.js +0 -3
- package/dist/github/branches.d.ts +4 -2
- package/dist/github/branches.js +4 -2
- package/dist/github/signing.d.ts +1 -1
- package/dist/github/signing.js +2 -2
- package/dist/log/events.d.ts +1 -1
- package/dist/media/bulk-delete-plan.d.ts +8 -4
- package/dist/media/config.d.ts +12 -6
- package/dist/media/config.js +16 -8
- package/dist/media/delivery-bucket.d.ts +4 -2
- package/dist/media/library-entry.d.ts +4 -2
- package/dist/media/library-entry.js +4 -2
- package/dist/media/manifest.d.ts +29 -15
- package/dist/media/manifest.js +29 -16
- package/dist/media/naming.d.ts +12 -6
- package/dist/media/naming.js +24 -12
- package/dist/media/orphan-scan.d.ts +4 -2
- package/dist/media/reconcile.d.ts +21 -11
- package/dist/media/reconcile.js +12 -6
- package/dist/media/reference.d.ts +8 -4
- package/dist/media/reference.js +12 -6
- package/dist/media/rewrite-plan.d.ts +12 -6
- package/dist/media/sniff.d.ts +4 -2
- package/dist/media/sniff.js +28 -14
- package/dist/media/store.d.ts +16 -8
- package/dist/media/store.js +4 -2
- package/dist/media/transform-url.d.ts +12 -6
- package/dist/media/transform-url.js +8 -4
- package/dist/media/usage.d.ts +8 -4
- package/dist/nav/site-config.d.ts +16 -8
- package/dist/render/component-grammar.d.ts +23 -10
- package/dist/render/component-grammar.js +19 -8
- package/dist/render/component-insert.d.ts +8 -4
- package/dist/render/component-insert.js +4 -2
- package/dist/render/component-reference.d.ts +4 -2
- package/dist/render/component-reference.js +4 -2
- package/dist/render/component-validate.d.ts +3 -0
- package/dist/render/component-validate.js +3 -0
- package/dist/render/glyph.d.ts +4 -2
- package/dist/render/glyph.js +4 -2
- package/dist/render/pipeline.d.ts +20 -10
- package/dist/render/pipeline.js +4 -2
- package/dist/render/registry.d.ts +40 -20
- package/dist/render/registry.js +16 -8
- package/dist/render/rehype-dispatch.d.ts +22 -8
- package/dist/render/rehype-dispatch.js +22 -8
- package/dist/render/remark-directives.d.ts +3 -0
- package/dist/render/remark-directives.js +3 -0
- package/dist/render/remark-figure.d.ts +4 -2
- package/dist/render/remark-figure.js +4 -2
- package/dist/render/resolve-links.d.ts +4 -2
- package/dist/render/resolve-links.js +4 -2
- package/dist/render/resolve-media.d.ts +16 -8
- package/dist/render/resolve-media.js +12 -6
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +9 -3
- package/dist/sveltekit/auth-routes.d.ts +3 -0
- package/dist/sveltekit/auth-routes.js +3 -0
- package/dist/sveltekit/cairn-admin.d.ts +16 -5
- package/dist/sveltekit/cairn-admin.js +26 -10
- package/dist/sveltekit/content-routes.d.ts +191 -86
- package/dist/sveltekit/content-routes.js +295 -107
- package/dist/sveltekit/editors-routes.d.ts +3 -0
- package/dist/sveltekit/editors-routes.js +3 -0
- package/dist/sveltekit/guard.d.ts +4 -2
- package/dist/sveltekit/guard.js +4 -2
- package/dist/sveltekit/https-required-page.d.ts +1 -1
- package/dist/sveltekit/https-required-page.js +1 -1
- package/dist/sveltekit/index.d.ts +1 -1
- package/dist/sveltekit/media-route.d.ts +1 -2
- package/dist/sveltekit/media-route.js +13 -8
- package/dist/sveltekit/nav-routes.d.ts +7 -2
- package/dist/sveltekit/nav-routes.js +3 -0
- package/dist/sveltekit/types.d.ts +4 -2
- package/dist/vite/index.d.ts +32 -16
- package/dist/vite/index.js +52 -26
- package/dist/vite/resolve-root.d.ts +8 -4
- package/dist/vite/resolve-root.js +4 -2
- package/package.json +8 -2
- package/src/lib/components/AdminLayout.svelte +22 -0
- package/src/lib/components/CairnAdmin.svelte +3 -0
- package/src/lib/components/CairnTidySettings.svelte +2 -2
- package/src/lib/components/ComponentForm.svelte +0 -1
- package/src/lib/components/EditPage.svelte +133 -41
- package/src/lib/components/HelpHome.svelte +850 -0
- package/src/lib/components/MarkdownHelpDialog.svelte +4 -15
- package/src/lib/components/client-ingest.ts +20 -10
- package/src/lib/components/editor-media.ts +20 -10
- package/src/lib/components/editor-placeholder.ts +12 -6
- package/src/lib/components/editor-tidy.ts +28 -14
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/link-completion.ts +12 -6
- package/src/lib/components/markdown-directives.ts +13 -8
- package/src/lib/components/markdown-format.ts +63 -30
- package/src/lib/components/markdown-reference.ts +30 -0
- package/src/lib/components/media-upload-outcome.ts +12 -6
- package/src/lib/components/objective-errors.ts +16 -8
- package/src/lib/components/preview-doc.ts +4 -2
- package/src/lib/components/spellcheck.ts +92 -40
- package/src/lib/components/tidy-categorize.ts +28 -14
- package/src/lib/components/tidy-validate.ts +28 -14
- package/src/lib/components/topbar-context.ts +4 -2
- package/src/lib/content/advisories.ts +141 -0
- package/src/lib/content/compose.ts +5 -2
- package/src/lib/content/excerpt.ts +4 -2
- package/src/lib/content/getting-started.ts +31 -0
- package/src/lib/content/links.ts +16 -8
- package/src/lib/content/manifest.ts +36 -18
- package/src/lib/content/media-refs.ts +4 -2
- package/src/lib/content/media-rewrite.ts +100 -50
- package/src/lib/content/schema.ts +20 -10
- package/src/lib/content/site-dictionary.ts +8 -4
- package/src/lib/content/types.ts +97 -42
- package/src/lib/delivery/content-index.ts +16 -8
- package/src/lib/delivery/feeds.ts +4 -2
- package/src/lib/delivery/json-ld.ts +3 -0
- package/src/lib/delivery/manifest.ts +4 -2
- package/src/lib/delivery/public-routes.ts +16 -8
- package/src/lib/delivery/seo-fields.ts +12 -6
- package/src/lib/delivery/site-indexes.ts +4 -2
- package/src/lib/delivery/site-resolver.ts +4 -2
- package/src/lib/doctor/cloudflare-api.ts +6 -0
- package/src/lib/doctor/index.ts +12 -6
- package/src/lib/doctor/report.ts +3 -0
- package/src/lib/doctor/run.ts +3 -0
- package/src/lib/doctor/types.ts +10 -2
- package/src/lib/doctor/wrangler-config.ts +7 -2
- package/src/lib/email.ts +4 -2
- package/src/lib/env.ts +0 -3
- package/src/lib/github/branches.ts +4 -2
- package/src/lib/github/signing.ts +2 -2
- package/src/lib/log/events.ts +1 -0
- package/src/lib/media/bulk-delete-plan.ts +8 -4
- package/src/lib/media/config.ts +24 -12
- package/src/lib/media/delivery-bucket.ts +4 -2
- package/src/lib/media/library-entry.ts +4 -2
- package/src/lib/media/manifest.ts +33 -18
- package/src/lib/media/naming.ts +24 -12
- package/src/lib/media/orphan-scan.ts +4 -2
- package/src/lib/media/reconcile.ts +21 -11
- package/src/lib/media/reference.ts +12 -6
- package/src/lib/media/rewrite-plan.ts +12 -6
- package/src/lib/media/sniff.ts +28 -14
- package/src/lib/media/store.ts +16 -8
- package/src/lib/media/transform-url.ts +12 -6
- package/src/lib/media/usage.ts +8 -4
- package/src/lib/nav/site-config.ts +16 -8
- package/src/lib/render/component-grammar.ts +23 -10
- package/src/lib/render/component-insert.ts +8 -4
- package/src/lib/render/component-reference.ts +4 -2
- package/src/lib/render/component-validate.ts +3 -0
- package/src/lib/render/glyph.ts +4 -2
- package/src/lib/render/pipeline.ts +20 -10
- package/src/lib/render/registry.ts +44 -22
- package/src/lib/render/rehype-dispatch.ts +22 -8
- package/src/lib/render/remark-directives.ts +3 -0
- package/src/lib/render/remark-figure.ts +4 -2
- package/src/lib/render/resolve-links.ts +4 -2
- package/src/lib/render/resolve-media.ts +16 -8
- package/src/lib/sveltekit/admin-dispatch.ts +10 -4
- package/src/lib/sveltekit/auth-routes.ts +3 -0
- package/src/lib/sveltekit/cairn-admin.ts +37 -15
- package/src/lib/sveltekit/content-routes.ts +492 -197
- package/src/lib/sveltekit/editors-routes.ts +3 -0
- package/src/lib/sveltekit/guard.ts +4 -2
- package/src/lib/sveltekit/https-required-page.ts +1 -1
- package/src/lib/sveltekit/index.ts +3 -0
- package/src/lib/sveltekit/media-route.ts +13 -8
- package/src/lib/sveltekit/nav-routes.ts +7 -2
- package/src/lib/sveltekit/types.ts +4 -2
- package/src/lib/vite/index.ts +60 -30
- package/src/lib/vite/resolve-root.ts +8 -4
|
@@ -16,1206 +16,733 @@ 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
|
-
function setZen(on: boolean) {
|
|
433
|
-
// Entering zen hides the band, the document title, the toolbar strip, and the footer. Focus on
|
|
434
|
-
// any of those (a band action like Publish, a strip button, the title input, a footer toggle)
|
|
435
|
-
// would strand on a detached node when its host leaves the DOM, so move focus into the editing
|
|
436
|
-
// surface first. The surface (.cm-editor) and the exit chip are all that survive, so any focus
|
|
437
|
-
// outside the surface is about to hide. Reading activeElement before the DOM updates is what
|
|
438
|
-
// tells a hiding control from the surviving one.
|
|
439
|
-
const surface = editorCard?.querySelector('.cm-editor');
|
|
440
|
-
const focusHides = on && !surface?.contains(document.activeElement);
|
|
441
|
-
zen = on;
|
|
442
|
-
localStorage.setItem(zenStorageKey, String(on));
|
|
443
|
-
if (focusHides) {
|
|
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;
|
|
448
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 {
|
|
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
369
|
}
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
tidyAppliedBody = null;
|
|
644
|
-
}
|
|
645
|
-
});
|
|
646
|
-
// Undo the whole applied tidy in one move (ordinary editor Undo of the one batched transaction). The
|
|
647
|
-
// chip names it so the author knows the whole tidy is one move back.
|
|
648
|
-
function undoTidy() {
|
|
649
|
-
undoEditor();
|
|
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
|
-
// The editor object the popover drives, delegating through the holders so the latest registered
|
|
677
|
-
// function is always used (the holders start as no-ops and are replaced on mount).
|
|
678
|
-
const editorApi = $derived({
|
|
679
|
-
caretCoords: () => caretCoords(),
|
|
680
|
-
focusEditor: () => focusEditor(),
|
|
681
|
-
placeholders: placeholders ?? noopPlaceholders,
|
|
682
|
-
insertImage: (alt: string, ref: string) => insertImageFn(alt, ref),
|
|
683
|
-
});
|
|
684
|
-
|
|
685
|
-
// The headless media insert popover, opened from the toolbar control, paste, or drop.
|
|
686
|
-
let mediaPopover = $state<MediaInsertPopover | null>(null);
|
|
687
|
-
|
|
688
|
-
// The rendered hero fields' refs (for the needs-alt notice's "Add alt text" action, which focuses
|
|
689
|
-
// the field's own alt input) and their reported needs-alt signals, keyed by field name. A hero is
|
|
690
|
-
// a frontmatter value with no body offset, so its needs-alt signal comes from the field, not the
|
|
691
|
-
// body scanner (findMediaImagesNeedingAlt), and its remediation focuses the alt input, never a
|
|
692
|
-
// source range (selectRange). The records are keyed by field name; `data.fields` is static for the
|
|
693
|
-
// page's lifetime, so a key never goes stale (no per-key cleanup on unmount is needed).
|
|
694
|
-
let heroFieldRefs = $state<Record<string, MediaHeroField>>({});
|
|
695
|
-
let heroNeedsAlt = $state<Record<string, boolean>>({});
|
|
696
|
-
|
|
697
|
-
// The server-owned records from each successful upload this session. They ride the save form as
|
|
698
|
-
// the hidden `media` field, so the save action merges them into media.json.
|
|
699
|
-
let uploadedRecords = $state<MediaEntry[]>([]);
|
|
700
|
-
// A headless dialog instance, typed structurally over its exported open() (the linkPicker idiom).
|
|
701
|
-
type DialogHandle = { open: () => void };
|
|
702
|
-
// The toolbar's insert dialogs. Each holds its own <form>, so they mount outside the edit form
|
|
703
|
-
// (a form nested in a form is invalid HTML the parser repairs by dropping the outer tag, which
|
|
704
|
-
// breaks SSR and hydration); the toolbar snippet renders plain triggers that open them here.
|
|
705
|
-
let webLinkDialog = $state<DialogHandle | null>(null);
|
|
706
|
-
let linkPicker = $state<DialogHandle | null>(null);
|
|
707
|
-
// The insert dialog binds the full instance, not the bare DialogHandle: the Edit-block control
|
|
708
|
-
// drives editComponent(def, values, range) on it, beyond the shared open().
|
|
709
|
-
let insertDialog = $state<ComponentInsertDialog | null>(null);
|
|
710
|
-
// The lifecycle dialogs, opened from the header's overflow menu.
|
|
711
|
-
let deleteDialog = $state<DialogHandle | null>(null);
|
|
712
|
-
let renameDialog = $state<DialogHandle | null>(null);
|
|
713
|
-
// The Markdown cheat sheet, opened from the editor card's footer.
|
|
714
|
-
let helpDialog = $state<DialogHandle | null>(null);
|
|
715
|
-
// The keyboard shortcuts sheet, opened from anywhere on the desk by Ctrl+/.
|
|
716
|
-
let shortcutsDialog = $state<DialogHandle | null>(null);
|
|
717
|
-
|
|
718
|
-
// Whether the registry offers anything insertable, the same condition the insert dialog lists
|
|
719
|
-
// by, so the toolbar trigger and the dialog appear and disappear together.
|
|
720
|
-
const hasComponents = $derived(insertableDefs(registry).length > 0);
|
|
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: () => {
|
|
766
410
|
}
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
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;
|
|
458
|
+
}
|
|
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
|
-
};
|
|
469
|
+
}).catch(() => {
|
|
470
|
+
if (stale || caretComponent !== current) return;
|
|
471
|
+
editable = null;
|
|
472
|
+
editReason = "none";
|
|
812
473
|
});
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
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';
|
|
909
|
-
});
|
|
910
|
-
|
|
911
|
-
// The band overflow menu's popover element and its open state, mirrored from the toggle
|
|
912
|
-
// event into aria-expanded on the trigger.
|
|
913
|
-
let actionsMenu = $state<HTMLUListElement | null>(null);
|
|
914
|
-
let actionsOpen = $state(false);
|
|
915
|
-
|
|
916
|
-
// The details slide-over. The aside below carries the frontmatter groups; it stays physically
|
|
917
|
-
// inside the edit form (so the uncontrolled fields submit) but presents as a fixed panel under
|
|
918
|
-
// the band, hidden when closed so it leaves the a11y tree and the tab order while its
|
|
919
|
-
// display:none fields still post. Focus moves into the panel on open and returns to the trigger
|
|
920
|
-
// on close, the region-with-focus-management pattern (the a11y reviewer adjudicates region vs
|
|
921
|
-
// dialog at the pass gate).
|
|
922
|
-
let detailsOpen = $state(false);
|
|
923
|
-
let detailsTrigger = $state<HTMLButtonElement | null>(null);
|
|
924
|
-
let detailsClose = $state<HTMLButtonElement | null>(null);
|
|
925
|
-
function openDetails() {
|
|
926
|
-
// flushSync removes the panel's `hidden` attribute synchronously; a hidden element cannot
|
|
927
|
-
// take focus, so the close button must be visible before we move focus to it.
|
|
928
|
-
flushSync(() => (detailsOpen = true));
|
|
929
|
-
detailsClose?.focus();
|
|
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 renderNotices = $derived([
|
|
564
|
+
...data.advisories.map((notice) => ({
|
|
565
|
+
kind: notice.kind,
|
|
566
|
+
message: notice.message,
|
|
567
|
+
rows: (notice.actions ?? []).map((action) => ({ label: action.label, href: action.href }))
|
|
568
|
+
})),
|
|
569
|
+
...needsAltCount ? [
|
|
570
|
+
{
|
|
571
|
+
kind: "needs-alt",
|
|
572
|
+
message: `${needsAltCount} ${needsAltCount === 1 ? "image needs" : "images need"} alt text`,
|
|
573
|
+
detail: "Alt text describes an image for readers who cannot see it. Add it now, or save and come back to it.",
|
|
574
|
+
rows: [
|
|
575
|
+
...needsAlt.map((item) => ({
|
|
576
|
+
rowLabel: item.ref,
|
|
577
|
+
rowCode: true,
|
|
578
|
+
label: "Add alt text",
|
|
579
|
+
onAct: () => selectRange(item.from, item.to)
|
|
580
|
+
})),
|
|
581
|
+
...heroRows.map((hero) => ({
|
|
582
|
+
rowLabel: hero.label,
|
|
583
|
+
label: "Add alt text",
|
|
584
|
+
onAct: () => heroFieldRefs[hero.name]?.focusAlt()
|
|
585
|
+
}))
|
|
586
|
+
]
|
|
587
|
+
}
|
|
588
|
+
] : []
|
|
589
|
+
]);
|
|
590
|
+
const deleteRefusedLinks = $derived(form?.inboundLinks ?? []);
|
|
591
|
+
const formError = $derived(
|
|
592
|
+
form?.error && !form.brokenLinks?.length && !form.inboundLinks?.length ? form.error : ""
|
|
593
|
+
);
|
|
594
|
+
const entryKey = $derived(data.conceptId + "/" + data.id);
|
|
595
|
+
let seededKey = untrack(() => entryKey);
|
|
596
|
+
$effect.pre(() => {
|
|
597
|
+
const key = entryKey;
|
|
598
|
+
if (key === seededKey) return;
|
|
599
|
+
seededKey = key;
|
|
600
|
+
untrack(() => {
|
|
601
|
+
body = form?.body ?? data.body;
|
|
602
|
+
saving = false;
|
|
603
|
+
publishing = false;
|
|
604
|
+
leaving = false;
|
|
605
|
+
fieldsDirty = false;
|
|
606
|
+
mode = "write";
|
|
932
607
|
detailsOpen = false;
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
608
|
+
previewHtml = "";
|
|
609
|
+
previewFailed = false;
|
|
610
|
+
removedLinks = [];
|
|
611
|
+
});
|
|
612
|
+
});
|
|
613
|
+
const draftWarning = $derived.by(() => {
|
|
614
|
+
const drafts = page.url.searchParams.get("drafts");
|
|
615
|
+
return drafts ? drafts.split(",").filter(Boolean).join(", ") : "";
|
|
616
|
+
});
|
|
617
|
+
const flash = $derived.by(() => {
|
|
618
|
+
if (data.saved && !draftWarning)
|
|
619
|
+
return "Saved. Your site keeps showing the published version until you publish.";
|
|
620
|
+
if (data.publishedFlash) return "Published. The live site is rebuilding.";
|
|
621
|
+
if (data.discardedFlash) return "Changes discarded.";
|
|
622
|
+
if (data.renamed) return `The URL is now ${data.slug}.`;
|
|
623
|
+
return "";
|
|
624
|
+
});
|
|
625
|
+
const politeMessage = $derived(
|
|
626
|
+
draftWarning ? `Saved. This page links to unpublished pages: ${draftWarning}.` : flash
|
|
627
|
+
);
|
|
628
|
+
const assertiveMessage = $derived.by(() => {
|
|
629
|
+
if (data.error) return data.error;
|
|
630
|
+
if (formError) return formError;
|
|
631
|
+
if (deleteRefusedLinks.length) {
|
|
632
|
+
const count = deleteRefusedLinks.length;
|
|
633
|
+
return `This ${data.label.toLowerCase()} could not be deleted. ${count} ${count === 1 ? "page links" : "pages link"} to it.`;
|
|
946
634
|
}
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
const brokenLinks = $derived(form?.brokenLinks ?? []);
|
|
951
|
-
// Track the hrefs the author has already fixed this session. The banner reads the immutable action
|
|
952
|
-
// result, so without this a fixed row would linger and "Remove link" would read as a no-op.
|
|
953
|
-
let removedLinks = $state<string[]>([]);
|
|
954
|
-
const visibleBrokenLinks = $derived(brokenLinks.filter((h) => !removedLinks.includes(h)));
|
|
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
|
-
}
|
|
635
|
+
if (visibleBrokenLinks.length) {
|
|
636
|
+
const count = visibleBrokenLinks.length;
|
|
637
|
+
return `This page links to ${count} missing ${count === 1 ? "page" : "pages"}.`;
|
|
962
638
|
}
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
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, ' ');
|
|
639
|
+
return "";
|
|
640
|
+
});
|
|
641
|
+
function proseOnly(line) {
|
|
642
|
+
let out = "";
|
|
643
|
+
let cursor = 0;
|
|
644
|
+
for (const { from, to } of findInlineDirectives(line)) {
|
|
645
|
+
out += line.slice(cursor, from);
|
|
646
|
+
cursor = to;
|
|
1072
647
|
}
|
|
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
|
-
|
|
648
|
+
out += line.slice(cursor);
|
|
649
|
+
return out.replace(/[*_~`[\]()#]/g, " ");
|
|
650
|
+
}
|
|
651
|
+
const countedBody = $derived(
|
|
652
|
+
body.split("\n").filter((line) => directiveLineKind(line) === null && !/^\s*\|/.test(line)).map(proseOnly).join("\n")
|
|
653
|
+
);
|
|
654
|
+
const wordCount = $derived(countedBody.trim() ? countedBody.trim().split(/\s+/).length : 0);
|
|
655
|
+
const wordLabel = $derived(wordCount === 1 ? "1 word" : `${wordCount} words`);
|
|
656
|
+
const resolveLink = $derived(manifestLinkResolver(data.linkTargets));
|
|
657
|
+
const resolveMediaTargets = $derived({
|
|
658
|
+
...data.mediaTargets,
|
|
659
|
+
...Object.fromEntries(
|
|
660
|
+
uploadedRecords.map((r) => [r.hash, { slug: r.slug, ext: r.ext, contentType: r.contentType }])
|
|
661
|
+
)
|
|
662
|
+
});
|
|
663
|
+
const resolveMedia = $derived(manifestMediaResolver(resolveMediaTargets));
|
|
664
|
+
const mediaLibrary = $derived({
|
|
665
|
+
...data.mediaLibrary,
|
|
666
|
+
...Object.fromEntries(uploadedRecords.map((r) => [r.hash, mediaLibraryEntry(r)]))
|
|
667
|
+
});
|
|
668
|
+
const completionSources = $derived([cairnLinkCompletionSource(data.linkTargets)]);
|
|
669
|
+
function setMode(m) {
|
|
670
|
+
mode = m;
|
|
671
|
+
}
|
|
672
|
+
let editorCard = $state(null);
|
|
673
|
+
$effect(() => {
|
|
674
|
+
const card = editorCard;
|
|
675
|
+
if (!card) return;
|
|
676
|
+
card.addEventListener("keydown", onEditorKeydown);
|
|
677
|
+
return () => card.removeEventListener("keydown", onEditorKeydown);
|
|
678
|
+
});
|
|
679
|
+
function onEditorKeydown(e) {
|
|
680
|
+
if (!(e.ctrlKey || e.metaKey)) return;
|
|
681
|
+
const key = e.key.toLowerCase();
|
|
682
|
+
const fmt = formatForKeydown(e);
|
|
683
|
+
if (fmt) {
|
|
684
|
+
e.preventDefault();
|
|
685
|
+
format(fmt);
|
|
686
|
+
} else if (key === "b") {
|
|
687
|
+
e.preventDefault();
|
|
688
|
+
format("bold");
|
|
689
|
+
} else if (key === "i") {
|
|
690
|
+
e.preventDefault();
|
|
691
|
+
format("italic");
|
|
692
|
+
} else if (key === "k") {
|
|
693
|
+
e.preventDefault();
|
|
694
|
+
webLinkDialog?.open();
|
|
1118
695
|
}
|
|
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
|
-
}
|
|
696
|
+
}
|
|
697
|
+
function formatForKeydown(e) {
|
|
698
|
+
if (e.altKey) {
|
|
699
|
+
if (e.key === "2") return "h2";
|
|
700
|
+
if (e.key === "3") return "h3";
|
|
701
|
+
return null;
|
|
1150
702
|
}
|
|
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';
|
|
703
|
+
if (e.shiftKey) {
|
|
704
|
+
if (e.code === "Digit9") return "quote";
|
|
705
|
+
if (e.code === "Digit8") return "ul";
|
|
706
|
+
if (e.code === "Digit7") return "ol";
|
|
1167
707
|
return null;
|
|
1168
708
|
}
|
|
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
|
-
}
|
|
709
|
+
if (e.key.toLowerCase() === "e") return "code";
|
|
710
|
+
return null;
|
|
711
|
+
}
|
|
712
|
+
let previewRun = 0;
|
|
713
|
+
$effect(() => {
|
|
714
|
+
if (mode !== "preview" || !render) return;
|
|
715
|
+
const md = body;
|
|
716
|
+
const resolve = resolveLink;
|
|
717
|
+
const resolveMediaRef = resolveMedia;
|
|
718
|
+
const run = ++previewRun;
|
|
719
|
+
const handle = setTimeout(async () => {
|
|
720
|
+
try {
|
|
721
|
+
const html = await render(md, { resolve, resolveMedia: resolveMediaRef });
|
|
722
|
+
if (run === previewRun) {
|
|
723
|
+
previewHtml = html;
|
|
724
|
+
previewFailed = false;
|
|
1193
725
|
}
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
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));
|
|
726
|
+
} catch {
|
|
727
|
+
if (run === previewRun) {
|
|
728
|
+
previewHtml = "";
|
|
729
|
+
previewFailed = true;
|
|
730
|
+
}
|
|
731
|
+
}
|
|
732
|
+
}, 150);
|
|
733
|
+
return () => {
|
|
734
|
+
clearTimeout(handle);
|
|
735
|
+
previewRun++;
|
|
736
|
+
};
|
|
737
|
+
});
|
|
738
|
+
function str(v) {
|
|
739
|
+
return v == null ? "" : String(v);
|
|
740
|
+
}
|
|
741
|
+
const eyebrowClass = "mb-2 text-[0.6875rem] font-semibold uppercase tracking-[0.08em] text-[var(--color-muted)]";
|
|
742
|
+
const titleField = $derived(data.fields.find((f) => f.name === "title"));
|
|
743
|
+
const draftField = $derived(data.fields.find((f) => f.type === "boolean" && f.name === "draft"));
|
|
744
|
+
const detailFields = $derived(data.fields.filter((f) => f !== titleField && f !== draftField));
|
|
745
|
+
const DATE_PUBLISH_HINT = "Sets the date for this post. Publishing is a separate step you choose.";
|
|
1219
746
|
</script>
|
|
1220
747
|
|
|
1221
748
|
<!-- The desk controls live in the one header band: AdminLayout renders this snippet through the
|
|
@@ -1342,6 +869,17 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1342
869
|
</div>
|
|
1343
870
|
{/snippet}
|
|
1344
871
|
|
|
872
|
+
<!-- The author-facing hint under a Details field. The id pairs with the input's aria-describedby
|
|
873
|
+
(`<name>-hint`); its uniqueness rests on schema field names being unique within a concept, which
|
|
874
|
+
is also the loop key. So assistive tech announces the sentence without bloating the accessible
|
|
875
|
+
name. Each field branch decides whether and where to render it; this snippet holds the one shape.
|
|
876
|
+
The `fld-hint` class is a styling hook with no rule today; the Tailwind utilities do the work. -->
|
|
877
|
+
{#snippet fieldHint(name: string, text: string)}
|
|
878
|
+
<p id={`${name}-hint`} class="fld-hint mt-1 text-sm text-[var(--color-muted)]">
|
|
879
|
+
{text}
|
|
880
|
+
</p>
|
|
881
|
+
{/snippet}
|
|
882
|
+
|
|
1345
883
|
<!-- The whole edit surface remounts when navigation lands on another entry (see the entryKey
|
|
1346
884
|
reset above); script-level state and the beforeNavigate registration sit outside the block,
|
|
1347
885
|
so only the template rebuilds. -->
|
|
@@ -1388,44 +926,61 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1388
926
|
</ul>
|
|
1389
927
|
</div>
|
|
1390
928
|
{/if}
|
|
1391
|
-
<!-- The
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
929
|
+
<!-- The shared advisory notices: one live-region surface for every non-blocking editor warning. It
|
|
930
|
+
carries the server's address-collision advisory and the client-derived needs-alt notice through
|
|
931
|
+
one snippet. Each renders as one alert-warning row: the caution glyph, the message, an optional
|
|
932
|
+
detail sentence, and a list of action rows. Each is a warning, never a block: the author can act
|
|
933
|
+
on it or save without it. The leading glyph carries the state alongside the message, so the
|
|
934
|
+
caution reads without relying on hue. A row with an href is a server advisory's link; a row with
|
|
935
|
+
onAct is the needs-alt jump that runs an editor callback (selecting the image source, or focusing
|
|
936
|
+
a hero alt input). -->
|
|
937
|
+
<!-- Keyed by index, not by notice.kind: the kind is a free string with no uniqueness constraint, so
|
|
938
|
+
two notices of one kind would otherwise throw each_key_duplicate. The list is append-only and
|
|
939
|
+
never reordered, so the index is a stable key here. -->
|
|
940
|
+
{#snippet advisoryNotices(notices: RenderNotice[])}
|
|
941
|
+
{#each notices as notice, i (i)}
|
|
1403
942
|
<div class="alert alert-warning mb-4 flex-col items-start text-sm">
|
|
1404
943
|
<p class="flex items-center gap-2 font-medium">
|
|
1405
944
|
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
|
|
1406
945
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v4m0 4h.01M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" />
|
|
1407
946
|
</svg>
|
|
1408
|
-
<span>{
|
|
947
|
+
<span>{notice.message}</span>
|
|
1409
948
|
</p>
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
949
|
+
{#if notice.detail}
|
|
950
|
+
<p>{notice.detail}</p>
|
|
951
|
+
{/if}
|
|
952
|
+
{#if notice.rows.length}
|
|
953
|
+
<ul class="mt-1 w-full">
|
|
954
|
+
{#each notice.rows as row, i (i)}
|
|
955
|
+
<li class="flex items-center justify-between gap-2">
|
|
956
|
+
{#if row.rowLabel}
|
|
957
|
+
<!-- A body needs-alt row labels with its source reference in a code span; a hero row
|
|
958
|
+
and any future labelled row use a plain label. -->
|
|
959
|
+
{#if row.rowCode}
|
|
960
|
+
<code class="text-xs">{row.rowLabel}</code>
|
|
961
|
+
{:else}
|
|
962
|
+
<span class="text-xs font-medium">{row.rowLabel}</span>
|
|
963
|
+
{/if}
|
|
964
|
+
{/if}
|
|
965
|
+
{#if row.href}
|
|
966
|
+
<a class="btn btn-xs" href={row.href}>{row.label}</a>
|
|
967
|
+
{:else}
|
|
968
|
+
<button type="button" class="btn btn-xs" onclick={row.onAct}>{row.label}</button>
|
|
969
|
+
{/if}
|
|
970
|
+
</li>
|
|
971
|
+
{/each}
|
|
972
|
+
</ul>
|
|
973
|
+
{/if}
|
|
1427
974
|
</div>
|
|
1428
|
-
{/
|
|
975
|
+
{/each}
|
|
976
|
+
{/snippet}
|
|
977
|
+
<!-- The role="status" live region renders unconditionally (present and empty at load), so when the
|
|
978
|
+
first notice appears it announces; a region conditionally mounted with its first content may not
|
|
979
|
+
be observed by assistive tech (WCAG 4.1.3). The notices gate on their own presence, so an empty
|
|
980
|
+
region shows nothing. A plain wrapper (not display:contents) carries the role, since some
|
|
981
|
+
assistive tech drops a role off a display:contents box. -->
|
|
982
|
+
<div role="status">
|
|
983
|
+
{@render advisoryNotices(renderNotices)}
|
|
1429
984
|
</div>
|
|
1430
985
|
{#if draftWarning}
|
|
1431
986
|
<div class="alert alert-warning mb-4 text-sm">
|
|
@@ -1817,23 +1372,37 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1817
1372
|
{@const f = field as TextareaField}
|
|
1818
1373
|
<label class="flex flex-col gap-1">
|
|
1819
1374
|
<span class="text-sm font-medium">{f.label}</span>
|
|
1820
|
-
<textarea class="textarea textarea-sm" name={f.name} aria-label={f.label} rows={f.rows ?? 3}>{str(data.frontmatter[f.name])}</textarea>
|
|
1375
|
+
<textarea class="textarea textarea-sm" name={f.name} aria-label={f.label} aria-describedby={f.description ? `${f.name}-hint` : undefined} rows={f.rows ?? 3}>{str(data.frontmatter[f.name])}</textarea>
|
|
1376
|
+
{#if f.description}
|
|
1377
|
+
{@render fieldHint(f.name, f.description)}
|
|
1378
|
+
{/if}
|
|
1821
1379
|
</label>
|
|
1822
1380
|
{:else if field.type === 'date'}
|
|
1823
1381
|
<label class="flex flex-col gap-1">
|
|
1824
1382
|
<span class="text-sm font-medium">{field.label}</span>
|
|
1825
|
-
|
|
1383
|
+
<!-- A date field always carries a hint: the adapter's description when set, else the
|
|
1384
|
+
built-in publish-clarity default. So aria-describedby always points at the paragraph. -->
|
|
1385
|
+
<input class="input input-sm" type="date" name={field.name} aria-label={field.label} aria-describedby={`${field.name}-hint`} value={str(data.frontmatter[field.name])} />
|
|
1386
|
+
{@render fieldHint(field.name, field.description || DATE_PUBLISH_HINT)}
|
|
1826
1387
|
</label>
|
|
1827
1388
|
{:else if field.type === 'boolean'}
|
|
1828
|
-
<
|
|
1829
|
-
<
|
|
1830
|
-
|
|
1831
|
-
|
|
1389
|
+
<div class="flex flex-col gap-1">
|
|
1390
|
+
<label class="label cursor-pointer justify-start gap-2">
|
|
1391
|
+
<input class="checkbox checkbox-sm" type="checkbox" name={field.name} aria-label={field.label} aria-describedby={field.description ? `${field.name}-hint` : undefined} checked={data.frontmatter[field.name] === true} />
|
|
1392
|
+
<span class="text-sm">{field.label}</span>
|
|
1393
|
+
</label>
|
|
1394
|
+
{#if field.description}
|
|
1395
|
+
{@render fieldHint(field.name, field.description)}
|
|
1396
|
+
{/if}
|
|
1397
|
+
</div>
|
|
1832
1398
|
{:else if field.type === 'tags'}
|
|
1833
1399
|
{@const f = field as TagsField}
|
|
1834
1400
|
{@const selected = (data.frontmatter[f.name] ?? []) as string[]}
|
|
1835
|
-
<fieldset class="fieldset">
|
|
1401
|
+
<fieldset class="fieldset" aria-describedby={f.description ? `${f.name}-hint` : undefined}>
|
|
1836
1402
|
<legend class="fieldset-legend">{f.label}</legend>
|
|
1403
|
+
{#if f.description}
|
|
1404
|
+
{@render fieldHint(f.name, f.description)}
|
|
1405
|
+
{/if}
|
|
1837
1406
|
<div class="flex flex-wrap gap-2">
|
|
1838
1407
|
{#each f.options as option (option)}
|
|
1839
1408
|
<label class="label cursor-pointer justify-start gap-2">
|
|
@@ -1858,9 +1427,13 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1858
1427
|
class="input input-sm"
|
|
1859
1428
|
name={f.name}
|
|
1860
1429
|
aria-label={f.label}
|
|
1430
|
+
aria-describedby={f.description ? `${f.name}-hint` : undefined}
|
|
1861
1431
|
placeholder={f.placeholder}
|
|
1862
1432
|
value={tagValue}
|
|
1863
1433
|
/>
|
|
1434
|
+
{#if f.description}
|
|
1435
|
+
{@render fieldHint(f.name, f.description)}
|
|
1436
|
+
{/if}
|
|
1864
1437
|
</label>
|
|
1865
1438
|
{:else if field.type === 'image'}
|
|
1866
1439
|
{@const heroValue = data.frontmatter[field.name] as ImageValue | undefined}
|
|
@@ -1879,7 +1452,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1879
1452
|
{:else}
|
|
1880
1453
|
<label class="flex flex-col gap-1">
|
|
1881
1454
|
<span class="text-sm font-medium">{field.label}</span>
|
|
1882
|
-
<input class="input input-sm" name={field.name} aria-label={field.label} value={str(data.frontmatter[field.name])} required={field.required} />
|
|
1455
|
+
<input class="input input-sm" name={field.name} aria-label={field.label} aria-describedby={field.description ? `${field.name}-hint` : undefined} value={str(data.frontmatter[field.name])} required={field.required} />
|
|
1456
|
+
{#if field.description}
|
|
1457
|
+
{@render fieldHint(field.name, field.description)}
|
|
1458
|
+
{/if}
|
|
1883
1459
|
</label>
|
|
1884
1460
|
{/if}
|
|
1885
1461
|
{/each}
|