@glw907/cairn-cms 0.53.0 → 0.54.0
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 +24 -0
- package/dist/components/AdminLayout.svelte +52 -19
- package/dist/components/EditPage.svelte +372 -110
- package/dist/components/EditPage.svelte.d.ts +2 -1
- package/dist/components/EditorToolbar.svelte +26 -10
- package/dist/components/MarkdownEditor.svelte +108 -14
- package/dist/components/MarkdownHelpDialog.svelte +5 -0
- package/dist/components/ShortcutsDialog.svelte +37 -0
- package/dist/components/ShortcutsDialog.svelte.d.ts +13 -0
- package/dist/components/ShortcutsGrid.svelte +18 -0
- package/dist/components/ShortcutsGrid.svelte.d.ts +23 -0
- package/dist/components/cairn-admin.css +138 -108
- package/dist/components/editor-folding.d.ts +7 -0
- package/dist/components/editor-folding.js +331 -0
- package/dist/components/editor-highlight.js +55 -6
- package/dist/components/editor-shortcuts.d.ts +16 -0
- package/dist/components/editor-shortcuts.js +36 -0
- package/dist/components/markdown-directives.d.ts +17 -0
- package/dist/components/markdown-directives.js +41 -0
- package/dist/components/topbar-context.d.ts +13 -0
- package/dist/components/topbar-context.js +17 -0
- package/package.json +1 -1
- package/src/lib/components/AdminLayout.svelte +52 -19
- package/src/lib/components/EditPage.svelte +372 -110
- package/src/lib/components/EditorToolbar.svelte +26 -10
- package/src/lib/components/MarkdownEditor.svelte +108 -14
- package/src/lib/components/MarkdownHelpDialog.svelte +5 -0
- package/src/lib/components/ShortcutsDialog.svelte +37 -0
- package/src/lib/components/ShortcutsGrid.svelte +18 -0
- package/src/lib/components/cairn-admin.css +24 -11
- package/src/lib/components/editor-folding.ts +356 -0
- package/src/lib/components/editor-highlight.ts +54 -4
- package/src/lib/components/editor-shortcuts.ts +42 -0
- package/src/lib/components/markdown-directives.ts +42 -0
- package/src/lib/components/topbar-context.ts +30 -0
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
// Container folding: CodeMirror's fold system driven by cairn's directive grammar, with the rail
|
|
2
|
+
// band as the affordance. Client-only like editor-highlight and editor-modes; MarkdownEditor
|
|
3
|
+
// reaches this module through a dynamic import, so the static @codemirror imports here never enter
|
|
4
|
+
// a server bundle (guarded by the editor-boundary test).
|
|
5
|
+
//
|
|
6
|
+
// The architecture (plan decision 4): @codemirror/language's codeFolding plus foldEffect/
|
|
7
|
+
// unfoldEffect, never a custom fold store. Fold ranges come only from containerRanges, the pure
|
|
8
|
+
// pairing helper beside fenceScan. The safety invariant lives in one transactionExtender that
|
|
9
|
+
// appends unfold effects when a change or selection touches a folded range. The chevrons are
|
|
10
|
+
// widget decorations from this module's own ViewPlugin (the rails are box-shadows, there is no CM
|
|
11
|
+
// gutter element to hang a foldGutter on).
|
|
12
|
+
//
|
|
13
|
+
// The safety invariant: an author never edits, deletes, or fails to see hidden text.
|
|
14
|
+
import { Decoration, EditorView, ViewPlugin, WidgetType, keymap, } from '@codemirror/view';
|
|
15
|
+
import { EditorState, Prec, RangeSetBuilder, StateEffect, StateField } from '@codemirror/state';
|
|
16
|
+
import { codeFolding, foldEffect, foldedRanges, unfoldEffect } from '@codemirror/language';
|
|
17
|
+
import { caretContainerRange, containerRanges, fenceScan } from './markdown-directives.js';
|
|
18
|
+
// Deeper nesting shares the third visual step, matching the rail stepping in editor-highlight.
|
|
19
|
+
const DEPTH_STEPS = 3;
|
|
20
|
+
// The chevron sits over the container's own innermost bar: depth 1 at x0, depth 2 at x8, depth 3
|
|
21
|
+
// at x16, so indentation telegraphs the nesting and depth 3 never collides with depth 2.
|
|
22
|
+
const chevronX = (depth) => (Math.min(depth, DEPTH_STEPS) - 1) * 8;
|
|
23
|
+
// The two chevron glyphs from the gold-standard mockup: down (caret inside) and right (folded).
|
|
24
|
+
const CHEVRON_DOWN = 'm6 9 6 6 6-6';
|
|
25
|
+
const CHEVRON_RIGHT = 'm9 6 6 6-6 6';
|
|
26
|
+
function chevronSvg(direction) {
|
|
27
|
+
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
|
28
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
29
|
+
svg.setAttribute('fill', 'none');
|
|
30
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
31
|
+
svg.setAttribute('stroke-width', '2.5');
|
|
32
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
33
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
34
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
35
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
36
|
+
path.setAttribute('d', direction === 'down' ? CHEVRON_DOWN : CHEVRON_RIGHT);
|
|
37
|
+
svg.appendChild(path);
|
|
38
|
+
return svg;
|
|
39
|
+
}
|
|
40
|
+
// The opener-row band: the whole 28px gutter is the click target (cursor pointer over the band
|
|
41
|
+
// only, the opener text never folds), and the chevron lives inside it at the container's bar x.
|
|
42
|
+
// The widget knows its own container and whether it is folded, so the click toggles the right
|
|
43
|
+
// range; the depth class lets the theme step the chevron ink.
|
|
44
|
+
class FoldBandWidget extends WidgetType {
|
|
45
|
+
range;
|
|
46
|
+
folded;
|
|
47
|
+
caretInside;
|
|
48
|
+
constructor(range, folded, caretInside) {
|
|
49
|
+
super();
|
|
50
|
+
this.range = range;
|
|
51
|
+
this.folded = folded;
|
|
52
|
+
this.caretInside = caretInside;
|
|
53
|
+
}
|
|
54
|
+
eq(other) {
|
|
55
|
+
return (other.range.fromLine === this.range.fromLine &&
|
|
56
|
+
other.range.toLine === this.range.toLine &&
|
|
57
|
+
other.range.depth === this.range.depth &&
|
|
58
|
+
other.folded === this.folded &&
|
|
59
|
+
other.caretInside === this.caretInside);
|
|
60
|
+
}
|
|
61
|
+
toDOM(view) {
|
|
62
|
+
const band = document.createElement('span');
|
|
63
|
+
const depth = Math.min(this.range.depth, DEPTH_STEPS);
|
|
64
|
+
// Folded rows always show the chevron (right); an open container shows it down while the caret
|
|
65
|
+
// is inside; otherwise it fades in on rail-band hover (the band's own :hover, in the theme).
|
|
66
|
+
// The depth class carries the stepped ink.
|
|
67
|
+
const state = this.folded ? ' cm-cairn-fold-folded' : this.caretInside ? ' cm-cairn-fold-active' : '';
|
|
68
|
+
band.className = `cm-cairn-fold-band cm-cairn-fold-depth-${depth}${state}`;
|
|
69
|
+
const chevron = chevronSvg(this.folded ? 'right' : 'down');
|
|
70
|
+
chevron.style.left = `${chevronX(this.range.depth)}px`;
|
|
71
|
+
band.appendChild(chevron);
|
|
72
|
+
band.addEventListener('mousedown', (e) => {
|
|
73
|
+
// mousedown, not click: a click would first move the caret into the line. preventDefault
|
|
74
|
+
// keeps the caret where it is and stops the band from stealing focus from the editor.
|
|
75
|
+
e.preventDefault();
|
|
76
|
+
e.stopPropagation();
|
|
77
|
+
toggleFold(view, this.range);
|
|
78
|
+
});
|
|
79
|
+
return band;
|
|
80
|
+
}
|
|
81
|
+
ignoreEvent() {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// The pill placeholder: a real focusable button counting the hidden lines, the screen-reader story
|
|
86
|
+
// for a fold. preparePlaceholder computes the count off the folded char range so placeholderDOM
|
|
87
|
+
// renders it without re-deriving. Clicking unfolds through CodeMirror's own onclick handler.
|
|
88
|
+
function preparePlaceholder(state, range) {
|
|
89
|
+
return state.doc.lineAt(range.to).number - state.doc.lineAt(range.from).number;
|
|
90
|
+
}
|
|
91
|
+
function placeholderDOM(view, onclick, lines) {
|
|
92
|
+
const pill = document.createElement('button');
|
|
93
|
+
pill.type = 'button';
|
|
94
|
+
pill.className = 'cm-cairn-fold-pill';
|
|
95
|
+
pill.textContent = `${lines} lines`;
|
|
96
|
+
pill.setAttribute('aria-label', `Show ${lines} hidden lines`);
|
|
97
|
+
pill.addEventListener('click', onclick);
|
|
98
|
+
return pill;
|
|
99
|
+
}
|
|
100
|
+
// The char range a container folds: end-of-opener-line to end-of-closer-line, so the bare closer
|
|
101
|
+
// never dangles. Null when the opener and closer share a line (nothing to hide). The one place
|
|
102
|
+
// that turns a line range into the fold range, shared by the toggle, the keymap, and the
|
|
103
|
+
// decoration build.
|
|
104
|
+
function foldCharRange(state, range) {
|
|
105
|
+
const opener = state.doc.line(range.fromLine + 1);
|
|
106
|
+
const closer = state.doc.line(range.toLine + 1);
|
|
107
|
+
if (closer.to <= opener.to)
|
|
108
|
+
return null;
|
|
109
|
+
return { from: opener.to, to: closer.to };
|
|
110
|
+
}
|
|
111
|
+
// Fold one container, or unfold it if already folded. The range comes from containerRanges, so a
|
|
112
|
+
// half-typed fence can never fold. A no-op when the container has nothing to hide.
|
|
113
|
+
function toggleFold(view, range) {
|
|
114
|
+
const span = foldCharRange(view.state, range);
|
|
115
|
+
if (!span)
|
|
116
|
+
return;
|
|
117
|
+
const effect = foldExists(view.state, span.from, span.to) ? unfoldEffect : foldEffect;
|
|
118
|
+
view.dispatch({ effects: effect.of(span) });
|
|
119
|
+
}
|
|
120
|
+
function foldExists(state, from, to) {
|
|
121
|
+
let found = false;
|
|
122
|
+
foldedRanges(state).between(from, from, (a, b) => {
|
|
123
|
+
if (a === from && b === to)
|
|
124
|
+
found = true;
|
|
125
|
+
});
|
|
126
|
+
return found;
|
|
127
|
+
}
|
|
128
|
+
// The innermost container at the caret, the unit the keymap folds and unfolds. caretContainerRange
|
|
129
|
+
// returns the nearest enclosing container, but a container that never closes (an unbalanced
|
|
130
|
+
// opener) must not fold, so the result is only honored when containerRanges actually pairs it.
|
|
131
|
+
function caretFoldRange(view) {
|
|
132
|
+
const scan = fenceScan(docLines(view));
|
|
133
|
+
const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
|
|
134
|
+
const inner = caretContainerRange(scan, caretLine);
|
|
135
|
+
if (!inner)
|
|
136
|
+
return null;
|
|
137
|
+
return (containerRanges(scan).find((r) => r.fromLine === inner.fromLine && r.toLine === inner.toLine) ?? null);
|
|
138
|
+
}
|
|
139
|
+
function docLines(view) {
|
|
140
|
+
const doc = view.state.doc;
|
|
141
|
+
const lines = [];
|
|
142
|
+
for (let n = 1; n <= doc.lines; n++)
|
|
143
|
+
lines.push(doc.line(n).text);
|
|
144
|
+
return lines;
|
|
145
|
+
}
|
|
146
|
+
// The keymap: Ctrl+Shift+[ folds, Ctrl+Shift+] unfolds, the innermost container at the caret. No
|
|
147
|
+
// fold-all, no chords. (CodeMirror's own foldKeymap binds the same keys to its tree-folding
|
|
148
|
+
// commands; this module replaces them with the container-aware versions and never adds foldKeymap.)
|
|
149
|
+
// At Prec.high so it resolves the shifted bracket ahead of the default keymap's Ctrl-[ indentLess,
|
|
150
|
+
// which the same keystroke also matches on the non-shift lookup.
|
|
151
|
+
const foldKeymap = Prec.high(keymap.of([
|
|
152
|
+
{
|
|
153
|
+
key: 'Mod-Shift-[',
|
|
154
|
+
run: (view) => {
|
|
155
|
+
const range = caretFoldRange(view);
|
|
156
|
+
const span = range && foldCharRange(view.state, range);
|
|
157
|
+
if (!span || foldExists(view.state, span.from, span.to))
|
|
158
|
+
return false;
|
|
159
|
+
view.dispatch({ effects: foldEffect.of(span) });
|
|
160
|
+
return true;
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
key: 'Mod-Shift-]',
|
|
165
|
+
run: (view) => {
|
|
166
|
+
const range = caretFoldRange(view);
|
|
167
|
+
const span = range && foldCharRange(view.state, range);
|
|
168
|
+
if (!span || !foldExists(view.state, span.from, span.to))
|
|
169
|
+
return false;
|
|
170
|
+
view.dispatch({ effects: unfoldEffect.of(span) });
|
|
171
|
+
return true;
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
]));
|
|
175
|
+
// The unfold flash: a one-time low-alpha accent line decoration on the revealed lines, faded out
|
|
176
|
+
// over ~400ms. A StateEffect carries the revealed char range; the field decorates those lines
|
|
177
|
+
// until a follow-up effect clears it. Driven by the plugin, which schedules the clear.
|
|
178
|
+
const flashEffect = StateEffect.define();
|
|
179
|
+
const flashLine = Decoration.line({ class: 'cm-cairn-fold-flash' });
|
|
180
|
+
const flashField = StateField.define({
|
|
181
|
+
create() {
|
|
182
|
+
return Decoration.none;
|
|
183
|
+
},
|
|
184
|
+
update(deco, tr) {
|
|
185
|
+
deco = deco.map(tr.changes);
|
|
186
|
+
for (const e of tr.effects) {
|
|
187
|
+
if (e.is(flashEffect)) {
|
|
188
|
+
if (!e.value) {
|
|
189
|
+
deco = Decoration.none;
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
const builder = new RangeSetBuilder();
|
|
193
|
+
const first = tr.state.doc.lineAt(e.value.from).number;
|
|
194
|
+
const last = tr.state.doc.lineAt(e.value.to).number;
|
|
195
|
+
for (let n = first; n <= last; n++) {
|
|
196
|
+
const line = tr.state.doc.line(n);
|
|
197
|
+
builder.add(line.from, line.from, flashLine);
|
|
198
|
+
}
|
|
199
|
+
deco = builder.finish();
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return deco;
|
|
204
|
+
},
|
|
205
|
+
provide: (f) => EditorView.decorations.from(f),
|
|
206
|
+
});
|
|
207
|
+
const FLASH_MS = 400;
|
|
208
|
+
// The safety invariant, in one transactionExtender. CodeMirror's own fold field already clears a
|
|
209
|
+
// fold the selection head sits inside and a fold a delete touches; this covers the rest: an insert
|
|
210
|
+
// touching a fold boundary, a paste across it, an undo/redo landing inside, and a selection range
|
|
211
|
+
// (not just its head) extending into hidden text. It reads the start state's folds, maps them
|
|
212
|
+
// forward, and appends an unfold effect for any the change or new selection touches. A replace
|
|
213
|
+
// inside a fold leaves it open afterward, which falls out of the same rule.
|
|
214
|
+
function safetyExtender() {
|
|
215
|
+
return EditorState.transactionExtender.of((tr) => {
|
|
216
|
+
if (!tr.docChanged && !tr.selection)
|
|
217
|
+
return null;
|
|
218
|
+
const startFolds = foldedRanges(tr.startState);
|
|
219
|
+
if (startFolds.size === 0)
|
|
220
|
+
return null;
|
|
221
|
+
const effects = [];
|
|
222
|
+
startFolds.between(0, tr.startState.doc.length, (from, to) => {
|
|
223
|
+
// The fold's position after the change, for the selection test.
|
|
224
|
+
const mappedFrom = tr.changes.mapPos(from, 1);
|
|
225
|
+
const mappedTo = tr.changes.mapPos(to, -1);
|
|
226
|
+
let touched = false;
|
|
227
|
+
if (tr.docChanged) {
|
|
228
|
+
tr.changes.iterChangedRanges((fromA, toA) => {
|
|
229
|
+
if (fromA <= to && toA >= from)
|
|
230
|
+
touched = true;
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
if (!touched && tr.selection) {
|
|
234
|
+
for (const range of tr.selection.ranges) {
|
|
235
|
+
if (range.from < mappedTo && range.to > mappedFrom)
|
|
236
|
+
touched = true;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (touched)
|
|
240
|
+
effects.push(unfoldEffect.of({ from, to }));
|
|
241
|
+
});
|
|
242
|
+
return effects.length ? { effects } : null;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
// The chevron-and-wash plugin: a widget on each opener row of a paired container (the band with
|
|
246
|
+
// the chevron) and the folded-row wash line decoration. Rebuilds on doc change (the container set
|
|
247
|
+
// moves), selection change (the caret-inside chevron state), viewport change, and any fold change
|
|
248
|
+
// (a fold flips a chevron and adds a wash). The hover reveal is the band's own CSS :hover in the
|
|
249
|
+
// theme, so it costs no rebuild.
|
|
250
|
+
function foldDecorations(view, scan, ranges) {
|
|
251
|
+
const builder = new RangeSetBuilder();
|
|
252
|
+
const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
|
|
253
|
+
const inner = caretContainerRange(scan, caretLine);
|
|
254
|
+
// One opener row may host several enclosing containers' bars, but only its OWN container opens
|
|
255
|
+
// there, so a single band per opener row. Sort by opener line so the builder receives ascending
|
|
256
|
+
// positions; equal openers cannot happen (one open per line).
|
|
257
|
+
const byOpener = [...ranges].sort((a, b) => a.fromLine - b.fromLine);
|
|
258
|
+
for (const range of byOpener) {
|
|
259
|
+
const span = foldCharRange(view.state, range);
|
|
260
|
+
if (!span)
|
|
261
|
+
continue;
|
|
262
|
+
const opener = view.state.doc.line(range.fromLine + 1);
|
|
263
|
+
const folded = foldExists(view.state, span.from, span.to);
|
|
264
|
+
// The folded opener row carries the wash; a line decoration so the rails (box-shadows on the
|
|
265
|
+
// same element) run through it unbroken.
|
|
266
|
+
if (folded)
|
|
267
|
+
builder.add(opener.from, opener.from, washLine);
|
|
268
|
+
const caretInside = !!inner && inner.fromLine === range.fromLine && inner.toLine === range.toLine;
|
|
269
|
+
builder.add(opener.from, opener.from, Decoration.widget({ widget: new FoldBandWidget(range, folded, caretInside), side: -1 }));
|
|
270
|
+
}
|
|
271
|
+
return builder.finish();
|
|
272
|
+
}
|
|
273
|
+
const washLine = Decoration.line({ class: 'cm-cairn-folded-row' });
|
|
274
|
+
function foldPlugin() {
|
|
275
|
+
return ViewPlugin.fromClass(class {
|
|
276
|
+
decorations;
|
|
277
|
+
scan;
|
|
278
|
+
ranges;
|
|
279
|
+
constructor(view) {
|
|
280
|
+
this.scan = fenceScan(docLines(view));
|
|
281
|
+
this.ranges = containerRanges(this.scan);
|
|
282
|
+
this.decorations = foldDecorations(view, this.scan, this.ranges);
|
|
283
|
+
}
|
|
284
|
+
update(update) {
|
|
285
|
+
if (update.docChanged) {
|
|
286
|
+
this.scan = fenceScan(docLines(update.view));
|
|
287
|
+
this.ranges = containerRanges(this.scan);
|
|
288
|
+
}
|
|
289
|
+
const foldChanged = update.transactions.some((tr) => tr.effects.some((e) => e.is(foldEffect) || e.is(unfoldEffect)));
|
|
290
|
+
if (update.docChanged || update.viewportChanged || update.selectionSet || foldChanged) {
|
|
291
|
+
this.decorations = foldDecorations(update.view, this.scan, this.ranges);
|
|
292
|
+
}
|
|
293
|
+
// Flash the revealed lines on an unfold, then schedule the clear. Folding adds no flash.
|
|
294
|
+
for (const tr of update.transactions) {
|
|
295
|
+
for (const e of tr.effects) {
|
|
296
|
+
if (e.is(unfoldEffect)) {
|
|
297
|
+
const { from, to } = e.value;
|
|
298
|
+
const view = update.view;
|
|
299
|
+
queueMicrotask(() => {
|
|
300
|
+
if (!view.dom.isConnected)
|
|
301
|
+
return;
|
|
302
|
+
view.dispatch({ effects: flashEffect.of({ from, to }) });
|
|
303
|
+
setTimeout(() => {
|
|
304
|
+
if (view.dom.isConnected)
|
|
305
|
+
view.dispatch({ effects: flashEffect.of(null) });
|
|
306
|
+
}, FLASH_MS);
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}, { decorations: (v) => v.decorations });
|
|
313
|
+
}
|
|
314
|
+
// The design notes' diagnostics rules (a deliberately refolded erroring container keeps its fold
|
|
315
|
+
// and tints the pill warning ink instead of re-springing on every lint) have no trigger today: the
|
|
316
|
+
// editor carries no lint source, so there is nothing to refold around or to tint. Deliberately not
|
|
317
|
+
// built; do not invent a lint system to satisfy a rule with no input.
|
|
318
|
+
/**
|
|
319
|
+
* The cairn fold extension: the CodeMirror fold system with the pill placeholder, the chevron and
|
|
320
|
+
* wash affordance, the safety invariant, and the Ctrl+Shift+[ / ] keymap. Session-local and never
|
|
321
|
+
* persisted: the fold state lives in CodeMirror's foldState field, which this never serializes.
|
|
322
|
+
*/
|
|
323
|
+
export function cairnFolding() {
|
|
324
|
+
return [
|
|
325
|
+
codeFolding({ preparePlaceholder, placeholderDOM }),
|
|
326
|
+
flashField,
|
|
327
|
+
safetyExtender(),
|
|
328
|
+
foldPlugin(),
|
|
329
|
+
foldKeymap,
|
|
330
|
+
];
|
|
331
|
+
}
|
|
@@ -5,7 +5,7 @@ import { HighlightStyle } from '@codemirror/language';
|
|
|
5
5
|
import { tags } from '@lezer/highlight';
|
|
6
6
|
import { Decoration, ViewPlugin } from '@codemirror/view';
|
|
7
7
|
import { RangeSetBuilder } from '@codemirror/state';
|
|
8
|
-
import { caretContainerRange, directiveLineKind, fenceScan, fenceTokens, findInlineDirectives, } from './markdown-directives.js';
|
|
8
|
+
import { caretContainerRange, containerRanges, directiveLineKind, fenceScan, fenceTokens, findInlineDirectives, markerPrefix, } from './markdown-directives.js';
|
|
9
9
|
/** Markdown token colors over the admin theme variables. */
|
|
10
10
|
export function cairnHighlightStyle() {
|
|
11
11
|
// Rule order is load-bearing. HighlightStyle emits its CSS in spec order, so on a span that
|
|
@@ -17,7 +17,9 @@ export function cairnHighlightStyle() {
|
|
|
17
17
|
{ tag: tags.heading1, fontSize: '1.5em', fontWeight: '700', color: 'var(--color-base-content)' },
|
|
18
18
|
{ tag: tags.heading2, fontSize: '1.3em', fontWeight: '700', color: 'var(--color-base-content)' },
|
|
19
19
|
{ tag: tags.heading3, fontSize: '1.17em', fontWeight: '700', color: 'var(--color-base-content)' },
|
|
20
|
-
//
|
|
20
|
+
// A real step for h4, between h3 and body, so a hand-typed #### reads as a heading.
|
|
21
|
+
{ tag: tags.heading4, fontSize: '1.05em', fontWeight: '700', color: 'var(--color-base-content)' },
|
|
22
|
+
// h5 and deeper share the weight only; body size keeps the low levels from outranking h4.
|
|
21
23
|
{ tag: tags.heading, fontWeight: '700', color: 'var(--color-base-content)' },
|
|
22
24
|
{ tag: tags.strong, fontWeight: '700' },
|
|
23
25
|
{ tag: tags.emphasis, fontStyle: 'italic' },
|
|
@@ -46,6 +48,11 @@ const DEPTH_STEPS = [1, 2, 3];
|
|
|
46
48
|
const fenceLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-fence cm-cairn-depth-${d}`, attributes: { title: MACHINERY_HINT } }));
|
|
47
49
|
const contentLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-content cm-cairn-depth-${d}` }));
|
|
48
50
|
const leafLine = Decoration.line({ class: 'cm-cairn-directive-leaf', attributes: { title: MACHINERY_HINT } });
|
|
51
|
+
// A paired container's opener row, where the fold chevron replaces the container's own innermost
|
|
52
|
+
// rail bar. The class is additive over the fence depth class, so the theme drops only the deepest
|
|
53
|
+
// bar on this row while the outer bars stay; an unbalanced opener never gets it (no chevron, bar
|
|
54
|
+
// intact). Added just after the fence depth line decoration so the row carries both classes.
|
|
55
|
+
const openerLine = Decoration.line({ class: 'cm-cairn-directive-opener' });
|
|
49
56
|
const inlineMark = Decoration.mark({ class: 'cm-cairn-directive-inline' });
|
|
50
57
|
// Within a fence line, machinery (colons, brackets, braces) dims to the marker tone while the
|
|
51
58
|
// directive name and label keep a depth-stepped ink: meaning over machinery.
|
|
@@ -55,6 +62,25 @@ const fenceLabels = DEPTH_STEPS.map((d) => Decoration.mark({ class: `cm-cairn-di
|
|
|
55
62
|
// alike, carries this class on top of its depth classes; the theme steps that block's rail and
|
|
56
63
|
// label ink up one notch while the other containers sit quieter.
|
|
57
64
|
const caretBlockLine = Decoration.line({ class: 'cm-cairn-caret-block' });
|
|
65
|
+
// The hanging-indent line decoration for a quote or list line, keyed by the marker's character
|
|
66
|
+
// width. The width rides a --cairn-hang custom property so the theme's padding-left rule adds it
|
|
67
|
+
// to the directive gutter rather than replacing it (an inline padding-left would win the cascade
|
|
68
|
+
// and erase the gutter). The equal negative text-indent pulls the first line back by the marker
|
|
69
|
+
// width, so the marker sits in the indent and a wrapped continuation resumes under the content
|
|
70
|
+
// (the Obsidian/HyperMD idiom). The surface is iA Writer Mono, fixed pitch, so n chars is exactly
|
|
71
|
+
// n ch. Built lazily and memoized; marker widths repeat.
|
|
72
|
+
const hangLines = new Map();
|
|
73
|
+
function hangLine(width) {
|
|
74
|
+
let deco = hangLines.get(width);
|
|
75
|
+
if (!deco) {
|
|
76
|
+
deco = Decoration.line({
|
|
77
|
+
class: 'cm-cairn-hang',
|
|
78
|
+
attributes: { style: `--cairn-hang:${width}ch;text-indent:-${width}ch` },
|
|
79
|
+
});
|
|
80
|
+
hangLines.set(width, deco);
|
|
81
|
+
}
|
|
82
|
+
return deco;
|
|
83
|
+
}
|
|
58
84
|
// Depth needs the whole document, since a visible line's containers can open above the viewport.
|
|
59
85
|
// One regex pass per line, linear in the document; at admin entry sizes (tens of kilobytes) that
|
|
60
86
|
// is well under a millisecond. The plugin caches the fence scan, so it reruns only when the
|
|
@@ -75,6 +101,9 @@ function buildDirectiveDecorations(view, scan) {
|
|
|
75
101
|
// as its own line decoration just ahead of the row's depth decoration.
|
|
76
102
|
const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
|
|
77
103
|
const caret = caretContainerRange(scan, caretLine);
|
|
104
|
+
// The opener rows that carry a fold chevron, so the rail rule drops their innermost bar. Only
|
|
105
|
+
// paired containers fold, so an unbalanced opener is absent here and keeps its full rail.
|
|
106
|
+
const openerLines = new Set(containerRanges(scan).map((r) => r.fromLine));
|
|
78
107
|
for (const { from, to } of view.visibleRanges) {
|
|
79
108
|
for (let pos = from; pos <= to;) {
|
|
80
109
|
const line = view.state.doc.lineAt(pos);
|
|
@@ -87,15 +116,35 @@ function buildDirectiveDecorations(view, scan) {
|
|
|
87
116
|
// inside a code block, outside any container); it gets no machinery treatment.
|
|
88
117
|
if (kind === 'fence' && depth > 0) {
|
|
89
118
|
builder.add(line.from, line.from, fenceLines[depth - 1]);
|
|
119
|
+
// A paired opener row gets the opener class just after its fence depth class, so the rail
|
|
120
|
+
// rule below drops the innermost bar where the chevron renders.
|
|
121
|
+
if (openerLines.has(line.number - 1))
|
|
122
|
+
builder.add(line.from, line.from, openerLine);
|
|
123
|
+
}
|
|
124
|
+
else if (kind === 'leaf') {
|
|
125
|
+
builder.add(line.from, line.from, leafLine);
|
|
126
|
+
}
|
|
127
|
+
else if (kind === null && depth > 0) {
|
|
128
|
+
builder.add(line.from, line.from, contentLines[depth - 1]);
|
|
129
|
+
}
|
|
130
|
+
// A quote or list line hangs its wrapped continuation under the content. The decoration is
|
|
131
|
+
// a line decoration too, so it enters at line.from after the depth and caret-block lines
|
|
132
|
+
// and before any mark decoration on the same row; inside a container it composes with the
|
|
133
|
+
// gutter padding. A fence or leaf machinery line is never a quote or list, so this only
|
|
134
|
+
// fires on prose and content rows.
|
|
135
|
+
if (kind === null) {
|
|
136
|
+
const prefix = markerPrefix(line.text);
|
|
137
|
+
if (prefix)
|
|
138
|
+
builder.add(line.from, line.from, hangLine(prefix.length));
|
|
139
|
+
}
|
|
140
|
+
// Mark decorations start at offsets past line.from, so they enter after every line
|
|
141
|
+
// decoration on the row.
|
|
142
|
+
if (kind === 'fence' && depth > 0) {
|
|
90
143
|
for (const token of fenceTokens(line.text)) {
|
|
91
144
|
builder.add(line.from + token.from, line.from + token.to, token.kind === 'mark' ? fenceMark : fenceLabels[depth - 1]);
|
|
92
145
|
}
|
|
93
146
|
}
|
|
94
|
-
else if (kind === 'leaf')
|
|
95
|
-
builder.add(line.from, line.from, leafLine);
|
|
96
147
|
else if (kind === null) {
|
|
97
|
-
if (depth > 0)
|
|
98
|
-
builder.add(line.from, line.from, contentLines[depth - 1]);
|
|
99
148
|
for (const r of findInlineDirectives(line.text)) {
|
|
100
149
|
builder.add(line.from + r.from, line.from + r.to, inlineMark);
|
|
101
150
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** One shortcut row: a human label and the chord that triggers it. */
|
|
2
|
+
export type ShortcutRow = {
|
|
3
|
+
label: string;
|
|
4
|
+
keys: string;
|
|
5
|
+
};
|
|
6
|
+
/**
|
|
7
|
+
* The shortcut vocabulary, in the mockup's reading order. Each entry is verified against the
|
|
8
|
+
* handler that implements it: Save / Publish / Details panel / Zen / Write-Preview / Focus mode and
|
|
9
|
+
* This sheet ride EditPage's window keydown; Bold / Italic / Inline code / Web link / the heading
|
|
10
|
+
* pair / Quote / the list pair ride EditPage's card keydown; Fold / unfold ride editor-folding's
|
|
11
|
+
* CodeMirror keymap; the command palette rides AdminLayout's Ctrl K; Continue list / quote is the
|
|
12
|
+
* built-in markdown keymap on Enter.
|
|
13
|
+
*/
|
|
14
|
+
export declare const editorShortcuts: ShortcutRow[];
|
|
15
|
+
/** The closing reassurance under the grid: the keys never gate the markdown that authors type. */
|
|
16
|
+
export declare const shortcutsClosingLine = "Typing markdown always works; the keys are conveniences, never requirements.";
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// The one keyboard-shortcut table, the single source the shortcuts sheet (ShortcutsDialog) and the
|
|
2
|
+
// Markdown help dialog both render. Order and content follow the gold-standard mockup's screen 5
|
|
3
|
+
// (docs/internal/design/2026-06-12-editor-shell-gold-standard.html), one change: Write / Preview
|
|
4
|
+
// shows Ctrl Alt P, the binding actually wired in EditPage (Firefox reserves Ctrl Shift P for
|
|
5
|
+
// private browsing at the browser level, below preventDefault), and the fold pair the editor wires
|
|
6
|
+
// in editor-folding.ts joins the editor-structure rows. The keys read with literal "Ctrl" to match
|
|
7
|
+
// the toolbar tooltips, which never localize to Cmd.
|
|
8
|
+
/**
|
|
9
|
+
* The shortcut vocabulary, in the mockup's reading order. Each entry is verified against the
|
|
10
|
+
* handler that implements it: Save / Publish / Details panel / Zen / Write-Preview / Focus mode and
|
|
11
|
+
* This sheet ride EditPage's window keydown; Bold / Italic / Inline code / Web link / the heading
|
|
12
|
+
* pair / Quote / the list pair ride EditPage's card keydown; Fold / unfold ride editor-folding's
|
|
13
|
+
* CodeMirror keymap; the command palette rides AdminLayout's Ctrl K; Continue list / quote is the
|
|
14
|
+
* built-in markdown keymap on Enter.
|
|
15
|
+
*/
|
|
16
|
+
export const editorShortcuts = [
|
|
17
|
+
{ label: 'Save', keys: 'Ctrl S' },
|
|
18
|
+
{ label: 'Bold', keys: 'Ctrl B' },
|
|
19
|
+
{ label: 'Publish', keys: 'Ctrl Shift S' },
|
|
20
|
+
{ label: 'Italic', keys: 'Ctrl I' },
|
|
21
|
+
{ label: 'Details panel', keys: 'Ctrl .' },
|
|
22
|
+
{ label: 'Web link', keys: 'Ctrl K' },
|
|
23
|
+
{ label: 'Zen', keys: 'Ctrl Shift .' },
|
|
24
|
+
{ label: 'Inline code', keys: 'Ctrl E' },
|
|
25
|
+
{ label: 'Write / Preview', keys: 'Ctrl Alt P' },
|
|
26
|
+
{ label: 'Heading / smaller', keys: 'Ctrl Alt 2 / 3' },
|
|
27
|
+
{ label: 'Focus mode', keys: 'Ctrl Shift F' },
|
|
28
|
+
{ label: 'Quote', keys: 'Ctrl Shift 9' },
|
|
29
|
+
{ label: 'Command palette', keys: 'Ctrl K (global)' },
|
|
30
|
+
{ label: 'Bulleted / numbered list', keys: 'Ctrl Shift 8 / 7' },
|
|
31
|
+
{ label: 'Fold / unfold', keys: 'Ctrl Shift [ / ]' },
|
|
32
|
+
{ label: 'This sheet', keys: 'Ctrl /' },
|
|
33
|
+
{ label: 'Continue list / quote', keys: 'Enter' },
|
|
34
|
+
];
|
|
35
|
+
/** The closing reassurance under the grid: the keys never gate the markdown that authors type. */
|
|
36
|
+
export const shortcutsClosingLine = 'Typing markdown always works; the keys are conveniences, never requirements.';
|
|
@@ -38,6 +38,17 @@ export interface ContainerRange {
|
|
|
38
38
|
* unclosed container runs to the document end.
|
|
39
39
|
*/
|
|
40
40
|
export declare function caretContainerRange(scan: FenceScan, caretLine: number): ContainerRange | null;
|
|
41
|
+
/**
|
|
42
|
+
* Every paired directive container in the document, as inclusive line ranges. Walks the scan's
|
|
43
|
+
* roles with a stack: an opener pushes its line, a closer pops the nearest open one and emits the
|
|
44
|
+
* pair at the opener's depth. The ranges come back in close order, so an inner container precedes
|
|
45
|
+
* the outer that holds it, which is exactly the order a fold consumer wants (folding the innermost
|
|
46
|
+
* first). An unbalanced opener is left on the stack and never emitted (a half-typed fence earns no
|
|
47
|
+
* fold range, the safety invariant), and a stray closer with nothing open is dropped. The scan
|
|
48
|
+
* already disowns a fence-shaped line inside a code block (its role is null), so a documented
|
|
49
|
+
* example can neither open nor close a range. This is the sole source of fold ranges.
|
|
50
|
+
*/
|
|
51
|
+
export declare function containerRanges(scan: FenceScan): ContainerRange[];
|
|
41
52
|
/** One span of a fence line, in line-local offsets: machinery (`mark`) or meaning (`label`). */
|
|
42
53
|
export interface FenceToken {
|
|
43
54
|
from: number;
|
|
@@ -51,6 +62,12 @@ export interface FenceToken {
|
|
|
51
62
|
* no spans at all.
|
|
52
63
|
*/
|
|
53
64
|
export declare function fenceTokens(line: string): FenceToken[];
|
|
65
|
+
/**
|
|
66
|
+
* The marker prefix of a line (indentation plus marker plus its trailing space), or null when the
|
|
67
|
+
* line carries no quote or list marker. The hanging-indent decoration uses the prefix length, in
|
|
68
|
+
* fixed-pitch chars, to wrap continuation lines under the content rather than under the marker.
|
|
69
|
+
*/
|
|
70
|
+
export declare function markerPrefix(line: string): string | null;
|
|
54
71
|
/** Inline directive ranges (`:name[...]{...}`) within a line of text. */
|
|
55
72
|
export declare function findInlineDirectives(text: string): {
|
|
56
73
|
from: number;
|
|
@@ -108,6 +108,33 @@ export function caretContainerRange(scan, caretLine) {
|
|
|
108
108
|
}
|
|
109
109
|
return { fromLine, toLine, depth };
|
|
110
110
|
}
|
|
111
|
+
/**
|
|
112
|
+
* Every paired directive container in the document, as inclusive line ranges. Walks the scan's
|
|
113
|
+
* roles with a stack: an opener pushes its line, a closer pops the nearest open one and emits the
|
|
114
|
+
* pair at the opener's depth. The ranges come back in close order, so an inner container precedes
|
|
115
|
+
* the outer that holds it, which is exactly the order a fold consumer wants (folding the innermost
|
|
116
|
+
* first). An unbalanced opener is left on the stack and never emitted (a half-typed fence earns no
|
|
117
|
+
* fold range, the safety invariant), and a stray closer with nothing open is dropped. The scan
|
|
118
|
+
* already disowns a fence-shaped line inside a code block (its role is null), so a documented
|
|
119
|
+
* example can neither open nor close a range. This is the sole source of fold ranges.
|
|
120
|
+
*/
|
|
121
|
+
export function containerRanges(scan) {
|
|
122
|
+
const { depths, roles } = scan;
|
|
123
|
+
const out = [];
|
|
124
|
+
const stack = [];
|
|
125
|
+
for (let i = 0; i < roles.length; i++) {
|
|
126
|
+
if (roles[i] === 'opener') {
|
|
127
|
+
stack.push(i);
|
|
128
|
+
}
|
|
129
|
+
else if (roles[i] === 'closer') {
|
|
130
|
+
const fromLine = stack.pop();
|
|
131
|
+
if (fromLine === undefined)
|
|
132
|
+
continue;
|
|
133
|
+
out.push({ fromLine, toLine: i, depth: depths[fromLine] ?? 1 });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
111
138
|
/**
|
|
112
139
|
* Split a fence line into machinery and meaning. The colon run, the label's brackets, and the
|
|
113
140
|
* whole {attrs} group are machinery; the directive name and the label text are meaning, the
|
|
@@ -141,6 +168,20 @@ export function fenceTokens(line) {
|
|
|
141
168
|
}
|
|
142
169
|
return out;
|
|
143
170
|
}
|
|
171
|
+
// The marker prefix of a quote or list line: leading indentation, the marker itself, and the one
|
|
172
|
+
// space after it. A task checkbox (`[ ]`/`[x]`) extends a bullet's marker. Ordered markers vary in
|
|
173
|
+
// width (a two-digit number is wider than a bullet), so the width is read from the match, never
|
|
174
|
+
// assumed. The anchored alternatives mirror the markers the highlight pass styles.
|
|
175
|
+
const MARKER = /^(\s*)(?:[-*+](?: \[[ xX]\])?|\d+[.)]|>) /;
|
|
176
|
+
/**
|
|
177
|
+
* The marker prefix of a line (indentation plus marker plus its trailing space), or null when the
|
|
178
|
+
* line carries no quote or list marker. The hanging-indent decoration uses the prefix length, in
|
|
179
|
+
* fixed-pitch chars, to wrap continuation lines under the content rather than under the marker.
|
|
180
|
+
*/
|
|
181
|
+
export function markerPrefix(line) {
|
|
182
|
+
const m = MARKER.exec(line);
|
|
183
|
+
return m ? m[0] : null;
|
|
184
|
+
}
|
|
144
185
|
/** Inline directive ranges (`:name[...]{...}`) within a line of text. */
|
|
145
186
|
export function findInlineDirectives(text) {
|
|
146
187
|
const out = [];
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Snippet } from 'svelte';
|
|
2
|
+
/** The shared holder: the desk snippet a document registers, or null on the office routes. */
|
|
3
|
+
export interface TopbarHolder {
|
|
4
|
+
desk: Snippet | null;
|
|
5
|
+
/** True while the document is in zen: AdminLayout drops the whole topbar element so the band
|
|
6
|
+
* slides away (the desk's three clusters include AdminLayout-owned chrome, the drawer toggle and
|
|
7
|
+
* breadcrumb, that must vanish with it). EditPage sets this; the office routes leave it false. */
|
|
8
|
+
zen: boolean;
|
|
9
|
+
}
|
|
10
|
+
/** Called by AdminLayout once: creates the holder, provides it on context, returns it to render. */
|
|
11
|
+
export declare function provideTopbar(holder: TopbarHolder): TopbarHolder;
|
|
12
|
+
/** Called by a descendant document (EditPage) to reach the holder it registers its desk into. */
|
|
13
|
+
export declare function useTopbar(): TopbarHolder | undefined;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { getContext, setContext } from 'svelte';
|
|
2
|
+
// The topbar context portal. On an edit route the document owns the band's live state (status,
|
|
3
|
+
// save-state, the lifecycle actions), so the desk's controls have to render up in AdminLayout's
|
|
4
|
+
// one topbar rather than in a second header of their own. AdminLayout owns a holder and provides
|
|
5
|
+
// it; EditPage, a descendant through the children snippet, registers its desk snippet into the
|
|
6
|
+
// holder and clears it on teardown. CairnAdmin's view switch unmounts EditPage, which nulls the
|
|
7
|
+
// holder, so the band reverts to the office layout with no route plumbing.
|
|
8
|
+
const TOPBAR_CONTEXT_KEY = Symbol('cairn-topbar');
|
|
9
|
+
/** Called by AdminLayout once: creates the holder, provides it on context, returns it to render. */
|
|
10
|
+
export function provideTopbar(holder) {
|
|
11
|
+
setContext(TOPBAR_CONTEXT_KEY, holder);
|
|
12
|
+
return holder;
|
|
13
|
+
}
|
|
14
|
+
/** Called by a descendant document (EditPage) to reach the holder it registers its desk into. */
|
|
15
|
+
export function useTopbar() {
|
|
16
|
+
return getContext(TOPBAR_CONTEXT_KEY);
|
|
17
|
+
}
|