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/src/theme.ts ADDED
@@ -0,0 +1,86 @@
1
+ import { EditorView } from "@codemirror/view";
2
+ import { HighlightStyle } from "@codemirror/language";
3
+
4
+ export const highlightStyle = HighlightStyle.define([]);
5
+
6
+ /**
7
+ * Base theme for draftly styling
8
+ * Note: Layout styles are scoped under .cm-draftly-enabled which is added by the view plugin
9
+ */
10
+ export const draftlyBaseTheme = EditorView.baseTheme({
11
+ // Container styles - only apply when view plugin is enabled
12
+ "&.cm-draftly-enabled": {
13
+ fontSize: "16px",
14
+ lineHeight: "1.6",
15
+ },
16
+
17
+ "&.cm-draftly-enabled .cm-content": {
18
+ maxWidth: "48rem",
19
+ margin: "0 auto",
20
+ fontFamily: "var(--font-sans, sans-serif)",
21
+ fontSize: "16px",
22
+ lineHeight: "1.6",
23
+ },
24
+
25
+ // Inline code
26
+ ".cm-draftly-inline-code": {
27
+ fontFamily: "var(--font-mono, monospace)",
28
+ fontSize: "0.9em",
29
+ backgroundColor: "rgba(175, 184, 193, 0.2)",
30
+ padding: "0.1em 0.3em",
31
+ borderRadius: "4px",
32
+ },
33
+ ".cm-draftly-code-mark": {
34
+ opacity: "0.4",
35
+ },
36
+
37
+ // Links
38
+ ".cm-draftly-link": {
39
+ color: "#0969da",
40
+ textDecoration: "none",
41
+ },
42
+ ".cm-draftly-link:hover": {
43
+ textDecoration: "underline",
44
+ },
45
+ ".cm-draftly-url": {
46
+ opacity: "0.6",
47
+ fontSize: "0.9em",
48
+ },
49
+
50
+ // Images (placeholder styling)
51
+ ".cm-draftly-image": {
52
+ color: "#8250df",
53
+ },
54
+
55
+ // Code blocks
56
+ ".cm-draftly-fenced-code": {
57
+ fontFamily: "var(--font-mono, monospace)",
58
+ fontSize: "0.9em",
59
+ },
60
+ ".cm-draftly-line-code": {
61
+ backgroundColor: "rgba(175, 184, 193, 0.15)",
62
+ borderRadius: "0",
63
+ },
64
+ ".cm-draftly-code-info": {
65
+ color: "#6e7781",
66
+ fontStyle: "italic",
67
+ },
68
+
69
+ // Blockquote
70
+ ".cm-draftly-line-blockquote": {
71
+ borderLeft: "3px solid #d0d7de",
72
+ paddingLeft: "1em",
73
+ color: "#656d76",
74
+ },
75
+ ".cm-draftly-quote-mark": {
76
+ opacity: "0.4",
77
+ },
78
+
79
+ // Horizontal rule
80
+ ".cm-draftly-line-hr": {
81
+ textAlign: "center",
82
+ },
83
+ ".cm-draftly-hr": {
84
+ opacity: "0.4",
85
+ },
86
+ });
package/src/utils.ts ADDED
@@ -0,0 +1,21 @@
1
+ import { EditorView } from "@codemirror/view";
2
+
3
+ /**
4
+ * Check if cursor is within the given range
5
+ */
6
+ export function cursorInRange(view: EditorView, from: number, to: number): boolean {
7
+ const selection = view.state.selection.main;
8
+ return selection.from <= to && selection.to >= from;
9
+ }
10
+
11
+ /**
12
+ * Check if any selection overlaps with the given range
13
+ */
14
+ export function selectionOverlapsRange(view: EditorView, from: number, to: number): boolean {
15
+ for (const range of view.state.selection.ranges) {
16
+ if (range.from <= to && range.to >= from) {
17
+ return true;
18
+ }
19
+ }
20
+ return false;
21
+ }
@@ -0,0 +1,304 @@
1
+ import { Decoration, DecorationSet, EditorView, ViewPlugin, ViewUpdate } from "@codemirror/view";
2
+ import { Extension, Facet, Range, RangeSetBuilder } from "@codemirror/state";
3
+ import { syntaxHighlighting, syntaxTree } from "@codemirror/language";
4
+ import { cursorInRange, selectionOverlapsRange } from "./utils";
5
+ import { highlightStyle, draftlyBaseTheme } from "./theme";
6
+ import { DecorationContext, DraftlyPlugin } from "./plugin";
7
+ import { DraftlyNode } from "./draftly";
8
+
9
+ /**
10
+ * Mark decorations for inline styling
11
+ */
12
+ const markDecorations = {
13
+ // Inline styles
14
+ "inline-code": Decoration.mark({ class: "cm-draftly-inline-code" }),
15
+
16
+ // Links and images
17
+ link: Decoration.mark({ class: "cm-draftly-link" }),
18
+ "link-text": Decoration.mark({ class: "cm-draftly-link-text" }),
19
+ url: Decoration.mark({ class: "cm-draftly-url" }),
20
+ image: Decoration.mark({ class: "cm-draftly-image" }),
21
+
22
+ // Emphasis markers (* _ ~~ `)
23
+ "emphasis-mark": Decoration.mark({ class: "cm-draftly-emphasis-mark" }),
24
+
25
+ // Code blocks
26
+ "fenced-code": Decoration.mark({ class: "cm-draftly-fenced-code" }),
27
+ "code-mark": Decoration.mark({ class: "cm-draftly-code-mark" }),
28
+ "code-info": Decoration.mark({ class: "cm-draftly-code-info" }),
29
+
30
+ // Blockquote
31
+ blockquote: Decoration.mark({ class: "cm-draftly-blockquote" }),
32
+ "quote-mark": Decoration.mark({ class: "cm-draftly-quote-mark" }),
33
+
34
+ // Horizontal rule
35
+ hr: Decoration.mark({ class: "cm-draftly-hr" }),
36
+ };
37
+
38
+ /**
39
+ * Line decorations for block-level elements
40
+ */
41
+ const lineDecorations = {
42
+ blockquote: Decoration.line({ class: "cm-draftly-line-blockquote" }),
43
+ "code-block": Decoration.line({ class: "cm-draftly-line-code" }),
44
+ hr: Decoration.line({ class: "cm-draftly-line-hr" }),
45
+ };
46
+
47
+ /**
48
+ * Facet to register plugins with the view plugin
49
+ */
50
+ export const DraftlyPluginsFacet = Facet.define<DraftlyPlugin[], DraftlyPlugin[]>({
51
+ combine: (values) => values.flat(),
52
+ });
53
+
54
+ /**
55
+ * Facet to register the onNodesChange callback
56
+ */
57
+ export const draftlyOnNodesChangeFacet = Facet.define<
58
+ ((nodes: DraftlyNode[]) => void) | undefined,
59
+ ((nodes: DraftlyNode[]) => void) | undefined
60
+ >({
61
+ combine: (values) => values.find((v) => v !== undefined),
62
+ });
63
+
64
+ /**
65
+ * Build decorations for the visible viewport
66
+ * @param view - The EditorView instance
67
+ * @param plugins - Optional array of plugins to invoke for decorations
68
+ */
69
+ function buildDecorations(view: EditorView, plugins: DraftlyPlugin[] = []): DecorationSet {
70
+ const builder = new RangeSetBuilder<Decoration>();
71
+ const decorations: Range<Decoration>[] = [];
72
+
73
+ const tree = syntaxTree(view.state);
74
+
75
+ // Iterate through the syntax tree
76
+ tree.iterate({
77
+ enter: (node) => {
78
+ const { from, to, name } = node;
79
+
80
+ // Skip if cursor is in this range (show raw markdown)
81
+ const cursorInNode = selectionOverlapsRange(view, from, to);
82
+
83
+ // Handle inline code
84
+ if (name === "InlineCode") {
85
+ decorations.push(markDecorations["inline-code"].range(from, to));
86
+
87
+ // Style the backticks
88
+ if (!cursorInNode) {
89
+ const marks = node.node.getChildren("CodeMark");
90
+ for (const mark of marks) {
91
+ decorations.push(markDecorations["code-mark"].range(mark.from, mark.to));
92
+ }
93
+ }
94
+ }
95
+
96
+ // Handle links
97
+ if (name === "Link") {
98
+ decorations.push(markDecorations.link.range(from, to));
99
+
100
+ // Find the URL child
101
+ const url = node.node.getChild("URL");
102
+ if (url) {
103
+ decorations.push(markDecorations.url.range(url.from, url.to));
104
+ }
105
+ }
106
+
107
+ // Handle images
108
+ if (name === "Image") {
109
+ decorations.push(markDecorations.image.range(from, to));
110
+ }
111
+
112
+ // Handle fenced code blocks
113
+ if (name === "FencedCode") {
114
+ decorations.push(markDecorations["fenced-code"].range(from, to));
115
+
116
+ // Add line decorations for each line in the code block
117
+ const startLine = view.state.doc.lineAt(from);
118
+ const endLine = view.state.doc.lineAt(to);
119
+ for (let i = startLine.number; i <= endLine.number; i++) {
120
+ const line = view.state.doc.line(i);
121
+ decorations.push(lineDecorations["code-block"].range(line.from));
122
+ }
123
+
124
+ // Style code info (language identifier)
125
+ const codeInfo = node.node.getChild("CodeInfo");
126
+ if (codeInfo) {
127
+ decorations.push(markDecorations["code-info"].range(codeInfo.from, codeInfo.to));
128
+ }
129
+
130
+ // Style code marks (```)
131
+ const codeMarks = node.node.getChildren("CodeMark");
132
+ for (const mark of codeMarks) {
133
+ decorations.push(markDecorations["code-mark"].range(mark.from, mark.to));
134
+ }
135
+ }
136
+
137
+ // Handle blockquotes
138
+ if (name === "Blockquote") {
139
+ decorations.push(markDecorations.blockquote.range(from, to));
140
+
141
+ // Add line decorations
142
+ const startLine = view.state.doc.lineAt(from);
143
+ const endLine = view.state.doc.lineAt(to);
144
+ for (let i = startLine.number; i <= endLine.number; i++) {
145
+ const line = view.state.doc.line(i);
146
+ decorations.push(lineDecorations.blockquote.range(line.from));
147
+ }
148
+
149
+ // Style quote marks (>)
150
+ const quoteMarks = node.node.getChildren("QuoteMark");
151
+ for (const mark of quoteMarks) {
152
+ decorations.push(markDecorations["quote-mark"].range(mark.from, mark.to));
153
+ }
154
+ }
155
+
156
+ // Handle horizontal rules
157
+ if (name === "HorizontalRule") {
158
+ const line = view.state.doc.lineAt(from);
159
+ decorations.push(lineDecorations.hr.range(line.from));
160
+ decorations.push(markDecorations.hr.range(from, to));
161
+ }
162
+ },
163
+ });
164
+
165
+ // Allow plugins to contribute decorations
166
+ if (plugins.length > 0) {
167
+ const ctx: DecorationContext = {
168
+ view,
169
+ decorations,
170
+ selectionOverlapsRange: (from, to) => selectionOverlapsRange(view, from, to),
171
+ cursorInRange: (from, to) => cursorInRange(view, from, to),
172
+ };
173
+
174
+ // Sort plugins by priority and invoke each one's decoration builder
175
+ const sortedPlugins = [...plugins].sort((a, b) => a.decorationPriority - b.decorationPriority);
176
+
177
+ for (const plugin of sortedPlugins) {
178
+ plugin.buildDecorations(ctx);
179
+ }
180
+ }
181
+
182
+ // Sort decorations by position (required for RangeSetBuilder)
183
+ decorations.sort((a, b) => a.from - b.from || a.value.startSide - b.value.startSide);
184
+
185
+ // Build the decoration set
186
+ for (const decoration of decorations) {
187
+ builder.add(decoration.from, decoration.to, decoration.value);
188
+ }
189
+
190
+ return builder.finish();
191
+ }
192
+
193
+ /**
194
+ * draftly View Plugin
195
+ * Handles rich markdown rendering with decorations
196
+ */
197
+ class draftlyViewPluginClass {
198
+ decorations: DecorationSet;
199
+ private plugins: DraftlyPlugin[];
200
+ private onNodesChange?: (nodes: DraftlyNode[]) => void;
201
+
202
+ constructor(view: EditorView) {
203
+ this.plugins = view.state.facet(DraftlyPluginsFacet);
204
+ this.onNodesChange = view.state.facet(draftlyOnNodesChangeFacet);
205
+ this.decorations = buildDecorations(view, this.plugins);
206
+
207
+ // Notify plugins that view is ready
208
+ for (const plugin of this.plugins) {
209
+ plugin.onViewReady(view);
210
+ }
211
+
212
+ // Call onNodesChange callback with initial nodes
213
+ if (this.onNodesChange) {
214
+ this.onNodesChange(this.buildNodes(view));
215
+ }
216
+ }
217
+
218
+ update(update: ViewUpdate) {
219
+ // Update plugins list if facet changed
220
+ this.plugins = update.view.state.facet(DraftlyPluginsFacet);
221
+ this.onNodesChange = update.view.state.facet(draftlyOnNodesChangeFacet);
222
+
223
+ // Notify plugins of the update
224
+ for (const plugin of this.plugins) {
225
+ plugin.onViewUpdate(update);
226
+ }
227
+
228
+ // Rebuild decorations when:
229
+ // - Document changes
230
+ // - Selection changes (to show/hide syntax markers)
231
+ // - Viewport changes
232
+ if (update.docChanged || update.selectionSet || update.viewportChanged) {
233
+ this.decorations = buildDecorations(update.view, this.plugins);
234
+
235
+ // Call onNodesChange callback
236
+ if (this.onNodesChange) {
237
+ this.onNodesChange(this.buildNodes(update.view));
238
+ }
239
+ }
240
+ }
241
+
242
+ private buildNodes(view: EditorView): DraftlyNode[] {
243
+ const tree = syntaxTree(view.state);
244
+ const roots: DraftlyNode[] = [];
245
+ const stack: DraftlyNode[] = [];
246
+
247
+ tree.iterate({
248
+ enter: (nodeRef) => {
249
+ const node: DraftlyNode = {
250
+ from: nodeRef.from,
251
+ to: nodeRef.to,
252
+ name: nodeRef.name,
253
+ children: [],
254
+ isSelected: selectionOverlapsRange(view, nodeRef.from, nodeRef.to),
255
+ };
256
+
257
+ if (stack.length > 0) {
258
+ stack[stack.length - 1]!.children.push(node);
259
+ } else {
260
+ roots.push(node);
261
+ }
262
+
263
+ stack.push(node);
264
+ },
265
+ leave: () => {
266
+ stack.pop();
267
+ },
268
+ });
269
+
270
+ return roots;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * The main draftly ViewPlugin extension
276
+ */
277
+ export const draftlyViewPlugin = ViewPlugin.fromClass(draftlyViewPluginClass, {
278
+ decorations: (v) => v.decorations,
279
+ provide: () => [syntaxHighlighting(highlightStyle)],
280
+ });
281
+
282
+ /**
283
+ * Extension to add the cm-draftly-enabled class to the editor
284
+ */
285
+ const draftlyEditorClass = EditorView.editorAttributes.of({ class: "cm-draftly-enabled" });
286
+
287
+ /**
288
+ * Create draftly view extension bundle with plugin support
289
+ * @param plugins - Optional array of DraftlyPlugin instances
290
+ * @param onNodesChange - Optional callback to receive nodes on every update
291
+ * @returns Extension array including view plugin, theme, and plugin facet
292
+ */
293
+ export function createDraftlyViewExtension(
294
+ plugins: DraftlyPlugin[] = [],
295
+ onNodesChange?: (nodes: DraftlyNode[]) => void
296
+ ): Extension[] {
297
+ return [
298
+ DraftlyPluginsFacet.of(plugins),
299
+ draftlyOnNodesChangeFacet.of(onNodesChange),
300
+ draftlyViewPlugin,
301
+ draftlyBaseTheme,
302
+ draftlyEditorClass,
303
+ ];
304
+ }