@glw907/cairn-cms 0.60.0 → 0.60.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/components/AdminLayout.svelte +130 -229
- package/dist/components/CairnAdmin.svelte +10 -42
- package/dist/components/CairnLogo.svelte +1 -6
- package/dist/components/CairnMediaLibrary.svelte +821 -1210
- package/dist/components/CairnTidySettings.svelte +192 -259
- package/dist/components/ComponentForm.svelte +110 -185
- package/dist/components/ComponentInsertDialog.svelte +163 -283
- package/dist/components/ConceptList.svelte +111 -191
- package/dist/components/ConfirmPage.svelte +5 -12
- package/dist/components/CsrfField.svelte +5 -11
- package/dist/components/DeleteDialog.svelte +15 -42
- package/dist/components/EditPage.svelte +665 -1166
- package/dist/components/EditorToolbar.svelte +108 -170
- package/dist/components/IconPicker.svelte +23 -53
- package/dist/components/LinkPicker.svelte +34 -58
- package/dist/components/LoginPage.svelte +14 -27
- package/dist/components/ManageEditors.svelte +3 -15
- package/dist/components/MarkdownEditor.svelte +689 -957
- package/dist/components/MarkdownHelpDialog.svelte +8 -12
- package/dist/components/MediaCaptureCard.svelte +18 -57
- package/dist/components/MediaFigureControl.svelte +32 -71
- package/dist/components/MediaHeroField.svelte +210 -329
- package/dist/components/MediaInsertPopover.svelte +156 -283
- package/dist/components/MediaPicker.svelte +67 -131
- package/dist/components/NavTree.svelte +46 -78
- package/dist/components/RenameDialog.svelte +16 -43
- package/dist/components/ShortcutsDialog.svelte +9 -13
- package/dist/components/ShortcutsGrid.svelte +1 -2
- package/dist/components/TidyReview.svelte +140 -248
- package/dist/components/WebLinkDialog.svelte +19 -40
- package/dist/components/cairn-admin.css +4 -0
- package/dist/components/spellcheck.d.ts +3 -1
- package/dist/components/spellcheck.js +14 -2
- package/dist/delivery/CairnHead.svelte +8 -11
- package/package.json +2 -2
- package/src/lib/components/spellcheck.ts +16 -2
|
@@ -7,975 +7,707 @@ the hidden field mirrors the value throughout. The host owns the toolbar and the
|
|
|
7
7
|
selection transforms through the registerFormat seam; the design-accurate preview lives in EditPage
|
|
8
8
|
through the adapter's render. Swapping the editor stays a one-file change.
|
|
9
9
|
-->
|
|
10
|
-
<script lang="ts">
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
/** Focus mode: dim every line outside the caret's paragraph. Off by default. */
|
|
92
|
-
focusMode?: boolean;
|
|
93
|
-
/** Typewriter scroll: hold the cursor line at vertical center while typing. Off by default. */
|
|
94
|
-
typewriter?: boolean;
|
|
95
|
-
/** The surface posture. Prose is the writing instrument (72ch measure, larger type, looser
|
|
96
|
-
* leading); markup is the working surface (fills the card, denser). Prose by default. */
|
|
97
|
-
surface?: 'prose' | 'markup';
|
|
98
|
-
/** Spellcheck and the objective-error layer: the markdown-aware lint underlines. On by default;
|
|
99
|
-
* when off the lint compartment reconfigures to empty (the underlines vanish, the Worker stays
|
|
100
|
-
* idle). The footer toggle drives this. */
|
|
101
|
-
spellcheck?: boolean;
|
|
102
|
-
/** The dialect-resolved dictionary filename, e.g. "dictionary-en-us.txt", from EditData. The
|
|
103
|
-
* source resolves it to a real asset URL and hands it to the spellcheck Worker's init. Defaults to
|
|
104
|
-
* US English. */
|
|
105
|
-
spellcheckDictionary?: string;
|
|
106
|
-
/** The committed personal-dictionary words (spec 1.6), from EditData.siteDictionary. The lint
|
|
107
|
-
* source seeds the spellcheck Worker's personal layer with these at init, so a word another editor
|
|
108
|
-
* committed answers correct from the first lint. Empty by default (dialect-only). */
|
|
109
|
-
siteDictionary?: ReadonlyArray<string>;
|
|
110
|
-
/** The caller-owned pending personal-dictionary additions. When an author chooses "Add to
|
|
111
|
-
* dictionary" the lint source adds the lowercased word here (the underline clears at once); the
|
|
112
|
-
* host (EditPage) commits this set through the addDictionaryWord action at save time and reconciles
|
|
113
|
-
* it against the merged response. A fresh set by default. */
|
|
114
|
-
pendingAdditions?: Set<string>;
|
|
115
|
-
/** Test-only seam for the spellcheck Worker. The real wasm and dictionary assets are resolved with
|
|
116
|
-
* `import.meta.url` and do not load under the vitest browser dev server, so the component test
|
|
117
|
-
* injects a deterministic fake Worker factory and asks the lint source to skip the `ready` wait.
|
|
118
|
-
* When this is absent the production path is untouched: the real `new Worker(...)` and the real
|
|
119
|
-
* asset resolution. Never set this outside a test. */
|
|
120
|
-
spellcheckTest?: {
|
|
121
|
-
createWorker?: () => import('./spellcheck.js').SpellWorker;
|
|
122
|
-
assumeReady?: boolean;
|
|
123
|
-
};
|
|
124
|
-
/** Tidy mode: while a tidy review is open the surface is read-only the way Preview disables the
|
|
125
|
-
* toolbar, so the author cannot edit underneath a pending review. The host sets this when it opens
|
|
126
|
-
* the review and clears it on apply or cancel. Off by default. */
|
|
127
|
-
tidyMode?: boolean;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
let {
|
|
131
|
-
value = $bindable(),
|
|
132
|
-
name,
|
|
133
|
-
registerInsert,
|
|
134
|
-
registerInsertLink,
|
|
135
|
-
registerInsertImage,
|
|
136
|
-
onImageIngest,
|
|
137
|
-
mediaLibrary = {},
|
|
138
|
-
registerCaretCoords,
|
|
139
|
-
registerFocusEditor,
|
|
140
|
-
registerImagePlaceholders,
|
|
141
|
-
registerGetSelection,
|
|
142
|
-
registerGetSelectionRange,
|
|
143
|
-
registerTidy,
|
|
144
|
-
registerUndo,
|
|
145
|
-
registerFormat,
|
|
146
|
-
onComponentAtCaret,
|
|
147
|
-
onMediaImageAtCaret,
|
|
148
|
-
registerReplaceRange,
|
|
149
|
-
registerSelectRange,
|
|
150
|
-
completionSources = [],
|
|
151
|
-
focusMode = false,
|
|
152
|
-
typewriter = false,
|
|
153
|
-
surface = 'prose',
|
|
154
|
-
spellcheck = true,
|
|
155
|
-
spellcheckDictionary = 'dictionary-en-us.txt',
|
|
156
|
-
siteDictionary = [],
|
|
157
|
-
pendingAdditions = new Set<string>(),
|
|
158
|
-
spellcheckTest,
|
|
159
|
-
tidyMode = false,
|
|
160
|
-
}: Props = $props();
|
|
161
|
-
|
|
162
|
-
let host = $state<HTMLDivElement | null>(null);
|
|
163
|
-
let mounted = $state(false);
|
|
164
|
-
// The CodeMirror view, untyped at the runtime boundary because @codemirror/* loads only in the
|
|
165
|
-
// browser. The type-only `import(...)` annotation is erased; the value import is dynamic in onMount,
|
|
166
|
-
// so the server bundle never pulls CodeMirror (guarded by the editor-boundary test).
|
|
167
|
-
let view: import('@codemirror/view').EditorView | null = null;
|
|
168
|
-
// The writing-mode extensions live in their own compartments so the toolbar toggles swap them
|
|
169
|
-
// in and out of the mounted editor without rebuilding it. Assigned in onMount with the rest of
|
|
170
|
-
// the dynamic editor modules.
|
|
171
|
-
let modes: typeof import('./editor-modes.js') | null = null;
|
|
172
|
-
let focusCompartment: import('@codemirror/state').Compartment | null = null;
|
|
173
|
-
let typewriterCompartment: import('@codemirror/state').Compartment | null = null;
|
|
174
|
-
let surfaceCompartment: import('@codemirror/state').Compartment | null = null;
|
|
175
|
-
// The media: source decoration lives in its own compartment, reconfigured when the mediaLibrary
|
|
176
|
-
// prop changes so a just-uploaded image decorates the moment it joins the library. The media
|
|
177
|
-
// module loads with the other dynamic editor modules in onMount.
|
|
178
|
-
let mediaCompartment: import('@codemirror/state').Compartment | null = null;
|
|
179
|
-
let mediaMod: typeof import('./editor-media.js') | null = null;
|
|
180
|
-
// The spellcheck lint source (and the objective-error layer it bundles) live in their own
|
|
181
|
-
// compartment, reconfigured to empty when the footer toggle turns spellcheck off. Both surfaces ride
|
|
182
|
-
// the one extension cairnSpellcheck returns, so one compartment gates both. The extension is built
|
|
183
|
-
// asynchronously (it lazy-imports CodeMirror and the lint modules), so it is held here once resolved
|
|
184
|
-
// and the on/off effect reconfigures against it.
|
|
185
|
-
let spellcheckCompartment: import('@codemirror/state').Compartment | null = null;
|
|
186
|
-
let spellcheckExt: import('@codemirror/state').Extension | null = null;
|
|
187
|
-
// The tidy decoration field lives in its own compartment (entering and leaving tidy is a reconfigure,
|
|
188
|
-
// not a rebuild) beside the media and fold decorations. A second compartment carries the read-only +
|
|
189
|
-
// edit-disable extension while a review is open, so the author cannot edit underneath a pending
|
|
190
|
-
// review (the same posture Preview takes on the toolbar). Both load with the other editor modules.
|
|
191
|
-
let tidyMod: typeof import('./editor-tidy.js') | null = null;
|
|
192
|
-
let tidyCompartment: import('@codemirror/state').Compartment | null = null;
|
|
193
|
-
let tidyReadonlyCompartment: import('@codemirror/state').Compartment | null = null;
|
|
194
|
-
let tidyReadonlyExt: import('@codemirror/state').Extension | null = null;
|
|
195
|
-
// The posture themes, swapped through the surface compartment. Each owns its type step and
|
|
196
|
-
// leading (the base theme deliberately sets neither on the content node, so the postures never
|
|
197
|
-
// contest it on adoption order). Built in onMount beside the base theme.
|
|
198
|
-
let proseTheme: import('@codemirror/state').Extension | null = null;
|
|
199
|
-
let markupTheme: import('@codemirror/state').Extension | null = null;
|
|
200
|
-
|
|
201
|
-
onMount(async () => {
|
|
202
|
-
const viewMod = await import('@codemirror/view');
|
|
203
|
-
const stateMod = await import('@codemirror/state');
|
|
204
|
-
const markdownMod = await import('@codemirror/lang-markdown');
|
|
205
|
-
const commandsMod = await import('@codemirror/commands');
|
|
206
|
-
const languageMod = await import('@codemirror/language');
|
|
207
|
-
const lintMod = await import('@codemirror/lint');
|
|
208
|
-
const autocompleteMod = await import('@codemirror/autocomplete');
|
|
209
|
-
const highlightMod = await import('./editor-highlight.js');
|
|
210
|
-
const modesMod = await import('./editor-modes.js');
|
|
211
|
-
const foldingMod = await import('./editor-folding.js');
|
|
212
|
-
const placeholderMod = await import('./editor-placeholder.js');
|
|
213
|
-
mediaMod = await import('./editor-media.js');
|
|
214
|
-
tidyMod = await import('./editor-tidy.js');
|
|
215
|
-
const spellcheckMod = await import('./spellcheck.js');
|
|
216
|
-
|
|
217
|
-
if (!host) return;
|
|
218
|
-
|
|
219
|
-
const { EditorView, keymap } = viewMod;
|
|
220
|
-
// Mirror the admin theme into CodeMirror's own dark flag, so its base chrome (the autocomplete
|
|
221
|
-
// tooltip above all) renders dark-on-dark instead of light-on-dark.
|
|
222
|
-
const isDark = host.closest('[data-theme]')?.getAttribute('data-theme')?.includes('dark') ?? false;
|
|
223
|
-
// The directive machinery treatment: rails, not bands. A row at depth N draws every rail
|
|
224
|
-
// 1..N as literal nested brackets: 2px accent bars on an 8px pitch (x offsets 0-2, 8-10,
|
|
225
|
-
// and 16-18) with 6px of surface between them (three times the bar weight, so nested bars
|
|
226
|
-
// separate cleanly instead of reading as one thick rule), stacked as inset box shadows (top
|
|
227
|
-
// layer first, so each bar sits over the spacer and deeper bar beneath it). The alphas step through the per-theme vars in
|
|
228
|
-
// cairn-admin.css; the fallbacks are the light values, so the editor still renders sensibly
|
|
229
|
-
// outside an admin theme wrapper. On a fence line the colon runs, brackets, and {attrs}
|
|
230
|
-
// braces dim to the marker tone while the name and label keep a depth-stepped ink. Leaf and
|
|
231
|
-
// inline directives keep a fixed 8% accent chip; the accent ink holds AA on it (4.75:1
|
|
232
|
-
// light, 5.20:1 dark).
|
|
233
|
-
const railFallbacks = ['72%', '82%', '92%'];
|
|
234
|
-
const railColor = (step: number | 'active', fallback: string) =>
|
|
235
|
-
`color-mix(in oklab, var(--color-accent) var(--cairn-directive-rail-${step}, ${fallback}), transparent)`;
|
|
236
|
-
// With `active`, the row's own (deepest) bar takes the full-strength -active mix at the same
|
|
237
|
-
// 2px width. The emphasis is strength only: a rail column carrying both an active and a
|
|
238
|
-
// quiet segment (two sibling containers at one depth) keeps one weight top to bottom. A paired
|
|
239
|
-
// opener row paints its full rail like any other fence row; the fold chevron lives in the gutter
|
|
240
|
-
// column left of the rails, so the opener no longer drops its innermost bar.
|
|
241
|
-
const rails = (depth: number, active = false): string => {
|
|
242
|
-
const layers: string[] = [];
|
|
243
|
-
for (let d = 1; d <= depth; d++) {
|
|
244
|
-
const edge = 8 * d - 6;
|
|
245
|
-
if (d > 1) layers.push(`inset ${edge - 2}px 0 0 0 var(--color-base-100, oklch(99% 0.004 75))`);
|
|
246
|
-
const own = active && d === depth;
|
|
247
|
-
layers.push(
|
|
248
|
-
`inset ${edge}px 0 0 0 ${own ? railColor('active', '100%') : railColor(d, railFallbacks[d - 1] ?? '92%')}`,
|
|
249
|
-
);
|
|
250
|
-
}
|
|
251
|
-
return layers.join(', ');
|
|
252
|
-
};
|
|
253
|
-
const directiveInk = {
|
|
254
|
-
backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
|
|
255
|
-
color: 'var(--color-accent)',
|
|
256
|
-
};
|
|
257
|
-
// The rail rules, one quiet and one caret-active pair per visual depth step (deeper nesting
|
|
258
|
-
// shares the third step). Fence and content rows at a depth share a rule, so a fence and its
|
|
259
|
-
// body rail identically. The caret-active selector adds the caret-block class, so it outranks
|
|
260
|
-
// its quiet twin on any contested row and the caret's container reads one step stronger.
|
|
261
|
-
const railRules: Record<string, { boxShadow: string }> = {};
|
|
262
|
-
for (const depth of [1, 2, 3]) {
|
|
263
|
-
const row = (prefix: string) =>
|
|
264
|
-
`${prefix}.cm-cairn-directive-fence.cm-cairn-depth-${depth}, ${prefix}.cm-cairn-directive-content.cm-cairn-depth-${depth}`;
|
|
265
|
-
railRules[row('')] = { boxShadow: rails(depth) };
|
|
266
|
-
railRules[row('.cm-cairn-caret-block')] = { boxShadow: rails(depth, true) };
|
|
10
|
+
<script lang="ts">import { onMount, onDestroy } from "svelte";
|
|
11
|
+
import { applyMarkdownFormat, figureAtImage, insertImage as insertImageFormat, insertInlineLink } from "./markdown-format.js";
|
|
12
|
+
import { fenceScan, caretContainerRange, directiveOpenerName } from "./markdown-directives.js";
|
|
13
|
+
import { firstImageFile, guardDropTarget } from "./client-ingest.js";
|
|
14
|
+
let {
|
|
15
|
+
value = $bindable(),
|
|
16
|
+
name,
|
|
17
|
+
registerInsert,
|
|
18
|
+
registerInsertLink,
|
|
19
|
+
registerInsertImage,
|
|
20
|
+
onImageIngest,
|
|
21
|
+
mediaLibrary = {},
|
|
22
|
+
registerCaretCoords,
|
|
23
|
+
registerFocusEditor,
|
|
24
|
+
registerImagePlaceholders,
|
|
25
|
+
registerGetSelection,
|
|
26
|
+
registerGetSelectionRange,
|
|
27
|
+
registerTidy,
|
|
28
|
+
registerUndo,
|
|
29
|
+
registerFormat,
|
|
30
|
+
onComponentAtCaret,
|
|
31
|
+
onMediaImageAtCaret,
|
|
32
|
+
registerReplaceRange,
|
|
33
|
+
registerSelectRange,
|
|
34
|
+
completionSources = [],
|
|
35
|
+
focusMode = false,
|
|
36
|
+
typewriter = false,
|
|
37
|
+
surface = "prose",
|
|
38
|
+
spellcheck = true,
|
|
39
|
+
spellcheckDictionary = "dictionary-en-us.txt",
|
|
40
|
+
siteDictionary = [],
|
|
41
|
+
pendingAdditions = /* @__PURE__ */ new Set(),
|
|
42
|
+
spellcheckTest,
|
|
43
|
+
tidyMode = false
|
|
44
|
+
} = $props();
|
|
45
|
+
let host = $state(null);
|
|
46
|
+
let mounted = $state(false);
|
|
47
|
+
let view = null;
|
|
48
|
+
let modes = null;
|
|
49
|
+
let focusCompartment = null;
|
|
50
|
+
let typewriterCompartment = null;
|
|
51
|
+
let surfaceCompartment = null;
|
|
52
|
+
let mediaCompartment = null;
|
|
53
|
+
let mediaMod = null;
|
|
54
|
+
let spellcheckCompartment = null;
|
|
55
|
+
let spellcheckExt = null;
|
|
56
|
+
let tidyMod = null;
|
|
57
|
+
let tidyCompartment = null;
|
|
58
|
+
let tidyReadonlyCompartment = null;
|
|
59
|
+
let tidyReadonlyExt = null;
|
|
60
|
+
let proseTheme = null;
|
|
61
|
+
let markupTheme = null;
|
|
62
|
+
onMount(async () => {
|
|
63
|
+
const viewMod = await import("@codemirror/view");
|
|
64
|
+
const stateMod = await import("@codemirror/state");
|
|
65
|
+
const markdownMod = await import("@codemirror/lang-markdown");
|
|
66
|
+
const commandsMod = await import("@codemirror/commands");
|
|
67
|
+
const languageMod = await import("@codemirror/language");
|
|
68
|
+
const lintMod = await import("@codemirror/lint");
|
|
69
|
+
const autocompleteMod = await import("@codemirror/autocomplete");
|
|
70
|
+
const highlightMod = await import("./editor-highlight.js");
|
|
71
|
+
const modesMod = await import("./editor-modes.js");
|
|
72
|
+
const foldingMod = await import("./editor-folding.js");
|
|
73
|
+
const placeholderMod = await import("./editor-placeholder.js");
|
|
74
|
+
mediaMod = await import("./editor-media.js");
|
|
75
|
+
tidyMod = await import("./editor-tidy.js");
|
|
76
|
+
const spellcheckMod = await import("./spellcheck.js");
|
|
77
|
+
if (!host) return;
|
|
78
|
+
const { EditorView, keymap } = viewMod;
|
|
79
|
+
const isDark = host.closest("[data-theme]")?.getAttribute("data-theme")?.includes("dark") ?? false;
|
|
80
|
+
const railFallbacks = ["72%", "82%", "92%"];
|
|
81
|
+
const railColor = (step, fallback) => `color-mix(in oklab, var(--color-accent) var(--cairn-directive-rail-${step}, ${fallback}), transparent)`;
|
|
82
|
+
const rails = (depth, active = false) => {
|
|
83
|
+
const layers = [];
|
|
84
|
+
for (let d = 1; d <= depth; d++) {
|
|
85
|
+
const edge = 8 * d - 6;
|
|
86
|
+
if (d > 1) layers.push(`inset ${edge - 2}px 0 0 0 var(--color-base-100, oklch(99% 0.004 75))`);
|
|
87
|
+
const own = active && d === depth;
|
|
88
|
+
layers.push(
|
|
89
|
+
`inset ${edge}px 0 0 0 ${own ? railColor("active", "100%") : railColor(d, railFallbacks[d - 1] ?? "92%")}`
|
|
90
|
+
);
|
|
267
91
|
}
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
// A quote or list line hangs its wrapped continuation under the content: padding-left
|
|
301
|
-
// holds the marker width (the --cairn-hang the decoration sets) and the line's own
|
|
302
|
-
// negative text-indent (set inline) pulls the first line back, so the marker sits in the
|
|
303
|
-
// indent. This rule sits before the gutter rule so a container content line, which
|
|
304
|
-
// carries both classes, takes the gutter-plus-hang rule below.
|
|
305
|
-
'.cm-cairn-hang': { paddingLeft: 'var(--cairn-hang, 0ch)' },
|
|
306
|
-
// The gutter: directive rows pad left so the text clears the deepest rail stack (the
|
|
307
|
-
// depth-3 bar ends at 18px; 1.75rem keeps 10px of air beyond it). Static structure
|
|
308
|
-
// (caret-independent), so caret movement shifts no layout. The --cairn-hang term composes
|
|
309
|
-
// a quote/list marker's hang on top of the gutter; it defaults to 0 on rows without one.
|
|
310
|
-
'.cm-cairn-directive-fence, .cm-cairn-directive-content': {
|
|
311
|
-
paddingLeft: 'calc(1.75rem + var(--cairn-hang, 0ch))',
|
|
312
|
-
},
|
|
313
|
-
...railRules,
|
|
314
|
-
'.cm-cairn-directive-mark': { color: 'var(--color-muted)' },
|
|
315
|
-
'.cm-cairn-directive-label': { color: 'var(--color-accent)' },
|
|
316
|
-
'.cm-cairn-directive-label.cm-cairn-depth-2': { color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))' },
|
|
317
|
-
'.cm-cairn-directive-label.cm-cairn-depth-3': { color: 'var(--cairn-directive-ink-3, oklch(48% 0.16 300))' },
|
|
318
|
-
// Cursor-aware emphasis for the label ink: the caret's container takes the strongest
|
|
319
|
-
// ink, through the -active variable in cairn-admin.css. This selector TIES the depth
|
|
320
|
-
// rules above at two classes, so its place after them breaks the tie in its favor.
|
|
321
|
-
'.cm-cairn-caret-block .cm-cairn-directive-label': {
|
|
322
|
-
color: 'var(--cairn-directive-ink-active, oklch(46% 0.16 300))',
|
|
323
|
-
},
|
|
324
|
-
'.cm-cairn-directive-leaf': directiveInk,
|
|
325
|
-
'.cm-cairn-directive-inline': directiveInk,
|
|
326
|
-
// The media: source chip: the inline widget that stands in for a media reference token, in the
|
|
327
|
-
// directive accent language (the 8% accent chip, the accent ink that holds AA on it). An
|
|
328
|
-
// inline-flex pill carrying a small thumbnail and the asset's display name, so a reference
|
|
329
|
-
// reads as the image it points at without leaving the source view.
|
|
330
|
-
'.cm-cairn-media-chip': {
|
|
331
|
-
display: 'inline-flex',
|
|
332
|
-
alignItems: 'center',
|
|
333
|
-
gap: '0.3em',
|
|
334
|
-
verticalAlign: 'baseline',
|
|
335
|
-
padding: '0.05em 0.4em 0.05em 0.25em',
|
|
336
|
-
borderRadius: '0.375rem',
|
|
337
|
-
backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
|
|
338
|
-
color: 'var(--color-accent)',
|
|
339
|
-
fontFamily: 'var(--font-body, ui-sans-serif, sans-serif)',
|
|
340
|
-
fontSize: '0.8125rem',
|
|
341
|
-
lineHeight: '1.4',
|
|
342
|
-
},
|
|
343
|
-
// The thumbnail: a small square crop, rounded to match the chip. object-fit keeps a
|
|
344
|
-
// non-square source from distorting. A faint border lifts a light image off the chip tint.
|
|
345
|
-
'.cm-cairn-media-thumb': {
|
|
346
|
-
width: '1.4em',
|
|
347
|
-
height: '1.4em',
|
|
348
|
-
objectFit: 'cover',
|
|
349
|
-
borderRadius: '0.25rem',
|
|
350
|
-
border: '1px solid color-mix(in oklab, var(--color-accent) 20%, transparent)',
|
|
351
|
-
flex: '0 0 auto',
|
|
352
|
-
},
|
|
353
|
-
'.cm-cairn-media-name': {
|
|
354
|
-
fontWeight: '500',
|
|
355
|
-
// Keep a long name from stretching the line; the title and the picker carry the full text.
|
|
356
|
-
maxWidth: '18ch',
|
|
357
|
-
overflow: 'hidden',
|
|
358
|
-
textOverflow: 'ellipsis',
|
|
359
|
-
whiteSpace: 'nowrap',
|
|
360
|
-
},
|
|
361
|
-
// The needs-alt marker: a glyph plus a label, never hue alone (the spec accessibility rule).
|
|
362
|
-
// It rides the warning tone so it reads as a caution, with the label spelling out the state.
|
|
363
|
-
// The text uses --cairn-warning-ink, the on-surface warning text token, not --color-warning
|
|
364
|
-
// (a fill tone that fails small-text contrast on the light chip tint, WCAG 1.4.3). The ink
|
|
365
|
-
// holds AA on the chip's tint on both themes and stands apart from the accent name.
|
|
366
|
-
'.cm-cairn-media-needs-alt': {
|
|
367
|
-
display: 'inline-flex',
|
|
368
|
-
alignItems: 'center',
|
|
369
|
-
gap: '0.2em',
|
|
370
|
-
color: 'var(--cairn-warning-ink, oklch(50% 0.13 70))',
|
|
371
|
-
fontSize: '0.6875rem',
|
|
372
|
-
fontWeight: '600',
|
|
373
|
-
textTransform: 'uppercase',
|
|
374
|
-
letterSpacing: '0.02em',
|
|
375
|
-
},
|
|
376
|
-
'.cm-cairn-media-needs-alt-glyph': { fontSize: '0.85em', lineHeight: '1' },
|
|
377
|
-
// The figure/role pill: a small bordered pill carrying the placement role (or "figure" for
|
|
378
|
-
// the measure default) when a media token sits inside a :::figure, in the directive accent
|
|
379
|
-
// language. The accent ink and a color-mix accent border on the base-100 surface read as a
|
|
380
|
-
// quiet tag beside the name. The ink is theme-defined, so it holds contrast in both themes
|
|
381
|
-
// (Task 8's polish confirms it visually). A bare token renders no pill at all.
|
|
382
|
-
'.cm-cairn-media-role': {
|
|
383
|
-
fontFamily: 'var(--font-body, ui-sans-serif, sans-serif)',
|
|
384
|
-
fontSize: '0.625rem',
|
|
385
|
-
fontWeight: '600',
|
|
386
|
-
letterSpacing: '0.01em',
|
|
387
|
-
color: 'var(--color-accent)',
|
|
388
|
-
backgroundColor: 'var(--color-base-100)',
|
|
389
|
-
border: '1px solid color-mix(in oklab, var(--color-accent) 35%, transparent)',
|
|
390
|
-
borderRadius: '0.3rem',
|
|
391
|
-
padding: '0.04rem 0.34rem',
|
|
392
|
-
flex: '0 0 auto',
|
|
393
|
-
},
|
|
394
|
-
// The optimistic upload placeholder: an inline pill in the accent language, carrying a small
|
|
395
|
-
// thumbnail of the image the author is placing and a determinate progress bar beneath it. It
|
|
396
|
-
// stands in for the committed image text only while the upload runs; on resolve the seam
|
|
397
|
-
// swaps it for the real reference, and on failure the seam removes it (the source untouched).
|
|
398
|
-
// The accent tint matches the media chip so the two read as one visual family.
|
|
399
|
-
'.cm-cairn-media-placeholder': {
|
|
400
|
-
display: 'inline-flex',
|
|
401
|
-
flexDirection: 'column',
|
|
402
|
-
gap: '0.2em',
|
|
403
|
-
verticalAlign: 'baseline',
|
|
404
|
-
padding: '0.2em 0.35em',
|
|
405
|
-
borderRadius: '0.375rem',
|
|
406
|
-
backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
|
|
407
|
-
border: '1px solid color-mix(in oklab, var(--color-accent) 20%, transparent)',
|
|
408
|
-
},
|
|
409
|
-
'.cm-cairn-media-placeholder-thumb': {
|
|
410
|
-
width: '2.4em',
|
|
411
|
-
height: '2.4em',
|
|
412
|
-
objectFit: 'cover',
|
|
413
|
-
borderRadius: '0.25rem',
|
|
414
|
-
// A gentle pulse marks the placeholder as in-flight; reduced-motion drops it below.
|
|
415
|
-
opacity: '0.85',
|
|
416
|
-
},
|
|
417
|
-
// The determinate bar: native <progress> restyled to the accent ink so the fill reads as the
|
|
418
|
-
// upload's progress. Sized to the thumbnail width so the pill stays compact.
|
|
419
|
-
'.cm-cairn-media-placeholder-bar': {
|
|
420
|
-
width: '2.4em',
|
|
421
|
-
height: '0.3em',
|
|
422
|
-
appearance: 'none',
|
|
423
|
-
border: '0',
|
|
424
|
-
borderRadius: '0.15em',
|
|
425
|
-
backgroundColor: 'color-mix(in oklab, var(--color-accent) 18%, transparent)',
|
|
426
|
-
overflow: 'hidden',
|
|
427
|
-
},
|
|
428
|
-
'.cm-cairn-media-placeholder-bar::-webkit-progress-bar': {
|
|
429
|
-
backgroundColor: 'transparent',
|
|
430
|
-
},
|
|
431
|
-
'.cm-cairn-media-placeholder-bar::-webkit-progress-value': {
|
|
432
|
-
backgroundColor: 'var(--color-accent)',
|
|
433
|
-
borderRadius: '0.15em',
|
|
434
|
-
transition: 'width 200ms ease',
|
|
435
|
-
},
|
|
436
|
-
'.cm-cairn-media-placeholder-bar::-moz-progress-bar': {
|
|
437
|
-
backgroundColor: 'var(--color-accent)',
|
|
438
|
-
borderRadius: '0.15em',
|
|
439
|
-
},
|
|
440
|
-
// Container folding lives in a real gutter column now, not an in-text band. The gutter is a
|
|
441
|
-
// fixed-x column left of the content; the chevron is empty at rest and reveals on hovering
|
|
442
|
-
// the gutter cell (the VS Code / Zed / Obsidian standard), forced on when folded or when the
|
|
443
|
-
// caret is inside the container. One rotating chevron in the directive ink; the rails carry
|
|
444
|
-
// depth, so the ink does not restep. The lone gutter's wrapper loses its default background
|
|
445
|
-
// and border so the column blends into the quiet surface.
|
|
446
|
-
// Neutralize the gutter wrapper so the column blends in. This assumes the fold gutter is the
|
|
447
|
-
// only gutter (it is today: no lineNumbers or foldGutter in the build); a future line-number
|
|
448
|
-
// or lint gutter would need its own chrome and a narrower selector here.
|
|
449
|
-
'.cm-gutters': { backgroundColor: 'transparent', border: '0', color: 'inherit' },
|
|
450
|
-
// 24px wide so the cell clears the WCAG 2.5.8 target-size floor unconditionally.
|
|
451
|
-
'.cm-cairn-fold-gutter': { width: '24px' },
|
|
452
|
-
'.cm-cairn-fold-gutter .cm-gutterElement': { display: 'flex', alignItems: 'stretch', padding: '0' },
|
|
453
|
-
'.cm-cairn-fold-btn': {
|
|
454
|
-
display: 'flex',
|
|
455
|
-
alignItems: 'center',
|
|
456
|
-
justifyContent: 'center',
|
|
457
|
-
width: '100%',
|
|
458
|
-
padding: '0',
|
|
459
|
-
background: 'transparent',
|
|
460
|
-
border: '0',
|
|
461
|
-
cursor: 'pointer',
|
|
462
|
-
color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))',
|
|
463
|
-
},
|
|
464
|
-
'.cm-cairn-fold-btn svg': {
|
|
465
|
-
width: '11px',
|
|
466
|
-
height: '11px',
|
|
467
|
-
// Empty at rest; the gutter-cell hover, the folded state, and the caret-active state each
|
|
468
|
-
// force it on. A 120ms fade in and out, and a 120ms rotate for the folded turn.
|
|
469
|
-
opacity: '0',
|
|
470
|
-
transition: 'opacity 120ms ease, transform 120ms ease',
|
|
471
|
-
},
|
|
472
|
-
// Reveal on gutter-cell hover, on the folded and caret-active states, and on keyboard focus
|
|
473
|
-
// so a focused control shows its glyph, not just the ring.
|
|
474
|
-
'.cm-cairn-fold-gutter .cm-gutterElement:hover .cm-cairn-fold-btn svg, .cm-cairn-fold-btn:focus-visible svg, .cm-cairn-fold-folded svg, .cm-cairn-fold-active svg':
|
|
475
|
-
{ opacity: '1' },
|
|
476
|
-
// Folded rotates the single chevron to point right; caret-active takes the stronger ink.
|
|
477
|
-
'.cm-cairn-fold-folded svg': { transform: 'rotate(-90deg)' },
|
|
478
|
-
'.cm-cairn-fold-active': { color: 'var(--cairn-directive-ink-active, oklch(46% 0.16 300))' },
|
|
479
|
-
// A visible focus ring for keyboard users landing on the gutter button or the pill, reusing
|
|
480
|
-
// the surface hairline's 70% primary mix (3:1+ non-text contrast on both themes).
|
|
481
|
-
'.cm-cairn-fold-btn:focus-visible': {
|
|
482
|
-
outline: '2px solid color-mix(in oklab, var(--color-primary) 70%, transparent)',
|
|
483
|
-
outlineOffset: '-2px',
|
|
484
|
-
borderRadius: '4px',
|
|
485
|
-
},
|
|
486
|
-
'.cm-cairn-fold-pill:focus-visible': {
|
|
487
|
-
outline: '2px solid color-mix(in oklab, var(--color-primary) 70%, transparent)',
|
|
488
|
-
outlineOffset: '1px',
|
|
489
|
-
},
|
|
490
|
-
// No-hover pointers (touch) cannot reveal on hover, so the rest-state chevron is persistent
|
|
491
|
-
// and legible. Scoped to the rest state (not folded, not caret-active) so those forced-on
|
|
492
|
-
// states still read at full strength on touch rather than this rule clamping them to 0.65.
|
|
493
|
-
'@media (hover: none)': {
|
|
494
|
-
'.cm-cairn-fold-btn:not(.cm-cairn-fold-folded):not(.cm-cairn-fold-active) svg': { opacity: '0.65' },
|
|
495
|
-
},
|
|
496
|
-
// Respect a reduced-motion preference: drop the chevron fade/rotate and the unfold flash.
|
|
497
|
-
'@media (prefers-reduced-motion: reduce)': {
|
|
498
|
-
'.cm-cairn-fold-btn svg': { transition: 'none' },
|
|
499
|
-
'.cm-cairn-fold-flash': { transition: 'none' },
|
|
500
|
-
'.cm-cairn-media-placeholder-bar::-webkit-progress-value': { transition: 'none' },
|
|
501
|
-
},
|
|
502
|
-
// The folded-row wash: a soft accent tint, square and full-row, returning as a STATE signal
|
|
503
|
-
// so folded spots read in a scan. The rails are inset box-shadows on the same line element
|
|
504
|
-
// and render above this background, so the rail column runs through the wash unbroken.
|
|
505
|
-
'.cm-cairn-folded-row': {
|
|
506
|
-
backgroundColor: 'color-mix(in oklab, var(--color-accent) 7%, transparent)',
|
|
507
|
-
},
|
|
508
|
-
// The fold pill: the placeholder widget and the screen-reader story, a real focusable
|
|
509
|
-
// button counting the hidden lines in accent ink. The 30% accent border lifts on hover.
|
|
510
|
-
'.cm-cairn-fold-pill': {
|
|
511
|
-
fontFamily: 'var(--font-body, ui-sans-serif, sans-serif)',
|
|
512
|
-
fontSize: '0.6875rem',
|
|
513
|
-
color: 'var(--color-accent)',
|
|
514
|
-
border: '1px solid color-mix(in oklab, var(--color-accent) 30%, transparent)',
|
|
515
|
-
borderRadius: '0.375rem',
|
|
516
|
-
padding: '1px 7px',
|
|
517
|
-
marginLeft: '10px',
|
|
518
|
-
verticalAlign: '1px',
|
|
519
|
-
backgroundColor: 'var(--color-base-100)',
|
|
520
|
-
cursor: 'pointer',
|
|
521
|
-
},
|
|
522
|
-
'.cm-cairn-fold-pill:hover': {
|
|
523
|
-
borderColor: 'color-mix(in oklab, var(--color-accent) 60%, transparent)',
|
|
524
|
-
},
|
|
525
|
-
// The one-time unfold flash: a low-alpha accent background on the revealed lines, removed
|
|
526
|
-
// after the animation. The transition runs as the field clears the class.
|
|
527
|
-
'.cm-cairn-fold-flash': {
|
|
528
|
-
backgroundColor: 'color-mix(in oklab, var(--color-accent) 12%, transparent)',
|
|
529
|
-
transition: 'background-color 400ms ease',
|
|
530
|
-
},
|
|
531
|
-
// Focus mode's dim ink, on the lines editor-modes marks outside the caret's paragraph.
|
|
532
|
-
// Last on purpose: a dimmed line's spans (markers, tokens, directive labels) all drop to
|
|
533
|
-
// the dim tone, and spec order breaks the specificity ties with the label rules above.
|
|
534
|
-
// The fallback is the light theme's value, like the rail fallbacks. Backgrounds flatten
|
|
535
|
-
// along with the ink: the dim tone on the code chip or an 8% accent chip measures under
|
|
536
|
-
// the design's 3:1 floor, so a dimmed line keeps no tinted chip behind its text. The
|
|
537
|
-
// span arm outranks the chip rules on specificity (the highlight style's generated
|
|
538
|
-
// class, the inline-directive mark); the line arm covers the leaf chip, where spec
|
|
539
|
-
// order breaks the tie.
|
|
540
|
-
'.cm-cairn-focus-dim, .cm-cairn-focus-dim span, .cm-cairn-focus-dim .cm-cairn-directive-label': {
|
|
541
|
-
color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
|
|
542
|
-
backgroundColor: 'transparent',
|
|
543
|
-
},
|
|
544
|
-
// The fold pill dims with its folded opener row like any machinery line (the pill is a
|
|
545
|
-
// widget inside the line). The gutter chevron lives in a separate DOM column that focus-dim
|
|
546
|
-
// cannot reach by descendant selector, and it is already hidden at rest and forced visible
|
|
547
|
-
// only when folded or caret-active, so a folded chevron stays findable without a dim rule.
|
|
548
|
-
'.cm-cairn-focus-dim .cm-cairn-fold-pill': {
|
|
549
|
-
color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
|
|
550
|
-
},
|
|
551
|
-
'.cm-cairn-focus-dim.cm-cairn-folded-row': { backgroundColor: 'transparent' },
|
|
552
|
-
// The rails dim with their text: the rail color-mix reads --cairn-directive-rail-N per
|
|
553
|
-
// element, so overriding the percentages on dimmed lines re-resolves every bar in place.
|
|
554
|
-
// Without this the directive block keeps full-strength bars and becomes the one
|
|
555
|
-
// chromatic object in the dimmed field. The active step needs the override too: focus
|
|
556
|
-
// mode's lit unit is the caret PARAGRAPH while the caret-block class spans the whole
|
|
557
|
-
// container, so a container holding a blank line has dimmed rows that still carry the
|
|
558
|
-
// active rail.
|
|
559
|
-
'.cm-cairn-focus-dim': {
|
|
560
|
-
'--cairn-directive-rail-1': 'var(--cairn-focus-dim-rail-1, 24%)',
|
|
561
|
-
'--cairn-directive-rail-2': 'var(--cairn-focus-dim-rail-2, 28%)',
|
|
562
|
-
'--cairn-directive-rail-3': 'var(--cairn-focus-dim-rail-3, 32%)',
|
|
563
|
-
'--cairn-directive-rail-active': 'var(--cairn-focus-dim-rail-active, 36%)',
|
|
564
|
-
},
|
|
565
|
-
// Tidy review decorations (spec 2.5). The author's original stays in the buffer; a deletion run
|
|
566
|
-
// strikes through in --cairn-error-ink (reserved for tidy deletions) and the proposed insertion
|
|
567
|
-
// shows as decoration content in --color-positive-ink (the locked addition token). The two are
|
|
568
|
-
// a locked pair: deletion red and insertion green never speak the same color, so the author sees
|
|
569
|
-
// exactly what tidy removes and what it adds. Both carry a non-color cue (the strike-through and
|
|
570
|
-
// the leading marker) so the change reads without hue alone.
|
|
571
|
-
'.cm-cairn-tidy-del': {
|
|
572
|
-
color: 'var(--cairn-error-ink, oklch(50% 0.19 25))',
|
|
573
|
-
textDecoration: 'line-through',
|
|
574
|
-
textDecorationThickness: '1px',
|
|
575
|
-
backgroundColor: 'color-mix(in oklab, var(--cairn-error-ink, oklch(50% 0.19 25)) 12%, transparent)',
|
|
576
|
-
borderRadius: '2px',
|
|
577
|
-
},
|
|
578
|
-
'.cm-cairn-tidy-del-marker': {
|
|
579
|
-
// A small leading wedge in the deletion ink, the non-color marker that pairs with the red.
|
|
580
|
-
display: 'inline-block',
|
|
581
|
-
width: '0',
|
|
582
|
-
borderLeft: '2px solid var(--cairn-error-ink, oklch(50% 0.19 25))',
|
|
583
|
-
height: '1em',
|
|
584
|
-
verticalAlign: '-0.15em',
|
|
585
|
-
marginRight: '1px',
|
|
586
|
-
},
|
|
587
|
-
'.cm-cairn-tidy-ins': {
|
|
588
|
-
color: 'var(--color-positive-ink, oklch(48% 0.12 150))',
|
|
589
|
-
backgroundColor: 'color-mix(in oklab, var(--color-positive-ink, oklch(48% 0.12 150)) 16%, transparent)',
|
|
590
|
-
borderRadius: '2px',
|
|
591
|
-
padding: '0 1px',
|
|
592
|
-
marginLeft: '2px',
|
|
593
|
-
// The non-color cue for an insertion: a leading caret glyph in the addition ink.
|
|
594
|
-
'&::before': { content: '"+"', fontSize: '0.8em', opacity: '0.7', marginRight: '1px' },
|
|
595
|
-
},
|
|
92
|
+
return layers.join(", ");
|
|
93
|
+
};
|
|
94
|
+
const directiveInk = {
|
|
95
|
+
backgroundColor: "color-mix(in oklab, var(--color-accent) 8%, transparent)",
|
|
96
|
+
color: "var(--color-accent)"
|
|
97
|
+
};
|
|
98
|
+
const railRules = {};
|
|
99
|
+
for (const depth of [1, 2, 3]) {
|
|
100
|
+
const row = (prefix) => `${prefix}.cm-cairn-directive-fence.cm-cairn-depth-${depth}, ${prefix}.cm-cairn-directive-content.cm-cairn-depth-${depth}`;
|
|
101
|
+
railRules[row("")] = { boxShadow: rails(depth) };
|
|
102
|
+
railRules[row(".cm-cairn-caret-block")] = { boxShadow: rails(depth, true) };
|
|
103
|
+
}
|
|
104
|
+
const theme = EditorView.theme(
|
|
105
|
+
{
|
|
106
|
+
"&": { backgroundColor: "var(--color-base-100)", color: "var(--color-base-content)", fontSize: "1rem" },
|
|
107
|
+
// The 60vh floor keeps the surface reading as the page's center stage even when the
|
|
108
|
+
// entry is short, and because the contenteditable content area carries the height, a
|
|
109
|
+
// click in the empty space below the text still lands in the editor and focuses it.
|
|
110
|
+
// No inner measure cap: the surface fills the card the way a code editor fills its
|
|
111
|
+
// pane, and the card's own width (the host caps it near 89ch of this face) is the one
|
|
112
|
+
// constraint. The surface carries tables, attributed directives, and long URLs, so the
|
|
113
|
+
// ceiling leans toward the code-editor end of the ergonomic band rather than the
|
|
114
|
+
// long-form ideal; paragraphs wrap comfortably below it.
|
|
115
|
+
".cm-content": {
|
|
116
|
+
// The theme roots set --font-editor to the self-hosted iA Writer Mono; the inline
|
|
117
|
+
// fallback keeps the surface monospace outside an admin theme wrapper.
|
|
118
|
+
fontFamily: "var(--font-editor, ui-monospace, monospace)",
|
|
119
|
+
// Vertical padding holds at least one line-height of the body (1.8 x 1rem), with a
|
|
120
|
+
// touch more below than above (the optical center sits high); the sides then read as
|
|
121
|
+
// gutters rather than letterboxing.
|
|
122
|
+
padding: "2rem 1.25rem 2.5rem",
|
|
123
|
+
minHeight: "60vh"
|
|
596
124
|
},
|
|
597
|
-
{
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
// Scoped to the content node (not the editor root) so the base theme's root font-size
|
|
607
|
-
// never contests it, and so the 72ch measure resolves against the prose type step.
|
|
608
|
-
'.cm-content': { fontSize: '1.0625rem', lineHeight: '1.9', maxWidth: '72ch', margin: '0 auto' },
|
|
125
|
+
".cm-cursor": { borderLeftColor: "var(--color-primary)" },
|
|
126
|
+
// A quiet always-on focus hairline. :focus-visible is no escape here: browsers treat a
|
|
127
|
+
// focused text-entry surface as keyboard-modal, so a 2px ring would shout through every
|
|
128
|
+
// typing session. One subtle line keeps focus visible (WCAG 2.4.7) without competing
|
|
129
|
+
// with the manuscript. The 70% primary mix clears the 3:1 non-text contrast floor
|
|
130
|
+
// (WCAG 1.4.11) on both themes (3.23:1 light, 3.32:1 dark), where 45% measured near 2:1.
|
|
131
|
+
"&.cm-focused": {
|
|
132
|
+
outline: "1px solid color-mix(in oklab, var(--color-primary) 70%, transparent)",
|
|
133
|
+
outlineOffset: "-1px"
|
|
609
134
|
},
|
|
610
|
-
{
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
//
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
//
|
|
639
|
-
//
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
135
|
+
".cm-line": { padding: "0" },
|
|
136
|
+
// A quote or list line hangs its wrapped continuation under the content: padding-left
|
|
137
|
+
// holds the marker width (the --cairn-hang the decoration sets) and the line's own
|
|
138
|
+
// negative text-indent (set inline) pulls the first line back, so the marker sits in the
|
|
139
|
+
// indent. This rule sits before the gutter rule so a container content line, which
|
|
140
|
+
// carries both classes, takes the gutter-plus-hang rule below.
|
|
141
|
+
".cm-cairn-hang": { paddingLeft: "var(--cairn-hang, 0ch)" },
|
|
142
|
+
// The gutter: directive rows pad left so the text clears the deepest rail stack (the
|
|
143
|
+
// depth-3 bar ends at 18px; 1.75rem keeps 10px of air beyond it). Static structure
|
|
144
|
+
// (caret-independent), so caret movement shifts no layout. The --cairn-hang term composes
|
|
145
|
+
// a quote/list marker's hang on top of the gutter; it defaults to 0 on rows without one.
|
|
146
|
+
".cm-cairn-directive-fence, .cm-cairn-directive-content": {
|
|
147
|
+
paddingLeft: "calc(1.75rem + var(--cairn-hang, 0ch))"
|
|
148
|
+
},
|
|
149
|
+
...railRules,
|
|
150
|
+
".cm-cairn-directive-mark": { color: "var(--color-muted)" },
|
|
151
|
+
".cm-cairn-directive-label": { color: "var(--color-accent)" },
|
|
152
|
+
".cm-cairn-directive-label.cm-cairn-depth-2": { color: "var(--cairn-directive-ink-2, oklch(50% 0.16 300))" },
|
|
153
|
+
".cm-cairn-directive-label.cm-cairn-depth-3": { color: "var(--cairn-directive-ink-3, oklch(48% 0.16 300))" },
|
|
154
|
+
// Cursor-aware emphasis for the label ink: the caret's container takes the strongest
|
|
155
|
+
// ink, through the -active variable in cairn-admin.css. This selector TIES the depth
|
|
156
|
+
// rules above at two classes, so its place after them breaks the tie in its favor.
|
|
157
|
+
".cm-cairn-caret-block .cm-cairn-directive-label": {
|
|
158
|
+
color: "var(--cairn-directive-ink-active, oklch(46% 0.16 300))"
|
|
159
|
+
},
|
|
160
|
+
".cm-cairn-directive-leaf": directiveInk,
|
|
161
|
+
".cm-cairn-directive-inline": directiveInk,
|
|
162
|
+
// The media: source chip: the inline widget that stands in for a media reference token, in the
|
|
163
|
+
// directive accent language (the 8% accent chip, the accent ink that holds AA on it). An
|
|
164
|
+
// inline-flex pill carrying a small thumbnail and the asset's display name, so a reference
|
|
165
|
+
// reads as the image it points at without leaving the source view.
|
|
166
|
+
".cm-cairn-media-chip": {
|
|
167
|
+
display: "inline-flex",
|
|
168
|
+
alignItems: "center",
|
|
169
|
+
gap: "0.3em",
|
|
170
|
+
verticalAlign: "baseline",
|
|
171
|
+
padding: "0.05em 0.4em 0.05em 0.25em",
|
|
172
|
+
borderRadius: "0.375rem",
|
|
173
|
+
backgroundColor: "color-mix(in oklab, var(--color-accent) 8%, transparent)",
|
|
174
|
+
color: "var(--color-accent)",
|
|
175
|
+
fontFamily: "var(--font-body, ui-sans-serif, sans-serif)",
|
|
176
|
+
fontSize: "0.8125rem",
|
|
177
|
+
lineHeight: "1.4"
|
|
178
|
+
},
|
|
179
|
+
// The thumbnail: a small square crop, rounded to match the chip. object-fit keeps a
|
|
180
|
+
// non-square source from distorting. A faint border lifts a light image off the chip tint.
|
|
181
|
+
".cm-cairn-media-thumb": {
|
|
182
|
+
width: "1.4em",
|
|
183
|
+
height: "1.4em",
|
|
184
|
+
objectFit: "cover",
|
|
185
|
+
borderRadius: "0.25rem",
|
|
186
|
+
border: "1px solid color-mix(in oklab, var(--color-accent) 20%, transparent)",
|
|
187
|
+
flex: "0 0 auto"
|
|
188
|
+
},
|
|
189
|
+
".cm-cairn-media-name": {
|
|
190
|
+
fontWeight: "500",
|
|
191
|
+
// Keep a long name from stretching the line; the title and the picker carry the full text.
|
|
192
|
+
maxWidth: "18ch",
|
|
193
|
+
overflow: "hidden",
|
|
194
|
+
textOverflow: "ellipsis",
|
|
195
|
+
whiteSpace: "nowrap"
|
|
196
|
+
},
|
|
197
|
+
// The needs-alt marker: a glyph plus a label, never hue alone (the spec accessibility rule).
|
|
198
|
+
// It rides the warning tone so it reads as a caution, with the label spelling out the state.
|
|
199
|
+
// The text uses --cairn-warning-ink, the on-surface warning text token, not --color-warning
|
|
200
|
+
// (a fill tone that fails small-text contrast on the light chip tint, WCAG 1.4.3). The ink
|
|
201
|
+
// holds AA on the chip's tint on both themes and stands apart from the accent name.
|
|
202
|
+
".cm-cairn-media-needs-alt": {
|
|
203
|
+
display: "inline-flex",
|
|
204
|
+
alignItems: "center",
|
|
205
|
+
gap: "0.2em",
|
|
206
|
+
color: "var(--cairn-warning-ink, oklch(50% 0.13 70))",
|
|
207
|
+
fontSize: "0.6875rem",
|
|
208
|
+
fontWeight: "600",
|
|
209
|
+
textTransform: "uppercase",
|
|
210
|
+
letterSpacing: "0.02em"
|
|
211
|
+
},
|
|
212
|
+
".cm-cairn-media-needs-alt-glyph": { fontSize: "0.85em", lineHeight: "1" },
|
|
213
|
+
// The figure/role pill: a small bordered pill carrying the placement role (or "figure" for
|
|
214
|
+
// the measure default) when a media token sits inside a :::figure, in the directive accent
|
|
215
|
+
// language. The accent ink and a color-mix accent border on the base-100 surface read as a
|
|
216
|
+
// quiet tag beside the name. The ink is theme-defined, so it holds contrast in both themes
|
|
217
|
+
// (Task 8's polish confirms it visually). A bare token renders no pill at all.
|
|
218
|
+
".cm-cairn-media-role": {
|
|
219
|
+
fontFamily: "var(--font-body, ui-sans-serif, sans-serif)",
|
|
220
|
+
fontSize: "0.625rem",
|
|
221
|
+
fontWeight: "600",
|
|
222
|
+
letterSpacing: "0.01em",
|
|
223
|
+
color: "var(--color-accent)",
|
|
224
|
+
backgroundColor: "var(--color-base-100)",
|
|
225
|
+
border: "1px solid color-mix(in oklab, var(--color-accent) 35%, transparent)",
|
|
226
|
+
borderRadius: "0.3rem",
|
|
227
|
+
padding: "0.04rem 0.34rem",
|
|
228
|
+
flex: "0 0 auto"
|
|
229
|
+
},
|
|
230
|
+
// The optimistic upload placeholder: an inline pill in the accent language, carrying a small
|
|
231
|
+
// thumbnail of the image the author is placing and a determinate progress bar beneath it. It
|
|
232
|
+
// stands in for the committed image text only while the upload runs; on resolve the seam
|
|
233
|
+
// swaps it for the real reference, and on failure the seam removes it (the source untouched).
|
|
234
|
+
// The accent tint matches the media chip so the two read as one visual family.
|
|
235
|
+
".cm-cairn-media-placeholder": {
|
|
236
|
+
display: "inline-flex",
|
|
237
|
+
flexDirection: "column",
|
|
238
|
+
gap: "0.2em",
|
|
239
|
+
verticalAlign: "baseline",
|
|
240
|
+
padding: "0.2em 0.35em",
|
|
241
|
+
borderRadius: "0.375rem",
|
|
242
|
+
backgroundColor: "color-mix(in oklab, var(--color-accent) 8%, transparent)",
|
|
243
|
+
border: "1px solid color-mix(in oklab, var(--color-accent) 20%, transparent)"
|
|
244
|
+
},
|
|
245
|
+
".cm-cairn-media-placeholder-thumb": {
|
|
246
|
+
width: "2.4em",
|
|
247
|
+
height: "2.4em",
|
|
248
|
+
objectFit: "cover",
|
|
249
|
+
borderRadius: "0.25rem",
|
|
250
|
+
// A gentle pulse marks the placeholder as in-flight; reduced-motion drops it below.
|
|
251
|
+
opacity: "0.85"
|
|
252
|
+
},
|
|
253
|
+
// The determinate bar: native <progress> restyled to the accent ink so the fill reads as the
|
|
254
|
+
// upload's progress. Sized to the thumbnail width so the pill stays compact.
|
|
255
|
+
".cm-cairn-media-placeholder-bar": {
|
|
256
|
+
width: "2.4em",
|
|
257
|
+
height: "0.3em",
|
|
258
|
+
appearance: "none",
|
|
259
|
+
border: "0",
|
|
260
|
+
borderRadius: "0.15em",
|
|
261
|
+
backgroundColor: "color-mix(in oklab, var(--color-accent) 18%, transparent)",
|
|
262
|
+
overflow: "hidden"
|
|
263
|
+
},
|
|
264
|
+
".cm-cairn-media-placeholder-bar::-webkit-progress-bar": {
|
|
265
|
+
backgroundColor: "transparent"
|
|
266
|
+
},
|
|
267
|
+
".cm-cairn-media-placeholder-bar::-webkit-progress-value": {
|
|
268
|
+
backgroundColor: "var(--color-accent)",
|
|
269
|
+
borderRadius: "0.15em",
|
|
270
|
+
transition: "width 200ms ease"
|
|
271
|
+
},
|
|
272
|
+
".cm-cairn-media-placeholder-bar::-moz-progress-bar": {
|
|
273
|
+
backgroundColor: "var(--color-accent)",
|
|
274
|
+
borderRadius: "0.15em"
|
|
275
|
+
},
|
|
276
|
+
// Container folding lives in a real gutter column now, not an in-text band. The gutter is a
|
|
277
|
+
// fixed-x column left of the content; the chevron is empty at rest and reveals on hovering
|
|
278
|
+
// the gutter cell (the VS Code / Zed / Obsidian standard), forced on when folded or when the
|
|
279
|
+
// caret is inside the container. One rotating chevron in the directive ink; the rails carry
|
|
280
|
+
// depth, so the ink does not restep. The lone gutter's wrapper loses its default background
|
|
281
|
+
// and border so the column blends into the quiet surface.
|
|
282
|
+
// Neutralize the gutter wrapper so the column blends in. This assumes the fold gutter is the
|
|
283
|
+
// only gutter (it is today: no lineNumbers or foldGutter in the build); a future line-number
|
|
284
|
+
// or lint gutter would need its own chrome and a narrower selector here.
|
|
285
|
+
".cm-gutters": { backgroundColor: "transparent", border: "0", color: "inherit" },
|
|
286
|
+
// 24px wide so the cell clears the WCAG 2.5.8 target-size floor unconditionally.
|
|
287
|
+
".cm-cairn-fold-gutter": { width: "24px" },
|
|
288
|
+
".cm-cairn-fold-gutter .cm-gutterElement": { display: "flex", alignItems: "stretch", padding: "0" },
|
|
289
|
+
".cm-cairn-fold-btn": {
|
|
290
|
+
display: "flex",
|
|
291
|
+
alignItems: "center",
|
|
292
|
+
justifyContent: "center",
|
|
293
|
+
width: "100%",
|
|
294
|
+
padding: "0",
|
|
295
|
+
background: "transparent",
|
|
296
|
+
border: "0",
|
|
297
|
+
cursor: "pointer",
|
|
298
|
+
color: "var(--cairn-directive-ink-2, oklch(50% 0.16 300))"
|
|
299
|
+
},
|
|
300
|
+
".cm-cairn-fold-btn svg": {
|
|
301
|
+
width: "11px",
|
|
302
|
+
height: "11px",
|
|
303
|
+
// Empty at rest; the gutter-cell hover, the folded state, and the caret-active state each
|
|
304
|
+
// force it on. A 120ms fade in and out, and a 120ms rotate for the folded turn.
|
|
305
|
+
opacity: "0",
|
|
306
|
+
transition: "opacity 120ms ease, transform 120ms ease"
|
|
307
|
+
},
|
|
308
|
+
// Reveal on gutter-cell hover, on the folded and caret-active states, and on keyboard focus
|
|
309
|
+
// so a focused control shows its glyph, not just the ring.
|
|
310
|
+
".cm-cairn-fold-gutter .cm-gutterElement:hover .cm-cairn-fold-btn svg, .cm-cairn-fold-btn:focus-visible svg, .cm-cairn-fold-folded svg, .cm-cairn-fold-active svg": { opacity: "1" },
|
|
311
|
+
// Folded rotates the single chevron to point right; caret-active takes the stronger ink.
|
|
312
|
+
".cm-cairn-fold-folded svg": { transform: "rotate(-90deg)" },
|
|
313
|
+
".cm-cairn-fold-active": { color: "var(--cairn-directive-ink-active, oklch(46% 0.16 300))" },
|
|
314
|
+
// A visible focus ring for keyboard users landing on the gutter button or the pill, reusing
|
|
315
|
+
// the surface hairline's 70% primary mix (3:1+ non-text contrast on both themes).
|
|
316
|
+
".cm-cairn-fold-btn:focus-visible": {
|
|
317
|
+
outline: "2px solid color-mix(in oklab, var(--color-primary) 70%, transparent)",
|
|
318
|
+
outlineOffset: "-2px",
|
|
319
|
+
borderRadius: "4px"
|
|
320
|
+
},
|
|
321
|
+
".cm-cairn-fold-pill:focus-visible": {
|
|
322
|
+
outline: "2px solid color-mix(in oklab, var(--color-primary) 70%, transparent)",
|
|
323
|
+
outlineOffset: "1px"
|
|
324
|
+
},
|
|
325
|
+
// No-hover pointers (touch) cannot reveal on hover, so the rest-state chevron is persistent
|
|
326
|
+
// and legible. Scoped to the rest state (not folded, not caret-active) so those forced-on
|
|
327
|
+
// states still read at full strength on touch rather than this rule clamping them to 0.65.
|
|
328
|
+
"@media (hover: none)": {
|
|
329
|
+
".cm-cairn-fold-btn:not(.cm-cairn-fold-folded):not(.cm-cairn-fold-active) svg": { opacity: "0.65" }
|
|
330
|
+
},
|
|
331
|
+
// Respect a reduced-motion preference: drop the chevron fade/rotate and the unfold flash.
|
|
332
|
+
"@media (prefers-reduced-motion: reduce)": {
|
|
333
|
+
".cm-cairn-fold-btn svg": { transition: "none" },
|
|
334
|
+
".cm-cairn-fold-flash": { transition: "none" },
|
|
335
|
+
".cm-cairn-media-placeholder-bar::-webkit-progress-value": { transition: "none" }
|
|
336
|
+
},
|
|
337
|
+
// The folded-row wash: a soft accent tint, square and full-row, returning as a STATE signal
|
|
338
|
+
// so folded spots read in a scan. The rails are inset box-shadows on the same line element
|
|
339
|
+
// and render above this background, so the rail column runs through the wash unbroken.
|
|
340
|
+
".cm-cairn-folded-row": {
|
|
341
|
+
backgroundColor: "color-mix(in oklab, var(--color-accent) 7%, transparent)"
|
|
342
|
+
},
|
|
343
|
+
// The fold pill: the placeholder widget and the screen-reader story, a real focusable
|
|
344
|
+
// button counting the hidden lines in accent ink. The 30% accent border lifts on hover.
|
|
345
|
+
".cm-cairn-fold-pill": {
|
|
346
|
+
fontFamily: "var(--font-body, ui-sans-serif, sans-serif)",
|
|
347
|
+
fontSize: "0.6875rem",
|
|
348
|
+
color: "var(--color-accent)",
|
|
349
|
+
border: "1px solid color-mix(in oklab, var(--color-accent) 30%, transparent)",
|
|
350
|
+
borderRadius: "0.375rem",
|
|
351
|
+
padding: "1px 7px",
|
|
352
|
+
marginLeft: "10px",
|
|
353
|
+
verticalAlign: "1px",
|
|
354
|
+
backgroundColor: "var(--color-base-100)",
|
|
355
|
+
cursor: "pointer"
|
|
356
|
+
},
|
|
357
|
+
".cm-cairn-fold-pill:hover": {
|
|
358
|
+
borderColor: "color-mix(in oklab, var(--color-accent) 60%, transparent)"
|
|
359
|
+
},
|
|
360
|
+
// The one-time unfold flash: a low-alpha accent background on the revealed lines, removed
|
|
361
|
+
// after the animation. The transition runs as the field clears the class.
|
|
362
|
+
".cm-cairn-fold-flash": {
|
|
363
|
+
backgroundColor: "color-mix(in oklab, var(--color-accent) 12%, transparent)",
|
|
364
|
+
transition: "background-color 400ms ease"
|
|
365
|
+
},
|
|
366
|
+
// Focus mode's dim ink, on the lines editor-modes marks outside the caret's paragraph.
|
|
367
|
+
// Last on purpose: a dimmed line's spans (markers, tokens, directive labels) all drop to
|
|
368
|
+
// the dim tone, and spec order breaks the specificity ties with the label rules above.
|
|
369
|
+
// The fallback is the light theme's value, like the rail fallbacks. Backgrounds flatten
|
|
370
|
+
// along with the ink: the dim tone on the code chip or an 8% accent chip measures under
|
|
371
|
+
// the design's 3:1 floor, so a dimmed line keeps no tinted chip behind its text. The
|
|
372
|
+
// span arm outranks the chip rules on specificity (the highlight style's generated
|
|
373
|
+
// class, the inline-directive mark); the line arm covers the leaf chip, where spec
|
|
374
|
+
// order breaks the tie.
|
|
375
|
+
".cm-cairn-focus-dim, .cm-cairn-focus-dim span, .cm-cairn-focus-dim .cm-cairn-directive-label": {
|
|
376
|
+
color: "var(--cairn-focus-dim-ink, oklch(66% 0.01 75))",
|
|
377
|
+
backgroundColor: "transparent"
|
|
378
|
+
},
|
|
379
|
+
// The fold pill dims with its folded opener row like any machinery line (the pill is a
|
|
380
|
+
// widget inside the line). The gutter chevron lives in a separate DOM column that focus-dim
|
|
381
|
+
// cannot reach by descendant selector, and it is already hidden at rest and forced visible
|
|
382
|
+
// only when folded or caret-active, so a folded chevron stays findable without a dim rule.
|
|
383
|
+
".cm-cairn-focus-dim .cm-cairn-fold-pill": {
|
|
384
|
+
color: "var(--cairn-focus-dim-ink, oklch(66% 0.01 75))"
|
|
385
|
+
},
|
|
386
|
+
".cm-cairn-focus-dim.cm-cairn-folded-row": { backgroundColor: "transparent" },
|
|
387
|
+
// The rails dim with their text: the rail color-mix reads --cairn-directive-rail-N per
|
|
388
|
+
// element, so overriding the percentages on dimmed lines re-resolves every bar in place.
|
|
389
|
+
// Without this the directive block keeps full-strength bars and becomes the one
|
|
390
|
+
// chromatic object in the dimmed field. The active step needs the override too: focus
|
|
391
|
+
// mode's lit unit is the caret PARAGRAPH while the caret-block class spans the whole
|
|
392
|
+
// container, so a container holding a blank line has dimmed rows that still carry the
|
|
393
|
+
// active rail.
|
|
394
|
+
".cm-cairn-focus-dim": {
|
|
395
|
+
"--cairn-directive-rail-1": "var(--cairn-focus-dim-rail-1, 24%)",
|
|
396
|
+
"--cairn-directive-rail-2": "var(--cairn-focus-dim-rail-2, 28%)",
|
|
397
|
+
"--cairn-directive-rail-3": "var(--cairn-focus-dim-rail-3, 32%)",
|
|
398
|
+
"--cairn-directive-rail-active": "var(--cairn-focus-dim-rail-active, 36%)"
|
|
399
|
+
},
|
|
400
|
+
// Tidy review decorations (spec 2.5). The author's original stays in the buffer; a deletion run
|
|
401
|
+
// strikes through in --cairn-error-ink (reserved for tidy deletions) and the proposed insertion
|
|
402
|
+
// shows as decoration content in --color-positive-ink (the locked addition token). The two are
|
|
403
|
+
// a locked pair: deletion red and insertion green never speak the same color, so the author sees
|
|
404
|
+
// exactly what tidy removes and what it adds. Both carry a non-color cue (the strike-through and
|
|
405
|
+
// the leading marker) so the change reads without hue alone.
|
|
406
|
+
".cm-cairn-tidy-del": {
|
|
407
|
+
color: "var(--cairn-error-ink, oklch(50% 0.19 25))",
|
|
408
|
+
textDecoration: "line-through",
|
|
409
|
+
textDecorationThickness: "1px",
|
|
410
|
+
backgroundColor: "color-mix(in oklab, var(--cairn-error-ink, oklch(50% 0.19 25)) 12%, transparent)",
|
|
411
|
+
borderRadius: "2px"
|
|
412
|
+
},
|
|
413
|
+
".cm-cairn-tidy-del-marker": {
|
|
414
|
+
// A small leading wedge in the deletion ink, the non-color marker that pairs with the red.
|
|
415
|
+
display: "inline-block",
|
|
416
|
+
width: "0",
|
|
417
|
+
borderLeft: "2px solid var(--cairn-error-ink, oklch(50% 0.19 25))",
|
|
418
|
+
height: "1em",
|
|
419
|
+
verticalAlign: "-0.15em",
|
|
420
|
+
marginRight: "1px"
|
|
421
|
+
},
|
|
422
|
+
".cm-cairn-tidy-ins": {
|
|
423
|
+
color: "var(--color-positive-ink, oklch(48% 0.12 150))",
|
|
424
|
+
backgroundColor: "color-mix(in oklab, var(--color-positive-ink, oklch(48% 0.12 150)) 16%, transparent)",
|
|
425
|
+
borderRadius: "2px",
|
|
426
|
+
padding: "0 1px",
|
|
427
|
+
marginLeft: "2px",
|
|
428
|
+
// The non-color cue for an insertion: a leading caret glyph in the addition ink.
|
|
429
|
+
"&::before": { content: '"+"', fontSize: "0.8em", opacity: "0.7", marginRight: "1px" }
|
|
430
|
+
}
|
|
431
|
+
},
|
|
432
|
+
{ dark: isDark }
|
|
433
|
+
);
|
|
434
|
+
proseTheme = EditorView.theme(
|
|
435
|
+
{
|
|
436
|
+
// Scoped to the content node (not the editor root) so the base theme's root font-size
|
|
437
|
+
// never contests it, and so the 72ch measure resolves against the prose type step.
|
|
438
|
+
".cm-content": { fontSize: "1.0625rem", lineHeight: "1.9", maxWidth: "72ch", margin: "0 auto" }
|
|
439
|
+
},
|
|
440
|
+
{ dark: isDark }
|
|
441
|
+
);
|
|
442
|
+
markupTheme = EditorView.theme({ ".cm-content": { lineHeight: "1.8" } }, { dark: isDark });
|
|
443
|
+
modes = modesMod;
|
|
444
|
+
focusCompartment = new stateMod.Compartment();
|
|
445
|
+
typewriterCompartment = new stateMod.Compartment();
|
|
446
|
+
surfaceCompartment = new stateMod.Compartment();
|
|
447
|
+
mediaCompartment = new stateMod.Compartment();
|
|
448
|
+
spellcheckCompartment = new stateMod.Compartment();
|
|
449
|
+
tidyCompartment = new stateMod.Compartment();
|
|
450
|
+
tidyReadonlyCompartment = new stateMod.Compartment();
|
|
451
|
+
tidyReadonlyExt = [stateMod.EditorState.readOnly.of(true), viewMod.EditorView.editable.of(false)];
|
|
452
|
+
spellcheckExt = await spellcheckMod.cairnSpellcheck({
|
|
453
|
+
dictionaryFile: spellcheckDictionary,
|
|
454
|
+
// Seed the Worker's personal layer from the committed site dictionary, and share the host's
|
|
455
|
+
// pending-additions set so an add-to-dictionary choice records here for the host to commit.
|
|
456
|
+
siteWords: siteDictionary,
|
|
457
|
+
pendingAdditions,
|
|
458
|
+
// Hand the lint source the editor's own CodeMirror module instances so its extension lands on the
|
|
459
|
+
// same copies; a separate dynamic import can resolve to a different instance and break instanceof.
|
|
460
|
+
modules: { lint: lintMod, language: languageMod, view: viewMod, state: stateMod },
|
|
461
|
+
// The test seam: a deterministic fake Worker and the skip-ready flag, both straight through to the
|
|
462
|
+
// lint source. Absent in production, where the real Worker and real asset resolution run.
|
|
463
|
+
createWorker: spellcheckTest?.createWorker,
|
|
464
|
+
assumeReady: spellcheckTest?.assumeReady
|
|
465
|
+
});
|
|
466
|
+
view = new EditorView({
|
|
467
|
+
parent: host,
|
|
468
|
+
state: stateMod.EditorState.create({
|
|
469
|
+
doc: value,
|
|
470
|
+
extensions: [
|
|
471
|
+
focusCompartment.of(focusMode ? modesMod.focusMode() : []),
|
|
472
|
+
typewriterCompartment.of(typewriter ? modesMod.typewriterScroll() : []),
|
|
473
|
+
commandsMod.history(),
|
|
474
|
+
keymap.of([...autocompleteMod.completionKeymap, ...commandsMod.defaultKeymap, ...commandsMod.historyKeymap]),
|
|
475
|
+
// The GFM base (strikethrough, tables, task lists, autolink) over the commonmark
|
|
476
|
+
// default. markdown() also wires markdownKeymap (Enter continues a list, Backspace
|
|
477
|
+
// removes an empty marker) at high precedence through its addKeymap default.
|
|
478
|
+
markdownMod.markdown({ base: markdownMod.markdownLanguage }),
|
|
479
|
+
...completionSources.length ? (
|
|
480
|
+
// interactionDelay 0: the popup opens only on an explicit `[[` trigger, so the default
|
|
481
|
+
// accidental-accept guard adds no value and would swallow an immediate Enter into a newline.
|
|
482
|
+
[autocompleteMod.autocompletion({ override: completionSources, interactionDelay: 0 })]
|
|
483
|
+
) : [],
|
|
484
|
+
EditorView.lineWrapping,
|
|
485
|
+
languageMod.syntaxHighlighting(highlightMod.cairnHighlightStyle()),
|
|
486
|
+
highlightMod.cairnDirectivePlugin(),
|
|
487
|
+
// Container folding: the fold system, the chevron and wash affordance, and the safety
|
|
488
|
+
// invariant. Placed after the directive plugin so its chevron widget on an opener row
|
|
489
|
+
// composes with the row's rail and gutter; its keymap is internal to the extension.
|
|
490
|
+
foldingMod.cairnFolding(),
|
|
491
|
+
// The optimistic image placeholder field: a widget-only decoration the insert popover
|
|
492
|
+
// drives through the registerImagePlaceholders api. It never writes doc text, so a failed
|
|
493
|
+
// upload leaves the source untouched (open risk 2). Placed after folding so a placeholder
|
|
494
|
+
// landing inside a directive composes with the rails.
|
|
495
|
+
placeholderMod.cairnImagePlaceholders(),
|
|
496
|
+
// The media: source decoration, in its own compartment so a mediaLibrary prop change
|
|
497
|
+
// reconfigures it without rebuilding the editor. The chip and the atomic ranges read the
|
|
498
|
+
// library; an empty library decorates nothing.
|
|
499
|
+
mediaCompartment.of(mediaMod.cairnMediaDecorations(mediaLibrary)),
|
|
500
|
+
// The spellcheck and objective-error lint sources plus the locked amber underline theme, in
|
|
501
|
+
// their own compartment so the footer toggle gates both surfaces at once. Empty when off.
|
|
502
|
+
spellcheckCompartment.of(spellcheck ? spellcheckExt : []),
|
|
503
|
+
// The tidy decoration field, in its own compartment so entering and leaving a review is a
|
|
504
|
+
// reconfigure beside the media and fold decorations. The api the host drives is built below.
|
|
505
|
+
tidyCompartment.of(tidyMod.cairnTidy()),
|
|
506
|
+
// The read-only posture while a review is open, empty until the host sets tidyMode.
|
|
507
|
+
tidyReadonlyCompartment.of(tidyMode ? tidyReadonlyExt : []),
|
|
508
|
+
// Paste and drop ingest: an image carried by either gesture is preventDefault'd and handed
|
|
509
|
+
// to onImageIngest (the host opens the capture card with the bytes); a gesture carrying no
|
|
510
|
+
// image falls through to CodeMirror's default. 2b is single-file per gesture (open risk 3),
|
|
511
|
+
// so only the first image routes.
|
|
512
|
+
EditorView.domEventHandlers({
|
|
513
|
+
dragover(event) {
|
|
514
|
+
if (event.dataTransfer && firstImageFile(event.dataTransfer)) {
|
|
703
515
|
guardDropTarget(event);
|
|
704
|
-
onImageIngest?.(file);
|
|
705
516
|
return true;
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
registerCaretCoords?.(caretCoords);
|
|
741
|
-
registerFocusEditor?.(focusEditor);
|
|
742
|
-
registerImagePlaceholders?.(placeholderMod.imagePlaceholderApi(view));
|
|
743
|
-
registerGetSelection?.(selectedText);
|
|
744
|
-
registerGetSelectionRange?.(selectedRange);
|
|
745
|
-
registerTidy?.(tidyMod.tidyApi(view));
|
|
746
|
-
registerUndo?.(() => {
|
|
747
|
-
if (view) commandsMod.undo(view);
|
|
748
|
-
});
|
|
749
|
-
registerFormat?.(applyFormat);
|
|
750
|
-
registerReplaceRange?.(replaceRange);
|
|
751
|
-
registerSelectRange?.(selectRange);
|
|
752
|
-
// Report the caret's starting container once the editor exists, so a caret that mounts inside
|
|
753
|
-
// a block is known without waiting for the first move.
|
|
754
|
-
if (onComponentAtCaret) reportComponentAtCaret(view.state);
|
|
755
|
-
if (onMediaImageAtCaret) reportMediaImageAtCaret(view.state);
|
|
756
|
-
mounted = true;
|
|
757
|
-
});
|
|
758
|
-
|
|
759
|
-
onDestroy(() => view?.destroy());
|
|
760
|
-
|
|
761
|
-
// Reconcile an externally reassigned `value` into the mounted editor. A no-op until `view` exists,
|
|
762
|
-
// and the doc-equality guard ignores the updateListener's own writes so the two never feed back.
|
|
763
|
-
$effect(() => {
|
|
764
|
-
const incoming = value;
|
|
765
|
-
if (!view) return;
|
|
766
|
-
const current = view.state.doc.toString();
|
|
767
|
-
if (incoming === current) return;
|
|
768
|
-
view.dispatch({ changes: { from: 0, to: current.length, insert: incoming } });
|
|
769
|
-
});
|
|
770
|
-
|
|
771
|
-
// Reconfigure the writing-mode compartments when their props change. Reading `mounted` re-runs
|
|
772
|
-
// the effect once the editor exists, so a preference arriving between render and mount still
|
|
773
|
-
// applies; the reconfigure is idempotent, so the extra pass after mount costs nothing.
|
|
774
|
-
$effect(() => {
|
|
775
|
-
const focus = focusMode;
|
|
776
|
-
const typing = typewriter;
|
|
777
|
-
const posture = surface;
|
|
778
|
-
if (!mounted || !view || !modes || !focusCompartment || !typewriterCompartment || !surfaceCompartment) return;
|
|
779
|
-
view.dispatch({
|
|
780
|
-
effects: [
|
|
781
|
-
focusCompartment.reconfigure(focus ? modes.focusMode() : []),
|
|
782
|
-
typewriterCompartment.reconfigure(typing ? modes.typewriterScroll() : []),
|
|
783
|
-
surfaceCompartment.reconfigure((posture === 'prose' ? proseTheme : markupTheme) ?? []),
|
|
784
|
-
],
|
|
785
|
-
});
|
|
786
|
-
});
|
|
787
|
-
|
|
788
|
-
// Reconfigure the media decoration when the mediaLibrary prop changes, so a just-uploaded image
|
|
789
|
-
// (added to the library by the host) decorates without rebuilding the editor. Reading the prop
|
|
790
|
-
// tracks it; the guard waits for the mounted editor and its media module.
|
|
791
|
-
$effect(() => {
|
|
792
|
-
const library = mediaLibrary;
|
|
793
|
-
if (!mounted || !view || !mediaMod || !mediaCompartment) return;
|
|
794
|
-
view.dispatch({ effects: mediaCompartment.reconfigure(mediaMod.cairnMediaDecorations(library)) });
|
|
517
|
+
}
|
|
518
|
+
return false;
|
|
519
|
+
},
|
|
520
|
+
drop(event) {
|
|
521
|
+
const file = event.dataTransfer ? firstImageFile(event.dataTransfer) : null;
|
|
522
|
+
if (!file) return false;
|
|
523
|
+
guardDropTarget(event);
|
|
524
|
+
onImageIngest?.(file);
|
|
525
|
+
return true;
|
|
526
|
+
},
|
|
527
|
+
paste(event) {
|
|
528
|
+
const file = event.clipboardData ? firstImageFile(event.clipboardData) : null;
|
|
529
|
+
if (!file) return false;
|
|
530
|
+
event.preventDefault();
|
|
531
|
+
onImageIngest?.(file);
|
|
532
|
+
return true;
|
|
533
|
+
}
|
|
534
|
+
}),
|
|
535
|
+
// No native text-correction override here (Task 7). The old `spellcheck: 'true'` is gone, so
|
|
536
|
+
// the content node falls back to CodeMirror's own defaults: spellcheck "false", autocorrect
|
|
537
|
+
// "off", autocapitalize "off". The cairn lint source replaces the browser's spellcheck
|
|
538
|
+
// (running both would double-underline), and autocorrect/autocapitalize stay off so a browser
|
|
539
|
+
// never silently rewrites a `media:` token, a directive name, or frontmatter.
|
|
540
|
+
theme,
|
|
541
|
+
surfaceCompartment.of(surface === "prose" ? proseTheme : markupTheme),
|
|
542
|
+
EditorView.updateListener.of((update) => {
|
|
543
|
+
if (update.docChanged) value = update.state.doc.toString();
|
|
544
|
+
if (onComponentAtCaret && (update.docChanged || update.selectionSet))
|
|
545
|
+
reportComponentAtCaret(update.state);
|
|
546
|
+
if (onMediaImageAtCaret && (update.docChanged || update.selectionSet))
|
|
547
|
+
reportMediaImageAtCaret(update.state);
|
|
548
|
+
})
|
|
549
|
+
]
|
|
550
|
+
})
|
|
795
551
|
});
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
552
|
+
registerInsert?.(insertAtCursor);
|
|
553
|
+
registerInsertLink?.(insertLink);
|
|
554
|
+
registerInsertImage?.(insertImage);
|
|
555
|
+
registerCaretCoords?.(caretCoords);
|
|
556
|
+
registerFocusEditor?.(focusEditor);
|
|
557
|
+
registerImagePlaceholders?.(placeholderMod.imagePlaceholderApi(view));
|
|
558
|
+
registerGetSelection?.(selectedText);
|
|
559
|
+
registerGetSelectionRange?.(selectedRange);
|
|
560
|
+
registerTidy?.(tidyMod.tidyApi(view));
|
|
561
|
+
registerUndo?.(() => {
|
|
562
|
+
if (view) commandsMod.undo(view);
|
|
805
563
|
});
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
564
|
+
registerFormat?.(applyFormat);
|
|
565
|
+
registerReplaceRange?.(replaceRange);
|
|
566
|
+
registerSelectRange?.(selectRange);
|
|
567
|
+
if (onComponentAtCaret) reportComponentAtCaret(view.state);
|
|
568
|
+
if (onMediaImageAtCaret) reportMediaImageAtCaret(view.state);
|
|
569
|
+
mounted = true;
|
|
570
|
+
});
|
|
571
|
+
onDestroy(() => view?.destroy());
|
|
572
|
+
$effect(() => {
|
|
573
|
+
const incoming = value;
|
|
574
|
+
if (!view) return;
|
|
575
|
+
const current = view.state.doc.toString();
|
|
576
|
+
if (incoming === current) return;
|
|
577
|
+
view.dispatch({ changes: { from: 0, to: current.length, insert: incoming } });
|
|
578
|
+
});
|
|
579
|
+
$effect(() => {
|
|
580
|
+
const focus = focusMode;
|
|
581
|
+
const typing = typewriter;
|
|
582
|
+
const posture = surface;
|
|
583
|
+
if (!mounted || !view || !modes || !focusCompartment || !typewriterCompartment || !surfaceCompartment) return;
|
|
584
|
+
view.dispatch({
|
|
585
|
+
effects: [
|
|
586
|
+
focusCompartment.reconfigure(focus ? modes.focusMode() : []),
|
|
587
|
+
typewriterCompartment.reconfigure(typing ? modes.typewriterScroll() : []),
|
|
588
|
+
surfaceCompartment.reconfigure((posture === "prose" ? proseTheme : markupTheme) ?? [])
|
|
589
|
+
]
|
|
814
590
|
});
|
|
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
|
-
prev.name === next.name &&
|
|
849
|
-
prev.markdown === next.markdown &&
|
|
850
|
-
prev.from === next.from &&
|
|
851
|
-
prev.to === next.to);
|
|
852
|
-
if (same) return;
|
|
853
|
-
lastCaretReport = next;
|
|
854
|
-
onComponentAtCaret?.(next);
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
// The last media-image report, so the reporter fires only on a change. The compared identity is
|
|
858
|
-
// the image span plus the figure's range/caption/role; a pure caret move within one image (or one
|
|
859
|
-
// figure) leaves them unchanged, so it does not refire, while an edit that shifts the figure span
|
|
860
|
-
// or rewrites the caption does. A null transitions to or from null on entering/leaving an image.
|
|
861
|
-
let lastMediaReport: FigureAtImage | null = null;
|
|
862
|
-
|
|
863
|
-
// Compute the media image at the caret from a CodeMirror state and report it through
|
|
864
|
-
// onMediaImageAtCaret, deduped so a caret move that stays on the same image does not refire.
|
|
865
|
-
function reportMediaImageAtCaret(state: import('@codemirror/state').EditorState) {
|
|
866
|
-
const next = figureAtImage(state.doc.toString(), state.selection.main.head);
|
|
867
|
-
const prev = lastMediaReport;
|
|
868
|
-
const same =
|
|
869
|
-
prev === next ||
|
|
870
|
-
(prev !== null &&
|
|
871
|
-
next !== null &&
|
|
872
|
-
prev.imageFrom === next.imageFrom &&
|
|
873
|
-
prev.imageTo === next.imageTo &&
|
|
874
|
-
(prev.figure?.from ?? null) === (next.figure?.from ?? null) &&
|
|
875
|
-
(prev.figure?.to ?? null) === (next.figure?.to ?? null) &&
|
|
876
|
-
(prev.figure?.caption ?? null) === (next.figure?.caption ?? null) &&
|
|
877
|
-
(prev.figure?.role ?? null) === (next.figure?.role ?? null));
|
|
878
|
-
if (same) return;
|
|
879
|
-
lastMediaReport = next;
|
|
880
|
-
onMediaImageAtCaret?.(next);
|
|
881
|
-
}
|
|
882
|
-
|
|
883
|
-
// Overwrite a document span with new text and drop the caret after it, mirroring insertAtCursor's
|
|
884
|
-
// dispatch shape. A no-op before the editor mounts, the same guard the other seams carry.
|
|
885
|
-
function replaceRange(from: number, to: number, text: string) {
|
|
886
|
-
if (!view) return;
|
|
887
|
-
view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + text.length } });
|
|
888
|
-
view.focus();
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
// Select a document span, focus the surface, and scroll the range into view. The needs-alt notice's
|
|
892
|
-
// jump control calls it to land the author on an image that lacks alt text. A no-op before the
|
|
893
|
-
// editor mounts, the same guard the other seams carry.
|
|
894
|
-
function selectRange(from: number, to: number) {
|
|
895
|
-
if (!view) return;
|
|
896
|
-
view.dispatch({ selection: { anchor: from, head: to }, scrollIntoView: true });
|
|
897
|
-
view.focus();
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
function insertAtCursor(text: string) {
|
|
901
|
-
if (!view) {
|
|
902
|
-
value = value ? `${value}\n\n${text}` : text;
|
|
903
|
-
return;
|
|
904
|
-
}
|
|
905
|
-
const pos = view.state.selection.main.head;
|
|
906
|
-
const prefix = pos > 0 ? '\n\n' : '';
|
|
907
|
-
const insert = `${prefix}${text}`;
|
|
908
|
-
view.dispatch({ changes: { from: pos, insert }, selection: { anchor: pos + insert.length } });
|
|
909
|
-
view.focus();
|
|
910
|
-
}
|
|
911
|
-
|
|
912
|
-
// Run a pure selection transform over the mounted editor: hand it the document and selection,
|
|
913
|
-
// dispatch the document and selection it returns, and put focus back on the surface.
|
|
914
|
-
function transformSelection(transform: (doc: string, from: number, to: number) => FormatResult) {
|
|
915
|
-
if (!view) return;
|
|
916
|
-
const { from, to } = view.state.selection.main;
|
|
917
|
-
const doc = view.state.doc.toString();
|
|
918
|
-
const next = transform(doc, from, to);
|
|
919
|
-
view.dispatch({
|
|
920
|
-
changes: { from: 0, to: doc.length, insert: next.doc },
|
|
921
|
-
selection: { anchor: next.from, head: next.to },
|
|
922
|
-
});
|
|
923
|
-
view.focus();
|
|
924
|
-
}
|
|
925
|
-
|
|
926
|
-
function insertLink(href: string, title: string) {
|
|
927
|
-
if (!view) {
|
|
928
|
-
// The editor has not mounted yet; append the link to the raw value so a pick is never lost,
|
|
929
|
-
// mirroring insertAtCursor's pre-mount fallback.
|
|
930
|
-
const link = insertInlineLink('', 0, 0, href, title).doc;
|
|
931
|
-
value = value ? `${value} ${link}` : link;
|
|
932
|
-
return;
|
|
933
|
-
}
|
|
934
|
-
transformSelection((doc, from, to) => insertInlineLink(doc, from, to, href, title));
|
|
935
|
-
}
|
|
936
|
-
|
|
937
|
-
function insertImage(alt: string, ref: string) {
|
|
938
|
-
if (!view) {
|
|
939
|
-
// The editor has not mounted yet; append the image to the raw value so a pick is never lost,
|
|
940
|
-
// mirroring insertLink's pre-mount fallback.
|
|
941
|
-
const image = insertImageFormat('', 0, 0, alt, ref).doc;
|
|
942
|
-
value = value ? `${value} ${image}` : image;
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
transformSelection((doc, from, to) => insertImageFormat(doc, from, to, alt, ref));
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
function selectedText(): string {
|
|
949
|
-
if (!view) return '';
|
|
950
|
-
const { from, to } = view.state.selection.main;
|
|
951
|
-
return view.state.sliceDoc(from, to);
|
|
952
|
-
}
|
|
953
|
-
|
|
954
|
-
// The selection's document offsets, for the tidy host to scope a selection tidy to the exact span.
|
|
955
|
-
// Null when the selection is empty (a bare caret), which the host reads as document scope.
|
|
956
|
-
function selectedRange(): { from: number; to: number } | null {
|
|
957
|
-
if (!view) return null;
|
|
958
|
-
const { from, to } = view.state.selection.main;
|
|
959
|
-
return from === to ? null : { from, to };
|
|
591
|
+
});
|
|
592
|
+
$effect(() => {
|
|
593
|
+
const library = mediaLibrary;
|
|
594
|
+
if (!mounted || !view || !mediaMod || !mediaCompartment) return;
|
|
595
|
+
view.dispatch({ effects: mediaCompartment.reconfigure(mediaMod.cairnMediaDecorations(library)) });
|
|
596
|
+
});
|
|
597
|
+
$effect(() => {
|
|
598
|
+
const on = spellcheck;
|
|
599
|
+
if (!mounted || !view || !spellcheckCompartment || !spellcheckExt) return;
|
|
600
|
+
view.dispatch({ effects: spellcheckCompartment.reconfigure(on ? spellcheckExt : []) });
|
|
601
|
+
});
|
|
602
|
+
$effect(() => {
|
|
603
|
+
const on = tidyMode;
|
|
604
|
+
if (!mounted || !view || !tidyReadonlyCompartment || !tidyReadonlyExt) return;
|
|
605
|
+
view.dispatch({ effects: tidyReadonlyCompartment.reconfigure(on ? tidyReadonlyExt : []) });
|
|
606
|
+
});
|
|
607
|
+
let lastCaretReport = null;
|
|
608
|
+
function reportComponentAtCaret(state) {
|
|
609
|
+
const doc = state.doc;
|
|
610
|
+
const lines = [];
|
|
611
|
+
for (let n = 1; n <= doc.lines; n++) lines.push(doc.line(n).text);
|
|
612
|
+
const caretLine = doc.lineAt(state.selection.main.head).number - 1;
|
|
613
|
+
const range = caretContainerRange(fenceScan(lines), caretLine);
|
|
614
|
+
let next = null;
|
|
615
|
+
if (range) {
|
|
616
|
+
const fromPos = doc.line(range.fromLine + 1).from;
|
|
617
|
+
const toPos = doc.line(range.toLine + 1).to;
|
|
618
|
+
next = {
|
|
619
|
+
name: directiveOpenerName(lines[range.fromLine] ?? ""),
|
|
620
|
+
markdown: doc.sliceString(fromPos, toPos),
|
|
621
|
+
from: fromPos,
|
|
622
|
+
to: toPos
|
|
623
|
+
};
|
|
960
624
|
}
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
625
|
+
const prev = lastCaretReport;
|
|
626
|
+
const same = prev === next || prev !== null && next !== null && prev.name === next.name && prev.markdown === next.markdown && prev.from === next.from && prev.to === next.to;
|
|
627
|
+
if (same) return;
|
|
628
|
+
lastCaretReport = next;
|
|
629
|
+
onComponentAtCaret?.(next);
|
|
630
|
+
}
|
|
631
|
+
let lastMediaReport = null;
|
|
632
|
+
function reportMediaImageAtCaret(state) {
|
|
633
|
+
const next = figureAtImage(state.doc.toString(), state.selection.main.head);
|
|
634
|
+
const prev = lastMediaReport;
|
|
635
|
+
const same = prev === next || prev !== null && next !== null && prev.imageFrom === next.imageFrom && prev.imageTo === next.imageTo && (prev.figure?.from ?? null) === (next.figure?.from ?? null) && (prev.figure?.to ?? null) === (next.figure?.to ?? null) && (prev.figure?.caption ?? null) === (next.figure?.caption ?? null) && (prev.figure?.role ?? null) === (next.figure?.role ?? null);
|
|
636
|
+
if (same) return;
|
|
637
|
+
lastMediaReport = next;
|
|
638
|
+
onMediaImageAtCaret?.(next);
|
|
639
|
+
}
|
|
640
|
+
function replaceRange(from, to, text) {
|
|
641
|
+
if (!view) return;
|
|
642
|
+
view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: from + text.length } });
|
|
643
|
+
view.focus();
|
|
644
|
+
}
|
|
645
|
+
function selectRange(from, to) {
|
|
646
|
+
if (!view) return;
|
|
647
|
+
view.dispatch({ selection: { anchor: from, head: to }, scrollIntoView: true });
|
|
648
|
+
view.focus();
|
|
649
|
+
}
|
|
650
|
+
function insertAtCursor(text) {
|
|
651
|
+
if (!view) {
|
|
652
|
+
value = value ? `${value}
|
|
653
|
+
|
|
654
|
+
${text}` : text;
|
|
655
|
+
return;
|
|
968
656
|
}
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
657
|
+
const pos = view.state.selection.main.head;
|
|
658
|
+
const prefix = pos > 0 ? "\n\n" : "";
|
|
659
|
+
const insert = `${prefix}${text}`;
|
|
660
|
+
view.dispatch({ changes: { from: pos, insert }, selection: { anchor: pos + insert.length } });
|
|
661
|
+
view.focus();
|
|
662
|
+
}
|
|
663
|
+
function transformSelection(transform) {
|
|
664
|
+
if (!view) return;
|
|
665
|
+
const { from, to } = view.state.selection.main;
|
|
666
|
+
const doc = view.state.doc.toString();
|
|
667
|
+
const next = transform(doc, from, to);
|
|
668
|
+
view.dispatch({
|
|
669
|
+
changes: { from: 0, to: doc.length, insert: next.doc },
|
|
670
|
+
selection: { anchor: next.from, head: next.to }
|
|
671
|
+
});
|
|
672
|
+
view.focus();
|
|
673
|
+
}
|
|
674
|
+
function insertLink(href, title) {
|
|
675
|
+
if (!view) {
|
|
676
|
+
const link = insertInlineLink("", 0, 0, href, title).doc;
|
|
677
|
+
value = value ? `${value} ${link}` : link;
|
|
678
|
+
return;
|
|
974
679
|
}
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
680
|
+
transformSelection((doc, from, to) => insertInlineLink(doc, from, to, href, title));
|
|
681
|
+
}
|
|
682
|
+
function insertImage(alt, ref) {
|
|
683
|
+
if (!view) {
|
|
684
|
+
const image = insertImageFormat("", 0, 0, alt, ref).doc;
|
|
685
|
+
value = value ? `${value} ${image}` : image;
|
|
686
|
+
return;
|
|
978
687
|
}
|
|
688
|
+
transformSelection((doc, from, to) => insertImageFormat(doc, from, to, alt, ref));
|
|
689
|
+
}
|
|
690
|
+
function selectedText() {
|
|
691
|
+
if (!view) return "";
|
|
692
|
+
const { from, to } = view.state.selection.main;
|
|
693
|
+
return view.state.sliceDoc(from, to);
|
|
694
|
+
}
|
|
695
|
+
function selectedRange() {
|
|
696
|
+
if (!view) return null;
|
|
697
|
+
const { from, to } = view.state.selection.main;
|
|
698
|
+
return from === to ? null : { from, to };
|
|
699
|
+
}
|
|
700
|
+
function caretCoords() {
|
|
701
|
+
if (!view) return null;
|
|
702
|
+
const rect = view.coordsAtPos(view.state.selection.main.head);
|
|
703
|
+
return rect ? { left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom } : null;
|
|
704
|
+
}
|
|
705
|
+
function focusEditor() {
|
|
706
|
+
view?.focus();
|
|
707
|
+
}
|
|
708
|
+
function applyFormat(kind) {
|
|
709
|
+
transformSelection((doc, from, to) => applyMarkdownFormat(doc, from, to, kind));
|
|
710
|
+
}
|
|
979
711
|
</script>
|
|
980
712
|
|
|
981
713
|
<input type="hidden" {name} {value} />
|