leepi 0.0.0 → 0.0.3
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/dist/core/active-marks.d.ts +15 -0
- package/dist/core/active-marks.js +57 -0
- package/dist/core/commands.d.ts +39 -0
- package/dist/core/commands.js +415 -0
- package/dist/core/editor.d.ts +25 -0
- package/dist/core/editor.js +102 -0
- package/dist/core/field-notifier.d.ts +21 -0
- package/dist/core/field-notifier.js +56 -0
- package/dist/core/highlight-style.js +60 -0
- package/dist/core/highlight.d.ts +8 -0
- package/dist/core/highlight.js +34 -0
- package/dist/core/plugins/blockquote.d.ts +11 -0
- package/dist/core/plugins/blockquote.js +78 -0
- package/dist/core/plugins/bracket.d.ts +6 -0
- package/dist/core/plugins/bracket.js +38 -0
- package/dist/core/plugins/code-block.d.ts +27 -0
- package/dist/core/plugins/code-block.js +207 -0
- package/dist/core/plugins/heading.d.ts +13 -0
- package/dist/core/plugins/heading.js +111 -0
- package/dist/core/plugins/inline.d.ts +14 -0
- package/dist/core/plugins/inline.js +103 -0
- package/dist/core/plugins/link.d.ts +25 -0
- package/dist/core/plugins/link.js +104 -0
- package/dist/core/plugins/list.d.ts +14 -0
- package/dist/core/plugins/list.js +91 -0
- package/dist/core/plugins/table.d.ts +12 -0
- package/dist/core/plugins/table.js +161 -0
- package/dist/core/plugins.d.ts +9 -0
- package/dist/core/plugins.js +9 -0
- package/dist/core/popover.d.ts +9 -0
- package/dist/core/popover.js +16 -0
- package/dist/core/registry.d.ts +10 -0
- package/dist/core/registry.js +8 -0
- package/dist/core/types.d.ts +25 -0
- package/dist/core/types.js +0 -0
- package/dist/core/utils.d.ts +13 -0
- package/dist/core/utils.js +32 -0
- package/dist/leepi.css +461 -0
- package/dist/react/code-block-popover.d.ts +76 -0
- package/dist/react/code-block-popover.js +223 -0
- package/dist/react/context.d.ts +42 -0
- package/dist/react/context.js +88 -0
- package/dist/react/editor.d.ts +30 -0
- package/dist/react/editor.js +60 -0
- package/dist/react/floating-toolbar.d.ts +30 -0
- package/dist/react/floating-toolbar.js +87 -0
- package/dist/react/link-popover.d.ts +70 -0
- package/dist/react/link-popover.js +222 -0
- package/dist/react/preview.d.ts +13 -0
- package/dist/react/preview.js +56 -0
- package/dist/react/toolbar.d.ts +51 -0
- package/dist/react/toolbar.js +161 -0
- package/package.json +90 -1
- package/src/core/active-marks.ts +89 -0
- package/src/core/commands.ts +461 -0
- package/src/core/editor.ts +139 -0
- package/src/core/field-notifier.ts +71 -0
- package/src/core/highlight-style.ts +66 -0
- package/src/core/highlight.ts +50 -0
- package/src/core/plugins/blockquote.ts +108 -0
- package/src/core/plugins/bracket.ts +34 -0
- package/src/core/plugins/code-block.ts +195 -0
- package/src/core/plugins/heading.ts +95 -0
- package/src/core/plugins/index.ts +16 -0
- package/src/core/plugins/inline.ts +62 -0
- package/src/core/plugins/link.ts +124 -0
- package/src/core/plugins/list.ts +68 -0
- package/src/core/plugins/table.ts +217 -0
- package/src/core/popover.ts +17 -0
- package/src/core/registry.ts +18 -0
- package/src/core/types.ts +25 -0
- package/src/core/utils.ts +38 -0
- package/src/react/code-block-popover.tsx +387 -0
- package/src/react/context.tsx +153 -0
- package/src/react/editor.tsx +106 -0
- package/src/react/floating-toolbar.tsx +161 -0
- package/src/react/link-popover.tsx +354 -0
- package/src/react/preview.tsx +80 -0
- package/src/react/toolbar.tsx +294 -0
- package/src/styles/floating-toolbar.css +52 -0
- package/src/styles/leepi.css +2 -0
- package/src/styles/popover.css +93 -0
- package/src/styles/preview.css +191 -0
- package/src/styles/theme.css +99 -0
- package/src/styles/tokens.css +63 -0
- package/src/styles/toolbar.css +55 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { EditorView } from "@codemirror/view";
|
|
2
|
+
import { Extension } from "@codemirror/state";
|
|
3
|
+
|
|
4
|
+
//#region src/core/active-marks.d.ts
|
|
5
|
+
/**
|
|
6
|
+
* Active marks as a dynamic record. Keys are mark names registered via `markRegistry`.
|
|
7
|
+
*/
|
|
8
|
+
type ActiveMarks = Record<string, boolean>;
|
|
9
|
+
declare const emptyMarks: ActiveMarks;
|
|
10
|
+
declare function detectMarks(view: EditorView): ActiveMarks;
|
|
11
|
+
declare function subscribeToMarks(view: EditorView, callback: () => void): () => void;
|
|
12
|
+
declare function getMarksSnapshot(view: EditorView): ActiveMarks;
|
|
13
|
+
declare const activeMarksPlugin: Extension;
|
|
14
|
+
//#endregion
|
|
15
|
+
export { ActiveMarks, activeMarksPlugin, detectMarks, emptyMarks, getMarksSnapshot, subscribeToMarks };
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { markRegistry } from "./registry.js";
|
|
2
|
+
import { ViewPlugin } from "@codemirror/view";
|
|
3
|
+
import { syntaxTree } from "@codemirror/language";
|
|
4
|
+
//#region src/core/active-marks.ts
|
|
5
|
+
const emptyMarks = {};
|
|
6
|
+
function detectMarks(view) {
|
|
7
|
+
const { state } = view;
|
|
8
|
+
const pos = state.selection.main.from;
|
|
9
|
+
const tree = syntaxTree(state);
|
|
10
|
+
const detectors = state.facet(markRegistry);
|
|
11
|
+
const marks = {};
|
|
12
|
+
let node = tree.resolveInner(pos, -1);
|
|
13
|
+
while (node) {
|
|
14
|
+
for (const det of detectors) if (!marks[det.mark] && det.detect(node.name, state, pos)) marks[det.mark] = true;
|
|
15
|
+
if (!node.parent) break;
|
|
16
|
+
node = node.parent;
|
|
17
|
+
}
|
|
18
|
+
for (const det of detectors) if (!marks[det.mark] && det.detect("__line__", state, pos)) marks[det.mark] = true;
|
|
19
|
+
return marks;
|
|
20
|
+
}
|
|
21
|
+
const viewListeners = /* @__PURE__ */ new WeakMap();
|
|
22
|
+
const viewSnapshots = /* @__PURE__ */ new WeakMap();
|
|
23
|
+
function subscribeToMarks(view, callback) {
|
|
24
|
+
let listeners = viewListeners.get(view);
|
|
25
|
+
if (!listeners) {
|
|
26
|
+
listeners = /* @__PURE__ */ new Set();
|
|
27
|
+
viewListeners.set(view, listeners);
|
|
28
|
+
}
|
|
29
|
+
listeners.add(callback);
|
|
30
|
+
return () => {
|
|
31
|
+
listeners.delete(callback);
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function getMarksSnapshot(view) {
|
|
35
|
+
return viewSnapshots.get(view) ?? emptyMarks;
|
|
36
|
+
}
|
|
37
|
+
function marksEqual(a, b) {
|
|
38
|
+
const aKeys = Object.keys(a);
|
|
39
|
+
const bKeys = Object.keys(b);
|
|
40
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
41
|
+
for (const key of aKeys) if (a[key] !== b[key]) return false;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
const activeMarksPlugin = ViewPlugin.fromClass(class {
|
|
45
|
+
update(update) {
|
|
46
|
+
if (update.selectionSet || update.docChanged) {
|
|
47
|
+
const marks = detectMarks(update.view);
|
|
48
|
+
if (!marksEqual(viewSnapshots.get(update.view) ?? emptyMarks, marks)) {
|
|
49
|
+
viewSnapshots.set(update.view, marks);
|
|
50
|
+
const listeners = viewListeners.get(update.view);
|
|
51
|
+
if (listeners) for (const cb of listeners) cb();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
//#endregion
|
|
57
|
+
export { activeMarksPlugin, detectMarks, emptyMarks, getMarksSnapshot, subscribeToMarks };
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { CodeBlockData } from "./plugins/code-block.js";
|
|
2
|
+
import { EditorView } from "@codemirror/view";
|
|
3
|
+
import { EditorState } from "@codemirror/state";
|
|
4
|
+
|
|
5
|
+
//#region src/core/commands.d.ts
|
|
6
|
+
/**
|
|
7
|
+
* Toggle an inline markdown marker (e.g. `**`, `_`, `` ` ``, `~~`) around
|
|
8
|
+
* each selection range. If the selected text is already wrapped with the
|
|
9
|
+
* marker, the marker is removed; otherwise it is added.
|
|
10
|
+
*
|
|
11
|
+
* When nothing is selected the marker pair is inserted at the cursor and the
|
|
12
|
+
* cursor is placed between them.
|
|
13
|
+
*/
|
|
14
|
+
declare function toggleMarker(marker: string): (view: EditorView) => boolean;
|
|
15
|
+
declare function toggleBlockquote(view: EditorView): boolean;
|
|
16
|
+
declare function toggleHeading(level: number): (view: EditorView) => boolean;
|
|
17
|
+
type ListKind = "ul" | "ol" | "task" | "none";
|
|
18
|
+
declare function toggleListKind(target: ListKind): (view: EditorView) => boolean;
|
|
19
|
+
declare function toggleTaskCheck(view: EditorView): boolean;
|
|
20
|
+
declare function findLinkAtCursor(state: EditorState, pos: number): {
|
|
21
|
+
from: number;
|
|
22
|
+
to: number;
|
|
23
|
+
label: string;
|
|
24
|
+
url: string;
|
|
25
|
+
} | null;
|
|
26
|
+
/** Insert a markdown link into the editor, replacing `from..to`. */
|
|
27
|
+
declare function insertLink(view: EditorView, from: number, to: number, label: string, url: string): void;
|
|
28
|
+
declare function findCodeFenceAtCursor(view: EditorView): {
|
|
29
|
+
fenceLine: {
|
|
30
|
+
from: number;
|
|
31
|
+
to: number;
|
|
32
|
+
};
|
|
33
|
+
lang: string;
|
|
34
|
+
filename: string;
|
|
35
|
+
} | null;
|
|
36
|
+
/** Apply code block settings. For existing blocks, replaces the fence line. For new blocks, inserts a fenced block. */
|
|
37
|
+
declare function applyCodeBlock(view: EditorView, data: CodeBlockData, lang: string, filename: string): void;
|
|
38
|
+
//#endregion
|
|
39
|
+
export { applyCodeBlock, findCodeFenceAtCursor, findLinkAtCursor, insertLink, toggleBlockquote, toggleHeading, toggleListKind, toggleMarker, toggleTaskCheck };
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { isInsideCodeBlock } from "./utils.js";
|
|
2
|
+
import { syntaxTree } from "@codemirror/language";
|
|
3
|
+
import { EditorSelection } from "@codemirror/state";
|
|
4
|
+
//#region src/core/commands.ts
|
|
5
|
+
/**
|
|
6
|
+
* Toggle an inline markdown marker (e.g. `**`, `_`, `` ` ``, `~~`) around
|
|
7
|
+
* each selection range. If the selected text is already wrapped with the
|
|
8
|
+
* marker, the marker is removed; otherwise it is added.
|
|
9
|
+
*
|
|
10
|
+
* When nothing is selected the marker pair is inserted at the cursor and the
|
|
11
|
+
* cursor is placed between them.
|
|
12
|
+
*/
|
|
13
|
+
function toggleMarker(marker) {
|
|
14
|
+
return (view) => {
|
|
15
|
+
if (isInsideCodeBlock(view)) return false;
|
|
16
|
+
const { state } = view;
|
|
17
|
+
const len = marker.length;
|
|
18
|
+
const changes = state.changeByRange((range) => {
|
|
19
|
+
const doc = state.doc;
|
|
20
|
+
if (range.empty) {
|
|
21
|
+
const line = doc.lineAt(range.from);
|
|
22
|
+
const lineText = line.text;
|
|
23
|
+
const cursorOffset = range.from - line.from;
|
|
24
|
+
const positions = [];
|
|
25
|
+
let searchFrom = 0;
|
|
26
|
+
while (searchFrom <= lineText.length - len) {
|
|
27
|
+
const idx = lineText.indexOf(marker, searchFrom);
|
|
28
|
+
if (idx === -1) break;
|
|
29
|
+
positions.push(idx);
|
|
30
|
+
searchFrom = idx + len;
|
|
31
|
+
}
|
|
32
|
+
for (let i = 0; i + 1 < positions.length; i += 2) {
|
|
33
|
+
const openEnd = positions[i] + len;
|
|
34
|
+
const closeStart = positions[i + 1];
|
|
35
|
+
if (cursorOffset >= openEnd && cursorOffset <= closeStart) {
|
|
36
|
+
const absOpen = line.from + positions[i];
|
|
37
|
+
const absClose = line.from + closeStart;
|
|
38
|
+
const innerText = doc.sliceString(absOpen + len, absClose);
|
|
39
|
+
const cursorInInner = range.from - (absOpen + len);
|
|
40
|
+
return {
|
|
41
|
+
changes: {
|
|
42
|
+
from: absOpen,
|
|
43
|
+
to: absClose + len,
|
|
44
|
+
insert: innerText
|
|
45
|
+
},
|
|
46
|
+
range: EditorSelection.cursor(absOpen + cursorInInner)
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return {
|
|
51
|
+
changes: {
|
|
52
|
+
from: range.from,
|
|
53
|
+
insert: marker + marker
|
|
54
|
+
},
|
|
55
|
+
range: EditorSelection.cursor(range.from + len)
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const selected = doc.sliceString(range.from, range.to);
|
|
59
|
+
if (selected.startsWith(marker) && selected.endsWith(marker) && selected.length >= len * 2) {
|
|
60
|
+
const inner = selected.slice(len, -len);
|
|
61
|
+
return {
|
|
62
|
+
changes: {
|
|
63
|
+
from: range.from,
|
|
64
|
+
to: range.to,
|
|
65
|
+
insert: inner
|
|
66
|
+
},
|
|
67
|
+
range: EditorSelection.range(range.from, range.from + inner.length)
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
const beforeSel = doc.sliceString(Math.max(0, range.from - len), range.from);
|
|
71
|
+
const afterSel = doc.sliceString(range.to, Math.min(doc.length, range.to + len));
|
|
72
|
+
if (beforeSel === marker && afterSel === marker) return {
|
|
73
|
+
changes: [{
|
|
74
|
+
from: range.from - len,
|
|
75
|
+
to: range.from
|
|
76
|
+
}, {
|
|
77
|
+
from: range.to,
|
|
78
|
+
to: range.to + len
|
|
79
|
+
}],
|
|
80
|
+
range: EditorSelection.range(range.from - len, range.to - len)
|
|
81
|
+
};
|
|
82
|
+
return {
|
|
83
|
+
changes: [{
|
|
84
|
+
from: range.from,
|
|
85
|
+
insert: marker
|
|
86
|
+
}, {
|
|
87
|
+
from: range.to,
|
|
88
|
+
insert: marker
|
|
89
|
+
}],
|
|
90
|
+
range: EditorSelection.range(range.from, range.to + len * 2)
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
view.dispatch(changes);
|
|
94
|
+
return true;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const BQ_RE = /^(\s*)>\s?/;
|
|
98
|
+
function toggleBlockquote(view) {
|
|
99
|
+
if (isInsideCodeBlock(view)) return false;
|
|
100
|
+
const { state } = view;
|
|
101
|
+
const changes = state.changeByRange((range) => {
|
|
102
|
+
const fromLine = state.doc.lineAt(range.from);
|
|
103
|
+
const toLine = state.doc.lineAt(range.to);
|
|
104
|
+
const edits = [];
|
|
105
|
+
let allQuoted = true;
|
|
106
|
+
for (let num = fromLine.number; num <= toLine.number; num++) {
|
|
107
|
+
const line = state.doc.line(num);
|
|
108
|
+
if (!BQ_RE.test(line.text)) {
|
|
109
|
+
allQuoted = false;
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
for (let num = fromLine.number; num <= toLine.number; num++) {
|
|
114
|
+
const line = state.doc.line(num);
|
|
115
|
+
if (allQuoted) {
|
|
116
|
+
const match = line.text.match(BQ_RE);
|
|
117
|
+
edits.push({
|
|
118
|
+
from: line.from,
|
|
119
|
+
to: line.from + match[0].length,
|
|
120
|
+
insert: match[1]
|
|
121
|
+
});
|
|
122
|
+
} else if (!BQ_RE.test(line.text)) {
|
|
123
|
+
const indent = line.text.match(/^(\s*)/)[1];
|
|
124
|
+
edits.push({
|
|
125
|
+
from: line.from + indent.length,
|
|
126
|
+
to: line.from + indent.length,
|
|
127
|
+
insert: "> "
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (edits.length === 0) return {
|
|
132
|
+
changes: [],
|
|
133
|
+
range
|
|
134
|
+
};
|
|
135
|
+
const firstDelta = edits[0].insert.length - (edits[0].to - edits[0].from);
|
|
136
|
+
const totalDelta = edits.reduce((sum, e) => sum + e.insert.length - (e.to - e.from), 0);
|
|
137
|
+
const newFrom = Math.max(0, range.from + firstDelta);
|
|
138
|
+
return {
|
|
139
|
+
changes: edits,
|
|
140
|
+
range: range.empty ? EditorSelection.cursor(newFrom) : EditorSelection.range(newFrom, Math.max(newFrom, range.to + totalDelta))
|
|
141
|
+
};
|
|
142
|
+
});
|
|
143
|
+
view.dispatch(changes);
|
|
144
|
+
return true;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Find the offset where content begins after any blockquote markers on a line.
|
|
148
|
+
* Uses the syntax tree to detect `QuoteMark` nodes, handling nested blockquotes.
|
|
149
|
+
*/
|
|
150
|
+
function getBlockContentOffset(state, lineFrom, lineTo) {
|
|
151
|
+
const tree = syntaxTree(state);
|
|
152
|
+
let lastQuoteEnd = lineFrom;
|
|
153
|
+
let foundQuote = false;
|
|
154
|
+
tree.iterate({
|
|
155
|
+
from: lineFrom,
|
|
156
|
+
to: lineTo,
|
|
157
|
+
enter(node) {
|
|
158
|
+
if (node.name === "QuoteMark" && node.from >= lineFrom && node.to <= lineTo) {
|
|
159
|
+
foundQuote = true;
|
|
160
|
+
lastQuoteEnd = node.to;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
if (!foundQuote) return 0;
|
|
165
|
+
const doc = state.doc;
|
|
166
|
+
let offset = lastQuoteEnd;
|
|
167
|
+
while (offset < lineTo && doc.sliceString(offset, offset + 1) === " ") offset++;
|
|
168
|
+
return offset - lineFrom;
|
|
169
|
+
}
|
|
170
|
+
function toggleHeading(level) {
|
|
171
|
+
return (view) => {
|
|
172
|
+
if (isInsideCodeBlock(view)) return false;
|
|
173
|
+
const { state } = view;
|
|
174
|
+
const line = state.doc.lineAt(state.selection.main.from);
|
|
175
|
+
const contentOffset = getBlockContentOffset(state, line.from, line.to);
|
|
176
|
+
const headingMatch = line.text.slice(contentOffset).match(/^(#{1,6})\s/);
|
|
177
|
+
const prefix = "#".repeat(level) + " ";
|
|
178
|
+
const contentFrom = line.from + contentOffset;
|
|
179
|
+
const cursorPos = state.selection.main.from;
|
|
180
|
+
if (headingMatch && headingMatch[1].length === level) {
|
|
181
|
+
const delta = -headingMatch[0].length;
|
|
182
|
+
view.dispatch({
|
|
183
|
+
changes: {
|
|
184
|
+
from: contentFrom,
|
|
185
|
+
to: contentFrom + headingMatch[0].length,
|
|
186
|
+
insert: ""
|
|
187
|
+
},
|
|
188
|
+
selection: EditorSelection.cursor(Math.max(contentFrom, cursorPos + delta))
|
|
189
|
+
});
|
|
190
|
+
} else if (headingMatch) {
|
|
191
|
+
const delta = prefix.length - headingMatch[0].length;
|
|
192
|
+
view.dispatch({
|
|
193
|
+
changes: {
|
|
194
|
+
from: contentFrom,
|
|
195
|
+
to: contentFrom + headingMatch[0].length,
|
|
196
|
+
insert: prefix
|
|
197
|
+
},
|
|
198
|
+
selection: EditorSelection.cursor(Math.max(contentFrom + prefix.length, cursorPos + delta))
|
|
199
|
+
});
|
|
200
|
+
} else view.dispatch({
|
|
201
|
+
changes: {
|
|
202
|
+
from: contentFrom,
|
|
203
|
+
to: contentFrom,
|
|
204
|
+
insert: prefix
|
|
205
|
+
},
|
|
206
|
+
selection: EditorSelection.cursor(Math.max(contentFrom + prefix.length, cursorPos + prefix.length))
|
|
207
|
+
});
|
|
208
|
+
return true;
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
const TASK_RE = /^(\s*)[-*+]\s\[([ xX])\]\s/;
|
|
212
|
+
function detectListKind(state, pos, contentText) {
|
|
213
|
+
let node = syntaxTree(state).resolveInner(pos, 1);
|
|
214
|
+
const indent = contentText.match(/^(\s*)/)[1];
|
|
215
|
+
while (node) {
|
|
216
|
+
if (node.name === "ListItem" && node.getChild("Task")) return {
|
|
217
|
+
kind: "task",
|
|
218
|
+
indent
|
|
219
|
+
};
|
|
220
|
+
if (node.name === "BulletList" || node.name === "OrderedList") return {
|
|
221
|
+
kind: node.name === "BulletList" ? "ul" : "ol",
|
|
222
|
+
indent
|
|
223
|
+
};
|
|
224
|
+
if (!node.parent) break;
|
|
225
|
+
node = node.parent;
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
kind: "none",
|
|
229
|
+
indent: ""
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function stripListPrefix(lineText) {
|
|
233
|
+
return lineText.replace(/^(\s*)(?:[-*+]\s\[([ xX])\]\s|[-*+]\s|\d+\.\s)/, "$1");
|
|
234
|
+
}
|
|
235
|
+
function toggleListKind(target) {
|
|
236
|
+
return (view) => {
|
|
237
|
+
if (isInsideCodeBlock(view)) return false;
|
|
238
|
+
const { state } = view;
|
|
239
|
+
const changes = state.changeByRange((range) => {
|
|
240
|
+
const fromLine = state.doc.lineAt(range.from);
|
|
241
|
+
const toLine = state.doc.lineAt(range.to);
|
|
242
|
+
const edits = [];
|
|
243
|
+
let olIdx = 1;
|
|
244
|
+
for (let num = fromLine.number; num <= toLine.number; num++) {
|
|
245
|
+
const line = state.doc.line(num);
|
|
246
|
+
const bqOffset = getBlockContentOffset(state, line.from, line.to);
|
|
247
|
+
const content = line.text.slice(bqOffset);
|
|
248
|
+
const { kind, indent } = detectListKind(state, line.from + bqOffset, content);
|
|
249
|
+
const stripped = stripListPrefix(content);
|
|
250
|
+
let newPrefix;
|
|
251
|
+
if (kind === target) newPrefix = indent;
|
|
252
|
+
else switch (target) {
|
|
253
|
+
case "ul":
|
|
254
|
+
newPrefix = `${indent}- `;
|
|
255
|
+
break;
|
|
256
|
+
case "ol":
|
|
257
|
+
newPrefix = `${indent}${olIdx++}. `;
|
|
258
|
+
break;
|
|
259
|
+
case "task":
|
|
260
|
+
newPrefix = `${indent}- [ ] `;
|
|
261
|
+
break;
|
|
262
|
+
default: newPrefix = indent;
|
|
263
|
+
}
|
|
264
|
+
const newContent = newPrefix + stripped.slice(indent.length);
|
|
265
|
+
const newLine = line.text.slice(0, bqOffset) + newContent;
|
|
266
|
+
if (newLine !== line.text) edits.push({
|
|
267
|
+
from: line.from,
|
|
268
|
+
to: line.to,
|
|
269
|
+
insert: newLine
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
if (edits.length === 0) return {
|
|
273
|
+
changes: [],
|
|
274
|
+
range
|
|
275
|
+
};
|
|
276
|
+
const firstDelta = edits[0].insert.length - (edits[0].to - edits[0].from);
|
|
277
|
+
const totalDelta = edits.reduce((sum, e) => sum + e.insert.length - (e.to - e.from), 0);
|
|
278
|
+
const newFrom = Math.max(0, range.from + firstDelta);
|
|
279
|
+
return {
|
|
280
|
+
changes: edits,
|
|
281
|
+
range: range.empty ? EditorSelection.cursor(newFrom) : EditorSelection.range(newFrom, Math.max(newFrom, range.to + totalDelta))
|
|
282
|
+
};
|
|
283
|
+
});
|
|
284
|
+
view.dispatch(changes);
|
|
285
|
+
return true;
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
function toggleTaskCheck(view) {
|
|
289
|
+
if (isInsideCodeBlock(view)) return false;
|
|
290
|
+
const { state } = view;
|
|
291
|
+
const changes = state.changeByRange((range) => {
|
|
292
|
+
const fromLine = state.doc.lineAt(range.from);
|
|
293
|
+
const toLine = state.doc.lineAt(range.to);
|
|
294
|
+
const edits = [];
|
|
295
|
+
for (let num = fromLine.number; num <= toLine.number; num++) {
|
|
296
|
+
const line = state.doc.line(num);
|
|
297
|
+
const match = line.text.match(TASK_RE);
|
|
298
|
+
if (!match) continue;
|
|
299
|
+
const isChecked = match[2] !== " ";
|
|
300
|
+
const bracketStart = line.from + line.text.indexOf("[");
|
|
301
|
+
edits.push({
|
|
302
|
+
from: bracketStart,
|
|
303
|
+
to: bracketStart + 3,
|
|
304
|
+
insert: isChecked ? "[ ]" : "[x]"
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
if (edits.length === 0) return {
|
|
308
|
+
changes: [],
|
|
309
|
+
range
|
|
310
|
+
};
|
|
311
|
+
return {
|
|
312
|
+
changes: edits,
|
|
313
|
+
range
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
view.dispatch(changes);
|
|
317
|
+
return true;
|
|
318
|
+
}
|
|
319
|
+
function findLinkAtCursor(state, pos) {
|
|
320
|
+
let node = syntaxTree(state).resolveInner(pos, -1);
|
|
321
|
+
while (node) {
|
|
322
|
+
if (node.name === "Link") {
|
|
323
|
+
const from = node.from;
|
|
324
|
+
const to = node.to;
|
|
325
|
+
const full = state.sliceDoc(from, to);
|
|
326
|
+
const labelStart = full.indexOf("[");
|
|
327
|
+
const labelEnd = full.indexOf("]");
|
|
328
|
+
const urlStart = full.indexOf("(", labelEnd);
|
|
329
|
+
const urlEnd = full.lastIndexOf(")");
|
|
330
|
+
if (labelStart === -1 || labelEnd === -1 || urlStart === -1 || urlEnd === -1) return null;
|
|
331
|
+
return {
|
|
332
|
+
from,
|
|
333
|
+
to,
|
|
334
|
+
label: full.slice(labelStart + 1, labelEnd),
|
|
335
|
+
url: full.slice(urlStart + 1, urlEnd)
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
if (!node.parent) break;
|
|
339
|
+
node = node.parent;
|
|
340
|
+
}
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
/** Insert a markdown link into the editor, replacing `from..to`. */
|
|
344
|
+
function insertLink(view, from, to, label, url) {
|
|
345
|
+
const insert = `[${label}](${url})`;
|
|
346
|
+
view.dispatch({
|
|
347
|
+
changes: {
|
|
348
|
+
from,
|
|
349
|
+
to,
|
|
350
|
+
insert
|
|
351
|
+
},
|
|
352
|
+
selection: EditorSelection.cursor(from + insert.length)
|
|
353
|
+
});
|
|
354
|
+
view.focus();
|
|
355
|
+
}
|
|
356
|
+
function parseFenceInfo(info) {
|
|
357
|
+
const parts = info.trim().split(/\s+/);
|
|
358
|
+
let lang = "";
|
|
359
|
+
let filename = "";
|
|
360
|
+
for (const part of parts) if (part.startsWith("filename=")) filename = part.slice(9);
|
|
361
|
+
else if (!lang) lang = part;
|
|
362
|
+
return {
|
|
363
|
+
lang,
|
|
364
|
+
filename
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
function findCodeFenceAtCursor(view) {
|
|
368
|
+
const { state } = view;
|
|
369
|
+
const pos = state.selection.main.from;
|
|
370
|
+
let node = syntaxTree(state).resolveInner(pos, -1);
|
|
371
|
+
while (node) {
|
|
372
|
+
if (node.name === "FencedCode") {
|
|
373
|
+
const fenceLine = state.doc.lineAt(node.from);
|
|
374
|
+
const infoNode = node.getChild("CodeInfo");
|
|
375
|
+
const { lang, filename } = parseFenceInfo(infoNode ? state.sliceDoc(infoNode.from, infoNode.to) : "");
|
|
376
|
+
return {
|
|
377
|
+
fenceLine: {
|
|
378
|
+
from: fenceLine.from,
|
|
379
|
+
to: fenceLine.to
|
|
380
|
+
},
|
|
381
|
+
lang,
|
|
382
|
+
filename
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
if (!node.parent) break;
|
|
386
|
+
node = node.parent;
|
|
387
|
+
}
|
|
388
|
+
return null;
|
|
389
|
+
}
|
|
390
|
+
/** Apply code block settings. For existing blocks, replaces the fence line. For new blocks, inserts a fenced block. */
|
|
391
|
+
function applyCodeBlock(view, data, lang, filename) {
|
|
392
|
+
const info = [lang, filename ? `filename=${filename}` : ""].filter(Boolean).join(" ");
|
|
393
|
+
if (data.isNew) {
|
|
394
|
+
const insert = `\`\`\`${info}\n\n\`\`\``;
|
|
395
|
+
const pos = data.insertPos;
|
|
396
|
+
view.dispatch({
|
|
397
|
+
changes: {
|
|
398
|
+
from: pos,
|
|
399
|
+
to: pos,
|
|
400
|
+
insert
|
|
401
|
+
},
|
|
402
|
+
selection: EditorSelection.cursor(pos + 3 + info.length + 1)
|
|
403
|
+
});
|
|
404
|
+
} else {
|
|
405
|
+
const fenceLine = `\`\`\`${info}`;
|
|
406
|
+
view.dispatch({ changes: {
|
|
407
|
+
from: data.fenceFrom,
|
|
408
|
+
to: data.fenceTo,
|
|
409
|
+
insert: fenceLine
|
|
410
|
+
} });
|
|
411
|
+
}
|
|
412
|
+
view.focus();
|
|
413
|
+
}
|
|
414
|
+
//#endregion
|
|
415
|
+
export { applyCodeBlock, findCodeFenceAtCursor, findLinkAtCursor, insertLink, toggleBlockquote, toggleHeading, toggleListKind, toggleMarker, toggleTaskCheck };
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { EditorView } from "@codemirror/view";
|
|
2
|
+
import { Compartment, EditorState, Extension } from "@codemirror/state";
|
|
3
|
+
|
|
4
|
+
//#region src/core/editor.d.ts
|
|
5
|
+
interface CreateEditorStateOptions {
|
|
6
|
+
doc?: string;
|
|
7
|
+
onUpdate?: (doc: string) => void;
|
|
8
|
+
/** Additional extensions (e.g. custom plugins) */
|
|
9
|
+
plugins?: Extension[];
|
|
10
|
+
placeholder?: string;
|
|
11
|
+
readOnly?: boolean;
|
|
12
|
+
/** When false, default plugins are NOT included. Defaults to true. */
|
|
13
|
+
includeDefaultPlugins?: boolean;
|
|
14
|
+
}
|
|
15
|
+
/** Compartment for swapping the markdown extension after lazy-loading language support */
|
|
16
|
+
declare const markdownCompartment: Compartment;
|
|
17
|
+
/** Compartment for toggling editable state */
|
|
18
|
+
declare const editableCompartment: Compartment;
|
|
19
|
+
/** Default set of all plugins */
|
|
20
|
+
declare function defaultPlugins(): Extension;
|
|
21
|
+
declare function createEditorState(options: CreateEditorStateOptions): EditorState;
|
|
22
|
+
/** Lazy-load code language support and reconfigure the markdown extension */
|
|
23
|
+
declare function loadLanguageSupport(view: EditorView): void;
|
|
24
|
+
//#endregion
|
|
25
|
+
export { CreateEditorStateOptions, createEditorState, defaultPlugins, editableCompartment, loadLanguageSupport, markdownCompartment };
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { activeMarksPlugin } from "./active-marks.js";
|
|
2
|
+
import { editorTheme, highlightStyle } from "./highlight-style.js";
|
|
3
|
+
import { fieldNotifier } from "./field-notifier.js";
|
|
4
|
+
import { popoverField } from "./popover.js";
|
|
5
|
+
import { inlinePlugin } from "./plugins/inline.js";
|
|
6
|
+
import { headingPlugin } from "./plugins/heading.js";
|
|
7
|
+
import { listPlugin } from "./plugins/list.js";
|
|
8
|
+
import { blockquotePlugin } from "./plugins/blockquote.js";
|
|
9
|
+
import { linkPlugin } from "./plugins/link.js";
|
|
10
|
+
import { codeBlockPlugin } from "./plugins/code-block.js";
|
|
11
|
+
import { tablePlugin } from "./plugins/table.js";
|
|
12
|
+
import { bracketPlugin } from "./plugins/bracket.js";
|
|
13
|
+
import "./plugins.js";
|
|
14
|
+
import { EditorView, keymap, placeholder } from "@codemirror/view";
|
|
15
|
+
import { syntaxHighlighting } from "@codemirror/language";
|
|
16
|
+
import { Compartment, EditorSelection, EditorState } from "@codemirror/state";
|
|
17
|
+
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
|
|
18
|
+
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
|
19
|
+
import { styleTags, tags } from "@lezer/highlight";
|
|
20
|
+
//#region src/core/editor.ts
|
|
21
|
+
/** Compartment for swapping the markdown extension after lazy-loading language support */
|
|
22
|
+
const markdownCompartment = new Compartment();
|
|
23
|
+
/** Compartment for toggling editable state */
|
|
24
|
+
const editableCompartment = new Compartment();
|
|
25
|
+
const markdownStyleTags = { props: [styleTags({
|
|
26
|
+
ListMark: tags.processingInstruction,
|
|
27
|
+
QuoteMark: tags.processingInstruction,
|
|
28
|
+
TaskMarker: tags.processingInstruction,
|
|
29
|
+
CodeText: tags.content
|
|
30
|
+
})] };
|
|
31
|
+
/** Select the word under the cursor. Does nothing if there is already a selection. */
|
|
32
|
+
function selectWord(view) {
|
|
33
|
+
const main = view.state.selection.main;
|
|
34
|
+
if (!main.empty) return false;
|
|
35
|
+
const word = view.state.wordAt(main.head);
|
|
36
|
+
if (!word) return false;
|
|
37
|
+
view.dispatch({ selection: EditorSelection.single(word.from, word.to) });
|
|
38
|
+
return true;
|
|
39
|
+
}
|
|
40
|
+
/** Default set of all plugins */
|
|
41
|
+
function defaultPlugins() {
|
|
42
|
+
return [
|
|
43
|
+
inlinePlugin(),
|
|
44
|
+
headingPlugin(),
|
|
45
|
+
listPlugin(),
|
|
46
|
+
blockquotePlugin(),
|
|
47
|
+
linkPlugin(),
|
|
48
|
+
codeBlockPlugin(),
|
|
49
|
+
tablePlugin(),
|
|
50
|
+
bracketPlugin()
|
|
51
|
+
];
|
|
52
|
+
}
|
|
53
|
+
function createEditorState(options) {
|
|
54
|
+
const { doc = "", onUpdate, plugins = [], placeholder: placeholder$1 = "", readOnly = false, includeDefaultPlugins = true } = options;
|
|
55
|
+
const extensions = [
|
|
56
|
+
editableCompartment.of(EditorView.editable.of(!readOnly)),
|
|
57
|
+
...includeDefaultPlugins ? [defaultPlugins()] : [],
|
|
58
|
+
...plugins,
|
|
59
|
+
keymap.of([
|
|
60
|
+
{
|
|
61
|
+
key: "Mod-d",
|
|
62
|
+
run: selectWord,
|
|
63
|
+
preventDefault: true
|
|
64
|
+
},
|
|
65
|
+
...defaultKeymap,
|
|
66
|
+
...historyKeymap
|
|
67
|
+
]),
|
|
68
|
+
history(),
|
|
69
|
+
popoverField,
|
|
70
|
+
activeMarksPlugin,
|
|
71
|
+
fieldNotifier(),
|
|
72
|
+
markdownCompartment.of(markdown({
|
|
73
|
+
base: markdownLanguage,
|
|
74
|
+
extensions: markdownStyleTags,
|
|
75
|
+
addKeymap: true
|
|
76
|
+
})),
|
|
77
|
+
syntaxHighlighting(highlightStyle),
|
|
78
|
+
editorTheme,
|
|
79
|
+
EditorView.lineWrapping
|
|
80
|
+
];
|
|
81
|
+
if (placeholder$1) extensions.push(placeholder(placeholder$1));
|
|
82
|
+
if (onUpdate) extensions.push(EditorView.updateListener.of((update) => {
|
|
83
|
+
if (update.docChanged) onUpdate(update.state.doc.toString());
|
|
84
|
+
}));
|
|
85
|
+
return EditorState.create({
|
|
86
|
+
doc,
|
|
87
|
+
extensions
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
/** Lazy-load code language support and reconfigure the markdown extension */
|
|
91
|
+
function loadLanguageSupport(view) {
|
|
92
|
+
import("@codemirror/language-data").then(({ languages }) => {
|
|
93
|
+
view.dispatch({ effects: markdownCompartment.reconfigure(markdown({
|
|
94
|
+
base: markdownLanguage,
|
|
95
|
+
codeLanguages: languages,
|
|
96
|
+
extensions: markdownStyleTags,
|
|
97
|
+
addKeymap: true
|
|
98
|
+
})) });
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
export { createEditorState, defaultPlugins, editableCompartment, loadLanguageSupport, markdownCompartment };
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { EditorView } from "@codemirror/view";
|
|
2
|
+
import { Extension, StateField } from "@codemirror/state";
|
|
3
|
+
|
|
4
|
+
//#region src/core/field-notifier.d.ts
|
|
5
|
+
type FieldListener = () => void;
|
|
6
|
+
/**
|
|
7
|
+
* Subscribe to changes of a StateField on a given EditorView.
|
|
8
|
+
* Returns an unsubscribe function.
|
|
9
|
+
*/
|
|
10
|
+
declare function subscribeToField<T>(view: EditorView, field: StateField<T>, cb: FieldListener): () => void;
|
|
11
|
+
/** Get the current snapshot of a StateField for useSyncExternalStore */
|
|
12
|
+
declare function getFieldSnapshot<T>(view: EditorView, field: StateField<T>): T | undefined;
|
|
13
|
+
/**
|
|
14
|
+
* ViewPlugin that watches all StateFields with subscribers and notifies
|
|
15
|
+
* when their values change (by reference).
|
|
16
|
+
*/
|
|
17
|
+
declare const fieldNotifierPlugin: Extension;
|
|
18
|
+
/** Extension to include in createEditorState */
|
|
19
|
+
declare function fieldNotifier(): Extension;
|
|
20
|
+
//#endregion
|
|
21
|
+
export { fieldNotifier, fieldNotifierPlugin, getFieldSnapshot, subscribeToField };
|