@k2b/editor 0.0.0 → 0.1.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/LICENSE +661 -0
- package/README.md +32 -1
- package/package.json +116 -4
- package/src/BubbleMenu.tsx +126 -0
- package/src/Editor.tsx +32 -0
- package/src/SlashMenu.tsx +13 -0
- package/src/Toolbar.tsx +205 -0
- package/src/actions.ts +102 -0
- package/src/chart-schema.ts +274 -0
- package/src/charts.ts +710 -0
- package/src/collab/history.ts +39 -0
- package/src/collab/index.ts +32 -0
- package/src/collab/relative-cursor.ts +18 -0
- package/src/collab/remote-cursors.ts +358 -0
- package/src/editor-state.ts +199 -0
- package/src/formula.ts +1283 -0
- package/src/index.ts +16 -0
- package/src/markdown/_lib/cursor-zone-field.ts +338 -0
- package/src/markdown/_lib/inline-syntax.ts +9 -0
- package/src/markdown/_lib/link-opener.ts +50 -0
- package/src/markdown/_lib/list-item-line.ts +34 -0
- package/src/markdown/_lib/list-structure.ts +227 -0
- package/src/markdown/_lib/math-syntax.ts +3 -0
- package/src/markdown/_lib/table-cell.ts +74 -0
- package/src/markdown/basic.ts +37 -0
- package/src/markdown/block-previews.tsx +453 -0
- package/src/markdown/chart-block-messages.ts +5 -0
- package/src/markdown/chart-copy.ts +20 -0
- package/src/markdown/code-fence-snippets.ts +167 -0
- package/src/markdown/code-font.ts +54 -0
- package/src/markdown/completion-icon.ts +9 -0
- package/src/markdown/data-block-template.ts +11 -0
- package/src/markdown/data-blocks.ts +214 -0
- package/src/markdown/editor-scope.ts +92 -0
- package/src/markdown/fade-scroll-margins.browser-harness.ts +78 -0
- package/src/markdown/fade-scroll-margins.ts +25 -0
- package/src/markdown/image-syntax.ts +26 -0
- package/src/markdown/images.ts +192 -0
- package/src/markdown/index.ts +54 -0
- package/src/markdown/info-block-snippets.ts +159 -0
- package/src/markdown/info-blocks.ts +156 -0
- package/src/markdown/katex.ts +303 -0
- package/src/markdown/lib/chart-blocks.ts +511 -0
- package/src/markdown/lib/heading-anchors.ts +40 -0
- package/src/markdown/lib/ligatures.ts +107 -0
- package/src/markdown/lib/markdown-context.ts +72 -0
- package/src/markdown/lib/markdown-fences.ts +22 -0
- package/src/markdown/lib/markdown-image.fixtures.ts +20 -0
- package/src/markdown/lib/markdown-image.ts +36 -0
- package/src/markdown/lib/named-blocks.ts +403 -0
- package/src/markdown/lib/query-blocks.ts +145 -0
- package/src/markdown/ligatures.ts +223 -0
- package/src/markdown/links.ts +158 -0
- package/src/markdown/list-commands.ts +1124 -0
- package/src/markdown/lists.ts +290 -0
- package/src/markdown/mark.ts +103 -0
- package/src/markdown/markdown.ts +78 -0
- package/src/markdown/markup.ts +385 -0
- package/src/markdown/mermaid-export.ts +78 -0
- package/src/markdown/mermaid-preview.tsx +76 -0
- package/src/markdown/mermaid-render.ts +22 -0
- package/src/markdown/mermaid-viewport.tsx +78 -0
- package/src/markdown/mermaid.ts +242 -0
- package/src/markdown/messages.ts +17 -0
- package/src/markdown/named-blocks.ts +84 -0
- package/src/markdown/note-scroller.browser-harness.ts +84 -0
- package/src/markdown/note-scroller.ts +57 -0
- package/src/markdown/notebook-charts.tsx +142 -0
- package/src/markdown/pretty-table-messages.ts +33 -0
- package/src/markdown/pretty-table.ts +220 -0
- package/src/markdown/search-theme.ts +98 -0
- package/src/markdown/shared-tables.ts +286 -0
- package/src/markdown/sub-sup.ts +115 -0
- package/src/markdown/surface-press.ts +17 -0
- package/src/markdown/tab-indent.ts +104 -0
- package/src/markdown/table-columns.ts +188 -0
- package/src/markdown/table-formulas.ts +178 -0
- package/src/markdown/tables.ts +703 -0
- package/src/markdown/theme.ts +243 -0
- package/src/markdown/urls.ts +29 -0
- package/src/messages.ts +45 -0
- package/src/slash-menu.ts +220 -0
- package/src/styles.css +376 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Autocomplete für ` ```language ` Code-Fences.
|
|
3
|
+
*
|
|
4
|
+
* Trigger: line starts with three backticks (or tildes) optionally
|
|
5
|
+
* followed by a partial language name. Accepting a suggestion expands
|
|
6
|
+
* to the full fenced block — opening fence with the chosen language,
|
|
7
|
+
* a body line for the cursor, AND the closing fence — so the user
|
|
8
|
+
* doesn't have to remember to close the fence themselves.
|
|
9
|
+
*
|
|
10
|
+
* Languages mirror exactly what `markdown.ts` registers as
|
|
11
|
+
* `codeLanguages`, plus two notebooks-specific specials that get
|
|
12
|
+
* their own rendering pipelines:
|
|
13
|
+
*
|
|
14
|
+
* - `mermaid` — diagram, rendered via `mermaid.ts`
|
|
15
|
+
* - `math` — block KaTeX, rendered via `katex.ts`
|
|
16
|
+
*
|
|
17
|
+
* Each suggestion ships an explicit Tabler `completionIcon` so the icon
|
|
18
|
+
* column reads at a glance (`ti-brand-javascript`, `ti-database` for
|
|
19
|
+
* SQL, `ti-math-function` for math, etc.) instead of falling through
|
|
20
|
+
* to the generic `class` icon for every language.
|
|
21
|
+
*
|
|
22
|
+
* Scope: only in regular markdown context. We skip when the cursor
|
|
23
|
+
* is already inside an existing `FencedCode` block — typing ` ``` `
|
|
24
|
+
* to CLOSE a fence shouldn't surface the language picker.
|
|
25
|
+
*/
|
|
26
|
+
import { type Completion, type CompletionContext, type CompletionResult, snippetCompletion } from "@codemirror/autocomplete";
|
|
27
|
+
import { withIcon } from "./completion-icon";
|
|
28
|
+
import { isInsideFencedCodeBody } from "./editor-scope";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Each fence-language entry. `name` is what we insert into the
|
|
32
|
+
* source; `aliases` is shown as a hint for users who know the short
|
|
33
|
+
* form ("js" → JavaScript). `icon` is a Tabler class.
|
|
34
|
+
*
|
|
35
|
+
* `bodyPlaceholder` lets us pre-fill the body for languages that
|
|
36
|
+
* benefit from a starting template (mermaid would render an error
|
|
37
|
+
* if you opened an empty diagram block — pre-filling a tiny example
|
|
38
|
+
* gives instant visual feedback). For most languages the body
|
|
39
|
+
* placeholder is empty so the user starts typing immediately.
|
|
40
|
+
*/
|
|
41
|
+
type FenceLang = {
|
|
42
|
+
name: string;
|
|
43
|
+
detail: string;
|
|
44
|
+
aliases?: string[];
|
|
45
|
+
icon: string;
|
|
46
|
+
bodyPlaceholder?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const LANGUAGES: FenceLang[] = [
|
|
50
|
+
// Notebooks-specific specials — surface FIRST in the list because
|
|
51
|
+
// they're the most distinctive feature of the editor, not generic
|
|
52
|
+
// syntax-highlighted code.
|
|
53
|
+
{
|
|
54
|
+
name: "mermaid",
|
|
55
|
+
detail: "Mermaid diagram",
|
|
56
|
+
icon: "ti-binary-tree",
|
|
57
|
+
bodyPlaceholder: "graph TD\n A[Start] --> B[End]",
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
name: "math",
|
|
61
|
+
detail: "Block math (KaTeX)",
|
|
62
|
+
icon: "ti-math-integral",
|
|
63
|
+
bodyPlaceholder: "E = mc^2",
|
|
64
|
+
},
|
|
65
|
+
// Standard languages — alphabetical so users can scan.
|
|
66
|
+
{ name: "javascript", aliases: ["js"], detail: "JavaScript", icon: "ti-brand-javascript" },
|
|
67
|
+
{ name: "typescript", aliases: ["ts"], detail: "TypeScript", icon: "ti-brand-typescript" },
|
|
68
|
+
{ name: "python", aliases: ["py"], detail: "Python", icon: "ti-brand-python" },
|
|
69
|
+
{ name: "go", aliases: ["golang"], detail: "Go", icon: "ti-brand-golang" },
|
|
70
|
+
{ name: "rust", aliases: ["rs"], detail: "Rust", icon: "ti-brand-rust" },
|
|
71
|
+
{ name: "java", detail: "Java", icon: "ti-coffee" },
|
|
72
|
+
{ name: "cpp", aliases: ["c", "h", "hpp"], detail: "C / C++", icon: "ti-brand-cpp" },
|
|
73
|
+
{ name: "php", detail: "PHP", icon: "ti-brand-php" },
|
|
74
|
+
{ name: "sql", detail: "SQL", icon: "ti-database" },
|
|
75
|
+
{ name: "html", aliases: ["htm"], detail: "HTML", icon: "ti-brand-html5" },
|
|
76
|
+
{ name: "css", detail: "CSS", icon: "ti-brand-css3" },
|
|
77
|
+
{ name: "json", detail: "JSON", icon: "ti-braces" },
|
|
78
|
+
{ name: "yaml", aliases: ["yml"], detail: "YAML", icon: "ti-file-text" },
|
|
79
|
+
{ name: "xml", aliases: ["svg"], detail: "XML / SVG", icon: "ti-code" },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
/** Build the snippet template for one language. Does NOT include
|
|
83
|
+
* the leading three backticks because the `from` position in the
|
|
84
|
+
* CompletionResult is anchored AFTER the backticks the user already
|
|
85
|
+
* typed — CM's prefix-filter compares the typed partial against
|
|
86
|
+
* `label` ("python", "javascript", …), and on acceptance only the
|
|
87
|
+
* text between `from` and the cursor is replaced. So we only emit
|
|
88
|
+
* the language tag + body + closing fence; the user's typed
|
|
89
|
+
* backticks stay in place. `${0}` parks the final cursor on the
|
|
90
|
+
* body line. */
|
|
91
|
+
const escapeSnippetPlaceholder = (value: string): string => value.replace(/[\\${}]/g, "\\$&");
|
|
92
|
+
|
|
93
|
+
const buildSnippet = (lang: FenceLang): string => {
|
|
94
|
+
const body = lang.bodyPlaceholder ? `\${0:${escapeSnippetPlaceholder(lang.bodyPlaceholder)}}` : "${0}";
|
|
95
|
+
return `${lang.name}\n${body}\n\`\`\``;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** Pre-built completion list. Constant per editor — we don't allocate
|
|
99
|
+
* fresh snippet objects on every keystroke.
|
|
100
|
+
*
|
|
101
|
+
* `boost`: numeric weight that CM adds to the option's relevance
|
|
102
|
+
* score. Mermaid and math receive a boost above generic languages.
|
|
103
|
+
*
|
|
104
|
+
* We post-augment each Completion with `completionIcon` because
|
|
105
|
+
* `snippetCompletion`'s second-arg type is `Completion` (no
|
|
106
|
+
* `completionIcon` field on the official interface); the icon renderer
|
|
107
|
+
* in `slash-commands/index.ts` reads the field via a structural
|
|
108
|
+
* cast. */
|
|
109
|
+
const LANG_BOOSTS: Record<string, number> = {
|
|
110
|
+
mermaid: 90,
|
|
111
|
+
math: 80,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const COMPLETIONS: Completion[] = LANGUAGES.map((lang) => {
|
|
115
|
+
const aliasHint = lang.aliases && lang.aliases.length > 0 ? ` · ${lang.aliases.join(", ")}` : "";
|
|
116
|
+
const c = snippetCompletion(buildSnippet(lang), {
|
|
117
|
+
label: lang.name,
|
|
118
|
+
type: "namespace",
|
|
119
|
+
detail: `${lang.detail}${aliasHint}`,
|
|
120
|
+
boost: LANG_BOOSTS[lang.name],
|
|
121
|
+
});
|
|
122
|
+
withIcon(c, lang.icon);
|
|
123
|
+
return c;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Completion source. Wire into `autocompletion({override: […]})`.
|
|
128
|
+
*/
|
|
129
|
+
export const codeFenceCompletionSource = (context: CompletionContext): CompletionResult | null => {
|
|
130
|
+
// matchBefore is anchored to the cursor: it returns a match only
|
|
131
|
+
// if the regex matches text ending at the cursor. We want the
|
|
132
|
+
// whole line text up to cursor to look like ` ```<partial> ` —
|
|
133
|
+
// anything before triple-backticks (e.g. leading whitespace inside
|
|
134
|
+
// a list item) would mean this isn't a top-level fence opener and
|
|
135
|
+
// we shouldn't trigger.
|
|
136
|
+
const word = context.matchBefore(/^(`{3,}|~{3,})\w*/);
|
|
137
|
+
if (!word) return null;
|
|
138
|
+
// Verify the match's `from` is at the line's `from` (paranoia —
|
|
139
|
+
// the `^` in the regex should already enforce this, but matchBefore
|
|
140
|
+
// operates on a substring so being explicit is cheap).
|
|
141
|
+
const line = context.state.doc.lineAt(context.pos);
|
|
142
|
+
if (word.from !== line.from) return null;
|
|
143
|
+
// Don't surface when the cursor is INSIDE an existing fence body
|
|
144
|
+
// (e.g. typing ` ``` ` on the closing line). We DO want the picker
|
|
145
|
+
// on the opener line — `isInsideFencedCodeBody` returns false there.
|
|
146
|
+
if (isInsideFencedCodeBody(context)) return null;
|
|
147
|
+
// Count the fence chars (3+ backticks/tildes) so `from` lands
|
|
148
|
+
// AFTER them. matchBefore captured the entire opener including
|
|
149
|
+
// any partial language name; we need to find where the fence
|
|
150
|
+
// marker ends and the language name begins. The match.text
|
|
151
|
+
// begins with ≥3 of the same char (backticks OR tildes) — count
|
|
152
|
+
// those and offset `from` accordingly.
|
|
153
|
+
const text = word.text;
|
|
154
|
+
let fenceLen = 0;
|
|
155
|
+
const fenceChar = text[0]!;
|
|
156
|
+
while (fenceLen < text.length && text[fenceLen] === fenceChar) fenceLen++;
|
|
157
|
+
return {
|
|
158
|
+
from: word.from + fenceLen,
|
|
159
|
+
to: word.to,
|
|
160
|
+
options: COMPLETIONS,
|
|
161
|
+
// `validFor` keeps the popup open while the user continues
|
|
162
|
+
// typing alphanumeric chars after the backticks — checked
|
|
163
|
+
// against text from `from` to cursor, which is just the
|
|
164
|
+
// language-name fragment (e.g. "py" → "python").
|
|
165
|
+
validFor: /^\w*$/,
|
|
166
|
+
};
|
|
167
|
+
};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-node mono font for fenced and inline code in the markdown editor.
|
|
3
|
+
*
|
|
4
|
+
* The editor body uses Sans (matches read-mode + the rest of the platform)
|
|
5
|
+
* but code should stay monospace — tabular alignment, escaped characters,
|
|
6
|
+
* indentation all rely on equal-width glyphs.
|
|
7
|
+
*
|
|
8
|
+
* This extension walks the markdown syntax tree and adds a `cm-md-code`
|
|
9
|
+
* class to every FencedCode / InlineCode / CodeBlock node, plus
|
|
10
|
+
* `cm-md-code-block` on blocks. The theme styles them with `var(--font-mono)`
|
|
11
|
+
* at the Book's code sizes.
|
|
12
|
+
*
|
|
13
|
+
* Only registered in rich mode — raw mode renders the entire document as
|
|
14
|
+
* mono via the theme directly (it's a source view).
|
|
15
|
+
*/
|
|
16
|
+
import { syntaxTree } from "@codemirror/language";
|
|
17
|
+
import type { EditorState, Extension, Range } from "@codemirror/state";
|
|
18
|
+
import { RangeSet, StateField } from "@codemirror/state";
|
|
19
|
+
import type { DecorationSet } from "@codemirror/view";
|
|
20
|
+
import { Decoration, EditorView } from "@codemirror/view";
|
|
21
|
+
import { refreshMarkdownDecorationsEffect, treeOrDocChanged } from "./_lib/cursor-zone-field";
|
|
22
|
+
|
|
23
|
+
const codeMark = Decoration.mark({ class: "cm-md-code" });
|
|
24
|
+
const blockCodeMark = Decoration.mark({ class: "cm-md-code cm-md-code-block" });
|
|
25
|
+
|
|
26
|
+
const buildCodeFontDecorations = (state: EditorState): Range<Decoration>[] => {
|
|
27
|
+
const decorations: Range<Decoration>[] = [];
|
|
28
|
+
syntaxTree(state).iterate({
|
|
29
|
+
enter: (node) => {
|
|
30
|
+
const { name, from, to } = node;
|
|
31
|
+
if (to <= from) return;
|
|
32
|
+
if (name === "InlineCode") decorations.push(codeMark.range(from, to));
|
|
33
|
+
else if (name === "FencedCode" || name === "CodeBlock") decorations.push(blockCodeMark.range(from, to));
|
|
34
|
+
},
|
|
35
|
+
});
|
|
36
|
+
return decorations;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export const codeFontExtension = (): Extension => {
|
|
40
|
+
return StateField.define<DecorationSet>({
|
|
41
|
+
create(state) {
|
|
42
|
+
return RangeSet.of(buildCodeFontDecorations(state), true);
|
|
43
|
+
},
|
|
44
|
+
update(decorations, tr) {
|
|
45
|
+
if (treeOrDocChanged(tr) || tr.effects.some((effect) => effect.is(refreshMarkdownDecorationsEffect))) {
|
|
46
|
+
return RangeSet.of(buildCodeFontDecorations(tr.state), true);
|
|
47
|
+
}
|
|
48
|
+
return decorations;
|
|
49
|
+
},
|
|
50
|
+
provide(field) {
|
|
51
|
+
return EditorView.decorations.from(field);
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Completion } from "@codemirror/autocomplete";
|
|
2
|
+
|
|
3
|
+
export type IconCompletion = Completion & { completionIcon?: string };
|
|
4
|
+
|
|
5
|
+
/** Attach the icon read by the shared autocomplete option renderer. */
|
|
6
|
+
export const withIcon = <C extends Completion>(completion: C, icon: string): C => {
|
|
7
|
+
(completion as IconCompletion).completionIcon = icon;
|
|
8
|
+
return completion;
|
|
9
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const DATA_BLOCK_DEFAULT_REF = "ref";
|
|
2
|
+
|
|
3
|
+
export const buildDataBlockTemplate = (ref = DATA_BLOCK_DEFAULT_REF): string => `@${ref}
|
|
4
|
+
:::data
|
|
5
|
+
key: value
|
|
6
|
+
:::`;
|
|
7
|
+
|
|
8
|
+
export const dataBlockRefSelection = (insertStart: number, ref = DATA_BLOCK_DEFAULT_REF) => ({
|
|
9
|
+
anchor: insertStart + 1,
|
|
10
|
+
head: insertStart + 1 + ref.length,
|
|
11
|
+
});
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import { type EditorState, type Extension, Prec, type Range, RangeSet, StateField } from "@codemirror/state";
|
|
2
|
+
import { Decoration, type DecorationSet, EditorView, keymap, WidgetType } from "@codemirror/view";
|
|
3
|
+
import { holdsDecorations, refreshMarkdownDecorationsEffect, selectionIntersectsRange } from "./_lib/cursor-zone-field";
|
|
4
|
+
import { type DataBlock, extractDataBlocks, namedBlockBody, renderDataBlockHtml } from "./lib/named-blocks";
|
|
5
|
+
|
|
6
|
+
type DataBlockRange = {
|
|
7
|
+
from: number;
|
|
8
|
+
to: number;
|
|
9
|
+
widgetFrom: number;
|
|
10
|
+
widgetTo: number;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type DataBlocksState = {
|
|
14
|
+
decorations: DecorationSet;
|
|
15
|
+
blockWidgetDecorations: DecorationSet;
|
|
16
|
+
ranges: DataBlockRange[];
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
class DataBlockWidget extends WidgetType {
|
|
20
|
+
constructor(
|
|
21
|
+
private block: DataBlock,
|
|
22
|
+
private body: string,
|
|
23
|
+
) {
|
|
24
|
+
super();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
override toDOM(view: EditorView) {
|
|
28
|
+
const el = document.createElement("div");
|
|
29
|
+
el.className = "cm-data-block-widget";
|
|
30
|
+
el.setAttribute("contenteditable", "false");
|
|
31
|
+
el.innerHTML = renderDataBlockHtml(this.block.name, this.body);
|
|
32
|
+
el.onmousedown = (event) => {
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
event.stopPropagation();
|
|
35
|
+
view.dispatch({ selection: { anchor: this.block.blockStart }, scrollIntoView: true });
|
|
36
|
+
view.focus();
|
|
37
|
+
};
|
|
38
|
+
el.ondblclick = (event) => {
|
|
39
|
+
event.stopPropagation();
|
|
40
|
+
};
|
|
41
|
+
return el;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
override eq(other: WidgetType) {
|
|
45
|
+
return (
|
|
46
|
+
other instanceof DataBlockWidget &&
|
|
47
|
+
other.block.name === this.block.name &&
|
|
48
|
+
other.block.blockStart === this.block.blockStart &&
|
|
49
|
+
other.body === this.body
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
override get estimatedHeight() {
|
|
54
|
+
const rows = Math.max(1, this.body.split("\n").filter((line) => line.trim().length > 0).length);
|
|
55
|
+
// 12px / 16px cells + 16px padding + divider; the handle and outer padding round up to 44px.
|
|
56
|
+
return 44 + rows * 33;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
override ignoreEvent() {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const dataBlocksExtension = (): Extension => {
|
|
65
|
+
const stateField = StateField.define<DataBlocksState>({
|
|
66
|
+
create: scanDataBlocks,
|
|
67
|
+
update(value, tr) {
|
|
68
|
+
if (tr.effects.some((effect) => effect.is(refreshMarkdownDecorationsEffect))) return scanDataBlocks(tr.state);
|
|
69
|
+
if (tr.docChanged) return scanDataBlocks(tr.state);
|
|
70
|
+
if (holdsDecorations(tr)) return value;
|
|
71
|
+
if (!tr.selection) return value;
|
|
72
|
+
const oldKey = cursorDataBlockKey(tr.startState, value.ranges);
|
|
73
|
+
const newKey = cursorDataBlockKey(tr.state, value.ranges);
|
|
74
|
+
if (oldKey === newKey) return value;
|
|
75
|
+
return scanDataBlocks(tr.state);
|
|
76
|
+
},
|
|
77
|
+
provide(field) {
|
|
78
|
+
return [
|
|
79
|
+
EditorView.decorations.from(field, (value) => value.decorations),
|
|
80
|
+
EditorView.atomicRanges.of((view) => view.state.field(field).blockWidgetDecorations),
|
|
81
|
+
];
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const navKeymap = Prec.highest(
|
|
86
|
+
keymap.of([
|
|
87
|
+
{ key: "ArrowUp", run: (view) => navigateLine(view, stateField, -1, false) },
|
|
88
|
+
{ key: "Shift-ArrowUp", run: (view) => navigateLine(view, stateField, -1, true) },
|
|
89
|
+
{ key: "ArrowDown", run: (view) => navigateLine(view, stateField, 1, false) },
|
|
90
|
+
{ key: "Shift-ArrowDown", run: (view) => navigateLine(view, stateField, 1, true) },
|
|
91
|
+
]),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
return [
|
|
95
|
+
stateField,
|
|
96
|
+
navKeymap,
|
|
97
|
+
EditorView.theme({
|
|
98
|
+
".cm-data-block-widget": {
|
|
99
|
+
display: "block",
|
|
100
|
+
padding: "0.5rem 0",
|
|
101
|
+
margin: "0 !important",
|
|
102
|
+
},
|
|
103
|
+
".cm-data-block-widget .md-data-block": {
|
|
104
|
+
margin: "0 !important",
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
];
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
const scanDataBlocks = (state: EditorState): DataBlocksState => {
|
|
111
|
+
const decorations: Range<Decoration>[] = [];
|
|
112
|
+
const blockWidgetDecorations: Range<Decoration>[] = [];
|
|
113
|
+
const ranges: DataBlockRange[] = [];
|
|
114
|
+
const cursor = state.selection.main;
|
|
115
|
+
const text = state.doc.toString();
|
|
116
|
+
|
|
117
|
+
for (const block of extractDataBlocks(text)) {
|
|
118
|
+
const prevLine = state.doc.lineAt(Math.max(block.handleStart - 1, 0));
|
|
119
|
+
const nextLine = state.doc.lineAt(Math.min(block.blockEnd + 1, state.doc.length));
|
|
120
|
+
const range = {
|
|
121
|
+
from: prevLine.from,
|
|
122
|
+
to: nextLine.to,
|
|
123
|
+
widgetFrom: block.handleStart,
|
|
124
|
+
widgetTo: block.blockEnd,
|
|
125
|
+
};
|
|
126
|
+
ranges.push(range);
|
|
127
|
+
if (selectionIntersectsRange(cursor, range.from, range.to)) continue;
|
|
128
|
+
const widgetDecoration = Decoration.replace({
|
|
129
|
+
widget: new DataBlockWidget(block, namedBlockBody(text, block)),
|
|
130
|
+
block: true,
|
|
131
|
+
}).range(block.handleStart, block.blockEnd);
|
|
132
|
+
decorations.push(widgetDecoration);
|
|
133
|
+
blockWidgetDecorations.push(widgetDecoration);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return {
|
|
137
|
+
decorations: decorations.length > 0 ? RangeSet.of(decorations, true) : Decoration.none,
|
|
138
|
+
blockWidgetDecorations: blockWidgetDecorations.length > 0 ? RangeSet.of(blockWidgetDecorations, true) : Decoration.none,
|
|
139
|
+
ranges,
|
|
140
|
+
};
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
const cursorDataBlockKey = (state: EditorState, ranges: DataBlockRange[]): string | null => {
|
|
144
|
+
const cursor = state.selection.main;
|
|
145
|
+
const hits: number[] = [];
|
|
146
|
+
for (const range of ranges) {
|
|
147
|
+
if (selectionIntersectsRange(cursor, range.from, range.to)) hits.push(range.widgetFrom);
|
|
148
|
+
}
|
|
149
|
+
return hits.length > 0 ? hits.sort((a, b) => a - b).join(",") : null;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
const isLineInBlockWidget = (state: DataBlocksState, from: number, to: number): DataBlockRange | null => {
|
|
153
|
+
for (const range of state.ranges) {
|
|
154
|
+
if (from > range.widgetTo || to < range.widgetFrom) continue;
|
|
155
|
+
let isWidget = false;
|
|
156
|
+
state.blockWidgetDecorations.between(range.widgetFrom, range.widgetTo, (decoFrom, decoTo) => {
|
|
157
|
+
if (decoFrom === range.widgetFrom && decoTo === range.widgetTo) {
|
|
158
|
+
isWidget = true;
|
|
159
|
+
return false;
|
|
160
|
+
}
|
|
161
|
+
return undefined;
|
|
162
|
+
});
|
|
163
|
+
if (isWidget) return range;
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
};
|
|
167
|
+
|
|
168
|
+
const navigateLine = (view: EditorView, stateField: StateField<DataBlocksState>, dir: -1 | 1, extend: boolean): boolean => {
|
|
169
|
+
const dataState = view.state.field(stateField, false);
|
|
170
|
+
if (!dataState || dataState.ranges.length === 0) return false;
|
|
171
|
+
|
|
172
|
+
const sel = view.state.selection.main;
|
|
173
|
+
const head = sel.head;
|
|
174
|
+
const caretLine = view.state.doc.lineAt(head);
|
|
175
|
+
const caretCol = head - caretLine.from;
|
|
176
|
+
|
|
177
|
+
let targetLineNumber = caretLine.number + dir;
|
|
178
|
+
while (targetLineNumber >= 1 && targetLineNumber <= view.state.doc.lines) {
|
|
179
|
+
const line = view.state.doc.line(targetLineNumber);
|
|
180
|
+
const inside = isLineInBlockWidget(dataState, line.from, line.to);
|
|
181
|
+
if (!inside) break;
|
|
182
|
+
const widgetStartLine = view.state.doc.lineAt(inside.widgetFrom).number;
|
|
183
|
+
const widgetEndLine = view.state.doc.lineAt(inside.widgetTo).number;
|
|
184
|
+
targetLineNumber = dir < 0 ? widgetStartLine - 1 : widgetEndLine + 1;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
if (targetLineNumber < 1) {
|
|
188
|
+
view.dispatch({
|
|
189
|
+
selection: extend ? { anchor: sel.anchor, head: 0 } : { anchor: 0 },
|
|
190
|
+
scrollIntoView: true,
|
|
191
|
+
userEvent: "select",
|
|
192
|
+
});
|
|
193
|
+
return true;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (targetLineNumber > view.state.doc.lines) {
|
|
197
|
+
const target = view.state.doc.length;
|
|
198
|
+
view.dispatch({
|
|
199
|
+
selection: extend ? { anchor: sel.anchor, head: target } : { anchor: target },
|
|
200
|
+
scrollIntoView: true,
|
|
201
|
+
userEvent: "select",
|
|
202
|
+
});
|
|
203
|
+
return true;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const targetLine = view.state.doc.line(targetLineNumber);
|
|
207
|
+
const target = targetLine.from + Math.min(caretCol, targetLine.length);
|
|
208
|
+
view.dispatch({
|
|
209
|
+
selection: extend ? { anchor: sel.anchor, head: target } : { anchor: target },
|
|
210
|
+
scrollIntoView: true,
|
|
211
|
+
userEvent: "select",
|
|
212
|
+
});
|
|
213
|
+
return true;
|
|
214
|
+
};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared scope helpers for completion sources.
|
|
3
|
+
*
|
|
4
|
+
* All sources that need to know "is the cursor inside a fenced code
|
|
5
|
+
* block?" go through here. Without this helper, each source would
|
|
6
|
+
* walk the syntax tree independently — multiplied across the 3-4
|
|
7
|
+
* completion sources that share this question, that's a lot of
|
|
8
|
+
* redundant work per keystroke.
|
|
9
|
+
*
|
|
10
|
+
* Cache strategy: keyed on the immutable `EditorState` object and
|
|
11
|
+
* the cursor position. CM6 gives us a fresh `EditorState` per
|
|
12
|
+
* transaction, so the WeakMap entry naturally lives only as long
|
|
13
|
+
* as the state is reachable — no manual eviction needed. Multiple
|
|
14
|
+
* sources running in the same transaction (= same state) hit the
|
|
15
|
+
* cache; the first one pays for the syntax-tree walk, the rest are
|
|
16
|
+
* O(1).
|
|
17
|
+
*/
|
|
18
|
+
import type { CompletionContext } from "@codemirror/autocomplete";
|
|
19
|
+
import { syntaxTree } from "@codemirror/language";
|
|
20
|
+
import type { EditorState } from "@codemirror/state";
|
|
21
|
+
|
|
22
|
+
type CacheEntry = {
|
|
23
|
+
/** Cursor position the cached answer is valid at. Different
|
|
24
|
+
* positions within the same state can resolve to different
|
|
25
|
+
* nodes (e.g. one on the opening fence line, one in the body),
|
|
26
|
+
* so we key by both. */
|
|
27
|
+
pos: number;
|
|
28
|
+
/** True iff cursor is inside any FencedCode node. */
|
|
29
|
+
insideFence: boolean;
|
|
30
|
+
/** True iff cursor is inside the BODY (or closing line) of a
|
|
31
|
+
* FencedCode — i.e. anywhere in the fence range EXCEPT the
|
|
32
|
+
* opening line. Used by the code-fence-snippets picker, which
|
|
33
|
+
* should fire ON the opening line (that IS the trigger position)
|
|
34
|
+
* but stay silent inside the body or on the closing line. */
|
|
35
|
+
insideFenceBody: boolean;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const cache = new WeakMap<EditorState, CacheEntry>();
|
|
39
|
+
|
|
40
|
+
const computeEntry = (context: CompletionContext): CacheEntry => {
|
|
41
|
+
const tree = syntaxTree(context.state);
|
|
42
|
+
let node = tree.resolveInner(context.pos, -1);
|
|
43
|
+
let fenceNode: typeof node | null = null;
|
|
44
|
+
while (node) {
|
|
45
|
+
if (node.name === "FencedCode" || node.name === "CodeBlock") {
|
|
46
|
+
fenceNode = node;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
if (!node.parent) break;
|
|
50
|
+
node = node.parent;
|
|
51
|
+
}
|
|
52
|
+
if (!fenceNode) return { pos: context.pos, insideFence: false, insideFenceBody: false };
|
|
53
|
+
// "Body" check: skip when the cursor line is the FENCE OPENING
|
|
54
|
+
// line. Compute by lineAt — fenceNode.from is the position of the
|
|
55
|
+
// opening backticks/tildes, so the line at that position is the
|
|
56
|
+
// opener.
|
|
57
|
+
const cursorLine = context.state.doc.lineAt(context.pos);
|
|
58
|
+
const openerLine = context.state.doc.lineAt(fenceNode.from);
|
|
59
|
+
return {
|
|
60
|
+
pos: context.pos,
|
|
61
|
+
insideFence: true,
|
|
62
|
+
insideFenceBody: cursorLine.from !== openerLine.from,
|
|
63
|
+
};
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
const getEntry = (context: CompletionContext): CacheEntry => {
|
|
67
|
+
const cached = cache.get(context.state);
|
|
68
|
+
if (cached && cached.pos === context.pos) return cached;
|
|
69
|
+
const entry = computeEntry(context);
|
|
70
|
+
cache.set(context.state, entry);
|
|
71
|
+
return entry;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* True when the cursor is inside any FencedCode / CodeBlock node.
|
|
76
|
+
*
|
|
77
|
+
* Used by notice-card + tag sources — they're literal-text inside a
|
|
78
|
+
* code fence and shouldn't surface suggestions there.
|
|
79
|
+
*/
|
|
80
|
+
export const isInsideFencedCode = (context: CompletionContext): boolean => getEntry(context).insideFence;
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* True when the cursor is inside the BODY (not the opener line) of
|
|
84
|
+
* a FencedCode. Used by the code-fence-snippets picker: typing
|
|
85
|
+
* ` ``` ` on the OPENER should surface the language list, but typing
|
|
86
|
+
* ` ``` ` on the CLOSING line shouldn't (the user is closing an
|
|
87
|
+
* existing fence, not starting a new one).
|
|
88
|
+
*
|
|
89
|
+
* Returns false when the cursor is not inside any fence (i.e. plain
|
|
90
|
+
* markdown context).
|
|
91
|
+
*/
|
|
92
|
+
export const isInsideFencedCodeBody = (context: CompletionContext): boolean => getEntry(context).insideFenceBody;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { defaultKeymap } from "@codemirror/commands";
|
|
2
|
+
import { EditorState } from "@codemirror/state";
|
|
3
|
+
import { EditorView, keymap } from "@codemirror/view";
|
|
4
|
+
import { ScrollArea } from "@k2b/ui";
|
|
5
|
+
import { createComponent, render } from "solid-js/web";
|
|
6
|
+
import { fadeScrollMarginsExtension, fadeScrollPadding } from "./fade-scroll-margins";
|
|
7
|
+
import { noteScroller } from "./note-scroller";
|
|
8
|
+
|
|
9
|
+
// The note editor's arrangement: CodeMirror grows inside a fading ScrollArea.
|
|
10
|
+
// The test overrides the fade size to a fake overlay height.
|
|
11
|
+
const params = new URLSearchParams(location.search);
|
|
12
|
+
const fade = params.get("fade") ?? "48px";
|
|
13
|
+
const margins = params.get("margins") !== "off";
|
|
14
|
+
|
|
15
|
+
let port: HTMLDivElement | undefined;
|
|
16
|
+
const host = document.createElement("div");
|
|
17
|
+
const root = document.getElementById("root")!;
|
|
18
|
+
render(
|
|
19
|
+
() =>
|
|
20
|
+
createComponent(ScrollArea, {
|
|
21
|
+
ref: (element: HTMLDivElement) => {
|
|
22
|
+
port = element;
|
|
23
|
+
},
|
|
24
|
+
style: { height: "320px", "--scroll-fade-size": fade, "scroll-padding-block": fadeScrollPadding },
|
|
25
|
+
children: host,
|
|
26
|
+
}),
|
|
27
|
+
root,
|
|
28
|
+
);
|
|
29
|
+
|
|
30
|
+
const doc = Array.from({ length: 60 }, (_, index) => `Line ${index + 1} of the demo note`).join("\n");
|
|
31
|
+
const view = new EditorView({
|
|
32
|
+
parent: host,
|
|
33
|
+
state: EditorState.create({
|
|
34
|
+
doc,
|
|
35
|
+
extensions: [margins ? fadeScrollMarginsExtension(() => port) : [], keymap.of(defaultKeymap), noteScroller],
|
|
36
|
+
}),
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
/** Where the cursor line sits relative to the fades that are drawn right now. */
|
|
40
|
+
const cursor = () => {
|
|
41
|
+
const head = view.state.selection.main.head;
|
|
42
|
+
const coords = view.coordsAtPos(head)!;
|
|
43
|
+
const rect = port!.getBoundingClientRect();
|
|
44
|
+
const edges = port!.getAttribute("data-scroll-fade") ?? "";
|
|
45
|
+
const style = getComputedStyle(port!);
|
|
46
|
+
return {
|
|
47
|
+
clearBelow: rect.bottom - coords.bottom,
|
|
48
|
+
clearAbove: coords.top - rect.top,
|
|
49
|
+
bottomFade: edges === "bottom" || edges === "both" ? Number.parseFloat(style.getPropertyValue("--scroll-fade-bottom")) : 0,
|
|
50
|
+
topFade: edges === "top" || edges === "both" ? Number.parseFloat(style.getPropertyValue("--scroll-fade-top")) : 0,
|
|
51
|
+
scrollTop: port!.scrollTop,
|
|
52
|
+
maxScrollTop: port!.scrollHeight - port!.clientHeight,
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Puts the cursor at the end of the last line that is fully visible above the bottom fade. */
|
|
57
|
+
const placeAtVisibleBottom = () => {
|
|
58
|
+
const rect = port!.getBoundingClientRect();
|
|
59
|
+
const pos = view.posAtCoords({ x: rect.left + 40, y: rect.bottom - Number.parseFloat(fade) - 24 })!;
|
|
60
|
+
const line = view.state.doc.lineAt(pos);
|
|
61
|
+
view.dispatch({ selection: { anchor: line.to } });
|
|
62
|
+
view.focus();
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const placeAtEnd = () => {
|
|
66
|
+
view.dispatch({ selection: { anchor: view.state.doc.length }, scrollIntoView: true });
|
|
67
|
+
view.focus();
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const paste = (text: string) => {
|
|
71
|
+
const data = new DataTransfer();
|
|
72
|
+
data.setData("text/plain", text);
|
|
73
|
+
view.contentDOM.dispatchEvent(new ClipboardEvent("paste", { clipboardData: data, bubbles: true, cancelable: true }));
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const settle = () => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)));
|
|
77
|
+
|
|
78
|
+
Object.assign(window, { harness: { cursor, placeAtVisibleBottom, placeAtEnd, paste, settle } });
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { EditorView } from "@codemirror/view";
|
|
2
|
+
import { measureScrollFade } from "@k2b/ui";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The note editor grows inside a `ScrollArea`, whose fades cover its top and
|
|
6
|
+
* bottom edge while more content remains there. Declared as `scroll-padding`,
|
|
7
|
+
* the browser resolves the fade to pixels on the actual element.
|
|
8
|
+
*/
|
|
9
|
+
export const fadeScrollPadding = "var(--scroll-fade-top, 0px) var(--scroll-fade-bottom, 0px)";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* CodeMirror scrolls the cursor into view itself and ignores CSS
|
|
13
|
+
* `scroll-padding`, so typing, Enter, paste, and arrow keys would leave the
|
|
14
|
+
* cursor line under the fade. This passes the scroll container's padding on as
|
|
15
|
+
* CodeMirror scroll margins; pointer scrolling is unaffected.
|
|
16
|
+
*/
|
|
17
|
+
export const fadeScrollMarginsExtension = (container: () => HTMLElement | undefined) =>
|
|
18
|
+
EditorView.scrollMargins.of(() => {
|
|
19
|
+
const element = container();
|
|
20
|
+
if (!element) return null;
|
|
21
|
+
// The scroll area updates its fades once per frame; CodeMirror measures in the same frame as the edit.
|
|
22
|
+
measureScrollFade(element);
|
|
23
|
+
const style = getComputedStyle(element);
|
|
24
|
+
return { top: Number.parseFloat(style.scrollPaddingTop) || 0, bottom: Number.parseFloat(style.scrollPaddingBottom) || 0 };
|
|
25
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { MarkdownConfig } from "@lezer/markdown";
|
|
2
|
+
import { parseSizedMarkdownImage } from "./lib/markdown-image";
|
|
3
|
+
|
|
4
|
+
export const sizedImageSyntax: MarkdownConfig = {
|
|
5
|
+
parseInline: [
|
|
6
|
+
{
|
|
7
|
+
name: "SizedImage",
|
|
8
|
+
before: "Image",
|
|
9
|
+
parse(cx, next, pos) {
|
|
10
|
+
if (next !== 33 || cx.char(pos + 1) !== 91) return -1;
|
|
11
|
+
const image = parseSizedMarkdownImage(cx.slice(pos, cx.end));
|
|
12
|
+
if (!image) return -1;
|
|
13
|
+
const { length, hrefFrom, hrefTo, titleFrom, titleTo } = image;
|
|
14
|
+
const children = [
|
|
15
|
+
cx.elt("LinkMark", pos, pos + 2),
|
|
16
|
+
cx.elt("LinkMark", pos + hrefFrom - 2, pos + hrefFrom - 1),
|
|
17
|
+
cx.elt("LinkMark", pos + hrefFrom - 1, pos + hrefFrom),
|
|
18
|
+
cx.elt("URL", pos + hrefFrom, pos + hrefTo),
|
|
19
|
+
];
|
|
20
|
+
if (titleFrom !== null && titleTo !== null) children.push(cx.elt("LinkTitle", pos + titleFrom, pos + titleTo));
|
|
21
|
+
children.push(cx.elt("LinkMark", pos + length - 1, pos + length));
|
|
22
|
+
return cx.addElement(cx.elt("Image", pos, pos + length, children));
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
};
|