leepi 0.0.0 → 0.0.2
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/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
package/package.json
CHANGED
|
@@ -1 +1,90 @@
|
|
|
1
|
-
{
|
|
1
|
+
{
|
|
2
|
+
"name": "leepi",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"description": "A composable React markdown editor with inline styling",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/brijbyte/leepi.git",
|
|
8
|
+
"directory": "packages/leepi"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist",
|
|
12
|
+
"src",
|
|
13
|
+
"!src/**/*.test.ts",
|
|
14
|
+
"!src/**/test-helpers.ts"
|
|
15
|
+
],
|
|
16
|
+
"type": "module",
|
|
17
|
+
"exports": {
|
|
18
|
+
"./core/active-marks": "./dist/core/active-marks.js",
|
|
19
|
+
"./core/commands": "./dist/core/commands.js",
|
|
20
|
+
"./core/editor": "./dist/core/editor.js",
|
|
21
|
+
"./core/field-notifier": "./dist/core/field-notifier.js",
|
|
22
|
+
"./core/highlight": "./dist/core/highlight.js",
|
|
23
|
+
"./core/plugins": "./dist/core/plugins.js",
|
|
24
|
+
"./core/popover": "./dist/core/popover.js",
|
|
25
|
+
"./core/registry": "./dist/core/registry.js",
|
|
26
|
+
"./core/types": "./dist/core/types.js",
|
|
27
|
+
"./core/utils": "./dist/core/utils.js",
|
|
28
|
+
"./react/code-block-popover": "./dist/react/code-block-popover.js",
|
|
29
|
+
"./react/context": "./dist/react/context.js",
|
|
30
|
+
"./react/editor": "./dist/react/editor.js",
|
|
31
|
+
"./react/floating-toolbar": "./dist/react/floating-toolbar.js",
|
|
32
|
+
"./react/link-popover": "./dist/react/link-popover.js",
|
|
33
|
+
"./react/preview": "./dist/react/preview.js",
|
|
34
|
+
"./react/toolbar": "./dist/react/toolbar.js",
|
|
35
|
+
"./package.json": "./package.json",
|
|
36
|
+
"./styles.css": "./dist/leepi.css",
|
|
37
|
+
"./styles/*.css": "./src/styles/*.css"
|
|
38
|
+
},
|
|
39
|
+
"publishConfig": {
|
|
40
|
+
"access": "public",
|
|
41
|
+
"provenance": true
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@codemirror/commands": "^6.0.0",
|
|
45
|
+
"@codemirror/lang-markdown": "^6.0.0",
|
|
46
|
+
"@codemirror/language": "^6.0.0",
|
|
47
|
+
"@codemirror/language-data": "^6.0.0",
|
|
48
|
+
"@codemirror/state": "^6.0.0",
|
|
49
|
+
"@codemirror/view": "^6.0.0",
|
|
50
|
+
"@lezer/highlight": "^1.0.0",
|
|
51
|
+
"marked": ">=15.0.0",
|
|
52
|
+
"marked-highlight": "^2.0.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@base-ui/react": "^1.3.0",
|
|
56
|
+
"@codemirror/commands": "^6.10.3",
|
|
57
|
+
"@codemirror/lang-markdown": "^6.5.0",
|
|
58
|
+
"@codemirror/language": "^6.12.3",
|
|
59
|
+
"@codemirror/language-data": "^6.5.2",
|
|
60
|
+
"@codemirror/state": "^6.6.0",
|
|
61
|
+
"@codemirror/view": "^6.41.0",
|
|
62
|
+
"@lezer/highlight": "^1.2.3",
|
|
63
|
+
"@types/react": "^19.2.14",
|
|
64
|
+
"@types/react-dom": "^19.2.3",
|
|
65
|
+
"marked": ">=18.0.0",
|
|
66
|
+
"marked-highlight": "^2.2.3",
|
|
67
|
+
"react": "^19.2.4",
|
|
68
|
+
"react-dom": "^19.2.4"
|
|
69
|
+
},
|
|
70
|
+
"peerDependencies": {
|
|
71
|
+
"@base-ui/react": "^1.0.0",
|
|
72
|
+
"react": "^19.0.0",
|
|
73
|
+
"react-dom": "^19.0.0"
|
|
74
|
+
},
|
|
75
|
+
"peerDependenciesMeta": {
|
|
76
|
+
"@base-ui/react": {
|
|
77
|
+
"optional": true
|
|
78
|
+
},
|
|
79
|
+
"react": {
|
|
80
|
+
"optional": true
|
|
81
|
+
},
|
|
82
|
+
"react-dom": {
|
|
83
|
+
"optional": true
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
"scripts": {
|
|
87
|
+
"build": "tsdown",
|
|
88
|
+
"typecheck": "tsc --noEmit"
|
|
89
|
+
}
|
|
90
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { type Extension } from "@codemirror/state";
|
|
2
|
+
import { type EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
|
|
3
|
+
import { syntaxTree } from "@codemirror/language";
|
|
4
|
+
import { markRegistry } from "./registry";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Active marks as a dynamic record. Keys are mark names registered via `markRegistry`.
|
|
8
|
+
*/
|
|
9
|
+
export type ActiveMarks = Record<string, boolean>;
|
|
10
|
+
|
|
11
|
+
export const emptyMarks: ActiveMarks = {};
|
|
12
|
+
|
|
13
|
+
export function detectMarks(view: EditorView): ActiveMarks {
|
|
14
|
+
const { state } = view;
|
|
15
|
+
const pos = state.selection.main.from;
|
|
16
|
+
const tree = syntaxTree(state);
|
|
17
|
+
const detectors = state.facet(markRegistry);
|
|
18
|
+
|
|
19
|
+
const marks: ActiveMarks = {};
|
|
20
|
+
|
|
21
|
+
// Tree walk: call all detectors with each node name
|
|
22
|
+
let node = tree.resolveInner(pos, -1);
|
|
23
|
+
while (node) {
|
|
24
|
+
for (const det of detectors) {
|
|
25
|
+
if (!marks[det.mark] && det.detect(node.name, state, pos)) {
|
|
26
|
+
marks[det.mark] = true;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (!node.parent) break;
|
|
30
|
+
node = node.parent;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Line pass: call detectors with "__line__" sentinel for line-level checks
|
|
34
|
+
for (const det of detectors) {
|
|
35
|
+
if (!marks[det.mark] && det.detect("__line__", state, pos)) {
|
|
36
|
+
marks[det.mark] = true;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return marks;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// Notification system: views notify React subscribers when marks change
|
|
44
|
+
const viewListeners = new WeakMap<EditorView, Set<() => void>>();
|
|
45
|
+
const viewSnapshots = new WeakMap<EditorView, ActiveMarks>();
|
|
46
|
+
|
|
47
|
+
export function subscribeToMarks(view: EditorView, callback: () => void): () => void {
|
|
48
|
+
let listeners = viewListeners.get(view);
|
|
49
|
+
if (!listeners) {
|
|
50
|
+
listeners = new Set();
|
|
51
|
+
viewListeners.set(view, listeners);
|
|
52
|
+
}
|
|
53
|
+
listeners.add(callback);
|
|
54
|
+
return () => {
|
|
55
|
+
listeners!.delete(callback);
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function getMarksSnapshot(view: EditorView): ActiveMarks {
|
|
60
|
+
return viewSnapshots.get(view) ?? emptyMarks;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function marksEqual(a: ActiveMarks, b: ActiveMarks): boolean {
|
|
64
|
+
const aKeys = Object.keys(a);
|
|
65
|
+
const bKeys = Object.keys(b);
|
|
66
|
+
if (aKeys.length !== bKeys.length) return false;
|
|
67
|
+
for (const key of aKeys) {
|
|
68
|
+
if (a[key] !== b[key]) return false;
|
|
69
|
+
}
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export const activeMarksPlugin: Extension = ViewPlugin.fromClass(
|
|
74
|
+
class {
|
|
75
|
+
update(update: ViewUpdate): void {
|
|
76
|
+
if (update.selectionSet || update.docChanged) {
|
|
77
|
+
const marks = detectMarks(update.view);
|
|
78
|
+
const prev = viewSnapshots.get(update.view) ?? emptyMarks;
|
|
79
|
+
if (!marksEqual(prev, marks)) {
|
|
80
|
+
viewSnapshots.set(update.view, marks);
|
|
81
|
+
const listeners = viewListeners.get(update.view);
|
|
82
|
+
if (listeners) {
|
|
83
|
+
for (const cb of listeners) cb();
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
);
|
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
import type { EditorView } from "@codemirror/view";
|
|
2
|
+
import { type EditorState, EditorSelection } from "@codemirror/state";
|
|
3
|
+
import { syntaxTree } from "@codemirror/language";
|
|
4
|
+
import { isInsideCodeBlock } from "./utils";
|
|
5
|
+
import type { CodeBlockData } from "./plugins/code-block";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Toggle an inline markdown marker (e.g. `**`, `_`, `` ` ``, `~~`) around
|
|
9
|
+
* each selection range. If the selected text is already wrapped with the
|
|
10
|
+
* marker, the marker is removed; otherwise it is added.
|
|
11
|
+
*
|
|
12
|
+
* When nothing is selected the marker pair is inserted at the cursor and the
|
|
13
|
+
* cursor is placed between them.
|
|
14
|
+
*/
|
|
15
|
+
export function toggleMarker(marker: string) {
|
|
16
|
+
return (view: EditorView): boolean => {
|
|
17
|
+
if (isInsideCodeBlock(view)) return false;
|
|
18
|
+
const { state } = view;
|
|
19
|
+
const len = marker.length;
|
|
20
|
+
|
|
21
|
+
const changes = state.changeByRange((range) => {
|
|
22
|
+
const doc = state.doc;
|
|
23
|
+
|
|
24
|
+
if (range.empty) {
|
|
25
|
+
const line = doc.lineAt(range.from);
|
|
26
|
+
const lineText = line.text;
|
|
27
|
+
const cursorOffset = range.from - line.from;
|
|
28
|
+
|
|
29
|
+
const positions: number[] = [];
|
|
30
|
+
let searchFrom = 0;
|
|
31
|
+
while (searchFrom <= lineText.length - len) {
|
|
32
|
+
const idx = lineText.indexOf(marker, searchFrom);
|
|
33
|
+
if (idx === -1) break;
|
|
34
|
+
positions.push(idx);
|
|
35
|
+
searchFrom = idx + len;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
for (let i = 0; i + 1 < positions.length; i += 2) {
|
|
39
|
+
const openEnd = positions[i] + len;
|
|
40
|
+
const closeStart = positions[i + 1];
|
|
41
|
+
if (cursorOffset >= openEnd && cursorOffset <= closeStart) {
|
|
42
|
+
const absOpen = line.from + positions[i];
|
|
43
|
+
const absClose = line.from + closeStart;
|
|
44
|
+
const innerText = doc.sliceString(absOpen + len, absClose);
|
|
45
|
+
const cursorInInner = range.from - (absOpen + len);
|
|
46
|
+
return {
|
|
47
|
+
changes: {
|
|
48
|
+
from: absOpen,
|
|
49
|
+
to: absClose + len,
|
|
50
|
+
insert: innerText,
|
|
51
|
+
},
|
|
52
|
+
range: EditorSelection.cursor(absOpen + cursorInInner),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return {
|
|
58
|
+
changes: { from: range.from, insert: marker + marker },
|
|
59
|
+
range: EditorSelection.cursor(range.from + len),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const selected = doc.sliceString(range.from, range.to);
|
|
64
|
+
|
|
65
|
+
if (selected.startsWith(marker) && selected.endsWith(marker) && selected.length >= len * 2) {
|
|
66
|
+
const inner = selected.slice(len, -len);
|
|
67
|
+
return {
|
|
68
|
+
changes: { from: range.from, to: range.to, insert: inner },
|
|
69
|
+
range: EditorSelection.range(range.from, range.from + inner.length),
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const beforeSel = doc.sliceString(Math.max(0, range.from - len), range.from);
|
|
74
|
+
const afterSel = doc.sliceString(range.to, Math.min(doc.length, range.to + len));
|
|
75
|
+
|
|
76
|
+
if (beforeSel === marker && afterSel === marker) {
|
|
77
|
+
return {
|
|
78
|
+
changes: [
|
|
79
|
+
{ from: range.from - len, to: range.from },
|
|
80
|
+
{ from: range.to, to: range.to + len },
|
|
81
|
+
],
|
|
82
|
+
range: EditorSelection.range(range.from - len, range.to - len),
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return {
|
|
87
|
+
changes: [
|
|
88
|
+
{ from: range.from, insert: marker },
|
|
89
|
+
{ from: range.to, insert: marker },
|
|
90
|
+
],
|
|
91
|
+
range: EditorSelection.range(range.from, range.to + len * 2),
|
|
92
|
+
};
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
view.dispatch(changes);
|
|
96
|
+
return true;
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- Blockquote toggle ---
|
|
101
|
+
|
|
102
|
+
const BQ_RE = /^(\s*)>\s?/;
|
|
103
|
+
|
|
104
|
+
export function toggleBlockquote(view: EditorView): boolean {
|
|
105
|
+
if (isInsideCodeBlock(view)) return false;
|
|
106
|
+
const { state } = view;
|
|
107
|
+
const changes = state.changeByRange((range) => {
|
|
108
|
+
const fromLine = state.doc.lineAt(range.from);
|
|
109
|
+
const toLine = state.doc.lineAt(range.to);
|
|
110
|
+
const edits: { from: number; to: number; insert: string }[] = [];
|
|
111
|
+
|
|
112
|
+
let allQuoted = true;
|
|
113
|
+
for (let num = fromLine.number; num <= toLine.number; num++) {
|
|
114
|
+
const line = state.doc.line(num);
|
|
115
|
+
if (!BQ_RE.test(line.text)) {
|
|
116
|
+
allQuoted = false;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
for (let num = fromLine.number; num <= toLine.number; num++) {
|
|
122
|
+
const line = state.doc.line(num);
|
|
123
|
+
if (allQuoted) {
|
|
124
|
+
const match = line.text.match(BQ_RE)!;
|
|
125
|
+
edits.push({
|
|
126
|
+
from: line.from,
|
|
127
|
+
to: line.from + match[0].length,
|
|
128
|
+
insert: match[1],
|
|
129
|
+
});
|
|
130
|
+
} else if (!BQ_RE.test(line.text)) {
|
|
131
|
+
const indent = line.text.match(/^(\s*)/)![1];
|
|
132
|
+
edits.push({
|
|
133
|
+
from: line.from + indent.length,
|
|
134
|
+
to: line.from + indent.length,
|
|
135
|
+
insert: "> ",
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
if (edits.length === 0) return { changes: [], range };
|
|
141
|
+
|
|
142
|
+
const firstDelta = edits[0].insert.length - (edits[0].to - edits[0].from);
|
|
143
|
+
const totalDelta = edits.reduce((sum, e) => sum + e.insert.length - (e.to - e.from), 0);
|
|
144
|
+
const newFrom = Math.max(0, range.from + firstDelta);
|
|
145
|
+
return {
|
|
146
|
+
changes: edits,
|
|
147
|
+
range: range.empty
|
|
148
|
+
? EditorSelection.cursor(newFrom)
|
|
149
|
+
: EditorSelection.range(newFrom, Math.max(newFrom, range.to + totalDelta)),
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
view.dispatch(changes);
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// --- Block prefix helper ---
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Find the offset where content begins after any blockquote markers on a line.
|
|
161
|
+
* Uses the syntax tree to detect `QuoteMark` nodes, handling nested blockquotes.
|
|
162
|
+
*/
|
|
163
|
+
function getBlockContentOffset(state: EditorState, lineFrom: number, lineTo: number): number {
|
|
164
|
+
const tree = syntaxTree(state);
|
|
165
|
+
let lastQuoteEnd = lineFrom;
|
|
166
|
+
let foundQuote = false;
|
|
167
|
+
|
|
168
|
+
tree.iterate({
|
|
169
|
+
from: lineFrom,
|
|
170
|
+
to: lineTo,
|
|
171
|
+
enter(node) {
|
|
172
|
+
if (node.name === "QuoteMark" && node.from >= lineFrom && node.to <= lineTo) {
|
|
173
|
+
foundQuote = true;
|
|
174
|
+
lastQuoteEnd = node.to;
|
|
175
|
+
}
|
|
176
|
+
},
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (!foundQuote) return 0;
|
|
180
|
+
|
|
181
|
+
// Skip whitespace after the last QuoteMark
|
|
182
|
+
const doc = state.doc;
|
|
183
|
+
let offset = lastQuoteEnd;
|
|
184
|
+
while (offset < lineTo && doc.sliceString(offset, offset + 1) === " ") {
|
|
185
|
+
offset++;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return offset - lineFrom;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// --- Heading toggle ---
|
|
192
|
+
|
|
193
|
+
export function toggleHeading(level: number) {
|
|
194
|
+
return (view: EditorView): boolean => {
|
|
195
|
+
if (isInsideCodeBlock(view)) return false;
|
|
196
|
+
const { state } = view;
|
|
197
|
+
const line = state.doc.lineAt(state.selection.main.from);
|
|
198
|
+
const contentOffset = getBlockContentOffset(state, line.from, line.to);
|
|
199
|
+
const content = line.text.slice(contentOffset);
|
|
200
|
+
const headingMatch = content.match(/^(#{1,6})\s/);
|
|
201
|
+
const prefix = "#".repeat(level) + " ";
|
|
202
|
+
const contentFrom = line.from + contentOffset;
|
|
203
|
+
|
|
204
|
+
const cursorPos = state.selection.main.from;
|
|
205
|
+
|
|
206
|
+
if (headingMatch && headingMatch[1].length === level) {
|
|
207
|
+
// Removing heading — place cursor at same relative content position
|
|
208
|
+
const delta = -headingMatch[0].length;
|
|
209
|
+
view.dispatch({
|
|
210
|
+
changes: { from: contentFrom, to: contentFrom + headingMatch[0].length, insert: "" },
|
|
211
|
+
selection: EditorSelection.cursor(Math.max(contentFrom, cursorPos + delta)),
|
|
212
|
+
});
|
|
213
|
+
} else if (headingMatch) {
|
|
214
|
+
// Switching heading level — adjust cursor for prefix length change
|
|
215
|
+
const delta = prefix.length - headingMatch[0].length;
|
|
216
|
+
view.dispatch({
|
|
217
|
+
changes: { from: contentFrom, to: contentFrom + headingMatch[0].length, insert: prefix },
|
|
218
|
+
selection: EditorSelection.cursor(Math.max(contentFrom + prefix.length, cursorPos + delta)),
|
|
219
|
+
});
|
|
220
|
+
} else {
|
|
221
|
+
// Adding heading — place cursor after the new prefix
|
|
222
|
+
view.dispatch({
|
|
223
|
+
changes: { from: contentFrom, to: contentFrom, insert: prefix },
|
|
224
|
+
selection: EditorSelection.cursor(
|
|
225
|
+
Math.max(contentFrom + prefix.length, cursorPos + prefix.length),
|
|
226
|
+
),
|
|
227
|
+
});
|
|
228
|
+
}
|
|
229
|
+
return true;
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// --- List toggle ---
|
|
234
|
+
|
|
235
|
+
const TASK_RE = /^(\s*)[-*+]\s\[([ xX])\]\s/;
|
|
236
|
+
|
|
237
|
+
type ListKind = "ul" | "ol" | "task" | "none";
|
|
238
|
+
|
|
239
|
+
function detectListKind(
|
|
240
|
+
state: EditorState,
|
|
241
|
+
pos: number,
|
|
242
|
+
contentText: string,
|
|
243
|
+
): { kind: ListKind; indent: string } {
|
|
244
|
+
const tree = syntaxTree(state);
|
|
245
|
+
let node = tree.resolveInner(pos, 1);
|
|
246
|
+
const indent = contentText.match(/^(\s*)/)![1];
|
|
247
|
+
|
|
248
|
+
while (node) {
|
|
249
|
+
if (node.name === "ListItem" && node.getChild("Task")) {
|
|
250
|
+
return { kind: "task", indent };
|
|
251
|
+
}
|
|
252
|
+
if (node.name === "BulletList" || node.name === "OrderedList") {
|
|
253
|
+
return { kind: node.name === "BulletList" ? "ul" : "ol", indent };
|
|
254
|
+
}
|
|
255
|
+
if (!node.parent) break;
|
|
256
|
+
node = node.parent;
|
|
257
|
+
}
|
|
258
|
+
return { kind: "none", indent: "" };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function stripListPrefix(lineText: string): string {
|
|
262
|
+
return lineText.replace(/^(\s*)(?:[-*+]\s\[([ xX])\]\s|[-*+]\s|\d+\.\s)/, "$1");
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
export function toggleListKind(target: ListKind) {
|
|
266
|
+
return (view: EditorView): boolean => {
|
|
267
|
+
if (isInsideCodeBlock(view)) return false;
|
|
268
|
+
const { state } = view;
|
|
269
|
+
const changes = state.changeByRange((range) => {
|
|
270
|
+
const fromLine = state.doc.lineAt(range.from);
|
|
271
|
+
const toLine = state.doc.lineAt(range.to);
|
|
272
|
+
const edits: { from: number; to: number; insert: string }[] = [];
|
|
273
|
+
let olIdx = 1;
|
|
274
|
+
|
|
275
|
+
for (let num = fromLine.number; num <= toLine.number; num++) {
|
|
276
|
+
const line = state.doc.line(num);
|
|
277
|
+
const bqOffset = getBlockContentOffset(state, line.from, line.to);
|
|
278
|
+
const content = line.text.slice(bqOffset);
|
|
279
|
+
const { kind, indent } = detectListKind(state, line.from + bqOffset, content);
|
|
280
|
+
const stripped = stripListPrefix(content);
|
|
281
|
+
|
|
282
|
+
let newPrefix: string;
|
|
283
|
+
if (kind === target) {
|
|
284
|
+
newPrefix = indent;
|
|
285
|
+
} else {
|
|
286
|
+
switch (target) {
|
|
287
|
+
case "ul":
|
|
288
|
+
newPrefix = `${indent}- `;
|
|
289
|
+
break;
|
|
290
|
+
case "ol":
|
|
291
|
+
newPrefix = `${indent}${olIdx++}. `;
|
|
292
|
+
break;
|
|
293
|
+
case "task":
|
|
294
|
+
newPrefix = `${indent}- [ ] `;
|
|
295
|
+
break;
|
|
296
|
+
default:
|
|
297
|
+
newPrefix = indent;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const newContent = newPrefix + stripped.slice(indent.length);
|
|
302
|
+
const newLine = line.text.slice(0, bqOffset) + newContent;
|
|
303
|
+
if (newLine !== line.text) {
|
|
304
|
+
edits.push({ from: line.from, to: line.to, insert: newLine });
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (edits.length === 0) return { changes: [], range };
|
|
309
|
+
|
|
310
|
+
const firstDelta = edits[0].insert.length - (edits[0].to - edits[0].from);
|
|
311
|
+
const totalDelta = edits.reduce((sum, e) => sum + e.insert.length - (e.to - e.from), 0);
|
|
312
|
+
const newFrom = Math.max(0, range.from + firstDelta);
|
|
313
|
+
return {
|
|
314
|
+
changes: edits,
|
|
315
|
+
range: range.empty
|
|
316
|
+
? EditorSelection.cursor(newFrom)
|
|
317
|
+
: EditorSelection.range(newFrom, Math.max(newFrom, range.to + totalDelta)),
|
|
318
|
+
};
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
view.dispatch(changes);
|
|
322
|
+
return true;
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
export function toggleTaskCheck(view: EditorView): boolean {
|
|
327
|
+
if (isInsideCodeBlock(view)) return false;
|
|
328
|
+
const { state } = view;
|
|
329
|
+
const changes = state.changeByRange((range) => {
|
|
330
|
+
const fromLine = state.doc.lineAt(range.from);
|
|
331
|
+
const toLine = state.doc.lineAt(range.to);
|
|
332
|
+
const edits: { from: number; to: number; insert: string }[] = [];
|
|
333
|
+
|
|
334
|
+
for (let num = fromLine.number; num <= toLine.number; num++) {
|
|
335
|
+
const line = state.doc.line(num);
|
|
336
|
+
const match = line.text.match(TASK_RE);
|
|
337
|
+
if (!match) continue;
|
|
338
|
+
const isChecked = match[2] !== " ";
|
|
339
|
+
const bracketStart = line.from + line.text.indexOf("[");
|
|
340
|
+
edits.push({
|
|
341
|
+
from: bracketStart,
|
|
342
|
+
to: bracketStart + 3,
|
|
343
|
+
insert: isChecked ? "[ ]" : "[x]",
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (edits.length === 0) return { changes: [], range };
|
|
348
|
+
return { changes: edits, range };
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
view.dispatch(changes);
|
|
352
|
+
return true;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// --- Link helpers ---
|
|
356
|
+
|
|
357
|
+
export function findLinkAtCursor(
|
|
358
|
+
state: EditorState,
|
|
359
|
+
pos: number,
|
|
360
|
+
): { from: number; to: number; label: string; url: string } | null {
|
|
361
|
+
const tree = syntaxTree(state);
|
|
362
|
+
let node = tree.resolveInner(pos, -1);
|
|
363
|
+
while (node) {
|
|
364
|
+
if (node.name === "Link") {
|
|
365
|
+
const from = node.from;
|
|
366
|
+
const to = node.to;
|
|
367
|
+
const full = state.sliceDoc(from, to);
|
|
368
|
+
const labelStart = full.indexOf("[");
|
|
369
|
+
const labelEnd = full.indexOf("]");
|
|
370
|
+
const urlStart = full.indexOf("(", labelEnd);
|
|
371
|
+
const urlEnd = full.lastIndexOf(")");
|
|
372
|
+
if (labelStart === -1 || labelEnd === -1 || urlStart === -1 || urlEnd === -1) return null;
|
|
373
|
+
const label = full.slice(labelStart + 1, labelEnd);
|
|
374
|
+
const url = full.slice(urlStart + 1, urlEnd);
|
|
375
|
+
return { from, to, label, url };
|
|
376
|
+
}
|
|
377
|
+
if (!node.parent) break;
|
|
378
|
+
node = node.parent;
|
|
379
|
+
}
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
/** Insert a markdown link into the editor, replacing `from..to`. */
|
|
384
|
+
export function insertLink(
|
|
385
|
+
view: EditorView,
|
|
386
|
+
from: number,
|
|
387
|
+
to: number,
|
|
388
|
+
label: string,
|
|
389
|
+
url: string,
|
|
390
|
+
): void {
|
|
391
|
+
const insert = `[${label}](${url})`;
|
|
392
|
+
view.dispatch({
|
|
393
|
+
changes: { from, to, insert },
|
|
394
|
+
selection: EditorSelection.cursor(from + insert.length),
|
|
395
|
+
});
|
|
396
|
+
view.focus();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// --- Code fence helpers ---
|
|
400
|
+
|
|
401
|
+
function parseFenceInfo(info: string): { lang: string; filename: string } {
|
|
402
|
+
const parts = info.trim().split(/\s+/);
|
|
403
|
+
let lang = "";
|
|
404
|
+
let filename = "";
|
|
405
|
+
for (const part of parts) {
|
|
406
|
+
if (part.startsWith("filename=")) {
|
|
407
|
+
filename = part.slice("filename=".length);
|
|
408
|
+
} else if (!lang) {
|
|
409
|
+
lang = part;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return { lang, filename };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export function findCodeFenceAtCursor(view: EditorView): {
|
|
416
|
+
fenceLine: { from: number; to: number };
|
|
417
|
+
lang: string;
|
|
418
|
+
filename: string;
|
|
419
|
+
} | null {
|
|
420
|
+
const { state } = view;
|
|
421
|
+
const pos = state.selection.main.from;
|
|
422
|
+
const tree = syntaxTree(state);
|
|
423
|
+
|
|
424
|
+
let node = tree.resolveInner(pos, -1);
|
|
425
|
+
while (node) {
|
|
426
|
+
if (node.name === "FencedCode") {
|
|
427
|
+
const fenceLine = state.doc.lineAt(node.from);
|
|
428
|
+
const infoNode = node.getChild("CodeInfo");
|
|
429
|
+
const info = infoNode ? state.sliceDoc(infoNode.from, infoNode.to) : "";
|
|
430
|
+
const { lang, filename } = parseFenceInfo(info);
|
|
431
|
+
return { fenceLine: { from: fenceLine.from, to: fenceLine.to }, lang, filename };
|
|
432
|
+
}
|
|
433
|
+
if (!node.parent) break;
|
|
434
|
+
node = node.parent;
|
|
435
|
+
}
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Apply code block settings. For existing blocks, replaces the fence line. For new blocks, inserts a fenced block. */
|
|
440
|
+
export function applyCodeBlock(
|
|
441
|
+
view: EditorView,
|
|
442
|
+
data: CodeBlockData,
|
|
443
|
+
lang: string,
|
|
444
|
+
filename: string,
|
|
445
|
+
): void {
|
|
446
|
+
const info = [lang, filename ? `filename=${filename}` : ""].filter(Boolean).join(" ");
|
|
447
|
+
if (data.isNew) {
|
|
448
|
+
const insert = `\`\`\`${info}\n\n\`\`\``;
|
|
449
|
+
const pos = data.insertPos!;
|
|
450
|
+
view.dispatch({
|
|
451
|
+
changes: { from: pos, to: pos, insert },
|
|
452
|
+
selection: EditorSelection.cursor(pos + 3 + info.length + 1),
|
|
453
|
+
});
|
|
454
|
+
} else {
|
|
455
|
+
const fenceLine = `\`\`\`${info}`;
|
|
456
|
+
view.dispatch({
|
|
457
|
+
changes: { from: data.fenceFrom, to: data.fenceTo, insert: fenceLine },
|
|
458
|
+
});
|
|
459
|
+
}
|
|
460
|
+
view.focus();
|
|
461
|
+
}
|