@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.
- package/CHANGELOG.md +21 -0
- package/dist/components/EditPage.svelte +32 -1
- package/dist/components/EditorToolbar.svelte +60 -6
- package/dist/components/EditorToolbar.svelte.d.ts +10 -1
- package/dist/components/MarkdownEditor.svelte +112 -24
- package/dist/components/MarkdownEditor.svelte.d.ts +4 -0
- package/dist/components/cairn-admin.css +23 -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 +32 -1
- package/src/lib/components/EditorToolbar.svelte +60 -6
- package/src/lib/components/MarkdownEditor.svelte +112 -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,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,
|
|
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.
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
{ tag: tags.quote, color: 'var(--color-
|
|
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
|
|
36
|
-
// document changes
|
|
37
|
-
|
|
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
|
|
68
|
+
return lines;
|
|
43
69
|
}
|
|
44
|
-
function buildDirectiveDecorations(view,
|
|
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
|
-
|
|
112
|
+
scan;
|
|
74
113
|
constructor(view) {
|
|
75
|
-
this.
|
|
76
|
-
this.decorations = buildDirectiveDecorations(view, this.
|
|
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.
|
|
81
|
-
|
|
82
|
-
|
|
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.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
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)
|
|
7
|
-
//
|
|
8
|
-
|
|
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
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
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[
|
|
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
|
@@ -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
|
|
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'}
|