draftly 0.1.0-alpha.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/dist/index.cjs +1144 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +257 -0
- package/dist/index.d.ts +257 -0
- package/dist/index.js +1128 -0
- package/dist/index.js.map +1 -0
- package/package.json +65 -0
- package/src/draftly.ts +198 -0
- package/src/index.ts +7 -0
- package/src/plugin.ts +245 -0
- package/src/plugins/heading-plugin.ts +163 -0
- package/src/plugins/html-plugin.ts +347 -0
- package/src/plugins/inline-plugin.ts +152 -0
- package/src/plugins/list-plugin.ts +211 -0
- package/src/plugins/plugins.ts +9 -0
- package/src/theme.ts +86 -0
- package/src/utils.ts +21 -0
- package/src/view-plugin.ts +304 -0
|
@@ -0,0 +1,347 @@
|
|
|
1
|
+
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
|
|
2
|
+
import { syntaxTree } from "@codemirror/language";
|
|
3
|
+
import { Extension } from "@codemirror/state";
|
|
4
|
+
import { DecorationContext, DecorationPlugin } from "../plugin";
|
|
5
|
+
import DOMPurify from "dompurify";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Mark decorations for HTML content
|
|
9
|
+
*/
|
|
10
|
+
const htmlMarkDecorations = {
|
|
11
|
+
"html-tag": Decoration.mark({ class: "cm-draftly-html-tag" }),
|
|
12
|
+
"html-comment": Decoration.mark({ class: "cm-draftly-html-comment" }),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Line decorations for HTML blocks (when visible)
|
|
17
|
+
*/
|
|
18
|
+
const htmlLineDecorations = {
|
|
19
|
+
"html-block": Decoration.line({ class: "cm-draftly-line-html-block" }),
|
|
20
|
+
"hidden-line": Decoration.line({ class: "cm-draftly-hidden-line" }),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Widget to render sanitized HTML (block)
|
|
25
|
+
*/
|
|
26
|
+
class HTMLPreviewWidget extends WidgetType {
|
|
27
|
+
constructor(readonly html: string) {
|
|
28
|
+
super();
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
eq(other: HTMLPreviewWidget): boolean {
|
|
32
|
+
return other.html === this.html;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
toDOM() {
|
|
36
|
+
const div = document.createElement("div");
|
|
37
|
+
div.className = "cm-draftly-html-preview";
|
|
38
|
+
div.innerHTML = DOMPurify.sanitize(this.html);
|
|
39
|
+
return div;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
ignoreEvent() {
|
|
43
|
+
return false;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Widget to render sanitized inline HTML
|
|
49
|
+
*/
|
|
50
|
+
class InlineHTMLPreviewWidget extends WidgetType {
|
|
51
|
+
constructor(readonly html: string) {
|
|
52
|
+
super();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
eq(other: InlineHTMLPreviewWidget): boolean {
|
|
56
|
+
return other.html === this.html;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
toDOM() {
|
|
60
|
+
const span = document.createElement("span");
|
|
61
|
+
span.className = "cm-draftly-inline-html-preview";
|
|
62
|
+
span.innerHTML = DOMPurify.sanitize(this.html);
|
|
63
|
+
return span;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
ignoreEvent() {
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface HTMLGroup {
|
|
72
|
+
from: number;
|
|
73
|
+
to: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
interface HTMLTagInfo {
|
|
77
|
+
from: number;
|
|
78
|
+
to: number;
|
|
79
|
+
tagName: string;
|
|
80
|
+
isClosing: boolean;
|
|
81
|
+
isSelfClosing: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface InlineHTMLElement {
|
|
85
|
+
from: number;
|
|
86
|
+
to: number;
|
|
87
|
+
content: string;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Parse an HTML tag to extract its name and type
|
|
92
|
+
*/
|
|
93
|
+
function parseHTMLTag(content: string): { tagName: string; isClosing: boolean; isSelfClosing: boolean } | null {
|
|
94
|
+
const match = content.match(/^<\s*(\/?)([a-zA-Z][a-zA-Z0-9-]*)[^>]*(\/?)>$/);
|
|
95
|
+
if (!match) return null;
|
|
96
|
+
|
|
97
|
+
return {
|
|
98
|
+
tagName: match[2]!.toLowerCase(),
|
|
99
|
+
isClosing: match[1] === "/",
|
|
100
|
+
isSelfClosing:
|
|
101
|
+
match[3] === "/" ||
|
|
102
|
+
["br", "hr", "img", "input", "meta", "link", "area", "base", "col", "embed", "source", "track", "wbr"].includes(
|
|
103
|
+
match[2]!.toLowerCase()
|
|
104
|
+
),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* HTMLPlugin - Decorates and Renders HTML in markdown
|
|
110
|
+
*/
|
|
111
|
+
export class HTMLPlugin extends DecorationPlugin {
|
|
112
|
+
readonly name = "html";
|
|
113
|
+
readonly version = "1.0.0";
|
|
114
|
+
|
|
115
|
+
override get decorationPriority(): number {
|
|
116
|
+
return 30;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
override getExtensions(): Extension[] {
|
|
120
|
+
return [htmlTheme];
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
override buildDecorations(ctx: DecorationContext): void {
|
|
124
|
+
const { view, decorations } = ctx;
|
|
125
|
+
const tree = syntaxTree(view.state);
|
|
126
|
+
|
|
127
|
+
// Collect blocks and inline tags
|
|
128
|
+
const htmlGroups: HTMLGroup[] = [];
|
|
129
|
+
const htmlTags: HTMLTagInfo[] = [];
|
|
130
|
+
|
|
131
|
+
tree.iterate({
|
|
132
|
+
enter: (node) => {
|
|
133
|
+
const { from, to, name } = node;
|
|
134
|
+
|
|
135
|
+
// Handle HTML Comments
|
|
136
|
+
if (name === "Comment") {
|
|
137
|
+
decorations.push(htmlMarkDecorations["html-comment"].range(from, to));
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Collect inline HTML tags for pairing
|
|
142
|
+
if (name === "HTMLTag") {
|
|
143
|
+
const content = view.state.sliceDoc(from, to);
|
|
144
|
+
const parsed = parseHTMLTag(content);
|
|
145
|
+
if (parsed) {
|
|
146
|
+
htmlTags.push({
|
|
147
|
+
from,
|
|
148
|
+
to,
|
|
149
|
+
tagName: parsed.tagName,
|
|
150
|
+
isClosing: parsed.isClosing,
|
|
151
|
+
isSelfClosing: parsed.isSelfClosing,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Handle HTML Blocks - Collect for grouping
|
|
157
|
+
if (name === "HTMLBlock") {
|
|
158
|
+
const last = htmlGroups[htmlGroups.length - 1];
|
|
159
|
+
if (last) {
|
|
160
|
+
const gap = view.state.sliceDoc(last.to, from);
|
|
161
|
+
if (!gap.trim()) {
|
|
162
|
+
last.to = to;
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
htmlGroups.push({ from, to });
|
|
167
|
+
}
|
|
168
|
+
},
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// Find complete inline HTML elements (must be on same line)
|
|
172
|
+
const inlineElements: InlineHTMLElement[] = [];
|
|
173
|
+
const usedTags = new Set<number>(); // Track used tag indices
|
|
174
|
+
|
|
175
|
+
for (let i = 0; i < htmlTags.length; i++) {
|
|
176
|
+
if (usedTags.has(i)) continue;
|
|
177
|
+
|
|
178
|
+
const openTag = htmlTags[i]!;
|
|
179
|
+
if (openTag.isClosing) continue;
|
|
180
|
+
|
|
181
|
+
// Handle self-closing tags
|
|
182
|
+
if (openTag.isSelfClosing) {
|
|
183
|
+
inlineElements.push({
|
|
184
|
+
from: openTag.from,
|
|
185
|
+
to: openTag.to,
|
|
186
|
+
content: view.state.sliceDoc(openTag.from, openTag.to),
|
|
187
|
+
});
|
|
188
|
+
usedTags.add(i);
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Find matching closing tag (must be on same line)
|
|
193
|
+
const openLine = view.state.doc.lineAt(openTag.from);
|
|
194
|
+
let depth = 1;
|
|
195
|
+
let closeTagIndex: number | null = null;
|
|
196
|
+
|
|
197
|
+
for (let j = i + 1; j < htmlTags.length && depth > 0; j++) {
|
|
198
|
+
const tag = htmlTags[j]!;
|
|
199
|
+
|
|
200
|
+
// Stop if we've gone past the open tag's line
|
|
201
|
+
if (tag.from > openLine.to) break;
|
|
202
|
+
|
|
203
|
+
if (tag.tagName === openTag.tagName) {
|
|
204
|
+
if (tag.isClosing) {
|
|
205
|
+
depth--;
|
|
206
|
+
if (depth === 0) {
|
|
207
|
+
closeTagIndex = j;
|
|
208
|
+
}
|
|
209
|
+
} else if (!tag.isSelfClosing) {
|
|
210
|
+
depth++;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (closeTagIndex !== null) {
|
|
216
|
+
const closeTag = htmlTags[closeTagIndex]!;
|
|
217
|
+
inlineElements.push({
|
|
218
|
+
from: openTag.from,
|
|
219
|
+
to: closeTag.to,
|
|
220
|
+
content: view.state.sliceDoc(openTag.from, closeTag.to),
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Mark all tags within this range as used (to handle nesting)
|
|
224
|
+
for (let k = i; k <= closeTagIndex; k++) {
|
|
225
|
+
usedTags.add(k);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Sort by position and filter out overlapping elements (keep outermost)
|
|
231
|
+
inlineElements.sort((a, b) => a.from - b.from);
|
|
232
|
+
const filteredElements: InlineHTMLElement[] = [];
|
|
233
|
+
let lastEnd = -1;
|
|
234
|
+
|
|
235
|
+
for (const elem of inlineElements) {
|
|
236
|
+
if (elem.from >= lastEnd) {
|
|
237
|
+
filteredElements.push(elem);
|
|
238
|
+
lastEnd = elem.to;
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Apply decorations for inline elements
|
|
243
|
+
for (const elem of filteredElements) {
|
|
244
|
+
const cursorInRange = ctx.cursorInRange(elem.from, elem.to);
|
|
245
|
+
|
|
246
|
+
if (cursorInRange) {
|
|
247
|
+
// Show source - find and style the tags within this element
|
|
248
|
+
for (const tag of htmlTags) {
|
|
249
|
+
if (tag.from >= elem.from && tag.to <= elem.to) {
|
|
250
|
+
decorations.push(htmlMarkDecorations["html-tag"].range(tag.from, tag.to));
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
// Render preview
|
|
255
|
+
decorations.push(
|
|
256
|
+
Decoration.replace({
|
|
257
|
+
widget: new InlineHTMLPreviewWidget(elem.content),
|
|
258
|
+
}).range(elem.from, elem.to)
|
|
259
|
+
);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Style any remaining unprocessed tags (orphan tags)
|
|
264
|
+
for (let i = 0; i < htmlTags.length; i++) {
|
|
265
|
+
if (!usedTags.has(i)) {
|
|
266
|
+
const tag = htmlTags[i]!;
|
|
267
|
+
decorations.push(htmlMarkDecorations["html-tag"].range(tag.from, tag.to));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Process gathered HTML block groups
|
|
272
|
+
for (const group of htmlGroups) {
|
|
273
|
+
const { from, to } = group;
|
|
274
|
+
|
|
275
|
+
const nodeLineStart = view.state.doc.lineAt(from);
|
|
276
|
+
const nodeLineEnd = view.state.doc.lineAt(to);
|
|
277
|
+
const cursorInRange = ctx.cursorInRange(nodeLineStart.from, nodeLineEnd.to);
|
|
278
|
+
|
|
279
|
+
if (cursorInRange) {
|
|
280
|
+
for (let i = nodeLineStart.number; i <= nodeLineEnd.number; i++) {
|
|
281
|
+
const line = view.state.doc.line(i);
|
|
282
|
+
decorations.push(htmlLineDecorations["html-block"].range(line.from));
|
|
283
|
+
}
|
|
284
|
+
} else {
|
|
285
|
+
const htmlContent = view.state.sliceDoc(from, to);
|
|
286
|
+
|
|
287
|
+
decorations.push(
|
|
288
|
+
Decoration.replace({
|
|
289
|
+
widget: new HTMLPreviewWidget(htmlContent.trim()),
|
|
290
|
+
}).range(from, nodeLineStart.to)
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
for (let i = nodeLineStart.number + 1; i <= nodeLineEnd.number; i++) {
|
|
294
|
+
const line = view.state.doc.line(i);
|
|
295
|
+
decorations.push(htmlLineDecorations["hidden-line"].range(line.from));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Theme for HTML styling
|
|
304
|
+
*/
|
|
305
|
+
const htmlTheme = EditorView.theme({
|
|
306
|
+
".cm-draftly-html-tag": {
|
|
307
|
+
color: "#6a737d",
|
|
308
|
+
fontFamily: "var(--font-jetbrains-mono, monospace)",
|
|
309
|
+
fontSize: "0.85em",
|
|
310
|
+
},
|
|
311
|
+
|
|
312
|
+
".cm-draftly-html-comment": {
|
|
313
|
+
color: "#6a737d",
|
|
314
|
+
fontStyle: "italic",
|
|
315
|
+
fontFamily: "var(--font-jetbrains-mono, monospace)",
|
|
316
|
+
fontSize: "0.85em",
|
|
317
|
+
opacity: 0.5,
|
|
318
|
+
},
|
|
319
|
+
|
|
320
|
+
".cm-draftly-line-html-block": {
|
|
321
|
+
backgroundColor: "rgba(0, 0, 0, 0.02)",
|
|
322
|
+
},
|
|
323
|
+
|
|
324
|
+
".cm-draftly-hidden-line": {
|
|
325
|
+
display: "none",
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
".cm-draftly-html-preview": {
|
|
329
|
+
display: "inline-block",
|
|
330
|
+
width: "100%",
|
|
331
|
+
verticalAlign: "top",
|
|
332
|
+
margin: "0",
|
|
333
|
+
whiteSpace: "normal",
|
|
334
|
+
lineHeight: "1.4",
|
|
335
|
+
},
|
|
336
|
+
".cm-draftly-html-preview > *:first-child": {
|
|
337
|
+
marginTop: "0",
|
|
338
|
+
},
|
|
339
|
+
".cm-draftly-html-preview > *:last-child": {
|
|
340
|
+
marginBottom: "0",
|
|
341
|
+
},
|
|
342
|
+
|
|
343
|
+
".cm-draftly-inline-html-preview": {
|
|
344
|
+
display: "inline",
|
|
345
|
+
whiteSpace: "normal",
|
|
346
|
+
},
|
|
347
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Decoration, EditorView } from "@codemirror/view";
|
|
2
|
+
import { syntaxTree } from "@codemirror/language";
|
|
3
|
+
import { DecorationContext, DecorationPlugin } from "../plugin";
|
|
4
|
+
import { Extension } from "@codemirror/state";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Node types for inline styling in markdown
|
|
8
|
+
*/
|
|
9
|
+
const INLINE_TYPES = {
|
|
10
|
+
Emphasis: "emphasis",
|
|
11
|
+
StrongEmphasis: "strong",
|
|
12
|
+
Strikethrough: "strikethrough",
|
|
13
|
+
Subscript: "subscript",
|
|
14
|
+
Superscript: "superscript",
|
|
15
|
+
} as const;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Mark decorations for inline content
|
|
19
|
+
*/
|
|
20
|
+
const inlineMarkDecorations = {
|
|
21
|
+
emphasis: Decoration.mark({ class: "cm-draftly-emphasis" }),
|
|
22
|
+
strong: Decoration.mark({ class: "cm-draftly-strong" }),
|
|
23
|
+
strikethrough: Decoration.mark({ class: "cm-draftly-strikethrough" }),
|
|
24
|
+
subscript: Decoration.mark({ class: "cm-draftly-subscript" }),
|
|
25
|
+
superscript: Decoration.mark({ class: "cm-draftly-superscript" }),
|
|
26
|
+
// Markers (* _ ~~ ^ ~)
|
|
27
|
+
"inline-mark": Decoration.mark({ class: "cm-draftly-inline-mark" }),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* InlinePlugin - Decorates inline markdown formatting
|
|
32
|
+
*
|
|
33
|
+
* Adds visual styling to inline elements:
|
|
34
|
+
* - Emphasis (italic) - *text* or _text_
|
|
35
|
+
* - Strong (bold) - **text** or __text__
|
|
36
|
+
* - Strikethrough - ~~text~~
|
|
37
|
+
* - Subscript - ~text~
|
|
38
|
+
* - Superscript - ^text^
|
|
39
|
+
*
|
|
40
|
+
* Hides formatting markers when cursor is not in the element
|
|
41
|
+
*/
|
|
42
|
+
export class InlinePlugin extends DecorationPlugin {
|
|
43
|
+
readonly name = "inline";
|
|
44
|
+
readonly version = "1.0.0";
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Moderate priority for inline styling
|
|
48
|
+
*/
|
|
49
|
+
override get decorationPriority(): number {
|
|
50
|
+
return 20;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the extensions for this plugin (theme)
|
|
55
|
+
*/
|
|
56
|
+
override getExtensions(): Extension[] {
|
|
57
|
+
return [inlineTheme];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Build inline decorations by iterating the syntax tree
|
|
62
|
+
*/
|
|
63
|
+
override buildDecorations(ctx: DecorationContext): void {
|
|
64
|
+
const { view, decorations } = ctx;
|
|
65
|
+
const tree = syntaxTree(view.state);
|
|
66
|
+
|
|
67
|
+
tree.iterate({
|
|
68
|
+
enter: (node) => {
|
|
69
|
+
const { from, to, name } = node;
|
|
70
|
+
|
|
71
|
+
// Check if this is an inline type we handle
|
|
72
|
+
const inlineType = INLINE_TYPES[name as keyof typeof INLINE_TYPES];
|
|
73
|
+
if (!inlineType) {
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Add mark decoration for the content
|
|
78
|
+
decorations.push(inlineMarkDecorations[inlineType].range(from, to));
|
|
79
|
+
|
|
80
|
+
// Only hide markers when cursor is not in the element
|
|
81
|
+
const cursorInNode = ctx.selectionOverlapsRange(from, to);
|
|
82
|
+
if (!cursorInNode) {
|
|
83
|
+
// Get the appropriate marker children based on type
|
|
84
|
+
const markerNames = this.getMarkerNames(name);
|
|
85
|
+
for (const markerName of markerNames) {
|
|
86
|
+
const marks = node.node.getChildren(markerName);
|
|
87
|
+
for (const mark of marks) {
|
|
88
|
+
decorations.push(inlineMarkDecorations["inline-mark"].range(mark.from, mark.to));
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Get the marker node names for a given inline type
|
|
98
|
+
*/
|
|
99
|
+
private getMarkerNames(nodeType: string): string[] {
|
|
100
|
+
switch (nodeType) {
|
|
101
|
+
case "Emphasis":
|
|
102
|
+
case "StrongEmphasis":
|
|
103
|
+
return ["EmphasisMark"];
|
|
104
|
+
case "Strikethrough":
|
|
105
|
+
return ["StrikethroughMark"];
|
|
106
|
+
case "Subscript":
|
|
107
|
+
return ["SubscriptMark"];
|
|
108
|
+
case "Superscript":
|
|
109
|
+
return ["SuperscriptMark"];
|
|
110
|
+
default:
|
|
111
|
+
return [];
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Theme for inline styling
|
|
118
|
+
*/
|
|
119
|
+
const inlineTheme = EditorView.theme({
|
|
120
|
+
// Emphasis (italic)
|
|
121
|
+
".cm-draftly-emphasis": {
|
|
122
|
+
fontStyle: "italic",
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
// Strong (bold)
|
|
126
|
+
".cm-draftly-strong": {
|
|
127
|
+
fontWeight: "bold",
|
|
128
|
+
},
|
|
129
|
+
|
|
130
|
+
// Strikethrough
|
|
131
|
+
".cm-draftly-strikethrough": {
|
|
132
|
+
textDecoration: "line-through",
|
|
133
|
+
opacity: "0.7",
|
|
134
|
+
},
|
|
135
|
+
|
|
136
|
+
// Subscript
|
|
137
|
+
".cm-draftly-subscript": {
|
|
138
|
+
fontSize: "0.75em",
|
|
139
|
+
verticalAlign: "sub",
|
|
140
|
+
},
|
|
141
|
+
|
|
142
|
+
// Superscript
|
|
143
|
+
".cm-draftly-superscript": {
|
|
144
|
+
fontSize: "0.75em",
|
|
145
|
+
verticalAlign: "super",
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
// Inline markers (* _ ~~ ^ ~) - hidden when not focused
|
|
149
|
+
".cm-draftly-inline-mark": {
|
|
150
|
+
display: "none",
|
|
151
|
+
},
|
|
152
|
+
});
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { Decoration, EditorView, WidgetType } from "@codemirror/view";
|
|
2
|
+
import { syntaxTree } from "@codemirror/language";
|
|
3
|
+
import { Extension } from "@codemirror/state";
|
|
4
|
+
import { DecorationContext, DecorationPlugin } from "../plugin";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Mark decorations for list items
|
|
8
|
+
*/
|
|
9
|
+
const listMarkDecorations = {
|
|
10
|
+
"list-mark-ul": Decoration.mark({ class: "cm-draftly-list-mark-ul" }),
|
|
11
|
+
"list-mark-ol": Decoration.mark({ class: "cm-draftly-list-mark-ol" }),
|
|
12
|
+
"task-marker": Decoration.mark({ class: "cm-draftly-task-marker" }),
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Task checkbox widget
|
|
17
|
+
*/
|
|
18
|
+
export class TaskCheckboxWidget extends WidgetType {
|
|
19
|
+
constructor(readonly checked: boolean) {
|
|
20
|
+
super();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
eq(other: TaskCheckboxWidget): boolean {
|
|
24
|
+
return other.checked === this.checked;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
toDOM(view: EditorView): HTMLElement {
|
|
28
|
+
const wrap = document.createElement("span");
|
|
29
|
+
wrap.className = `cm-draftly-task-checkbox ${this.checked ? "checked" : ""}`;
|
|
30
|
+
wrap.setAttribute("aria-hidden", "true");
|
|
31
|
+
|
|
32
|
+
const checkbox = document.createElement("input");
|
|
33
|
+
checkbox.type = "checkbox";
|
|
34
|
+
checkbox.checked = this.checked;
|
|
35
|
+
checkbox.tabIndex = -1;
|
|
36
|
+
|
|
37
|
+
checkbox.addEventListener("mousedown", (e) => {
|
|
38
|
+
e.preventDefault();
|
|
39
|
+
const pos = view.posAtDOM(wrap);
|
|
40
|
+
// Find the task marker in the document and toggle it
|
|
41
|
+
const line = view.state.doc.lineAt(pos);
|
|
42
|
+
const match = line.text.match(/^(\s*[-*+]\s*)\[([ xX])\]/);
|
|
43
|
+
if (match) {
|
|
44
|
+
const markerStart = line.from + match[1]!.length + 1;
|
|
45
|
+
const newChar = this.checked ? " " : "x";
|
|
46
|
+
view.dispatch({
|
|
47
|
+
changes: { from: markerStart, to: markerStart + 1, insert: newChar },
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
wrap.appendChild(checkbox);
|
|
53
|
+
|
|
54
|
+
// Add a label for better accessibility and click area
|
|
55
|
+
// const label = document.createElement("span");
|
|
56
|
+
// wrap.appendChild(label);
|
|
57
|
+
|
|
58
|
+
return wrap;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
ignoreEvent(): boolean {
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* ListPlugin - Decorates markdown lists
|
|
68
|
+
*
|
|
69
|
+
* Handles:
|
|
70
|
+
* - Unordered lists (bullet points) - Auto-styles markers (*, -, +)
|
|
71
|
+
* - Ordered lists (numbered) - Auto-styles numbers (1., 2.)
|
|
72
|
+
* - Task lists (checkboxes) - Replaces [ ]/[x] with interactive widget when not editing
|
|
73
|
+
*/
|
|
74
|
+
export class ListPlugin extends DecorationPlugin {
|
|
75
|
+
readonly name = "list";
|
|
76
|
+
readonly version = "1.0.0";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Moderate priority
|
|
80
|
+
*/
|
|
81
|
+
override get decorationPriority(): number {
|
|
82
|
+
return 20;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Get the extensions for this plugin (theme)
|
|
87
|
+
*/
|
|
88
|
+
override getExtensions(): Extension[] {
|
|
89
|
+
return [listTheme];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build list decorations by iterating the syntax tree
|
|
94
|
+
*/
|
|
95
|
+
override buildDecorations(ctx: DecorationContext): void {
|
|
96
|
+
const { view, decorations } = ctx;
|
|
97
|
+
const tree = syntaxTree(view.state);
|
|
98
|
+
|
|
99
|
+
tree.iterate({
|
|
100
|
+
enter: (node) => {
|
|
101
|
+
const { from, to, name } = node;
|
|
102
|
+
const line = view.state.doc.lineAt(from);
|
|
103
|
+
const cursorInLine = ctx.cursorInRange(line.from, line.to);
|
|
104
|
+
|
|
105
|
+
// Handle list markers (bullets, numbers)
|
|
106
|
+
if (name === "ListMark") {
|
|
107
|
+
// Determine list type by checking grandparent (BulletList vs OrderedList)
|
|
108
|
+
// Parent is ListItem, Grandparent is the list container
|
|
109
|
+
const parent = node.node.parent;
|
|
110
|
+
const grandparent = parent?.parent;
|
|
111
|
+
const listType = grandparent?.name;
|
|
112
|
+
|
|
113
|
+
if (!cursorInLine) {
|
|
114
|
+
if (listType === "OrderedList") {
|
|
115
|
+
decorations.push(listMarkDecorations["list-mark-ol"].range(from, to));
|
|
116
|
+
} else {
|
|
117
|
+
// Default to generic/unordered for BulletList or others
|
|
118
|
+
decorations.push(listMarkDecorations["list-mark-ul"].range(from, to));
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Handle task lists
|
|
124
|
+
if (name === "TaskMarker") {
|
|
125
|
+
const text = view.state.sliceDoc(from, to);
|
|
126
|
+
const isChecked = text.includes("x") || text.includes("X");
|
|
127
|
+
|
|
128
|
+
if (cursorInLine) {
|
|
129
|
+
// If editing the line, just style the marker text
|
|
130
|
+
decorations.push(listMarkDecorations["task-marker"].range(from, to));
|
|
131
|
+
} else {
|
|
132
|
+
// If not editing, REPLACE the marker text with the widget
|
|
133
|
+
decorations.push(
|
|
134
|
+
Decoration.replace({
|
|
135
|
+
widget: new TaskCheckboxWidget(isChecked),
|
|
136
|
+
}).range(from, to)
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
},
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Theme for list styling
|
|
147
|
+
*/
|
|
148
|
+
const listTheme = EditorView.theme({
|
|
149
|
+
// Unordered List markers (*, -, +)
|
|
150
|
+
".cm-draftly-list-mark-ul": {
|
|
151
|
+
position: "relative",
|
|
152
|
+
},
|
|
153
|
+
|
|
154
|
+
".cm-draftly-list-mark-ul > span": {
|
|
155
|
+
visibility: "hidden",
|
|
156
|
+
},
|
|
157
|
+
|
|
158
|
+
".cm-draftly-list-mark-ul::after": {
|
|
159
|
+
content: '"•"',
|
|
160
|
+
position: "absolute",
|
|
161
|
+
left: "50%",
|
|
162
|
+
top: "50%",
|
|
163
|
+
transform: "translate(-50%, -50%)",
|
|
164
|
+
color: "var(--color-link)",
|
|
165
|
+
fontWeight: "bold",
|
|
166
|
+
pointerEvents: "none",
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
// Ordered List markers (1., 2.)
|
|
170
|
+
".cm-draftly-list-mark-ol": {
|
|
171
|
+
color: "var(--draftly-highlight, #a4a4a4)",
|
|
172
|
+
fontFamily: "monospace",
|
|
173
|
+
marginRight: "2px",
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
// Task markers text ([ ] or [x]) - visible only when editing
|
|
177
|
+
".cm-draftly-task-marker": {
|
|
178
|
+
color: "var(--draftly-highlight, #a4a4a4)",
|
|
179
|
+
fontFamily: "monospace",
|
|
180
|
+
},
|
|
181
|
+
|
|
182
|
+
// Task Checkbox Widget
|
|
183
|
+
".cm-draftly-task-checkbox": {
|
|
184
|
+
display: "inline-flex",
|
|
185
|
+
verticalAlign: "middle",
|
|
186
|
+
marginRight: "0.3em",
|
|
187
|
+
cursor: "pointer",
|
|
188
|
+
userSelect: "none",
|
|
189
|
+
alignItems: "center",
|
|
190
|
+
height: "1.2em",
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
".cm-draftly-task-checkbox input": {
|
|
194
|
+
cursor: "pointer",
|
|
195
|
+
margin: 0,
|
|
196
|
+
width: "1.1em",
|
|
197
|
+
height: "1.1em",
|
|
198
|
+
appearance: "none",
|
|
199
|
+
border: "1px solid",
|
|
200
|
+
borderRadius: "0.25em",
|
|
201
|
+
backgroundColor: "transparent",
|
|
202
|
+
position: "relative",
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
".cm-draftly-task-checkbox.checked input::after": {
|
|
206
|
+
content: '"✓"',
|
|
207
|
+
position: "absolute",
|
|
208
|
+
left: "1px",
|
|
209
|
+
top: "-3px",
|
|
210
|
+
},
|
|
211
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { DraftlyPlugin } from "../plugin";
|
|
2
|
+
import { HeadingPlugin } from "./heading-plugin";
|
|
3
|
+
import { InlinePlugin } from "./inline-plugin";
|
|
4
|
+
import { ListPlugin } from "./list-plugin";
|
|
5
|
+
import { HTMLPlugin } from "./html-plugin";
|
|
6
|
+
|
|
7
|
+
const defaultPlugins: DraftlyPlugin[] = [new HeadingPlugin(), new InlinePlugin(), new ListPlugin(), new HTMLPlugin()];
|
|
8
|
+
|
|
9
|
+
export { defaultPlugins };
|