@glw907/cairn-cms 0.51.0 → 0.52.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 +29 -0
- package/dist/components/EditPage.svelte +38 -2
- package/dist/components/EditorToolbar.svelte +60 -6
- package/dist/components/EditorToolbar.svelte.d.ts +10 -1
- package/dist/components/MarkdownEditor.svelte +114 -24
- package/dist/components/MarkdownEditor.svelte.d.ts +4 -0
- package/dist/components/cairn-admin.css +27 -11
- package/dist/components/editor-highlight.d.ts +2 -1
- package/dist/components/editor-highlight.js +60 -19
- package/dist/components/editor-modes.d.ts +26 -0
- package/dist/components/editor-modes.js +92 -0
- package/dist/components/fonts/iAWriterMono-OFL.txt +100 -0
- package/dist/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
- package/dist/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
- package/dist/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
- package/dist/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
- package/dist/components/markdown-directives.d.ts +48 -7
- package/dist/components/markdown-directives.js +89 -13
- package/package.json +1 -1
- package/src/lib/components/EditPage.svelte +38 -2
- package/src/lib/components/EditorToolbar.svelte +60 -6
- package/src/lib/components/MarkdownEditor.svelte +114 -24
- package/src/lib/components/cairn-admin.css +62 -31
- package/src/lib/components/editor-highlight.ts +72 -20
- package/src/lib/components/editor-modes.ts +106 -0
- package/src/lib/components/fonts/iAWriterMono-OFL.txt +100 -0
- package/src/lib/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
- package/src/lib/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
- package/src/lib/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
- package/src/lib/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
- package/src/lib/components/markdown-directives.ts +113 -13
|
@@ -5,7 +5,8 @@ More overflow menu, then the host's Insert controls) and the Write/Preview segme
|
|
|
5
5
|
right. Format buttons ask the host to transform the editor's current selection; the host supplies the
|
|
6
6
|
Insert group through the `insertControls` snippet so the strip stays free of picker wiring. While
|
|
7
7
|
Preview shows, a device trigger joins the segmented capsule and opens a popover menu of preview
|
|
8
|
-
widths, reported to the host through `onDevice`. The
|
|
8
|
+
widths, reported to the host through `onDevice`. The More menu also carries the host's persisted
|
|
9
|
+
writing-mode toggles (focus mode, typewriter scrolling) as pressed-state buttons above the format picks. The glyphs are stroke SVG icons in the admin's
|
|
9
10
|
house style (24x24 viewBox, `currentColor`, round caps).
|
|
10
11
|
-->
|
|
11
12
|
<script lang="ts">
|
|
@@ -25,11 +26,30 @@ house style (24x24 viewBox, `currentColor`, round caps).
|
|
|
25
26
|
/** Pick a preview-frame width. When set, a device trigger joins the Write/Preview capsule
|
|
26
27
|
* while Preview shows. */
|
|
27
28
|
onDevice?: (id: PreviewDeviceId) => void;
|
|
29
|
+
/** Whether focus mode is on; the More menu's toggle reflects it. */
|
|
30
|
+
focusMode?: boolean;
|
|
31
|
+
/** Flip focus mode. When set, the toggle joins the More menu. */
|
|
32
|
+
onFocusMode?: (on: boolean) => void;
|
|
33
|
+
/** Whether typewriter scrolling is on; the More menu's toggle reflects it. */
|
|
34
|
+
typewriter?: boolean;
|
|
35
|
+
/** Flip typewriter scrolling. When set, the toggle joins the More menu. */
|
|
36
|
+
onTypewriter?: (on: boolean) => void;
|
|
28
37
|
/** The host's Insert controls (link picker, component insert, image), rendered in the Insert group. */
|
|
29
38
|
insertControls?: Snippet;
|
|
30
39
|
}
|
|
31
40
|
|
|
32
|
-
let {
|
|
41
|
+
let {
|
|
42
|
+
format,
|
|
43
|
+
mode,
|
|
44
|
+
onMode,
|
|
45
|
+
device = 'desktop',
|
|
46
|
+
onDevice,
|
|
47
|
+
focusMode = false,
|
|
48
|
+
onFocusMode,
|
|
49
|
+
typewriter = false,
|
|
50
|
+
onTypewriter,
|
|
51
|
+
insertControls,
|
|
52
|
+
}: Props = $props();
|
|
33
53
|
|
|
34
54
|
// Each icon is a set of stroke `<path>` d-strings rendered into the shared 24x24 svg below, so the
|
|
35
55
|
// markup stays declarative (no per-icon raw html). Paths follow the house outline style.
|
|
@@ -76,6 +96,8 @@ house style (24x24 viewBox, `currentColor`, round caps).
|
|
|
76
96
|
];
|
|
77
97
|
|
|
78
98
|
const ellipsisPaths = ['M5 12h.01', 'M12 12h.01', 'M19 12h.01'];
|
|
99
|
+
// The check glyph marking an active pick, shared by the More menu's toggles and the device list.
|
|
100
|
+
const checkPaths = ['M20 6 9 17l-5-5'];
|
|
79
101
|
|
|
80
102
|
const moreItems: { kind: FormatKind; label: string }[] = [
|
|
81
103
|
{ kind: 'strike', label: 'Strikethrough' },
|
|
@@ -91,10 +113,14 @@ house style (24x24 viewBox, `currentColor`, round caps).
|
|
|
91
113
|
let moreMenu = $state<HTMLUListElement | null>(null);
|
|
92
114
|
let moreOpen = $state(false);
|
|
93
115
|
|
|
116
|
+
// Picking dismisses the menu; hiding returns focus to the trigger, keeping the roving order.
|
|
117
|
+
function hideMenu(menu: HTMLUListElement | null) {
|
|
118
|
+
if (menu?.matches(':popover-open')) menu.hidePopover();
|
|
119
|
+
}
|
|
120
|
+
|
|
94
121
|
function pickMore(kind: FormatKind) {
|
|
95
122
|
format(kind);
|
|
96
|
-
|
|
97
|
-
if (moreMenu?.matches(':popover-open')) moreMenu.hidePopover();
|
|
123
|
+
hideMenu(moreMenu);
|
|
98
124
|
}
|
|
99
125
|
|
|
100
126
|
// The device menu's popover element and its open state, mirrored from the toggle event into
|
|
@@ -107,7 +133,7 @@ house style (24x24 viewBox, `currentColor`, round caps).
|
|
|
107
133
|
|
|
108
134
|
function pickDevice(id: PreviewDeviceId) {
|
|
109
135
|
onDevice?.(id);
|
|
110
|
-
|
|
136
|
+
hideMenu(deviceMenu);
|
|
111
137
|
}
|
|
112
138
|
|
|
113
139
|
let toolbarEl = $state<HTMLDivElement | null>(null);
|
|
@@ -239,6 +265,34 @@ house style (24x24 viewBox, `currentColor`, round caps).
|
|
|
239
265
|
ontoggle={(e) => (moreOpen = e.newState === 'open')}
|
|
240
266
|
class="dropdown menu menu-sm bg-base-100 rounded-box w-44 border border-[var(--cairn-card-border)] p-1 shadow-[var(--cairn-shadow)]"
|
|
241
267
|
>
|
|
268
|
+
<!-- The writing modes sit above the format items behind a hairline, persisted by the host.
|
|
269
|
+
The device list's idiom: plain buttons with aria-pressed carrying the on/off state (this
|
|
270
|
+
popover list is not an ARIA menu, so a menuitemcheckbox would sit in an invalid context);
|
|
271
|
+
the check glyph mirrors the state visually. A flip leaves the menu open so the new
|
|
272
|
+
pressed state is perceivable in place; only a format pick dismisses it. -->
|
|
273
|
+
{#if onFocusMode}
|
|
274
|
+
<li>
|
|
275
|
+
<button type="button" aria-pressed={focusMode} onclick={() => onFocusMode(!focusMode)}>
|
|
276
|
+
<span class="grow">Focus mode</span>
|
|
277
|
+
{#if focusMode}
|
|
278
|
+
{@render strokeIcon(checkPaths)}
|
|
279
|
+
{/if}
|
|
280
|
+
</button>
|
|
281
|
+
</li>
|
|
282
|
+
{/if}
|
|
283
|
+
{#if onTypewriter}
|
|
284
|
+
<li>
|
|
285
|
+
<button type="button" aria-pressed={typewriter} onclick={() => onTypewriter(!typewriter)}>
|
|
286
|
+
<span class="grow">Typewriter scrolling</span>
|
|
287
|
+
{#if typewriter}
|
|
288
|
+
{@render strokeIcon(checkPaths)}
|
|
289
|
+
{/if}
|
|
290
|
+
</button>
|
|
291
|
+
</li>
|
|
292
|
+
{/if}
|
|
293
|
+
{#if onFocusMode || onTypewriter}
|
|
294
|
+
<li class="my-1 border-t border-[var(--cairn-card-border)]" role="separator"></li>
|
|
295
|
+
{/if}
|
|
242
296
|
{#each moreItems as item (item.kind)}
|
|
243
297
|
<li><button type="button" onclick={() => pickMore(item.kind)}>{item.label}</button></li>
|
|
244
298
|
{/each}
|
|
@@ -299,7 +353,7 @@ house style (24x24 viewBox, `currentColor`, round caps).
|
|
|
299
353
|
<button type="button" aria-pressed={device === d.id} onclick={() => pickDevice(d.id)}>
|
|
300
354
|
<span class="grow">{deviceLabel(d)}</span>
|
|
301
355
|
{#if device === d.id}
|
|
302
|
-
{@render strokeIcon(
|
|
356
|
+
{@render strokeIcon(checkPaths)}
|
|
303
357
|
{/if}
|
|
304
358
|
</button>
|
|
305
359
|
</li>
|
|
@@ -27,6 +27,10 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
27
27
|
/** Generic CodeMirror completion sources wired into the editor; the link autocomplete is one. The
|
|
28
28
|
* type is referenced inline so no static `@codemirror/*` import sits in this client-only file. */
|
|
29
29
|
completionSources?: import('@codemirror/autocomplete').CompletionSource[];
|
|
30
|
+
/** Focus mode: dim every line outside the caret's paragraph. Off by default. */
|
|
31
|
+
focusMode?: boolean;
|
|
32
|
+
/** Typewriter scroll: hold the cursor line at vertical center while typing. Off by default. */
|
|
33
|
+
typewriter?: boolean;
|
|
30
34
|
}
|
|
31
35
|
|
|
32
36
|
let {
|
|
@@ -37,6 +41,8 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
37
41
|
registerGetSelection,
|
|
38
42
|
registerFormat,
|
|
39
43
|
completionSources = [],
|
|
44
|
+
focusMode = false,
|
|
45
|
+
typewriter = false,
|
|
40
46
|
}: Props = $props();
|
|
41
47
|
|
|
42
48
|
let host = $state<HTMLDivElement | null>(null);
|
|
@@ -45,6 +51,12 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
45
51
|
// browser. The type-only `import(...)` annotation is erased; the value import is dynamic in onMount,
|
|
46
52
|
// so the server bundle never pulls CodeMirror (guarded by the editor-boundary test).
|
|
47
53
|
let view: import('@codemirror/view').EditorView | null = null;
|
|
54
|
+
// The writing-mode extensions live in their own compartments so the toolbar toggles swap them
|
|
55
|
+
// in and out of the mounted editor without rebuilding it. Assigned in onMount with the rest of
|
|
56
|
+
// the dynamic editor modules.
|
|
57
|
+
let modes: typeof import('./editor-modes.js') | null = null;
|
|
58
|
+
let focusCompartment: import('@codemirror/state').Compartment | null = null;
|
|
59
|
+
let typewriterCompartment: import('@codemirror/state').Compartment | null = null;
|
|
48
60
|
|
|
49
61
|
onMount(async () => {
|
|
50
62
|
const viewMod = await import('@codemirror/view');
|
|
@@ -54,6 +66,7 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
54
66
|
const languageMod = await import('@codemirror/language');
|
|
55
67
|
const autocompleteMod = await import('@codemirror/autocomplete');
|
|
56
68
|
const highlightMod = await import('./editor-highlight.js');
|
|
69
|
+
const modesMod = await import('./editor-modes.js');
|
|
57
70
|
|
|
58
71
|
if (!host) return;
|
|
59
72
|
|
|
@@ -61,29 +74,66 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
61
74
|
// Mirror the admin theme into CodeMirror's own dark flag, so its base chrome (the autocomplete
|
|
62
75
|
// tooltip above all) renders dark-on-dark instead of light-on-dark.
|
|
63
76
|
const isDark = host.closest('[data-theme]')?.getAttribute('data-theme')?.includes('dark') ?? false;
|
|
64
|
-
// The directive machinery treatment
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
77
|
+
// The directive machinery treatment: rails, not bands. A row at depth N draws every rail
|
|
78
|
+
// 1..N as literal nested brackets: 2px accent bars at x offsets 0-2, 6-8, and 12-14 with 4px
|
|
79
|
+
// of surface between them (a gap of twice the bar weight, the floor for two parallel rules
|
|
80
|
+
// to read as separate lines rather than one thick one), stacked as inset box shadows (top
|
|
81
|
+
// layer first, so each bar sits over the spacer and deeper bar beneath it). The alphas step through the per-theme vars in
|
|
82
|
+
// cairn-admin.css; the fallbacks are the light values, so the editor still renders sensibly
|
|
83
|
+
// outside an admin theme wrapper. On a fence line the colon runs, brackets, and {attrs}
|
|
84
|
+
// braces dim to the marker tone while the name and label keep a depth-stepped ink. Leaf and
|
|
85
|
+
// inline directives keep a fixed 8% accent chip; the accent ink holds AA on it (4.75:1
|
|
86
|
+
// light, 5.20:1 dark).
|
|
87
|
+
const railFallbacks = ['72%', '82%', '92%'];
|
|
88
|
+
const railColor = (step: number | 'active', fallback: string) =>
|
|
89
|
+
`color-mix(in oklab, var(--color-accent) var(--cairn-directive-rail-${step}, ${fallback}), transparent)`;
|
|
90
|
+
// With `active`, the row's own (deepest) bar takes the full-strength -active mix and widens
|
|
91
|
+
// 1px, so the caret's container reads at a glance; a bar-width change shifts no text.
|
|
92
|
+
const rails = (depth: number, active = false): string => {
|
|
93
|
+
const layers: string[] = [];
|
|
94
|
+
for (let d = 1; d <= depth; d++) {
|
|
95
|
+
const edge = 6 * d - 4;
|
|
96
|
+
if (d > 1) layers.push(`inset ${edge - 2}px 0 0 0 var(--color-base-100, oklch(99% 0.004 75))`);
|
|
97
|
+
const own = active && d === depth;
|
|
98
|
+
layers.push(
|
|
99
|
+
own
|
|
100
|
+
? `inset ${edge + 1}px 0 0 0 ${railColor('active', '100%')}`
|
|
101
|
+
: `inset ${edge}px 0 0 0 ${railColor(d, railFallbacks[d - 1] ?? '92%')}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return layers.join(', ');
|
|
105
|
+
};
|
|
72
106
|
const directiveInk = {
|
|
73
|
-
backgroundColor:
|
|
107
|
+
backgroundColor: 'color-mix(in oklab, var(--color-accent) 8%, transparent)',
|
|
74
108
|
color: 'var(--color-accent)',
|
|
75
109
|
};
|
|
110
|
+
// The rail rules, one quiet and one caret-active pair per visual depth step (deeper nesting
|
|
111
|
+
// shares the third step). Fence and content rows at a depth share a rule, so a fence and its
|
|
112
|
+
// body rail identically. The caret-active selector adds the caret-block class, so it outranks
|
|
113
|
+
// its quiet twin on any contested row and the caret's container reads one step stronger.
|
|
114
|
+
const railRules: Record<string, { boxShadow: string }> = {};
|
|
115
|
+
for (const depth of [1, 2, 3]) {
|
|
116
|
+
const row = (prefix: string) =>
|
|
117
|
+
`${prefix}.cm-cairn-directive-fence.cm-cairn-depth-${depth}, ${prefix}.cm-cairn-directive-content.cm-cairn-depth-${depth}`;
|
|
118
|
+
railRules[row('')] = { boxShadow: rails(depth) };
|
|
119
|
+
railRules[row('.cm-cairn-caret-block')] = { boxShadow: rails(depth, true) };
|
|
120
|
+
}
|
|
76
121
|
const theme = EditorView.theme(
|
|
77
122
|
{
|
|
78
|
-
'&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '
|
|
123
|
+
'&': { backgroundColor: 'var(--color-base-100)', color: 'var(--color-base-content)', fontSize: '1rem' },
|
|
79
124
|
// The 50vh floor keeps a short entry reading as a writing surface, and because the
|
|
80
125
|
// contenteditable content area carries the height, a click in the empty space below the
|
|
81
|
-
// text still lands in the editor and focuses it.
|
|
126
|
+
// text still lands in the editor and focuses it. The 70ch cap with auto margins holds
|
|
127
|
+
// the manuscript to a readable measure, centered in whatever width the card gives it.
|
|
82
128
|
'.cm-content': {
|
|
83
|
-
|
|
129
|
+
// The theme roots set --font-editor to the self-hosted iA Writer Mono; the inline
|
|
130
|
+
// fallback keeps the surface monospace outside an admin theme wrapper.
|
|
131
|
+
fontFamily: "var(--font-editor, ui-monospace, monospace)",
|
|
84
132
|
padding: '0.875rem 1.25rem',
|
|
85
133
|
lineHeight: '1.8',
|
|
86
134
|
minHeight: '50vh',
|
|
135
|
+
maxWidth: '70ch',
|
|
136
|
+
margin: '0 auto',
|
|
87
137
|
},
|
|
88
138
|
'.cm-cursor': { borderLeftColor: 'var(--color-primary)' },
|
|
89
139
|
// A quiet always-on focus hairline. :focus-visible is no escape here: browsers treat a
|
|
@@ -96,32 +146,57 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
96
146
|
outlineOffset: '-1px',
|
|
97
147
|
},
|
|
98
148
|
'.cm-line': { padding: '0' },
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
'.cm-cairn-directive-
|
|
105
|
-
|
|
106
|
-
|
|
149
|
+
// The gutter: directive rows pad left so the text clears the deepest rail stack (the
|
|
150
|
+
// depth-3 bar ends at 14px, the active one at 15px; 1.5rem keeps ~10px of air beyond
|
|
151
|
+
// it). Static structure (caret-independent), so caret movement shifts no layout.
|
|
152
|
+
'.cm-cairn-directive-fence, .cm-cairn-directive-content': { paddingLeft: '1.5rem' },
|
|
153
|
+
...railRules,
|
|
154
|
+
'.cm-cairn-directive-mark': { color: 'var(--color-muted)' },
|
|
155
|
+
'.cm-cairn-directive-label': { color: 'var(--color-accent)' },
|
|
156
|
+
'.cm-cairn-directive-label.cm-cairn-depth-2': { color: 'var(--cairn-directive-ink-2, oklch(50% 0.16 300))' },
|
|
157
|
+
'.cm-cairn-directive-label.cm-cairn-depth-3': { color: 'var(--cairn-directive-ink-3, oklch(48% 0.16 300))' },
|
|
158
|
+
// Cursor-aware emphasis for the label ink: the caret's container takes the strongest
|
|
159
|
+
// ink, through the -active variable in cairn-admin.css. This selector TIES the depth
|
|
160
|
+
// rules above at two classes, so its place after them breaks the tie in its favor.
|
|
161
|
+
'.cm-cairn-caret-block .cm-cairn-directive-label': {
|
|
162
|
+
color: 'var(--cairn-directive-ink-active, oklch(46% 0.16 300))',
|
|
107
163
|
},
|
|
108
|
-
'.cm-cairn-directive-content.cm-cairn-depth-1': { boxShadow: rail(1, '75%') },
|
|
109
|
-
'.cm-cairn-directive-content.cm-cairn-depth-2': { boxShadow: rail(2, '82%') },
|
|
110
|
-
'.cm-cairn-directive-content.cm-cairn-depth-3': { boxShadow: rail(3, '90%') },
|
|
111
164
|
'.cm-cairn-directive-leaf': directiveInk,
|
|
112
165
|
'.cm-cairn-directive-inline': directiveInk,
|
|
166
|
+
// Focus mode's dim ink, on the lines editor-modes marks outside the caret's paragraph.
|
|
167
|
+
// Last on purpose: a dimmed line's spans (markers, tokens, directive labels) all drop to
|
|
168
|
+
// the dim tone, and spec order breaks the specificity ties with the label rules above.
|
|
169
|
+
// The fallback is the light theme's value, like the rail fallbacks. Backgrounds flatten
|
|
170
|
+
// along with the ink: the dim tone on the code chip or an 8% accent chip measures under
|
|
171
|
+
// the design's 3:1 floor, so a dimmed line keeps no tinted chip behind its text. The
|
|
172
|
+
// span arm outranks the chip rules on specificity (the highlight style's generated
|
|
173
|
+
// class, the inline-directive mark); the line arm covers the leaf chip, where spec
|
|
174
|
+
// order breaks the tie.
|
|
175
|
+
'.cm-cairn-focus-dim, .cm-cairn-focus-dim span, .cm-cairn-focus-dim .cm-cairn-directive-label': {
|
|
176
|
+
color: 'var(--cairn-focus-dim-ink, oklch(66% 0.01 75))',
|
|
177
|
+
backgroundColor: 'transparent',
|
|
178
|
+
},
|
|
113
179
|
},
|
|
114
180
|
{ dark: isDark },
|
|
115
181
|
);
|
|
116
182
|
|
|
183
|
+
modes = modesMod;
|
|
184
|
+
focusCompartment = new stateMod.Compartment();
|
|
185
|
+
typewriterCompartment = new stateMod.Compartment();
|
|
186
|
+
|
|
117
187
|
view = new EditorView({
|
|
118
188
|
parent: host,
|
|
119
189
|
state: stateMod.EditorState.create({
|
|
120
190
|
doc: value,
|
|
121
191
|
extensions: [
|
|
192
|
+
focusCompartment.of(focusMode ? modesMod.focusMode() : []),
|
|
193
|
+
typewriterCompartment.of(typewriter ? modesMod.typewriterScroll() : []),
|
|
122
194
|
commandsMod.history(),
|
|
123
195
|
keymap.of([...autocompleteMod.completionKeymap, ...commandsMod.defaultKeymap, ...commandsMod.historyKeymap]),
|
|
124
|
-
|
|
196
|
+
// The GFM base (strikethrough, tables, task lists, autolink) over the commonmark
|
|
197
|
+
// default. markdown() also wires markdownKeymap (Enter continues a list, Backspace
|
|
198
|
+
// removes an empty marker) at high precedence through its addKeymap default.
|
|
199
|
+
markdownMod.markdown({ base: markdownMod.markdownLanguage }),
|
|
125
200
|
...(completionSources.length
|
|
126
201
|
? // interactionDelay 0: the popup opens only on an explicit `[[` trigger, so the default
|
|
127
202
|
// accidental-accept guard adds no value and would swallow an immediate Enter into a newline.
|
|
@@ -158,6 +233,21 @@ through the adapter's render. Swapping the editor stays a one-file change.
|
|
|
158
233
|
view.dispatch({ changes: { from: 0, to: current.length, insert: incoming } });
|
|
159
234
|
});
|
|
160
235
|
|
|
236
|
+
// Reconfigure the writing-mode compartments when their props change. Reading `mounted` re-runs
|
|
237
|
+
// the effect once the editor exists, so a preference arriving between render and mount still
|
|
238
|
+
// applies; the reconfigure is idempotent, so the extra pass after mount costs nothing.
|
|
239
|
+
$effect(() => {
|
|
240
|
+
const focus = focusMode;
|
|
241
|
+
const typing = typewriter;
|
|
242
|
+
if (!mounted || !view || !modes || !focusCompartment || !typewriterCompartment) return;
|
|
243
|
+
view.dispatch({
|
|
244
|
+
effects: [
|
|
245
|
+
focusCompartment.reconfigure(focus ? modes.focusMode() : []),
|
|
246
|
+
typewriterCompartment.reconfigure(typing ? modes.typewriterScroll() : []),
|
|
247
|
+
],
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
161
251
|
function insertAtCursor(text: string) {
|
|
162
252
|
if (!view) {
|
|
163
253
|
value = value ? `${value}\n\n${text}` : text;
|
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
/* Cairn's own
|
|
2
|
-
body and UI (friendly, highly legible at small sizes); Bricolage Grotesque gives the brand and
|
|
3
|
-
page headings a distinct voice
|
|
4
|
-
|
|
5
|
-
|
|
1
|
+
/* Cairn's own typefaces, self-hosted so the admin needs no external font request. Figtree carries
|
|
2
|
+
the body and UI (friendly, highly legible at small sizes); Bricolage Grotesque gives the brand and
|
|
3
|
+
the page headings a distinct voice; iA Writer Mono is the editor writing surface. The first two are
|
|
4
|
+
variable (one file spans the weight range), the editor face ships four static files, and all three
|
|
5
|
+
are licensed under the SIL Open Font License. The `@font-face` rules are added by
|
|
6
|
+
scripts/build-admin-css.mjs after compile (so the woff2 url stays relative to the shipped sheet,
|
|
7
|
+
not the source tree). */
|
|
6
8
|
|
|
7
9
|
/* Warm Stone: the cairn admin theme. Self-contained, since DaisyUI v5 reads these vars at point
|
|
8
10
|
of use, so this fully overrides the host's theme with no @plugin and no host build step. */
|
|
@@ -10,6 +12,7 @@
|
|
|
10
12
|
color-scheme: light;
|
|
11
13
|
--font-body: 'Figtree Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
12
14
|
--font-display: 'Bricolage Grotesque Variable', var(--font-body);
|
|
15
|
+
--font-editor: 'iA Writer Mono', ui-monospace, monospace;
|
|
13
16
|
font-family: var(--font-body);
|
|
14
17
|
/* Crisper, lighter glyph rendering for the variable fonts. */
|
|
15
18
|
-webkit-font-smoothing: antialiased;
|
|
@@ -29,22 +32,37 @@
|
|
|
29
32
|
--color-accent: oklch(54% 0.16 300);
|
|
30
33
|
--color-accent-content: oklch(98% 0.012 300);
|
|
31
34
|
|
|
32
|
-
/* The editor's nested-directive depth scale:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
--cairn-directive-band-1: 8%;
|
|
38
|
-
--cairn-directive-band-2: 14%;
|
|
39
|
-
--cairn-directive-band-3: 20%;
|
|
35
|
+
/* The editor's nested-directive depth scale: fence and content rows share a 2px inset accent
|
|
36
|
+
rail stepped by nesting depth, and a fence line's name and label take a depth-stepped ink
|
|
37
|
+
on plain base-100 (the tinted bands are gone). Locked pairs (label ink on base-100):
|
|
38
|
+
depth 1 = accent 54% (5.28:1), depth 2 = 50% (6.28:1), depth 3 = 48% (6.85:1). Do not
|
|
39
|
+
lighten an ink without re-checking. */
|
|
40
40
|
--cairn-directive-ink-2: oklch(50% 0.16 300);
|
|
41
41
|
--cairn-directive-ink-3: oklch(48% 0.16 300);
|
|
42
|
-
/*
|
|
43
|
-
against base-100 (WCAG 1.4.11)
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
/* Each rail bar is a 2px non-text cue, so its composited color must clear the 3:1 floor
|
|
43
|
+
against base-100 (WCAG 1.4.11); 71% is the measured floor, which caps how far the ramp can
|
|
44
|
+
spread below the active 100%. Locked margins (rail vs base-100): depth 1 = 72% (3.08:1),
|
|
45
|
+
depth 2 = 82% (3.71:1), depth 3 = 92% (4.51:1). Do not lower an alpha without re-checking. */
|
|
46
|
+
--cairn-directive-rail-1: 72%;
|
|
46
47
|
--cairn-directive-rail-2: 82%;
|
|
47
|
-
--cairn-directive-rail-3:
|
|
48
|
+
--cairn-directive-rail-3: 92%;
|
|
49
|
+
/* Cursor-aware emphasis: the caret container's own bar steps to a full-strength accent rail
|
|
50
|
+
(also 1px wider) and an ink one notch past depth 3. Locked pairs on base-100: rail = solid
|
|
51
|
+
accent (5.28:1, non-text floor 3:1), ink 46% (7.47:1). Do not lighten without re-checking. */
|
|
52
|
+
--cairn-directive-rail-active: 100%;
|
|
53
|
+
--cairn-directive-ink-active: oklch(46% 0.16 300);
|
|
54
|
+
/* The editor's inline-code chip, a quiet tint beside base-200. Locked pair: base-content ink
|
|
55
|
+
on the chip measures 13.2:1 (AA). Do not darken the chip without re-checking. */
|
|
56
|
+
--cairn-code-chip: oklch(94.5% 0.008 75);
|
|
57
|
+
/* Focus mode's dim ink for the lines outside the caret's paragraph. A color, not opacity, so
|
|
58
|
+
the contrast is checkable. Deliberately sub-AA: the dim is a transient writing state the
|
|
59
|
+
editor opts into, and an AA-grade dim (the old 54%, 4.92:1) sat so close to muted that the
|
|
60
|
+
mode read as nothing. Locked pair on base-100: 3.03:1, the transient-state floor. The
|
|
61
|
+
editor's dim rule also flattens chip backgrounds (the code chip and the 8% accent
|
|
62
|
+
directive chips) to transparent on dimmed lines, so base-100 is the only surface this ink
|
|
63
|
+
ever sits on; on the chips it measured as low as 2.63:1. Do not lighten without
|
|
64
|
+
re-checking. */
|
|
65
|
+
--cairn-focus-dim-ink: oklch(66% 0.01 75);
|
|
48
66
|
--color-neutral: oklch(32% 0.012 75);
|
|
49
67
|
--color-neutral-content: oklch(96% 0.004 75);
|
|
50
68
|
|
|
@@ -87,6 +105,7 @@
|
|
|
87
105
|
color-scheme: dark;
|
|
88
106
|
--font-body: 'Figtree Variable', system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
|
89
107
|
--font-display: 'Bricolage Grotesque Variable', var(--font-body);
|
|
108
|
+
--font-editor: 'iA Writer Mono', ui-monospace, monospace;
|
|
90
109
|
font-family: var(--font-body);
|
|
91
110
|
/* Crisper, lighter glyph rendering for the variable fonts. */
|
|
92
111
|
-webkit-font-smoothing: antialiased;
|
|
@@ -107,21 +126,33 @@
|
|
|
107
126
|
--color-accent: oklch(70% 0.14 300);
|
|
108
127
|
--color-accent-content: oklch(20% 0.04 300);
|
|
109
128
|
|
|
110
|
-
/* The nested-directive
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
re-checking. */
|
|
115
|
-
--cairn-directive-band-1: 10%;
|
|
116
|
-
--cairn-directive-band-2: 16%;
|
|
117
|
-
--cairn-directive-band-3: 22%;
|
|
129
|
+
/* The nested-directive label inks on dark lighten with depth so a deeper fence's name reads
|
|
130
|
+
stronger on base-100 (the tinted bands are gone). Locked pairs (label ink on base-100):
|
|
131
|
+
depth 1 = accent 70% (5.86:1), depth 2 = 74% (6.81:1), depth 3 = 78% (7.82:1). Do not
|
|
132
|
+
darken an ink without re-checking. */
|
|
118
133
|
--cairn-directive-ink-2: oklch(74% 0.14 300);
|
|
119
134
|
--cairn-directive-ink-3: oklch(78% 0.14 300);
|
|
120
|
-
/* The
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
--cairn-directive-rail-
|
|
124
|
-
--cairn-directive-rail-
|
|
135
|
+
/* The rail bars hold the same 3:1 non-text floor on dark; 61% is the measured floor, which
|
|
136
|
+
caps the ramp's spread. Locked margins (rail vs base-100): depth 1 = 62% (3.09:1),
|
|
137
|
+
depth 2 = 74% (3.83:1), depth 3 = 86% (4.69:1). */
|
|
138
|
+
--cairn-directive-rail-1: 62%;
|
|
139
|
+
--cairn-directive-rail-2: 74%;
|
|
140
|
+
--cairn-directive-rail-3: 86%;
|
|
141
|
+
/* Cursor-aware emphasis on dark: the caret container's own bar steps to a solid accent rail
|
|
142
|
+
(also 1px wider) and an ink one notch past depth 3. Locked pairs on base-100: rail = solid
|
|
143
|
+
accent (5.86:1, non-text floor 3:1), ink 82% (8.84:1, priced on the gamut-clipped sRGB
|
|
144
|
+
render; the 0.14 chroma sits outside sRGB at this lightness). Do not darken without
|
|
145
|
+
re-checking. */
|
|
146
|
+
--cairn-directive-rail-active: 100%;
|
|
147
|
+
--cairn-directive-ink-active: oklch(82% 0.14 300);
|
|
148
|
+
/* The inline-code chip on dark sits one step above base-100 so it reads raised. Locked pair:
|
|
149
|
+
base-content ink on the chip measures 11.3:1 (AA). Do not lighten the chip without
|
|
150
|
+
re-checking. */
|
|
151
|
+
--cairn-code-chip: oklch(29.5% 0.012 75);
|
|
152
|
+
/* Focus mode's dim ink on dark, the same deliberate sub-AA transient-state call as light;
|
|
153
|
+
the proposed 50% measured 2.74:1, under the 3:1 floor, so it sits at 53%. Locked pair on
|
|
154
|
+
base-100: 3.12:1. Do not darken without re-checking. */
|
|
155
|
+
--cairn-focus-dim-ink: oklch(53% 0.01 75);
|
|
125
156
|
--color-neutral: oklch(80% 0.01 75);
|
|
126
157
|
--color-neutral-content: oklch(22% 0.008 75);
|
|
127
158
|
|
|
@@ -5,21 +5,45 @@ import { HighlightStyle } from '@codemirror/language';
|
|
|
5
5
|
import { tags } from '@lezer/highlight';
|
|
6
6
|
import { Decoration, ViewPlugin, type DecorationSet, type EditorView, type ViewUpdate } from '@codemirror/view';
|
|
7
7
|
import { RangeSetBuilder } from '@codemirror/state';
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
caretContainerRange,
|
|
10
|
+
directiveLineKind,
|
|
11
|
+
fenceScan,
|
|
12
|
+
fenceTokens,
|
|
13
|
+
findInlineDirectives,
|
|
14
|
+
type FenceScan,
|
|
15
|
+
} from './markdown-directives.js';
|
|
9
16
|
|
|
10
17
|
/** Markdown token colors over the admin theme variables. */
|
|
11
18
|
export function cairnHighlightStyle(): HighlightStyle {
|
|
19
|
+
// Rule order is load-bearing. HighlightStyle emits its CSS in spec order, so on a span that
|
|
20
|
+
// carries several classes (a marker inherits its heading's or link's class on top of its own
|
|
21
|
+
// processingInstruction one) the later rule wins the tie. The url and processingInstruction
|
|
22
|
+
// rules sit last so the URL part of a link and every syntax marker stay muted under the
|
|
23
|
+
// heading, emphasis, and link inks.
|
|
12
24
|
return HighlightStyle.define([
|
|
13
|
-
{ tag: tags.
|
|
25
|
+
{ tag: tags.heading1, fontSize: '1.5em', fontWeight: '700', color: 'var(--color-base-content)' },
|
|
26
|
+
{ tag: tags.heading2, fontSize: '1.3em', fontWeight: '700', color: 'var(--color-base-content)' },
|
|
27
|
+
{ tag: tags.heading3, fontSize: '1.17em', fontWeight: '700', color: 'var(--color-base-content)' },
|
|
28
|
+
// h4 and deeper share the weight only; body size keeps the low levels from outranking h3.
|
|
29
|
+
{ tag: tags.heading, fontWeight: '700', color: 'var(--color-base-content)' },
|
|
14
30
|
{ tag: tags.strong, fontWeight: '700' },
|
|
15
31
|
{ tag: tags.emphasis, fontStyle: 'italic' },
|
|
16
32
|
{ tag: tags.strikethrough, textDecoration: 'line-through' },
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
{ tag: tags.quote, color: 'var(--color-
|
|
20
|
-
{ tag: tags.monospace, color: 'var(--color-accent)' },
|
|
21
|
-
{ tag: tags.processingInstruction, color: 'var(--color-muted)' },
|
|
33
|
+
// Quote TEXT is content, so it keeps the full ink; muted means machinery, and only the >
|
|
34
|
+
// marker (QuoteMark, under processingInstruction below) recedes to it.
|
|
35
|
+
{ tag: tags.quote, color: 'var(--color-base-content)', fontStyle: 'italic' },
|
|
22
36
|
{ tag: tags.list, color: 'var(--color-muted)' },
|
|
37
|
+
{ tag: tags.link, color: 'var(--color-accent)' },
|
|
38
|
+
{ tag: tags.url, color: 'var(--color-muted)' },
|
|
39
|
+
{
|
|
40
|
+
tag: tags.monospace,
|
|
41
|
+
color: 'var(--color-base-content)',
|
|
42
|
+
backgroundColor: 'var(--cairn-code-chip)',
|
|
43
|
+
borderRadius: '0.25rem',
|
|
44
|
+
padding: '0.05em 0.3em',
|
|
45
|
+
},
|
|
46
|
+
{ tag: tags.processingInstruction, color: 'var(--color-muted)', fontWeight: '400' },
|
|
23
47
|
]);
|
|
24
48
|
}
|
|
25
49
|
|
|
@@ -36,29 +60,55 @@ const fenceLines = DEPTH_STEPS.map((d) =>
|
|
|
36
60
|
const contentLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-content cm-cairn-depth-${d}` }));
|
|
37
61
|
const leafLine = Decoration.line({ class: 'cm-cairn-directive-leaf', attributes: { title: MACHINERY_HINT } });
|
|
38
62
|
const inlineMark = Decoration.mark({ class: 'cm-cairn-directive-inline' });
|
|
63
|
+
// Within a fence line, machinery (colons, brackets, braces) dims to the marker tone while the
|
|
64
|
+
// directive name and label keep a depth-stepped ink: meaning over machinery.
|
|
65
|
+
const fenceMark = Decoration.mark({ class: 'cm-cairn-directive-mark' });
|
|
66
|
+
const fenceLabels = DEPTH_STEPS.map((d) => Decoration.mark({ class: `cm-cairn-directive-label cm-cairn-depth-${d}` }));
|
|
67
|
+
// Cursor-aware emphasis: every row of the container the caret sits inside, fence and content
|
|
68
|
+
// alike, carries this class on top of its depth classes; the theme steps that block's rail and
|
|
69
|
+
// label ink up one notch while the other containers sit quieter.
|
|
70
|
+
const caretBlockLine = Decoration.line({ class: 'cm-cairn-caret-block' });
|
|
39
71
|
|
|
40
72
|
// Depth needs the whole document, since a visible line's containers can open above the viewport.
|
|
41
73
|
// One regex pass per line, linear in the document; at admin entry sizes (tens of kilobytes) that
|
|
42
|
-
// is well under a millisecond. The plugin caches the
|
|
43
|
-
// document changes
|
|
44
|
-
|
|
74
|
+
// is well under a millisecond. The plugin caches the fence scan, so it reruns only when the
|
|
75
|
+
// document changes; a scroll or a caret move rebuilds the viewport decorations from the cached
|
|
76
|
+
// scan.
|
|
77
|
+
function docLines(view: EditorView): string[] {
|
|
45
78
|
const doc = view.state.doc;
|
|
46
79
|
const lines: string[] = [];
|
|
47
80
|
for (let n = 1; n <= doc.lines; n++) lines.push(doc.line(n).text);
|
|
48
|
-
return
|
|
81
|
+
return lines;
|
|
49
82
|
}
|
|
50
83
|
|
|
51
|
-
function buildDirectiveDecorations(view: EditorView,
|
|
84
|
+
function buildDirectiveDecorations(view: EditorView, scan: FenceScan): DecorationSet {
|
|
85
|
+
const { depths } = scan;
|
|
52
86
|
const builder = new RangeSetBuilder<Decoration>();
|
|
87
|
+
// The caret's container, one helper call over the cached scan per rebuild. Line decorations
|
|
88
|
+
// at the same position must enter the builder in add order, so the caret-block class goes in
|
|
89
|
+
// as its own line decoration just ahead of the row's depth decoration.
|
|
90
|
+
const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
|
|
91
|
+
const caret = caretContainerRange(scan, caretLine);
|
|
53
92
|
for (const { from, to } of view.visibleRanges) {
|
|
54
93
|
for (let pos = from; pos <= to; ) {
|
|
55
94
|
const line = view.state.doc.lineAt(pos);
|
|
56
95
|
const kind = directiveLineKind(line.text);
|
|
57
96
|
const depth = Math.min(depths[line.number - 1] ?? 0, DEPTH_STEPS.length);
|
|
97
|
+
if (caret && line.number - 1 >= caret.fromLine && line.number - 1 <= caret.toLine) {
|
|
98
|
+
builder.add(line.from, line.from, caretBlockLine);
|
|
99
|
+
}
|
|
58
100
|
// A fence-shaped line at depth 0 is one the depth scan disowned (a documented example
|
|
59
101
|
// inside a code block, outside any container); it gets no machinery treatment.
|
|
60
|
-
if (kind === 'fence' && depth > 0)
|
|
61
|
-
|
|
102
|
+
if (kind === 'fence' && depth > 0) {
|
|
103
|
+
builder.add(line.from, line.from, fenceLines[depth - 1]);
|
|
104
|
+
for (const token of fenceTokens(line.text)) {
|
|
105
|
+
builder.add(
|
|
106
|
+
line.from + token.from,
|
|
107
|
+
line.from + token.to,
|
|
108
|
+
token.kind === 'mark' ? fenceMark : fenceLabels[depth - 1],
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
} else if (kind === 'leaf') builder.add(line.from, line.from, leafLine);
|
|
62
112
|
else if (kind === null) {
|
|
63
113
|
if (depth > 0) builder.add(line.from, line.from, contentLines[depth - 1]);
|
|
64
114
|
for (const r of findInlineDirectives(line.text)) {
|
|
@@ -76,15 +126,17 @@ export function cairnDirectivePlugin() {
|
|
|
76
126
|
return ViewPlugin.fromClass(
|
|
77
127
|
class {
|
|
78
128
|
decorations: DecorationSet;
|
|
79
|
-
|
|
129
|
+
scan: FenceScan;
|
|
80
130
|
constructor(view: EditorView) {
|
|
81
|
-
this.
|
|
82
|
-
this.decorations = buildDirectiveDecorations(view, this.
|
|
131
|
+
this.scan = fenceScan(docLines(view));
|
|
132
|
+
this.decorations = buildDirectiveDecorations(view, this.scan);
|
|
83
133
|
}
|
|
84
134
|
update(update: ViewUpdate) {
|
|
85
|
-
if (update.docChanged) this.
|
|
86
|
-
|
|
87
|
-
|
|
135
|
+
if (update.docChanged) this.scan = fenceScan(docLines(update.view));
|
|
136
|
+
// A selection change rebuilds too, so the caret-block emphasis follows the cursor; the
|
|
137
|
+
// fence scan stays cached, keeping a caret move at viewport cost.
|
|
138
|
+
if (update.docChanged || update.viewportChanged || update.selectionSet)
|
|
139
|
+
this.decorations = buildDirectiveDecorations(update.view, this.scan);
|
|
88
140
|
}
|
|
89
141
|
},
|
|
90
142
|
{ decorations: (v) => v.decorations },
|