@glw907/cairn-cms 0.51.0 → 0.52.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.
Files changed (31) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/components/EditPage.svelte +32 -1
  3. package/dist/components/EditorToolbar.svelte +60 -6
  4. package/dist/components/EditorToolbar.svelte.d.ts +10 -1
  5. package/dist/components/MarkdownEditor.svelte +112 -24
  6. package/dist/components/MarkdownEditor.svelte.d.ts +4 -0
  7. package/dist/components/cairn-admin.css +23 -11
  8. package/dist/components/editor-highlight.d.ts +2 -1
  9. package/dist/components/editor-highlight.js +60 -19
  10. package/dist/components/editor-modes.d.ts +26 -0
  11. package/dist/components/editor-modes.js +92 -0
  12. package/dist/components/fonts/iAWriterMono-OFL.txt +100 -0
  13. package/dist/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
  14. package/dist/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
  15. package/dist/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
  16. package/dist/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
  17. package/dist/components/markdown-directives.d.ts +48 -7
  18. package/dist/components/markdown-directives.js +89 -13
  19. package/package.json +1 -1
  20. package/src/lib/components/EditPage.svelte +32 -1
  21. package/src/lib/components/EditorToolbar.svelte +60 -6
  22. package/src/lib/components/MarkdownEditor.svelte +112 -24
  23. package/src/lib/components/cairn-admin.css +62 -31
  24. package/src/lib/components/editor-highlight.ts +72 -20
  25. package/src/lib/components/editor-modes.ts +106 -0
  26. package/src/lib/components/fonts/iAWriterMono-OFL.txt +100 -0
  27. package/src/lib/components/fonts/ia-writer-mono-latin-400-italic.woff2 +0 -0
  28. package/src/lib/components/fonts/ia-writer-mono-latin-400-normal.woff2 +0 -0
  29. package/src/lib/components/fonts/ia-writer-mono-latin-700-italic.woff2 +0 -0
  30. package/src/lib/components/fonts/ia-writer-mono-latin-700-normal.woff2 +0 -0
  31. package/src/lib/components/markdown-directives.ts +113 -13
@@ -5,20 +5,37 @@ 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 { directiveLineKind, fenceDepths, findInlineDirectives } from './markdown-directives.js';
8
+ import { caretContainerRange, directiveLineKind, fenceScan, fenceTokens, findInlineDirectives, } from './markdown-directives.js';
9
9
  /** Markdown token colors over the admin theme variables. */
10
10
  export function cairnHighlightStyle() {
11
+ // Rule order is load-bearing. HighlightStyle emits its CSS in spec order, so on a span that
12
+ // carries several classes (a marker inherits its heading's or link's class on top of its own
13
+ // processingInstruction one) the later rule wins the tie. The url and processingInstruction
14
+ // rules sit last so the URL part of a link and every syntax marker stay muted under the
15
+ // heading, emphasis, and link inks.
11
16
  return HighlightStyle.define([
12
- { tag: tags.heading, color: 'var(--color-primary)', fontWeight: '700' },
17
+ { tag: tags.heading1, fontSize: '1.5em', fontWeight: '700', color: 'var(--color-base-content)' },
18
+ { tag: tags.heading2, fontSize: '1.3em', fontWeight: '700', color: 'var(--color-base-content)' },
19
+ { tag: tags.heading3, fontSize: '1.17em', fontWeight: '700', color: 'var(--color-base-content)' },
20
+ // h4 and deeper share the weight only; body size keeps the low levels from outranking h3.
21
+ { tag: tags.heading, fontWeight: '700', color: 'var(--color-base-content)' },
13
22
  { tag: tags.strong, fontWeight: '700' },
14
23
  { tag: tags.emphasis, fontStyle: 'italic' },
15
24
  { tag: tags.strikethrough, textDecoration: 'line-through' },
16
- { tag: tags.link, color: 'var(--color-info)' },
17
- { tag: tags.url, color: 'var(--color-info)' },
18
- { tag: tags.quote, color: 'var(--color-muted)', fontStyle: 'italic' },
19
- { tag: tags.monospace, color: 'var(--color-accent)' },
20
- { tag: tags.processingInstruction, color: 'var(--color-muted)' },
25
+ // Quote TEXT is content, so it keeps the full ink; muted means machinery, and only the >
26
+ // marker (QuoteMark, under processingInstruction below) recedes to it.
27
+ { tag: tags.quote, color: 'var(--color-base-content)', fontStyle: 'italic' },
21
28
  { tag: tags.list, color: 'var(--color-muted)' },
29
+ { tag: tags.link, color: 'var(--color-accent)' },
30
+ { tag: tags.url, color: 'var(--color-muted)' },
31
+ {
32
+ tag: tags.monospace,
33
+ color: 'var(--color-base-content)',
34
+ backgroundColor: 'var(--cairn-code-chip)',
35
+ borderRadius: '0.25rem',
36
+ padding: '0.05em 0.3em',
37
+ },
38
+ { tag: tags.processingInstruction, color: 'var(--color-muted)', fontWeight: '400' },
22
39
  ]);
23
40
  }
24
41
  // The machinery lines explain themselves on hover, so an editor who has never seen ::: syntax
@@ -30,28 +47,50 @@ const fenceLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-dir
30
47
  const contentLines = DEPTH_STEPS.map((d) => Decoration.line({ class: `cm-cairn-directive-content cm-cairn-depth-${d}` }));
31
48
  const leafLine = Decoration.line({ class: 'cm-cairn-directive-leaf', attributes: { title: MACHINERY_HINT } });
32
49
  const inlineMark = Decoration.mark({ class: 'cm-cairn-directive-inline' });
50
+ // Within a fence line, machinery (colons, brackets, braces) dims to the marker tone while the
51
+ // directive name and label keep a depth-stepped ink: meaning over machinery.
52
+ const fenceMark = Decoration.mark({ class: 'cm-cairn-directive-mark' });
53
+ const fenceLabels = DEPTH_STEPS.map((d) => Decoration.mark({ class: `cm-cairn-directive-label cm-cairn-depth-${d}` }));
54
+ // Cursor-aware emphasis: every row of the container the caret sits inside, fence and content
55
+ // alike, carries this class on top of its depth classes; the theme steps that block's rail and
56
+ // label ink up one notch while the other containers sit quieter.
57
+ const caretBlockLine = Decoration.line({ class: 'cm-cairn-caret-block' });
33
58
  // Depth needs the whole document, since a visible line's containers can open above the viewport.
34
59
  // One regex pass per line, linear in the document; at admin entry sizes (tens of kilobytes) that
35
- // is well under a millisecond. The plugin caches the result, so the scan reruns only when the
36
- // document changes and a scroll rebuilds the viewport decorations from the cached array.
37
- function docDepths(view) {
60
+ // is well under a millisecond. The plugin caches the fence scan, so it reruns only when the
61
+ // document changes; a scroll or a caret move rebuilds the viewport decorations from the cached
62
+ // scan.
63
+ function docLines(view) {
38
64
  const doc = view.state.doc;
39
65
  const lines = [];
40
66
  for (let n = 1; n <= doc.lines; n++)
41
67
  lines.push(doc.line(n).text);
42
- return fenceDepths(lines);
68
+ return lines;
43
69
  }
44
- function buildDirectiveDecorations(view, depths) {
70
+ function buildDirectiveDecorations(view, scan) {
71
+ const { depths } = scan;
45
72
  const builder = new RangeSetBuilder();
73
+ // The caret's container, one helper call over the cached scan per rebuild. Line decorations
74
+ // at the same position must enter the builder in add order, so the caret-block class goes in
75
+ // as its own line decoration just ahead of the row's depth decoration.
76
+ const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
77
+ const caret = caretContainerRange(scan, caretLine);
46
78
  for (const { from, to } of view.visibleRanges) {
47
79
  for (let pos = from; pos <= to;) {
48
80
  const line = view.state.doc.lineAt(pos);
49
81
  const kind = directiveLineKind(line.text);
50
82
  const depth = Math.min(depths[line.number - 1] ?? 0, DEPTH_STEPS.length);
83
+ if (caret && line.number - 1 >= caret.fromLine && line.number - 1 <= caret.toLine) {
84
+ builder.add(line.from, line.from, caretBlockLine);
85
+ }
51
86
  // A fence-shaped line at depth 0 is one the depth scan disowned (a documented example
52
87
  // inside a code block, outside any container); it gets no machinery treatment.
53
- if (kind === 'fence' && depth > 0)
88
+ if (kind === 'fence' && depth > 0) {
54
89
  builder.add(line.from, line.from, fenceLines[depth - 1]);
90
+ for (const token of fenceTokens(line.text)) {
91
+ builder.add(line.from + token.from, line.from + token.to, token.kind === 'mark' ? fenceMark : fenceLabels[depth - 1]);
92
+ }
93
+ }
55
94
  else if (kind === 'leaf')
56
95
  builder.add(line.from, line.from, leafLine);
57
96
  else if (kind === null) {
@@ -70,16 +109,18 @@ function buildDirectiveDecorations(view, depths) {
70
109
  export function cairnDirectivePlugin() {
71
110
  return ViewPlugin.fromClass(class {
72
111
  decorations;
73
- depths;
112
+ scan;
74
113
  constructor(view) {
75
- this.depths = docDepths(view);
76
- this.decorations = buildDirectiveDecorations(view, this.depths);
114
+ this.scan = fenceScan(docLines(view));
115
+ this.decorations = buildDirectiveDecorations(view, this.scan);
77
116
  }
78
117
  update(update) {
79
118
  if (update.docChanged)
80
- this.depths = docDepths(update.view);
81
- if (update.docChanged || update.viewportChanged)
82
- this.decorations = buildDirectiveDecorations(update.view, this.depths);
119
+ this.scan = fenceScan(docLines(update.view));
120
+ // A selection change rebuilds too, so the caret-block emphasis follows the cursor; the
121
+ // fence scan stays cached, keeping a caret move at viewport cost.
122
+ if (update.docChanged || update.viewportChanged || update.selectionSet)
123
+ this.decorations = buildDirectiveDecorations(update.view, this.scan);
83
124
  }
84
125
  }, { decorations: (v) => v.decorations });
85
126
  }
@@ -0,0 +1,26 @@
1
+ import { type Extension } from '@codemirror/state';
2
+ /** An inclusive 0-based line range. */
3
+ export interface LineRange {
4
+ fromLine: number;
5
+ toLine: number;
6
+ }
7
+ /**
8
+ * The contiguous non-blank block around the caret line, the unit focus mode keeps at full ink.
9
+ * On a blank line the caret stands alone; the walk clamps at the document edges, and an
10
+ * out-of-range caret clamps into the document first.
11
+ */
12
+ export declare function paragraphRange(lines: string[], caretLine: number): LineRange;
13
+ /**
14
+ * Focus mode: a line class (`cm-cairn-focus-dim`) on every line outside the caret's paragraph.
15
+ * The class only marks the lines; the dim ink itself lives in the editor theme so the per-theme
16
+ * `--cairn-focus-dim-ink` variable resolves it.
17
+ */
18
+ export declare function focusMode(): Extension;
19
+ /**
20
+ * Typewriter scroll: on every doc change, recenter the selection head vertically. An update
21
+ * listener may not dispatch while its update runs, so the recenter is queued as a microtask:
22
+ * it fires as soon as the update finishes, still ahead of the next paint, where an animation
23
+ * frame would trail the edit by a frame. The isConnected guard skips a view destroyed in the
24
+ * queue window.
25
+ */
26
+ export declare function typewriterScroll(): Extension;
@@ -0,0 +1,92 @@
1
+ // The iA writing modes: focus mode dims every line outside the caret's paragraph, and typewriter
2
+ // scroll holds the cursor line at vertical center while typing. Client-only, like editor-highlight:
3
+ // MarkdownEditor reaches this module through a dynamic import, so the static @codemirror imports
4
+ // here never enter a server bundle (guarded by the editor-boundary test).
5
+ import { Decoration, EditorView, ViewPlugin, } from '@codemirror/view';
6
+ import { RangeSetBuilder } from '@codemirror/state';
7
+ const isBlank = (line) => !line || /^\s*$/.test(line);
8
+ /**
9
+ * The contiguous non-blank block around the caret line, the unit focus mode keeps at full ink.
10
+ * On a blank line the caret stands alone; the walk clamps at the document edges, and an
11
+ * out-of-range caret clamps into the document first.
12
+ */
13
+ export function paragraphRange(lines, caretLine) {
14
+ const last = Math.max(lines.length - 1, 0);
15
+ const caret = Math.min(Math.max(caretLine, 0), last);
16
+ if (isBlank(lines[caret]))
17
+ return { fromLine: caret, toLine: caret };
18
+ let fromLine = caret;
19
+ while (fromLine > 0 && !isBlank(lines[fromLine - 1]))
20
+ fromLine--;
21
+ let toLine = caret;
22
+ while (toLine < last && !isBlank(lines[toLine + 1]))
23
+ toLine++;
24
+ return { fromLine, toLine };
25
+ }
26
+ const dimLine = Decoration.line({ class: 'cm-cairn-focus-dim' });
27
+ // The line cache mirrors editor-highlight's: one full-document read per doc change, so a caret
28
+ // move or scroll rebuilds the viewport decorations from the cached array.
29
+ function docLines(view) {
30
+ const doc = view.state.doc;
31
+ const lines = [];
32
+ for (let n = 1; n <= doc.lines; n++)
33
+ lines.push(doc.line(n).text);
34
+ return lines;
35
+ }
36
+ function buildFocusDecorations(view, lines) {
37
+ const builder = new RangeSetBuilder();
38
+ const caretLine = view.state.doc.lineAt(view.state.selection.main.head).number - 1;
39
+ const paragraph = paragraphRange(lines, caretLine);
40
+ for (const { from, to } of view.visibleRanges) {
41
+ for (let pos = from; pos <= to;) {
42
+ const line = view.state.doc.lineAt(pos);
43
+ const n = line.number - 1;
44
+ if (n < paragraph.fromLine || n > paragraph.toLine)
45
+ builder.add(line.from, line.from, dimLine);
46
+ pos = line.to + 1;
47
+ }
48
+ }
49
+ return builder.finish();
50
+ }
51
+ /**
52
+ * Focus mode: a line class (`cm-cairn-focus-dim`) on every line outside the caret's paragraph.
53
+ * The class only marks the lines; the dim ink itself lives in the editor theme so the per-theme
54
+ * `--cairn-focus-dim-ink` variable resolves it.
55
+ */
56
+ export function focusMode() {
57
+ return ViewPlugin.fromClass(class {
58
+ decorations;
59
+ lines;
60
+ constructor(view) {
61
+ this.lines = docLines(view);
62
+ this.decorations = buildFocusDecorations(view, this.lines);
63
+ }
64
+ update(update) {
65
+ if (update.docChanged)
66
+ this.lines = docLines(update.view);
67
+ if (update.docChanged || update.viewportChanged || update.selectionSet)
68
+ this.decorations = buildFocusDecorations(update.view, this.lines);
69
+ }
70
+ }, { decorations: (v) => v.decorations });
71
+ }
72
+ /**
73
+ * Typewriter scroll: on every doc change, recenter the selection head vertically. An update
74
+ * listener may not dispatch while its update runs, so the recenter is queued as a microtask:
75
+ * it fires as soon as the update finishes, still ahead of the next paint, where an animation
76
+ * frame would trail the edit by a frame. The isConnected guard skips a view destroyed in the
77
+ * queue window.
78
+ */
79
+ export function typewriterScroll() {
80
+ return EditorView.updateListener.of((update) => {
81
+ if (!update.docChanged)
82
+ return;
83
+ const view = update.view;
84
+ queueMicrotask(() => {
85
+ if (!view.dom.isConnected)
86
+ return;
87
+ view.dispatch({
88
+ effects: EditorView.scrollIntoView(view.state.selection.main.head, { y: 'center' }),
89
+ });
90
+ });
91
+ });
92
+ }
@@ -0,0 +1,100 @@
1
+ # iA Writer Typeface
2
+
3
+ Copyright © 2018 Information Architects Inc. with Reserved Font Name "iA Writer"
4
+
5
+ # Based on IBM Plex Typeface
6
+
7
+ Copyright © 2017 IBM Corp. with Reserved Font Name "Plex"
8
+
9
+ # License
10
+
11
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
12
+ This license is copied below, and is also available with a FAQ at:
13
+ http://scripts.sil.org/OFL
14
+
15
+ -----------------------------------------------------------
16
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
17
+ -----------------------------------------------------------
18
+
19
+ PREAMBLE
20
+ The goals of the Open Font License (OFL) are to stimulate worldwide
21
+ development of collaborative font projects, to support the font creation
22
+ efforts of academic and linguistic communities, and to provide a free and
23
+ open framework in which fonts may be shared and improved in partnership
24
+ with others.
25
+
26
+ The OFL allows the licensed fonts to be used, studied, modified and
27
+ redistributed freely as long as they are not sold by themselves. The
28
+ fonts, including any derivative works, can be bundled, embedded,
29
+ redistributed and/or sold with any software provided that any reserved
30
+ names are not used by derivative works. The fonts and derivatives,
31
+ however, cannot be released under any other type of license. The
32
+ requirement for fonts to remain under this license does not apply
33
+ to any document created using the fonts or their derivatives.
34
+
35
+ DEFINITIONS
36
+ "Font Software" refers to the set of files released by the Copyright
37
+ Holder(s) under this license and clearly marked as such. This may
38
+ include source files, build scripts and documentation.
39
+
40
+ "Reserved Font Name" refers to any names specified as such after the
41
+ copyright statement(s).
42
+
43
+ "Original Version" refers to the collection of Font Software components as
44
+ distributed by the Copyright Holder(s).
45
+
46
+ "Modified Version" refers to any derivative made by adding to, deleting,
47
+ or substituting -- in part or in whole -- any of the components of the
48
+ Original Version, by changing formats or by porting the Font Software to a
49
+ new environment.
50
+
51
+ "Author" refers to any designer, engineer, programmer, technical
52
+ writer or other person who contributed to the Font Software.
53
+
54
+ PERMISSION & CONDITIONS
55
+ Permission is hereby granted, free of charge, to any person obtaining
56
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
57
+ redistribute, and sell modified and unmodified copies of the Font
58
+ Software, subject to the following conditions:
59
+
60
+ 1) Neither the Font Software nor any of its individual components,
61
+ in Original or Modified Versions, may be sold by itself.
62
+
63
+ 2) Original or Modified Versions of the Font Software may be bundled,
64
+ redistributed and/or sold with any software, provided that each copy
65
+ contains the above copyright notice and this license. These can be
66
+ included either as stand-alone text files, human-readable headers or
67
+ in the appropriate machine-readable metadata fields within text or
68
+ binary files as long as those fields can be easily viewed by the user.
69
+
70
+ 3) No Modified Version of the Font Software may use the Reserved Font
71
+ Name(s) unless explicit written permission is granted by the corresponding
72
+ Copyright Holder. This restriction only applies to the primary font name as
73
+ presented to the users.
74
+
75
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
76
+ Software shall not be used to promote, endorse or advertise any
77
+ Modified Version, except to acknowledge the contribution(s) of the
78
+ Copyright Holder(s) and the Author(s) or with their explicit written
79
+ permission.
80
+
81
+ 5) The Font Software, modified or unmodified, in part or in whole,
82
+ must be distributed entirely under this license, and must not be
83
+ distributed under any other license. The requirement for fonts to
84
+ remain under this license does not apply to any document created
85
+ using the Font Software.
86
+
87
+ TERMINATION
88
+ This license becomes null and void if any of the above conditions are
89
+ not met.
90
+
91
+ DISCLAIMER
92
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
93
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
94
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
95
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
96
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
97
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
98
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
99
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
100
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -1,15 +1,56 @@
1
1
  /** Classify a whole line as a container fence, a leaf directive, or neither. */
2
2
  export declare function directiveLineKind(line: string): 'fence' | 'leaf' | null;
3
+ /** One pass over the document: each line's container depth alongside its fence role. */
4
+ export interface FenceScan {
5
+ /** The 1-based container depth per line, or null outside any container. */
6
+ depths: (number | null)[];
7
+ /** Whether a line opened or closed a container, or null for everything else. A fence-shaped
8
+ * line the code-block tracking disowned is null too, so the role array is the one source of
9
+ * truth for pairing and no caller re-parses a line the scan already judged. */
10
+ roles: ('opener' | 'closer' | null)[];
11
+ }
3
12
  /**
4
- * The 1-based container depth each line sits at, or null outside any container. A named fence
5
- * opens a container; a bare fence closes the most recent one (colon counts are not trusted for
6
- * pairing, since authors vary them). An opener and its closer share the opener's depth, and a
7
- * line between them carries the depth of its innermost container. Lines inside a fenced code
8
- * block are plain content, so a documented ::: example cannot open a phantom container running
9
- * to end of document. Author errors are tolerated: an unmatched closer reads as depth 1 and the
10
- * count never goes below zero.
13
+ * Scan the document's container structure in one pass. A named fence opens a container; a bare
14
+ * fence closes the most recent one (colon counts are not trusted for pairing, since authors
15
+ * vary them). An opener and its closer share the opener's depth, and a line between them
16
+ * carries the depth of its innermost container. Lines inside a fenced code block are plain
17
+ * content, so a documented ::: example cannot open a phantom container running to end of
18
+ * document. Author errors are tolerated: an unmatched closer reads as depth 1 and the count
19
+ * never goes below zero.
11
20
  */
21
+ export declare function fenceScan(lines: string[]): FenceScan;
22
+ /** The depth half of {@link fenceScan}, for callers that need no roles. */
12
23
  export declare function fenceDepths(lines: string[]): (number | null)[];
24
+ /** The inclusive line span of one directive container. */
25
+ export interface ContainerRange {
26
+ fromLine: number;
27
+ toLine: number;
28
+ depth: number;
29
+ }
30
+ /**
31
+ * The innermost container around a caret line, as an inclusive line range, or null outside any
32
+ * container. Works from the cached scan without re-parsing: the caret line's own depth names
33
+ * the container (fence rows carry the depth they delimit, so a caret on a fence belongs to
34
+ * that fence's container), and within a container the only same-depth real fences are its
35
+ * opener and closer (nested containers sit deeper, siblings sit outside), so the nearest
36
+ * opener above and the nearest closer below bound the range. The scan's roles already disown a
37
+ * fence-shaped line inside a code block, so a documented example can never clip the range. An
38
+ * unclosed container runs to the document end.
39
+ */
40
+ export declare function caretContainerRange(scan: FenceScan, caretLine: number): ContainerRange | null;
41
+ /** One span of a fence line, in line-local offsets: machinery (`mark`) or meaning (`label`). */
42
+ export interface FenceToken {
43
+ from: number;
44
+ to: number;
45
+ kind: 'mark' | 'label';
46
+ }
47
+ /**
48
+ * Split a fence line into machinery and meaning. The colon run, the label's brackets, and the
49
+ * whole {attrs} group are machinery; the directive name and the label text are meaning, the
50
+ * parts an editor reads. A bare closer is a single machinery span, and a non-fence line yields
51
+ * no spans at all.
52
+ */
53
+ export declare function fenceTokens(line: string): FenceToken[];
13
54
  /** Inline directive ranges (`:name[...]{...}`) within a line of text. */
14
55
  export declare function findInlineDirectives(text: string): {
15
56
  from: number;
@@ -3,9 +3,11 @@
3
3
  // CodeMirror decoration plugin wraps them.
4
4
  // A container fence: three or more colons, then an optional name, an optional [label], and
5
5
  // optional {attrs}, in remark-directive order. The name is captured so the depth scan below can
6
- // tell an opener (named) from a closer (bare colons). Matching is tolerant of stray whitespace,
7
- // the same posture as the leaf form: a slightly off fence should still read as machinery.
8
- const FENCE = /^\s{0,3}:{3,}\s*([\w-]*)\s*(\[[^\]]*\])?\s*(\{[^}]*\})?\s*$/;
6
+ // tell an opener (named) from a closer (bare colons), and the d flag records each group's span
7
+ // so fenceTokens can split the line without re-parsing. Matching is tolerant of stray
8
+ // whitespace, the same posture as the leaf form: a slightly off fence should still read as
9
+ // machinery.
10
+ const FENCE = /^\s{0,3}(:{3,})\s*([\w-]*)\s*(\[[^\]]*\])?\s*(\{[^}]*\})?\s*$/d;
9
11
  const LEAF = /^\s{0,3}::[\w-]+(\[[^\]]*\])?(\{[^}]*\})?\s*$/;
10
12
  const INLINE = /(?<![:\w]):[\w-]+\[[^\]]*\](\{[^}]*\})?/g;
11
13
  // A fenced code block's delimiter: three or more backticks or tildes, indent-tolerant like the
@@ -21,16 +23,17 @@ export function directiveLineKind(line) {
21
23
  return null;
22
24
  }
23
25
  /**
24
- * The 1-based container depth each line sits at, or null outside any container. A named fence
25
- * opens a container; a bare fence closes the most recent one (colon counts are not trusted for
26
- * pairing, since authors vary them). An opener and its closer share the opener's depth, and a
27
- * line between them carries the depth of its innermost container. Lines inside a fenced code
28
- * block are plain content, so a documented ::: example cannot open a phantom container running
29
- * to end of document. Author errors are tolerated: an unmatched closer reads as depth 1 and the
30
- * count never goes below zero.
26
+ * Scan the document's container structure in one pass. A named fence opens a container; a bare
27
+ * fence closes the most recent one (colon counts are not trusted for pairing, since authors
28
+ * vary them). An opener and its closer share the opener's depth, and a line between them
29
+ * carries the depth of its innermost container. Lines inside a fenced code block are plain
30
+ * content, so a documented ::: example cannot open a phantom container running to end of
31
+ * document. Author errors are tolerated: an unmatched closer reads as depth 1 and the count
32
+ * never goes below zero.
31
33
  */
32
- export function fenceDepths(lines) {
34
+ export function fenceScan(lines) {
33
35
  const depths = [];
36
+ const roles = [];
34
37
  let open = 0;
35
38
  // The marker character that opened the current code block, or null outside one. Only a line
36
39
  // opening with the same character closes it, so tildes inside a backtick block stay literal.
@@ -43,27 +46,100 @@ export function fenceDepths(lines) {
43
46
  else if (code[1][0] === codeMarker)
44
47
  codeMarker = null;
45
48
  depths.push(open > 0 ? open : null);
49
+ roles.push(null);
46
50
  continue;
47
51
  }
48
52
  if (codeMarker !== null) {
49
53
  depths.push(open > 0 ? open : null);
54
+ roles.push(null);
50
55
  continue;
51
56
  }
52
57
  const fence = FENCE.exec(line);
53
58
  if (!fence) {
54
59
  depths.push(open > 0 ? open : null);
60
+ roles.push(null);
55
61
  }
56
- else if (fence[1]) {
62
+ else if (fence[2]) {
57
63
  open += 1;
58
64
  depths.push(open);
65
+ roles.push('opener');
59
66
  }
60
67
  else {
61
68
  depths.push(Math.max(open, 1));
69
+ roles.push('closer');
62
70
  if (open > 0)
63
71
  open -= 1;
64
72
  }
65
73
  }
66
- return depths;
74
+ return { depths, roles };
75
+ }
76
+ /** The depth half of {@link fenceScan}, for callers that need no roles. */
77
+ export function fenceDepths(lines) {
78
+ return fenceScan(lines).depths;
79
+ }
80
+ /**
81
+ * The innermost container around a caret line, as an inclusive line range, or null outside any
82
+ * container. Works from the cached scan without re-parsing: the caret line's own depth names
83
+ * the container (fence rows carry the depth they delimit, so a caret on a fence belongs to
84
+ * that fence's container), and within a container the only same-depth real fences are its
85
+ * opener and closer (nested containers sit deeper, siblings sit outside), so the nearest
86
+ * opener above and the nearest closer below bound the range. The scan's roles already disown a
87
+ * fence-shaped line inside a code block, so a documented example can never clip the range. An
88
+ * unclosed container runs to the document end.
89
+ */
90
+ export function caretContainerRange(scan, caretLine) {
91
+ const { depths, roles } = scan;
92
+ const depth = depths[caretLine] ?? null;
93
+ if (depth === null)
94
+ return null;
95
+ let fromLine = caretLine;
96
+ for (let i = caretLine; i >= 0; i--) {
97
+ if (depths[i] === depth && roles[i] === 'opener') {
98
+ fromLine = i;
99
+ break;
100
+ }
101
+ }
102
+ let toLine = depths.length - 1;
103
+ for (let i = caretLine; i < depths.length; i++) {
104
+ if (depths[i] === depth && roles[i] === 'closer') {
105
+ toLine = i;
106
+ break;
107
+ }
108
+ }
109
+ return { fromLine, toLine, depth };
110
+ }
111
+ /**
112
+ * Split a fence line into machinery and meaning. The colon run, the label's brackets, and the
113
+ * whole {attrs} group are machinery; the directive name and the label text are meaning, the
114
+ * parts an editor reads. A bare closer is a single machinery span, and a non-fence line yields
115
+ * no spans at all.
116
+ */
117
+ export function fenceTokens(line) {
118
+ const m = FENCE.exec(line);
119
+ if (!m?.indices)
120
+ return [];
121
+ // A group's span exists whenever the group matched: group 1 (the colons) always does on a
122
+ // fence, and the optional groups are read only behind their own m[n] guard.
123
+ const indices = m.indices;
124
+ const out = [];
125
+ const [colonFrom, colonTo] = indices[1];
126
+ out.push({ from: colonFrom, to: colonTo, kind: 'mark' });
127
+ if (m[2]) {
128
+ const [from, to] = indices[2];
129
+ out.push({ from, to, kind: 'label' });
130
+ }
131
+ if (m[3]) {
132
+ const [from, to] = indices[3];
133
+ out.push({ from, to: from + 1, kind: 'mark' });
134
+ if (to - from > 2)
135
+ out.push({ from: from + 1, to: to - 1, kind: 'label' });
136
+ out.push({ from: to - 1, to, kind: 'mark' });
137
+ }
138
+ if (m[4]) {
139
+ const [from, to] = indices[4];
140
+ out.push({ from, to, kind: 'mark' });
141
+ }
142
+ return out;
67
143
  }
68
144
  /** Inline directive ranges (`:name[...]{...}`) within a line of text. */
69
145
  export function findInlineDirectives(text) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@glw907/cairn-cms",
3
- "version": "0.51.0",
3
+ "version": "0.52.0",
4
4
  "description": "Embedded, magic-link, GitHub-committing CMS for SvelteKit/Cloudflare sites.",
5
5
  "type": "module",
6
6
  "sideEffects": [
@@ -178,6 +178,25 @@ transient flashes, and the editor card's footer holds the word count and the Mar
178
178
  device = id;
179
179
  localStorage.setItem(deviceStorageKey, id);
180
180
  }
181
+ // The writing modes (focus, typewriter), per-browser preferences on the device pick's pattern:
182
+ // off by default, read in an effect so SSR never touches localStorage, written by the
183
+ // toolbar's toggles. The effect tracks nothing reactive, so it runs once.
184
+ const focusStorageKey = 'cairn-editor-focus-mode';
185
+ const typewriterStorageKey = 'cairn-editor-typewriter';
186
+ let focusMode = $state(false);
187
+ let typewriter = $state(false);
188
+ $effect(() => {
189
+ focusMode = localStorage.getItem(focusStorageKey) === 'true';
190
+ typewriter = localStorage.getItem(typewriterStorageKey) === 'true';
191
+ });
192
+ function setFocusMode(on: boolean) {
193
+ focusMode = on;
194
+ localStorage.setItem(focusStorageKey, String(on));
195
+ }
196
+ function setTypewriter(on: boolean) {
197
+ typewriter = on;
198
+ localStorage.setItem(typewriterStorageKey, String(on));
199
+ }
181
200
  const activeDevice = $derived(previewDevice(device));
182
201
  // The iframe document around the rendered html: the site's stylesheets from the adapter's
183
202
  // preview knob, or a styleless document (behind the hint below) when the site sets none.
@@ -641,7 +660,17 @@ transient flashes, and the editor card's footer holds the word count and the Mar
641
660
  role="group"
642
661
  aria-label="Editor"
643
662
  >
644
- <EditorToolbar {format} {mode} onMode={setMode} {device} onDevice={setDevice}>
663
+ <EditorToolbar
664
+ {format}
665
+ {mode}
666
+ onMode={setMode}
667
+ {device}
668
+ onDevice={setDevice}
669
+ {focusMode}
670
+ onFocusMode={setFocusMode}
671
+ {typewriter}
672
+ onTypewriter={setTypewriter}
673
+ >
645
674
  {#snippet insertControls()}
646
675
  <!-- Plain triggers only: the dialogs they open hold their own <form> elements, so the
647
676
  dialogs themselves mount outside the edit form at the bottom of this component. -->
@@ -704,6 +733,8 @@ transient flashes, and the editor card's footer holds the word count and the Mar
704
733
  registerGetSelection={(fn) => (getSelection = fn)}
705
734
  registerFormat={(fn) => (format = fn)}
706
735
  {completionSources}
736
+ {focusMode}
737
+ {typewriter}
707
738
  />
708
739
  </div>
709
740
  {#if mode === 'preview'}