@glw907/cairn-cms 0.59.0 → 0.60.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +60 -0
- package/dist/components/AdminLayout.svelte +130 -229
- package/dist/components/CairnAdmin.svelte +12 -41
- package/dist/components/CairnLogo.svelte +1 -6
- package/dist/components/CairnMediaLibrary.svelte +821 -1210
- package/dist/components/CairnTidySettings.svelte +486 -0
- package/dist/components/CairnTidySettings.svelte.d.ts +32 -0
- package/dist/components/ComponentForm.svelte +110 -185
- package/dist/components/ComponentInsertDialog.svelte +163 -283
- package/dist/components/ConceptList.svelte +111 -191
- package/dist/components/ConfirmPage.svelte +5 -12
- package/dist/components/CsrfField.svelte +5 -11
- package/dist/components/DeleteDialog.svelte +15 -42
- package/dist/components/EditPage.svelte +786 -918
- package/dist/components/EditorToolbar.svelte +108 -170
- package/dist/components/IconPicker.svelte +23 -53
- package/dist/components/LinkPicker.svelte +34 -58
- package/dist/components/LoginPage.svelte +14 -27
- package/dist/components/ManageEditors.svelte +3 -15
- package/dist/components/MarkdownEditor.svelte +688 -789
- package/dist/components/MarkdownEditor.svelte.d.ts +44 -0
- package/dist/components/MarkdownHelpDialog.svelte +8 -12
- package/dist/components/MediaCaptureCard.svelte +18 -57
- package/dist/components/MediaFigureControl.svelte +32 -71
- package/dist/components/MediaHeroField.svelte +210 -329
- package/dist/components/MediaInsertPopover.svelte +156 -283
- package/dist/components/MediaPicker.svelte +67 -131
- package/dist/components/NavTree.svelte +46 -78
- package/dist/components/RenameDialog.svelte +16 -43
- package/dist/components/ShortcutsDialog.svelte +9 -13
- package/dist/components/ShortcutsGrid.svelte +1 -2
- package/dist/components/TidyReview.svelte +355 -0
- package/dist/components/TidyReview.svelte.d.ts +47 -0
- package/dist/components/WebLinkDialog.svelte +19 -40
- package/dist/components/cairn-admin.css +768 -0
- package/dist/components/editor-tidy.d.ts +31 -0
- package/dist/components/editor-tidy.js +199 -0
- package/dist/components/index.d.ts +1 -0
- package/dist/components/index.js +1 -0
- package/dist/components/markdown-directives.d.ts +16 -0
- package/dist/components/markdown-directives.js +34 -0
- package/dist/components/objective-errors.d.ts +30 -0
- package/dist/components/objective-errors.js +113 -0
- package/dist/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
- package/dist/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
- package/dist/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
- package/dist/components/spellcheck-worker.d.ts +80 -0
- package/dist/components/spellcheck-worker.js +161 -0
- package/dist/components/spellcheck.d.ts +148 -0
- package/dist/components/spellcheck.js +553 -0
- package/dist/components/tidy-categorize.d.ts +67 -0
- package/dist/components/tidy-categorize.js +392 -0
- package/dist/components/tidy-diff.d.ts +60 -0
- package/dist/components/tidy-diff.js +147 -0
- package/dist/components/tidy-validate.d.ts +37 -0
- package/dist/components/tidy-validate.js +174 -0
- package/dist/content/compose.d.ts +1 -1
- package/dist/content/compose.js +11 -0
- package/dist/content/site-dictionary.d.ts +31 -0
- package/dist/content/site-dictionary.js +82 -0
- package/dist/content/types.d.ts +25 -0
- package/dist/delivery/CairnHead.svelte +8 -11
- package/dist/doctor/checks-local.d.ts +1 -0
- package/dist/doctor/checks-local.js +55 -6
- package/dist/doctor/index.js +2 -1
- package/dist/log/events.d.ts +1 -1
- package/dist/nav/site-config.d.ts +98 -0
- package/dist/nav/site-config.js +132 -0
- package/dist/sveltekit/admin-dispatch.d.ts +2 -0
- package/dist/sveltekit/admin-dispatch.js +6 -2
- package/dist/sveltekit/cairn-admin.d.ts +13 -1
- package/dist/sveltekit/cairn-admin.js +22 -3
- package/dist/sveltekit/content-routes.d.ts +135 -1
- package/dist/sveltekit/content-routes.js +351 -3
- package/dist/sveltekit/tidy-prompt.d.ts +11 -0
- package/dist/sveltekit/tidy-prompt.js +118 -0
- package/package.json +11 -2
- package/src/lib/components/CairnAdmin.svelte +3 -0
- package/src/lib/components/CairnTidySettings.svelte +553 -0
- package/src/lib/components/EditPage.svelte +371 -2
- package/src/lib/components/MarkdownEditor.svelte +168 -1
- package/src/lib/components/TidyReview.svelte +463 -0
- package/src/lib/components/cairn-admin.css +25 -0
- package/src/lib/components/editor-tidy.ts +241 -0
- package/src/lib/components/index.ts +1 -0
- package/src/lib/components/markdown-directives.ts +35 -0
- package/src/lib/components/objective-errors.ts +155 -0
- package/src/lib/components/spellcheck-assets/dictionary-en-us.txt +104743 -0
- package/src/lib/components/spellcheck-assets/spellchecker-wasm-LICENSE.txt +21 -0
- package/src/lib/components/spellcheck-assets/spellchecker-wasm.wasm +0 -0
- package/src/lib/components/spellcheck-worker.ts +279 -0
- package/src/lib/components/spellcheck.ts +693 -0
- package/src/lib/components/tidy-categorize.ts +460 -0
- package/src/lib/components/tidy-diff.ts +196 -0
- package/src/lib/components/tidy-validate.ts +202 -0
- package/src/lib/content/compose.ts +11 -1
- package/src/lib/content/site-dictionary.ts +84 -0
- package/src/lib/content/types.ts +25 -0
- package/src/lib/doctor/checks-local.ts +59 -5
- package/src/lib/doctor/index.ts +2 -0
- package/src/lib/log/events.ts +7 -1
- package/src/lib/nav/site-config.ts +197 -0
- package/src/lib/sveltekit/admin-dispatch.ts +7 -3
- package/src/lib/sveltekit/cairn-admin.ts +32 -4
- package/src/lib/sveltekit/content-routes.ts +504 -4
- package/src/lib/sveltekit/tidy-prompt.ts +153 -0
|
@@ -17,8 +17,9 @@ transient flashes, and the editor card's footer is the writing-environment strip
|
|
|
17
17
|
count, the Prose/Markup posture pair, the focus and typewriter toggles, and the Markdown help.
|
|
18
18
|
-->
|
|
19
19
|
<script lang="ts">
|
|
20
|
-
import { flushSync, untrack } from 'svelte';
|
|
20
|
+
import { flushSync, untrack, getContext } from 'svelte';
|
|
21
21
|
import { beforeNavigate } from '$app/navigation';
|
|
22
|
+
import { deserialize } from '$app/forms';
|
|
22
23
|
import { page } from '$app/state';
|
|
23
24
|
import BlocksIcon from '@lucide/svelte/icons/blocks';
|
|
24
25
|
import SquarePenIcon from '@lucide/svelte/icons/square-pen';
|
|
@@ -40,6 +41,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
40
41
|
import RenameDialog from './RenameDialog.svelte';
|
|
41
42
|
import MarkdownHelpDialog from './MarkdownHelpDialog.svelte';
|
|
42
43
|
import ShortcutsDialog from './ShortcutsDialog.svelte';
|
|
44
|
+
import TidyReview from './TidyReview.svelte';
|
|
45
|
+
import SparklesIcon from '@lucide/svelte/icons/sparkles';
|
|
46
|
+
import { validateTidy, TIDY_REJECTION_MESSAGE } from './tidy-validate.js';
|
|
47
|
+
import type { Change } from './tidy-diff.js';
|
|
43
48
|
import { cairnLinkCompletionSource } from './link-completion.js';
|
|
44
49
|
import {
|
|
45
50
|
findMediaImagesNeedingAlt,
|
|
@@ -64,6 +69,7 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
64
69
|
import { manifestMediaResolver } from '../render/resolve-media.js';
|
|
65
70
|
import type { MediaEntry } from '../media/manifest.js';
|
|
66
71
|
import { mediaLibraryEntry } from '../media/library-entry.js';
|
|
72
|
+
import { CSRF_CONTEXT_KEY } from './csrf-context.js';
|
|
67
73
|
|
|
68
74
|
interface Props {
|
|
69
75
|
/** The edit load's data, plus the site name for the heading. */
|
|
@@ -84,6 +90,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
84
90
|
|
|
85
91
|
let { data, registry, render, icons, form }: Props = $props();
|
|
86
92
|
|
|
93
|
+
// The client-side tidy deadline (spec 2.1, Task 14): a slow call becomes a cancel/retry rather than a
|
|
94
|
+
// hung review. Set above the action's own 30s Worker deadline so the server's retryable fail lands
|
|
95
|
+
// first when the model is merely slow; this catches a stalled connection past that.
|
|
96
|
+
const TIDY_CLIENT_TIMEOUT_MS = 45_000;
|
|
97
|
+
|
|
87
98
|
// The topbar context portal (AdminLayout owns the holder). The desk snippet below carries the
|
|
88
99
|
// document's status and action clusters; this effect registers it into the band on mount and
|
|
89
100
|
// nulls it on teardown, so CairnAdmin's view switch (which unmounts EditPage) clears the band.
|
|
@@ -120,6 +131,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
120
131
|
const formaction = (e.submitter as HTMLButtonElement | null)?.getAttribute('formaction');
|
|
121
132
|
if (formaction === '?/publish') publishing = true;
|
|
122
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();
|
|
123
138
|
}
|
|
124
139
|
// Either in-flight submit disables both buttons, so a second click cannot fire a second POST
|
|
125
140
|
// while the first navigation is still pending.
|
|
@@ -294,7 +309,15 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
294
309
|
// Preview is read-only, so the insert controls the page renders into the toolbar disable with the
|
|
295
310
|
// strip's own format buttons. Declared here (above the Edit-block derivations that read it) so it
|
|
296
311
|
// is in scope before its first use.
|
|
297
|
-
|
|
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);
|
|
298
321
|
let previewHtml = $state('');
|
|
299
322
|
// True after a render call threw, so the preview pane can say so instead of going blank.
|
|
300
323
|
let previewFailed = $state(false);
|
|
@@ -319,8 +342,14 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
319
342
|
const typewriterStorageKey = 'cairn-editor-typewriter';
|
|
320
343
|
const surfaceStorageKey = 'cairn-editor-surface';
|
|
321
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';
|
|
322
350
|
let focusMode = $state(false);
|
|
323
351
|
let typewriter = $state(false);
|
|
352
|
+
let spellcheck = $state(true);
|
|
324
353
|
// Zen: the manuscript alone on the recessed ground. The band, the document title, the toolbar
|
|
325
354
|
// strip, and the footer go; the editing surface stays. It joins the editor-preference family on
|
|
326
355
|
// the same pattern (a localStorage key, read once below, written by the setter), and composes
|
|
@@ -334,6 +363,8 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
334
363
|
typewriter = localStorage.getItem(typewriterStorageKey) === 'true';
|
|
335
364
|
zen = localStorage.getItem(zenStorageKey) === 'true';
|
|
336
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';
|
|
337
368
|
});
|
|
338
369
|
function setFocusMode(on: boolean) {
|
|
339
370
|
focusMode = on;
|
|
@@ -343,6 +374,57 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
343
374
|
typewriter = on;
|
|
344
375
|
localStorage.setItem(typewriterStorageKey, String(on));
|
|
345
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.
|
|
426
|
+
}
|
|
427
|
+
}
|
|
346
428
|
function setSurface(posture: 'prose' | 'markup') {
|
|
347
429
|
surface = posture;
|
|
348
430
|
localStorage.setItem(surfaceStorageKey, posture);
|
|
@@ -395,9 +477,180 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
395
477
|
// The editor's current selection, registered by MarkdownEditor on mount; the web link dialog
|
|
396
478
|
// reads it for the Text field's default.
|
|
397
479
|
let getSelection = $state.raw<() => string>(() => '');
|
|
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);
|
|
398
484
|
// The editor's selection transform, registered by MarkdownEditor on mount; a no-op until then.
|
|
399
485
|
let format = $state.raw<(kind: FormatKind) => void>(() => {});
|
|
400
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);
|
|
552
|
+
}
|
|
553
|
+
const text = useSelection ? selected : body;
|
|
554
|
+
|
|
555
|
+
tidyBusy = true;
|
|
556
|
+
tidyController = new AbortController();
|
|
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;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/** Cancel an in-flight tidy: abort the request and clear the working state. The buffer is untouched. */
|
|
622
|
+
function cancelTidy() {
|
|
623
|
+
tidyController?.abort();
|
|
624
|
+
tidyBusy = false;
|
|
625
|
+
tidyMessage = null;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/** Close the review: clear tidy mode and the review data. On apply the "Undo tidy" affordance shows
|
|
629
|
+
* until the next edit; on cancel nothing changed. */
|
|
630
|
+
function closeTidyReview(applied: boolean) {
|
|
631
|
+
tidyMode = false;
|
|
632
|
+
tidyReview = null;
|
|
633
|
+
tidyApplied = applied;
|
|
634
|
+
// Record the body the apply produced, so the next edit (a different body) dismisses the Undo chip.
|
|
635
|
+
tidyAppliedBody = applied ? body : null;
|
|
636
|
+
}
|
|
637
|
+
// The body snapshot right after Apply; the Undo-tidy chip dismisses once the body diverges from it.
|
|
638
|
+
let tidyAppliedBody = $state<string | null>(null);
|
|
639
|
+
$effect(() => {
|
|
640
|
+
const current = body;
|
|
641
|
+
if (tidyApplied && tidyAppliedBody !== null && current !== tidyAppliedBody) {
|
|
642
|
+
tidyApplied = false;
|
|
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();
|
|
650
|
+
tidyApplied = false;
|
|
651
|
+
tidyAppliedBody = null;
|
|
652
|
+
}
|
|
653
|
+
|
|
401
654
|
// The media insert seams, registered by MarkdownEditor on mount, mirroring the range holders
|
|
402
655
|
// above. The popover drives the optimistic upload loop through them: the caret anchor, the focus
|
|
403
656
|
// restore, the placeholder api, and the direct-insert path for a picked image. The placeholder
|
|
@@ -988,6 +1241,15 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
988
1241
|
{#if dirty}<span class="h-1.5 w-1.5 shrink-0 rounded-full bg-warning" aria-hidden="true"></span>{/if}
|
|
989
1242
|
{saveState}
|
|
990
1243
|
</span>
|
|
1244
|
+
{#if tidyApplied}
|
|
1245
|
+
<!-- The session-level Undo tidy (graft 6): surfaced right after Apply, dismissed on the next
|
|
1246
|
+
edit. Ordinary editor Undo covers it mechanically (the apply is one history entry); this
|
|
1247
|
+
chip names it so the author knows the whole tidy is one move back. -->
|
|
1248
|
+
<span class="flex items-center gap-2 border-l border-[var(--cairn-card-border)] pl-3 text-xs text-[var(--color-muted)]" data-testid="tidy-undo-chip">
|
|
1249
|
+
<span class="inline-flex items-center gap-1 font-semibold text-[var(--color-positive-ink)]">Tidy applied</span>
|
|
1250
|
+
<button type="button" class="underline decoration-[color-mix(in_oklab,currentColor_40%,transparent)] underline-offset-2 hover:text-primary" onclick={undoTidy}>Undo tidy</button>
|
|
1251
|
+
</span>
|
|
1252
|
+
{/if}
|
|
991
1253
|
</div>
|
|
992
1254
|
|
|
993
1255
|
<div class="ml-auto flex items-center gap-2 border-l border-[var(--cairn-card-border)] pl-3">
|
|
@@ -1295,6 +1557,21 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1295
1557
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
|
|
1296
1558
|
</svg>
|
|
1297
1559
|
</button>
|
|
1560
|
+
{#if tidyEnabled}
|
|
1561
|
+
<!-- Tidy (spec 2.1): the single desk entry point for the light copy-edit. A labelled
|
|
1562
|
+
accent-quiet action (something you invoke, not a format you toggle). Disabled in
|
|
1563
|
+
Preview, while a review is open, and while a request is in flight. -->
|
|
1564
|
+
<button
|
|
1565
|
+
type="button"
|
|
1566
|
+
class="btn btn-sm btn-ghost gap-1.5"
|
|
1567
|
+
aria-label="Tidy"
|
|
1568
|
+
title="Tidy: a light copy-edit you review before it lands"
|
|
1569
|
+
disabled={insertDisabled || tidyBusy}
|
|
1570
|
+
onclick={runTidy}
|
|
1571
|
+
>
|
|
1572
|
+
<SparklesIcon class="h-4 w-4" aria-hidden="true" />Tidy
|
|
1573
|
+
</button>
|
|
1574
|
+
{/if}
|
|
1298
1575
|
<!-- The Figure control: always rendered, enabled only when the caret sits on a media image
|
|
1299
1576
|
(and the Write surface is up). It never mounts or unmounts on caret movement; only its
|
|
1300
1577
|
enabled state changes (the Edit-block pattern). The unavailable state uses aria-disabled,
|
|
@@ -1332,7 +1609,11 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1332
1609
|
registerSelectRange={(fn) => (selectRange = fn)}
|
|
1333
1610
|
registerInsertLink={(fn) => (insertLink = fn)}
|
|
1334
1611
|
registerGetSelection={(fn) => (getSelection = fn)}
|
|
1612
|
+
registerGetSelectionRange={(fn) => (getSelectionRange = fn)}
|
|
1335
1613
|
registerFormat={(fn) => (format = fn)}
|
|
1614
|
+
registerTidy={(api) => (tidyApi = api)}
|
|
1615
|
+
registerUndo={(fn) => (undoEditor = fn)}
|
|
1616
|
+
{tidyMode}
|
|
1336
1617
|
registerCaretCoords={(fn) => (caretCoords = fn)}
|
|
1337
1618
|
registerFocusEditor={(fn) => (focusEditor = fn)}
|
|
1338
1619
|
registerImagePlaceholders={(api) => (placeholders = api)}
|
|
@@ -1342,6 +1623,10 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1342
1623
|
{mediaLibrary}
|
|
1343
1624
|
{focusMode}
|
|
1344
1625
|
{typewriter}
|
|
1626
|
+
{spellcheck}
|
|
1627
|
+
spellcheckDictionary={data.spellcheckDictionary}
|
|
1628
|
+
siteDictionary={data.siteDictionary}
|
|
1629
|
+
{pendingAdditions}
|
|
1345
1630
|
/>
|
|
1346
1631
|
<!-- The accumulated uploaded records ride the save form alongside the body. The save action
|
|
1347
1632
|
reads `media` and merges these records into media.json (publish submits the same form). -->
|
|
@@ -1454,6 +1739,17 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1454
1739
|
{#if typewriter}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
|
|
1455
1740
|
Typewriter
|
|
1456
1741
|
</button>
|
|
1742
|
+
<!-- Spellcheck: the markdown-aware lint underlines. Off reconfigures the lint compartment
|
|
1743
|
+
to empty and idles the Worker. Same check-and-tint grammar as the modes beside it. -->
|
|
1744
|
+
<button
|
|
1745
|
+
type="button"
|
|
1746
|
+
class={ftrToggleClass(spellcheck)}
|
|
1747
|
+
aria-pressed={spellcheck}
|
|
1748
|
+
onclick={() => setSpellcheck(!spellcheck)}
|
|
1749
|
+
>
|
|
1750
|
+
{#if spellcheck}<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 6 9 17l-5-5" /></svg>{/if}
|
|
1751
|
+
Spellcheck
|
|
1752
|
+
</button>
|
|
1457
1753
|
<!-- Zen enters from the footer (and Ctrl+Shift+.); it reads as a peer writing-mode
|
|
1458
1754
|
toggle here, but once on it hides the whole footer, so the chip carries the way out. -->
|
|
1459
1755
|
<button
|
|
@@ -1719,6 +2015,79 @@ count, the Prose/Markup posture pair, the focus and typewriter toggles, and the
|
|
|
1719
2015
|
/>
|
|
1720
2016
|
<MarkdownHelpDialog bind:this={helpDialog} />
|
|
1721
2017
|
<ShortcutsDialog bind:this={shortcutsDialog} />
|
|
2018
|
+
|
|
2019
|
+
<!-- The tidy review surface (spec 2.5). Mounted only while a review is open, keyed by the validated
|
|
2020
|
+
change set so a fresh review remounts. It drives the in-buffer decorations and the batched apply
|
|
2021
|
+
through tidyApi; the host clears tidy mode on close. -->
|
|
2022
|
+
{#if tidyReview && tidyApi}
|
|
2023
|
+
<TidyReview
|
|
2024
|
+
changes={tidyReview.changes}
|
|
2025
|
+
original={tidyReview.original}
|
|
2026
|
+
conventions={data.tidy.conventions}
|
|
2027
|
+
model={tidyReview.model}
|
|
2028
|
+
title={data.title}
|
|
2029
|
+
api={tidyApi}
|
|
2030
|
+
onclose={closeTidyReview}
|
|
2031
|
+
onshow={(from, to) => selectRange(from, to)}
|
|
2032
|
+
/>
|
|
2033
|
+
{/if}
|
|
2034
|
+
|
|
2035
|
+
<!-- The tidy working state: a cancelable dialog wired to the real abort (Task 11's AbortController
|
|
2036
|
+
plus the bounded client timeout). Shown while the model call is in flight. -->
|
|
2037
|
+
{#if tidyBusy}
|
|
2038
|
+
<dialog
|
|
2039
|
+
class="modal"
|
|
2040
|
+
aria-labelledby="cairn-tidy-working-title"
|
|
2041
|
+
data-testid="tidy-working"
|
|
2042
|
+
bind:this={tidyWorkingDialog}
|
|
2043
|
+
onclose={cancelTidy}
|
|
2044
|
+
>
|
|
2045
|
+
<div class="modal-box flex flex-col items-center gap-3 text-center">
|
|
2046
|
+
<span class="loading loading-spinner loading-lg text-primary" aria-hidden="true"></span>
|
|
2047
|
+
<h2 id="cairn-tidy-working-title" class="text-base font-semibold">Tidying your text</h2>
|
|
2048
|
+
<p class="max-w-prose text-sm text-[var(--color-muted)]">
|
|
2049
|
+
Claude is reading your draft for a light copy-edit. You will review every change before it lands.
|
|
2050
|
+
</p>
|
|
2051
|
+
<button type="button" class="btn btn-sm" onclick={() => tidyWorkingDialog?.close()}>Cancel</button>
|
|
2052
|
+
</div>
|
|
2053
|
+
</dialog>
|
|
2054
|
+
{/if}
|
|
2055
|
+
|
|
2056
|
+
<!-- The no-op confirmation: tidy found nothing to fix. Quiet, never an empty review. -->
|
|
2057
|
+
{#if tidyNoop}
|
|
2058
|
+
<dialog
|
|
2059
|
+
class="modal"
|
|
2060
|
+
aria-labelledby="cairn-tidy-noop-title"
|
|
2061
|
+
data-testid="tidy-noop"
|
|
2062
|
+
bind:this={tidyNoopDialog}
|
|
2063
|
+
onclose={() => (tidyNoop = false)}
|
|
2064
|
+
>
|
|
2065
|
+
<div class="modal-box flex flex-col items-center gap-3 text-center">
|
|
2066
|
+
<h2 id="cairn-tidy-noop-title" class="text-base font-semibold">Nothing to fix</h2>
|
|
2067
|
+
<p class="max-w-prose text-sm text-[var(--color-muted)]">Tidy read your text and found nothing to change.</p>
|
|
2068
|
+
<button type="button" class="btn btn-sm btn-primary" onclick={() => tidyNoopDialog?.close()}>Close</button>
|
|
2069
|
+
</div>
|
|
2070
|
+
</dialog>
|
|
2071
|
+
{/if}
|
|
2072
|
+
|
|
2073
|
+
<!-- A refused, failed, or rejected tidy: the honest message; the document is unchanged. -->
|
|
2074
|
+
{#if tidyMessage}
|
|
2075
|
+
<dialog
|
|
2076
|
+
class="modal"
|
|
2077
|
+
aria-labelledby="cairn-tidy-message-title"
|
|
2078
|
+
data-testid="tidy-message"
|
|
2079
|
+
bind:this={tidyMessageDialog}
|
|
2080
|
+
onclose={() => (tidyMessage = null)}
|
|
2081
|
+
>
|
|
2082
|
+
<div class="modal-box flex flex-col gap-3">
|
|
2083
|
+
<h2 id="cairn-tidy-message-title" class="text-base font-semibold">Tidy could not run</h2>
|
|
2084
|
+
<p class="text-sm text-[var(--color-muted)]">{tidyMessage}</p>
|
|
2085
|
+
<div class="flex justify-end">
|
|
2086
|
+
<button type="button" class="btn btn-sm btn-primary" onclick={() => tidyMessageDialog?.close()}>Close</button>
|
|
2087
|
+
</div>
|
|
2088
|
+
</div>
|
|
2089
|
+
</dialog>
|
|
2090
|
+
{/if}
|
|
1722
2091
|
<DeleteDialog
|
|
1723
2092
|
bind:this={deleteDialog}
|
|
1724
2093
|
trigger={false}
|