@d34dman/flowdrop 0.0.57 → 0.0.59

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.
Files changed (54) hide show
  1. package/README.md +9 -8
  2. package/dist/adapters/WorkflowAdapter.d.ts +2 -1
  3. package/dist/adapters/agentspec/AgentSpecAdapter.d.ts +4 -0
  4. package/dist/adapters/agentspec/AgentSpecAdapter.js +27 -22
  5. package/dist/adapters/agentspec/componentTypeDefaults.d.ts +73 -0
  6. package/dist/adapters/agentspec/componentTypeDefaults.js +238 -0
  7. package/dist/adapters/agentspec/{nodeTypeRegistry.d.ts → defaultNodeTypes.d.ts} +21 -30
  8. package/dist/adapters/agentspec/{nodeTypeRegistry.js → defaultNodeTypes.js} +31 -59
  9. package/dist/adapters/agentspec/index.d.ts +3 -1
  10. package/dist/adapters/agentspec/index.js +4 -2
  11. package/dist/components/App.svelte +57 -13
  12. package/dist/components/NodeSidebar.svelte +20 -8
  13. package/dist/components/NodeSidebar.svelte.d.ts +2 -1
  14. package/dist/components/WorkflowEditor.svelte +14 -13
  15. package/dist/components/form/FormMarkdownEditor.svelte +546 -422
  16. package/dist/components/form/FormMarkdownEditor.svelte.d.ts +2 -0
  17. package/dist/components/form/FormUISchemaRenderer.svelte +4 -8
  18. package/dist/components/form/types.d.ts +1 -1
  19. package/dist/components/nodes/WorkflowNode.svelte +1 -2
  20. package/dist/core/index.d.ts +13 -3
  21. package/dist/core/index.js +16 -3
  22. package/dist/form/code.js +6 -1
  23. package/dist/form/fieldRegistry.d.ts +79 -15
  24. package/dist/form/fieldRegistry.js +104 -49
  25. package/dist/form/full.d.ts +2 -2
  26. package/dist/form/full.js +2 -2
  27. package/dist/form/index.d.ts +3 -3
  28. package/dist/form/index.js +6 -2
  29. package/dist/form/markdown.d.ts +3 -3
  30. package/dist/form/markdown.js +8 -4
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.js +2 -2
  33. package/dist/registry/BaseRegistry.d.ts +92 -0
  34. package/dist/registry/BaseRegistry.js +124 -0
  35. package/dist/registry/builtinFormats.d.ts +23 -0
  36. package/dist/registry/builtinFormats.js +70 -0
  37. package/dist/registry/builtinNodes.js +4 -0
  38. package/dist/registry/index.d.ts +2 -1
  39. package/dist/registry/index.js +2 -0
  40. package/dist/registry/nodeComponentRegistry.d.ts +26 -57
  41. package/dist/registry/nodeComponentRegistry.js +29 -82
  42. package/dist/registry/workflowFormatRegistry.d.ts +122 -0
  43. package/dist/registry/workflowFormatRegistry.js +96 -0
  44. package/dist/schema/index.d.ts +23 -0
  45. package/dist/schema/index.js +23 -0
  46. package/dist/schemas/v1/workflow.schema.json +1078 -0
  47. package/dist/stores/portCoordinateStore.js +1 -4
  48. package/dist/stores/workflowStore.d.ts +3 -0
  49. package/dist/stores/workflowStore.js +3 -0
  50. package/dist/svelte-app.d.ts +4 -0
  51. package/dist/svelte-app.js +9 -1
  52. package/dist/types/index.d.ts +18 -0
  53. package/dist/types/index.js +4 -0
  54. package/package.json +20 -13
@@ -1,28 +1,30 @@
1
1
  <!--
2
2
  FormMarkdownEditor Component
3
- EasyMDE-based Markdown editor for rich text content
4
-
3
+ CodeMirror 6-based Markdown editor for rich text content
4
+
5
5
  Features:
6
- - Full Markdown editing with EasyMDE (fork of SimpleMDE)
7
- - Live preview with syntax highlighting
8
- - Toolbar with common formatting options
9
- - Autosave support (optional)
10
- - Spell checking
6
+ - Full Markdown editing with CodeMirror 6
7
+ - Markdown syntax highlighting via @codemirror/lang-markdown
8
+ - Toolbar with common formatting options + keyboard shortcuts
9
+ - Autosave support (optional, localStorage)
10
+ - Status bar with word/line/character count
11
11
  - Consistent styling with other form components
12
12
  - Proper ARIA attributes for accessibility
13
- - SSR-safe: Only loads EasyMDE on the client side
14
-
13
+ - Dark/light theme support
14
+
15
15
  Usage:
16
16
  Use with schema format: "markdown" to render this editor
17
17
  -->
18
18
 
19
19
  <script lang="ts">
20
20
  import { onMount, onDestroy } from 'svelte';
21
- import { browser } from '$app/environment';
22
- import type EasyMDEConstructor from 'easymde';
23
-
24
- /** EasyMDE instance type */
25
- type EasyMDEInstance = EasyMDEConstructor;
21
+ import { EditorView, lineNumbers, drawSelection, keymap } from '@codemirror/view';
22
+ import { EditorState, Compartment } from '@codemirror/state';
23
+ import { history, historyKeymap, defaultKeymap, indentWithTab } from '@codemirror/commands';
24
+ import { highlightSpecialChars, highlightActiveLine } from '@codemirror/view';
25
+ import { syntaxHighlighting, defaultHighlightStyle } from '@codemirror/language';
26
+ import { markdown } from '@codemirror/lang-markdown';
27
+ import { oneDark } from '@codemirror/theme-one-dark';
26
28
 
27
29
  interface Props {
28
30
  /** Field identifier */
@@ -47,6 +49,8 @@
47
49
  autosaveDelay?: number;
48
50
  /** Whether the field is disabled (read-only) */
49
51
  disabled?: boolean;
52
+ /** Whether to use dark theme */
53
+ darkTheme?: boolean;
50
54
  /** ARIA description ID */
51
55
  ariaDescribedBy?: string;
52
56
  /** Callback when value changes */
@@ -65,162 +69,406 @@
65
69
  autosave = false,
66
70
  autosaveDelay = 10000,
67
71
  disabled = false,
72
+ darkTheme = false,
68
73
  ariaDescribedBy,
69
74
  onChange
70
75
  }: Props = $props();
71
76
 
72
- /** Reference to the textarea element */
73
- let textareaRef: HTMLTextAreaElement | undefined = $state(undefined);
74
-
75
- /** EasyMDE editor instance */
76
- let easymde: EasyMDEInstance | undefined = $state(undefined);
77
+ /** Reference to the editor container element */
78
+ let containerRef: HTMLDivElement | undefined = $state(undefined);
77
79
 
78
- /** Loading state for the editor */
79
- let isLoading = $state(true);
80
+ /** CodeMirror editor instance */
81
+ let editorView: EditorView | undefined = $state(undefined);
80
82
 
81
83
  /** Flag to prevent update loops */
82
84
  let isInternalUpdate = false;
83
85
 
84
- /**
85
- * Custom toolbar configuration
86
- * Provides a clean set of commonly used formatting options
87
- */
88
- const toolbarConfig = [
89
- 'bold',
90
- 'italic',
91
- 'strikethrough',
92
- '|',
93
- 'heading-1',
94
- 'heading-2',
95
- 'heading-3',
96
- '|',
97
- 'quote',
98
- 'unordered-list',
99
- 'ordered-list',
86
+ /** Flag to skip $effect when change originated from the editor */
87
+ let isEditorUpdate = false;
88
+
89
+ /** Status bar stats */
90
+ let wordCount = $state(0);
91
+ let lineCount = $state(0);
92
+ let charCount = $state(0);
93
+
94
+ /** Autosave timer */
95
+ let autosaveTimer: ReturnType<typeof setTimeout> | undefined;
96
+
97
+ /** Theme compartment for dynamic theme switching */
98
+ const themeCompartment = new Compartment();
99
+
100
+ // ── Toolbar actions ──────────────────────────────────────
101
+
102
+ type ToolbarAction = {
103
+ id: string;
104
+ label: string;
105
+ icon: string;
106
+ /** If true, icon is an SVG string rendered with {@html} */
107
+ isSvg?: boolean;
108
+ shortcut?: string;
109
+ action: () => void;
110
+ };
111
+
112
+ // Inline SVG icons (heroicons outline, 16x16)
113
+ const icons = {
114
+ link: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path d="M12.232 4.232a2.5 2.5 0 0 1 3.536 3.536l-1.225 1.224a.75.75 0 0 0 1.061 1.06l1.224-1.224a4 4 0 0 0-5.656-5.656l-3 3a4 4 0 0 0 .225 5.865.75.75 0 0 0 .977-1.138 2.5 2.5 0 0 1-.142-3.667l3-3Z"/><path d="M11.603 7.963a.75.75 0 0 0-.977 1.138 2.5 2.5 0 0 1 .142 3.667l-3 3a2.5 2.5 0 0 1-3.536-3.536l1.225-1.224a.75.75 0 0 0-1.061-1.06l-1.224 1.224a4 4 0 1 0 5.656 5.656l3-3a4 4 0 0 0-.225-5.865Z"/></svg>',
115
+ image:
116
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M1 5.25A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm1.5 5.81v3.69c0 .414.336.75.75.75h13.5a.75.75 0 0 0 .75-.75v-2.69l-2.22-2.219a.75.75 0 0 0-1.06 0l-1.91 1.909.47.47a.75.75 0 1 1-1.06 1.06L6.53 8.091a.75.75 0 0 0-1.06 0L2.5 11.06Zm6.5-3.31a1.5 1.5 0 1 1 3 0 1.5 1.5 0 0 1-3 0Z" clip-rule="evenodd"/></svg>',
117
+ table:
118
+ '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" width="16" height="16"><path fill-rule="evenodd" d="M.99 5.24A2.25 2.25 0 0 1 3.25 3h13.5A2.25 2.25 0 0 1 19 5.25v9.5A2.25 2.25 0 0 1 16.75 17H3.25A2.25 2.25 0 0 1 1 14.75v-9.5Zm8.26 4.51v3.75h1.5v-3.75h-1.5Zm1.5-1.5v-3.75h-1.5v3.75h1.5Zm-3-3.75H3.25a.75.75 0 0 0-.75.75v3h5.25v-3.75Zm-5.25 5.25v3.75c0 .414.336.75.75.75h4.5v-4.5H2.5Zm14.5 0h-5.25v4.5h4.5a.75.75 0 0 0 .75-.75v-3.75Zm0-1.5v-3a.75.75 0 0 0-.75-.75h-4.5v3.75H17Z" clip-rule="evenodd"/></svg>'
119
+ };
120
+
121
+ function wrapSelection(before: string, after: string) {
122
+ if (!editorView) return;
123
+ const { from, to } = editorView.state.selection.main;
124
+ const selected = editorView.state.sliceDoc(from, to);
125
+ const replacement = `${before}${selected || 'text'}${after}`;
126
+ editorView.dispatch({
127
+ changes: { from, to, insert: replacement },
128
+ selection: {
129
+ anchor: selected ? from + before.length : from + before.length,
130
+ head: selected ? from + before.length + selected.length : from + before.length + 4
131
+ }
132
+ });
133
+ editorView.focus();
134
+ }
135
+
136
+ function prefixLine(prefix: string) {
137
+ if (!editorView) return;
138
+ const { from } = editorView.state.selection.main;
139
+ const line = editorView.state.doc.lineAt(from);
140
+ const currentText = line.text;
141
+
142
+ // If already has this prefix, remove it (toggle)
143
+ if (currentText.startsWith(prefix)) {
144
+ editorView.dispatch({
145
+ changes: { from: line.from, to: line.from + prefix.length, insert: '' }
146
+ });
147
+ } else {
148
+ // Remove any existing heading prefix before adding new one
149
+ const headingMatch = currentText.match(/^#{1,6}\s/);
150
+ const removeLen = headingMatch ? headingMatch[0].length : 0;
151
+ editorView.dispatch({
152
+ changes: { from: line.from, to: line.from + removeLen, insert: prefix }
153
+ });
154
+ }
155
+ editorView.focus();
156
+ }
157
+
158
+ function insertAtCursor(text: string) {
159
+ if (!editorView) return;
160
+ const { from, to } = editorView.state.selection.main;
161
+ editorView.dispatch({
162
+ changes: { from, to, insert: text },
163
+ selection: { anchor: from + text.length }
164
+ });
165
+ editorView.focus();
166
+ }
167
+
168
+ const toolbarActions: (ToolbarAction | '|')[] = [
169
+ {
170
+ id: 'bold',
171
+ label: 'Bold',
172
+ icon: 'B',
173
+ shortcut: 'Mod-b',
174
+ action: () => wrapSelection('**', '**')
175
+ },
176
+ {
177
+ id: 'italic',
178
+ label: 'Italic',
179
+ icon: 'I',
180
+ shortcut: 'Mod-i',
181
+ action: () => wrapSelection('_', '_')
182
+ },
183
+ {
184
+ id: 'strikethrough',
185
+ label: 'Strikethrough',
186
+ icon: 'S',
187
+ action: () => wrapSelection('~~', '~~')
188
+ },
100
189
  '|',
101
- 'link',
102
- 'image',
103
- 'table',
190
+ {
191
+ id: 'heading-1',
192
+ label: 'Heading 1',
193
+ icon: 'H1',
194
+ action: () => prefixLine('# ')
195
+ },
196
+ {
197
+ id: 'heading-2',
198
+ label: 'Heading 2',
199
+ icon: 'H2',
200
+ action: () => prefixLine('## ')
201
+ },
202
+ {
203
+ id: 'heading-3',
204
+ label: 'Heading 3',
205
+ icon: 'H3',
206
+ action: () => prefixLine('### ')
207
+ },
104
208
  '|',
105
- 'preview',
209
+ {
210
+ id: 'quote',
211
+ label: 'Quote',
212
+ icon: '"',
213
+ action: () => prefixLine('> ')
214
+ },
215
+ {
216
+ id: 'unordered-list',
217
+ label: 'Unordered List',
218
+ icon: '•',
219
+ action: () => prefixLine('- ')
220
+ },
221
+ {
222
+ id: 'ordered-list',
223
+ label: 'Ordered List',
224
+ icon: '1.',
225
+ action: () => prefixLine('1. ')
226
+ },
106
227
  '|',
107
- 'guide'
108
- ] as const;
109
-
110
- /**
111
- * Initialize EasyMDE editor on mount (client-side only)
112
- */
113
- onMount(async () => {
114
- // Only run in browser environment
115
- if (!browser || !textareaRef) {
116
- isLoading = false;
117
- return;
228
+ {
229
+ id: 'link',
230
+ label: 'Link',
231
+ icon: icons.link,
232
+ isSvg: true,
233
+ shortcut: 'Mod-k',
234
+ action: () => {
235
+ if (!editorView) return;
236
+ const { from, to } = editorView.state.selection.main;
237
+ const selected = editorView.state.sliceDoc(from, to);
238
+ const text = selected || 'link text';
239
+ const replacement = `[${text}](url)`;
240
+ editorView.dispatch({
241
+ changes: { from, to, insert: replacement },
242
+ selection: {
243
+ anchor: from + text.length + 3,
244
+ head: from + text.length + 6
245
+ }
246
+ });
247
+ editorView.focus();
248
+ }
249
+ },
250
+ {
251
+ id: 'image',
252
+ label: 'Image',
253
+ icon: icons.image,
254
+ isSvg: true,
255
+ action: () => insertAtCursor('![alt text](image-url)')
256
+ },
257
+ {
258
+ id: 'table',
259
+ label: 'Table',
260
+ icon: icons.table,
261
+ isSvg: true,
262
+ action: () =>
263
+ insertAtCursor('\n| Header | Header |\n| ------ | ------ |\n| Cell | Cell |\n')
118
264
  }
265
+ ];
119
266
 
120
- try {
121
- // Dynamically import EasyMDE and its styles (client-side only)
122
- const [EasyMDE] = await Promise.all([
123
- import('easymde').then((m) => m.default),
124
- import('easymde/dist/easymde.min.css')
125
- ]);
126
-
127
- // Build autosave config if enabled
128
- const autosaveConfig = autosave
129
- ? {
130
- enabled: true,
131
- uniqueId: `flowdrop-markdown-${id}`,
132
- delay: autosaveDelay
133
- }
134
- : undefined;
135
-
136
- // Create EasyMDE instance
137
- easymde = new EasyMDE({
138
- element: textareaRef,
139
- initialValue: value,
140
- placeholder: placeholder,
141
- spellChecker: spellChecker,
142
- autosave: autosaveConfig,
143
- toolbar: disabled ? false : showToolbar ? [...toolbarConfig] : false,
144
- status: showStatusBar,
145
- forceSync: true,
146
- minHeight: height,
147
- renderingConfig: {
148
- singleLineBreaks: false,
149
- codeSyntaxHighlighting: true
150
- },
151
- shortcuts: disabled
152
- ? {}
153
- : {
154
- toggleBold: 'Cmd-B',
155
- toggleItalic: 'Cmd-I',
156
- toggleStrikethrough: 'Cmd-Alt-S',
157
- toggleHeadingSmaller: 'Cmd-H',
158
- toggleHeadingBigger: 'Shift-Cmd-H',
159
- toggleCodeBlock: 'Cmd-Alt-C',
160
- toggleBlockquote: "Cmd-'",
161
- toggleOrderedList: 'Cmd-Alt-L',
162
- toggleUnorderedList: 'Cmd-L',
163
- cleanBlock: 'Cmd-E',
164
- drawLink: 'Cmd-K',
165
- drawImage: 'Cmd-Alt-I',
166
- drawTable: 'Cmd-Alt-T',
167
- togglePreview: 'Cmd-P',
168
- toggleSideBySide: 'F9',
169
- toggleFullScreen: 'F11'
170
- }
171
- });
267
+ // ── CM6 Keyboard shortcuts for toolbar actions ───────────
172
268
 
173
- // When disabled, make the underlying CodeMirror read-only
174
- if (disabled && easymde.codemirror) {
175
- easymde.codemirror.setOption('readOnly', true);
269
+ function createToolbarKeymap() {
270
+ return keymap.of([
271
+ {
272
+ key: 'Mod-b',
273
+ run: () => {
274
+ wrapSelection('**', '**');
275
+ return true;
276
+ }
277
+ },
278
+ {
279
+ key: 'Mod-i',
280
+ run: () => {
281
+ wrapSelection('_', '_');
282
+ return true;
283
+ }
284
+ },
285
+ {
286
+ key: 'Mod-k',
287
+ run: () => {
288
+ const action = toolbarActions.find((a) => a !== '|' && a.id === 'link');
289
+ if (action && action !== '|') action.action();
290
+ return true;
291
+ }
292
+ },
293
+ {
294
+ key: 'Mod-h',
295
+ run: () => {
296
+ prefixLine('## ');
297
+ return true;
298
+ }
299
+ },
300
+ {
301
+ key: "Mod-'",
302
+ run: () => {
303
+ prefixLine('> ');
304
+ return true;
305
+ }
306
+ },
307
+ {
308
+ key: 'Mod-l',
309
+ run: () => {
310
+ prefixLine('- ');
311
+ return true;
312
+ }
176
313
  }
177
-
178
- // Listen for changes
179
- easymde.codemirror.on('change', () => {
180
- if (isInternalUpdate) {
181
- return;
314
+ ]);
315
+ }
316
+
317
+ // ── Stats computation ────────────────────────────────────
318
+
319
+ function updateStats(doc: { toString: () => string; lines: number }) {
320
+ const text = doc.toString();
321
+ charCount = text.length;
322
+ lineCount = doc.lines;
323
+ const trimmed = text.trim();
324
+ wordCount = trimmed ? trimmed.split(/\s+/).length : 0;
325
+ }
326
+
327
+ // ── Editor setup ─────────────────────────────────────────
328
+
329
+ function createExtensions() {
330
+ const extensions = [
331
+ lineNumbers(),
332
+ highlightSpecialChars(),
333
+ highlightActiveLine(),
334
+ drawSelection(),
335
+
336
+ // Editing features (skip when read-only)
337
+ ...(disabled
338
+ ? [EditorState.readOnly.of(true), EditorView.editable.of(false)]
339
+ : [
340
+ history(),
341
+ keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
342
+ createToolbarKeymap()
343
+ ]),
344
+
345
+ // Theme
346
+ themeCompartment.of(
347
+ darkTheme ? oneDark : syntaxHighlighting(defaultHighlightStyle, { fallback: true })
348
+ ),
349
+
350
+ // Markdown language support
351
+ markdown(),
352
+
353
+ // Update listener
354
+ EditorView.updateListener.of((update) => {
355
+ if (!update.docChanged || isInternalUpdate) return;
356
+
357
+ const content = update.state.doc.toString();
358
+ isEditorUpdate = true;
359
+ onChange(content);
360
+
361
+ updateStats(update.state.doc);
362
+
363
+ // Autosave
364
+ if (autosave) {
365
+ clearTimeout(autosaveTimer);
366
+ autosaveTimer = setTimeout(() => {
367
+ try {
368
+ localStorage.setItem(`flowdrop-markdown-${id}`, content);
369
+ } catch {
370
+ // localStorage may be full or unavailable
371
+ }
372
+ }, autosaveDelay);
182
373
  }
374
+ }),
375
+
376
+ // Custom theme
377
+ EditorView.theme({
378
+ '&': {
379
+ height: height,
380
+ fontSize: 'var(--fd-text-sm, 0.8125rem)',
381
+ fontFamily: "'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', monospace"
382
+ },
383
+ '.cm-scroller': {
384
+ overflow: 'auto'
385
+ },
386
+ '.cm-content': {
387
+ minHeight: '100px',
388
+ padding: '0.5rem 0'
389
+ },
390
+ '&.cm-focused': {
391
+ outline: 'none'
392
+ }
393
+ }),
394
+ EditorView.lineWrapping,
395
+
396
+ // Accessibility
397
+ EditorView.contentAttributes.of({
398
+ 'aria-label': 'Markdown editor',
399
+ 'aria-multiline': 'true'
400
+ })
401
+ ];
402
+
403
+ return extensions;
404
+ }
405
+
406
+ onMount(() => {
407
+ if (!containerRef) return;
408
+
409
+ // Load autosaved content if available
410
+ let initialContent = value;
411
+ if (autosave) {
412
+ try {
413
+ const saved = localStorage.getItem(`flowdrop-markdown-${id}`);
414
+ if (saved !== null) {
415
+ initialContent = saved;
416
+ onChange(saved);
417
+ }
418
+ } catch {
419
+ // localStorage unavailable
420
+ }
421
+ }
183
422
 
184
- const newValue = easymde?.value() ?? '';
185
- onChange(newValue);
186
- });
423
+ editorView = new EditorView({
424
+ state: EditorState.create({
425
+ doc: initialContent,
426
+ extensions: createExtensions()
427
+ }),
428
+ parent: containerRef
429
+ });
187
430
 
188
- isLoading = false;
189
- } catch (error) {
190
- console.error('Failed to load EasyMDE:', error);
191
- isLoading = false;
192
- }
431
+ updateStats(editorView.state.doc);
193
432
  });
194
433
 
195
- /**
196
- * Clean up editor on destroy
197
- */
198
434
  onDestroy(() => {
199
- if (easymde) {
200
- easymde.toTextArea();
201
- }
435
+ if (autosaveTimer) clearTimeout(autosaveTimer);
436
+ if (editorView) editorView.destroy();
202
437
  });
203
438
 
204
439
  /**
205
440
  * Update editor content when value prop changes externally
206
441
  */
207
442
  $effect(() => {
208
- if (!easymde) {
443
+ if (!editorView) return;
444
+
445
+ // Skip if the change originated from the editor itself
446
+ if (isEditorUpdate) {
447
+ isEditorUpdate = false;
209
448
  return;
210
449
  }
211
450
 
212
- const currentValue = easymde.value();
213
-
214
- // Only update if content actually changed and wasn't from internal edit
215
- if (value !== currentValue && !isInternalUpdate) {
451
+ const currentContent = editorView.state.doc.toString();
452
+ if (value !== currentContent && !isInternalUpdate) {
216
453
  isInternalUpdate = true;
217
- easymde.value(value);
454
+ editorView.dispatch({
455
+ changes: {
456
+ from: 0,
457
+ to: editorView.state.doc.length,
458
+ insert: value
459
+ }
460
+ });
218
461
  isInternalUpdate = false;
462
+ updateStats(editorView.state.doc);
219
463
  }
220
464
  });
221
465
  </script>
222
466
 
223
- <div class="form-markdown-editor" style="--editor-height: {height}">
467
+ <div
468
+ class="form-markdown-editor"
469
+ class:form-markdown-editor--dark={darkTheme}
470
+ style="--editor-height: {height}"
471
+ >
224
472
  <!-- Hidden input for form submission compatibility -->
225
473
  <input
226
474
  type="hidden"
@@ -231,20 +479,47 @@
231
479
  aria-required={required}
232
480
  />
233
481
 
234
- <!-- Loading state -->
235
- {#if isLoading}
236
- <div class="form-markdown-editor__loading">
237
- <div class="form-markdown-editor__spinner"></div>
238
- <span>Loading editor...</span>
482
+ <!-- Toolbar -->
483
+ {#if showToolbar && !disabled}
484
+ <div class="form-markdown-editor__toolbar" role="toolbar" aria-label="Markdown formatting">
485
+ {#each toolbarActions as item}
486
+ {#if item === '|'}
487
+ <span class="form-markdown-editor__separator"></span>
488
+ {:else}
489
+ <button
490
+ type="button"
491
+ class="form-markdown-editor__btn"
492
+ title="{item.label}{item.shortcut ? ` (${item.shortcut.replace('Mod', '⌘')})` : ''}"
493
+ onclick={item.action}
494
+ >
495
+ {#if item.isSvg}
496
+ <span class="form-markdown-editor__btn-svg">{@html item.icon}</span>
497
+ {:else}
498
+ <span
499
+ class="form-markdown-editor__btn-icon"
500
+ class:form-markdown-editor__btn-icon--bold={item.id === 'bold'}
501
+ class:form-markdown-editor__btn-icon--italic={item.id === 'italic'}
502
+ class:form-markdown-editor__btn-icon--strike={item.id === 'strikethrough'}
503
+ >{item.icon}</span
504
+ >
505
+ {/if}
506
+ </button>
507
+ {/if}
508
+ {/each}
239
509
  </div>
240
510
  {/if}
241
511
 
242
- <!-- EasyMDE textarea container -->
243
- <textarea
244
- bind:this={textareaRef}
245
- aria-label="Markdown editor"
246
- class:form-markdown-editor__textarea--hidden={!isLoading && easymde}>{value}</textarea
247
- >
512
+ <!-- CodeMirror container -->
513
+ <div bind:this={containerRef} class="form-markdown-editor__body"></div>
514
+
515
+ <!-- Status bar -->
516
+ {#if showStatusBar}
517
+ <div class="form-markdown-editor__status">
518
+ <span>words: {wordCount}</span>
519
+ <span>lines: {lineCount}</span>
520
+ <span>characters: {charCount}</span>
521
+ </div>
522
+ {/if}
248
523
  </div>
249
524
 
250
525
  <style>
@@ -253,376 +528,225 @@
253
528
  width: 100%;
254
529
  }
255
530
 
256
- /* Loading state */
257
- .form-markdown-editor__loading {
531
+ /* ── Toolbar ───────────────────────────────────── */
532
+
533
+ .form-markdown-editor__toolbar {
258
534
  display: flex;
259
535
  align-items: center;
260
- justify-content: center;
261
- gap: 0.75rem;
262
- padding: 2rem;
263
- background-color: var(--fd-muted);
536
+ gap: 0.125rem;
264
537
  border: 1px solid var(--fd-border);
265
- border-radius: var(--fd-radius-lg);
266
- color: var(--fd-muted-foreground);
267
- font-size: var(--fd-text-sm);
268
- }
269
-
270
- .form-markdown-editor__spinner {
271
- width: 1.25rem;
272
- height: 1.25rem;
273
- border: 2px solid var(--fd-border);
274
- border-top-color: var(--fd-primary);
275
- border-radius: 50%;
276
- animation: spin 0.8s linear infinite;
277
- }
278
-
279
- @keyframes spin {
280
- to {
281
- transform: rotate(360deg);
282
- }
283
- }
284
-
285
- /* Hide the raw textarea when editor is loaded */
286
- .form-markdown-editor__textarea--hidden {
287
- display: none;
288
- }
289
-
290
- /* Fallback textarea styling (shown during loading or if editor fails) */
291
- .form-markdown-editor textarea:not(.form-markdown-editor__textarea--hidden) {
292
- width: 100%;
293
- min-height: var(--editor-height, 300px);
294
- padding: 0.75rem;
295
- border: 1px solid var(--fd-border);
296
- border-radius: var(--fd-radius-lg);
297
- font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', monospace;
298
- font-size: var(--fd-text-sm);
299
- line-height: 1.5;
300
- resize: vertical;
301
- background-color: var(--fd-muted);
302
- }
303
-
304
- /* EasyMDE container styling */
305
- .form-markdown-editor :global(.CodeMirror) {
306
- border: 1px solid var(--fd-border);
307
- border-top: none;
308
- border-radius: 0;
309
- background-color: var(--fd-muted);
310
- color: var(--fd-foreground);
311
- font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', monospace;
312
- font-size: var(--fd-text-sm);
313
- min-height: var(--editor-height, 300px);
314
- transition: border-color var(--fd-transition-normal);
315
- }
316
-
317
- /* CodeMirror cursor styling for visibility in dark mode */
318
- .form-markdown-editor :global(.CodeMirror-cursor) {
319
- border-left-color: var(--fd-foreground);
320
- }
321
-
322
- /* CodeMirror selection styling */
323
- .form-markdown-editor :global(.CodeMirror-selected) {
324
- background-color: var(--fd-primary-muted) !important;
325
- }
326
-
327
- .form-markdown-editor :global(.CodeMirror-focused .CodeMirror-selected) {
328
- background-color: var(--fd-primary-muted) !important;
329
- }
330
-
331
- /* CodeMirror line number gutter */
332
- .form-markdown-editor :global(.CodeMirror-gutters) {
538
+ border-bottom: none;
539
+ border-radius: var(--fd-radius-lg) var(--fd-radius-lg) 0 0;
333
540
  background-color: var(--fd-subtle);
334
- border-right: 1px solid var(--fd-border);
541
+ padding: 0.375rem 0.5rem;
335
542
  }
336
543
 
337
- .form-markdown-editor :global(.CodeMirror-linenumber) {
544
+ .form-markdown-editor__btn {
545
+ display: flex;
546
+ align-items: center;
547
+ justify-content: center;
548
+ width: 2rem;
549
+ height: 2rem;
550
+ border: none;
551
+ border-radius: var(--fd-radius-md);
552
+ background: none;
338
553
  color: var(--fd-muted-foreground);
554
+ cursor: pointer;
555
+ font-size: 0.8125rem;
556
+ transition: all var(--fd-transition-fast);
339
557
  }
340
558
 
341
- /* Header styling inside the editor - keep sizes reasonable */
342
- .form-markdown-editor :global(.cm-header-1) {
343
- font-size: 1.25rem;
344
- line-height: 1.4;
345
- }
346
-
347
- .form-markdown-editor :global(.cm-header-2) {
348
- font-size: 1.125rem;
349
- line-height: 1.4;
350
- }
351
-
352
- .form-markdown-editor :global(.cm-header-3) {
353
- font-size: 1rem;
354
- line-height: 1.4;
355
- }
356
-
357
- .form-markdown-editor :global(.cm-header-4),
358
- .form-markdown-editor :global(.cm-header-5),
359
- .form-markdown-editor :global(.cm-header-6) {
360
- font-size: 0.9375rem;
361
- line-height: 1.4;
362
- }
363
-
364
- /* Keep all headers in monospace and reasonable weight */
365
- .form-markdown-editor :global(.cm-header) {
366
- font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', monospace;
367
- font-weight: 600;
559
+ .form-markdown-editor__btn:hover {
560
+ background-color: var(--fd-border);
368
561
  color: var(--fd-foreground);
369
562
  }
370
563
 
371
- /* Markdown syntax highlighting in editor */
372
- .form-markdown-editor :global(.cm-s-easymde .cm-comment) {
373
- color: var(--fd-muted-foreground);
374
- }
375
-
376
- .form-markdown-editor :global(.cm-s-easymde .cm-link) {
377
- color: var(--fd-primary);
564
+ .form-markdown-editor__btn-icon--bold {
565
+ font-weight: 700;
378
566
  }
379
567
 
380
- .form-markdown-editor :global(.cm-s-easymde .cm-url) {
381
- color: var(--fd-primary-hover);
568
+ .form-markdown-editor__btn-icon--italic {
569
+ font-style: italic;
382
570
  }
383
571
 
384
- .form-markdown-editor :global(.cm-s-easymde .cm-string) {
385
- color: var(--fd-success);
572
+ .form-markdown-editor__btn-icon--strike {
573
+ text-decoration: line-through;
386
574
  }
387
575
 
388
- .form-markdown-editor :global(.cm-s-easymde .cm-formatting) {
389
- color: var(--fd-muted-foreground);
576
+ .form-markdown-editor__btn-svg {
577
+ display: flex;
578
+ align-items: center;
579
+ justify-content: center;
390
580
  }
391
581
 
392
- .form-markdown-editor :global(.cm-s-easymde .cm-quote) {
393
- color: var(--fd-muted-foreground);
394
- font-style: italic;
582
+ .form-markdown-editor__separator {
583
+ width: 1px;
584
+ height: 1.25rem;
585
+ background-color: var(--fd-border-strong);
586
+ margin: 0 0.25rem;
395
587
  }
396
588
 
397
- .form-markdown-editor :global(.cm-s-easymde .cm-strong) {
398
- color: var(--fd-foreground);
399
- font-weight: 700;
400
- }
589
+ /* ── Editor body ───────────────────────────────── */
401
590
 
402
- .form-markdown-editor :global(.cm-s-easymde .cm-em) {
403
- color: var(--fd-foreground);
404
- font-style: italic;
591
+ .form-markdown-editor__body {
592
+ border: 1px solid var(--fd-border);
593
+ border-radius: var(--fd-radius-lg);
594
+ overflow: hidden;
595
+ background-color: var(--fd-muted);
596
+ transition: border-color var(--fd-transition-normal);
405
597
  }
406
598
 
407
- .form-markdown-editor :global(.cm-s-easymde .cm-strikethrough) {
408
- color: var(--fd-muted-foreground);
409
- text-decoration: line-through;
599
+ /* When toolbar is present, remove top radius */
600
+ .form-markdown-editor__toolbar + .form-markdown-editor__body {
601
+ border-top: none;
602
+ border-radius: 0;
410
603
  }
411
604
 
412
- .form-markdown-editor :global(.CodeMirror:hover) {
605
+ .form-markdown-editor__body:hover {
413
606
  border-color: var(--fd-border-strong);
414
607
  }
415
608
 
416
- .form-markdown-editor :global(.CodeMirror-focused) {
609
+ .form-markdown-editor__body:focus-within {
417
610
  border-color: var(--fd-primary);
418
611
  background-color: var(--fd-background);
419
- color: var(--fd-foreground);
420
612
  box-shadow:
421
- 0 0 0 3px rgba(59, 130, 246, 0.12),
613
+ 0 0 0 3px var(--fd-primary-muted),
422
614
  var(--fd-shadow-sm);
423
615
  }
424
616
 
425
- /* Editor wrapper */
426
- .form-markdown-editor :global(.editor-toolbar) {
427
- border: 1px solid var(--fd-border);
428
- border-bottom: none;
429
- border-radius: var(--fd-radius-lg) var(--fd-radius-lg) 0 0;
430
- background-color: var(--fd-subtle);
431
- padding: 0.5rem;
432
- }
433
-
434
- .form-markdown-editor :global(.editor-toolbar::before),
435
- .form-markdown-editor :global(.editor-toolbar::after) {
436
- display: none;
437
- }
438
-
439
- /* Toolbar buttons */
440
- .form-markdown-editor :global(.editor-toolbar button) {
441
- color: var(--fd-muted-foreground);
442
- border: none;
443
- border-radius: var(--fd-radius-md);
444
- width: 2rem;
445
- height: 2rem;
446
- transition: all var(--fd-transition-fast);
447
- }
448
-
449
- .form-markdown-editor :global(.editor-toolbar button:hover) {
450
- background-color: var(--fd-border);
451
- color: var(--fd-foreground);
452
- }
453
-
454
- .form-markdown-editor :global(.editor-toolbar button.active) {
455
- background-color: var(--fd-primary-muted);
456
- color: var(--fd-primary-hover);
457
- }
617
+ /* ── Status bar ────────────────────────────────── */
458
618
 
459
- /* Separator */
460
- .form-markdown-editor :global(.editor-toolbar i.separator) {
461
- border-left: 1px solid var(--fd-border-strong);
462
- margin: 0 0.25rem;
463
- }
464
-
465
- /* Status bar */
466
- .form-markdown-editor :global(.editor-statusbar) {
619
+ .form-markdown-editor__status {
620
+ display: flex;
621
+ gap: 1rem;
622
+ justify-content: flex-end;
467
623
  border: 1px solid var(--fd-border);
468
624
  border-top: none;
469
625
  border-radius: 0 0 var(--fd-radius-lg) var(--fd-radius-lg);
470
626
  background-color: var(--fd-muted);
471
- padding: 0.5rem 0.75rem;
627
+ padding: 0.375rem 0.75rem;
472
628
  font-size: var(--fd-text-xs);
473
629
  color: var(--fd-muted-foreground);
474
630
  }
475
631
 
476
- /* Preview pane */
477
- .form-markdown-editor :global(.editor-preview) {
478
- background-color: var(--fd-background);
479
- padding: 1rem;
480
- font-family: inherit;
481
- font-size: var(--fd-text-sm);
482
- line-height: 1.6;
483
- color: var(--fd-foreground);
632
+ /* When no toolbar, body gets top radius */
633
+ .form-markdown-editor:not(:has(.form-markdown-editor__toolbar)) .form-markdown-editor__body {
634
+ border-radius: var(--fd-radius-lg) var(--fd-radius-lg) 0 0;
484
635
  }
485
636
 
486
- .form-markdown-editor :global(.editor-preview h1),
487
- .form-markdown-editor :global(.editor-preview h2),
488
- .form-markdown-editor :global(.editor-preview h3),
489
- .form-markdown-editor :global(.editor-preview h4),
490
- .form-markdown-editor :global(.editor-preview h5),
491
- .form-markdown-editor :global(.editor-preview h6) {
492
- margin-top: 1.5em;
493
- margin-bottom: 0.5em;
494
- font-weight: 600;
495
- color: var(--fd-foreground);
637
+ /* When no status bar, body gets bottom radius */
638
+ .form-markdown-editor:not(:has(.form-markdown-editor__status)) .form-markdown-editor__body {
639
+ border-radius: 0 0 var(--fd-radius-lg) var(--fd-radius-lg);
496
640
  }
497
641
 
498
- .form-markdown-editor :global(.editor-preview h1) {
499
- font-size: 1.5rem;
500
- border-bottom: 1px solid var(--fd-border);
501
- padding-bottom: 0.5rem;
642
+ /* When no toolbar AND no status bar, body gets full radius */
643
+ .form-markdown-editor:not(:has(.form-markdown-editor__toolbar)):not(
644
+ :has(.form-markdown-editor__status)
645
+ )
646
+ .form-markdown-editor__body {
647
+ border-radius: var(--fd-radius-lg);
502
648
  }
503
649
 
504
- .form-markdown-editor :global(.editor-preview h2) {
505
- font-size: 1.25rem;
506
- }
650
+ /* ── CM6 overrides ─────────────────────────────── */
651
+ /* Design tokens (--fd-*) auto-resolve for dark mode via [data-theme='dark'] */
652
+ /* !important needed to override oneDark's JS-injected styles */
507
653
 
508
- .form-markdown-editor :global(.editor-preview h3) {
509
- font-size: 1.125rem;
654
+ .form-markdown-editor__body :global(.cm-editor) {
655
+ height: var(--editor-height, 300px);
656
+ background-color: var(--fd-muted) !important;
657
+ color: var(--fd-foreground) !important;
510
658
  }
511
659
 
512
- .form-markdown-editor :global(.editor-preview p) {
513
- margin: 0.75em 0;
660
+ .form-markdown-editor__body :global(.cm-scroller) {
661
+ overflow: auto;
514
662
  }
515
663
 
516
- .form-markdown-editor :global(.editor-preview code) {
517
- padding: 0.125rem 0.375rem;
518
- background-color: var(--fd-subtle);
519
- border-radius: var(--fd-radius-sm);
520
- font-family: 'JetBrains Mono', 'Fira Code', 'Monaco', 'Menlo', monospace;
521
- font-size: 0.8125rem;
664
+ .form-markdown-editor__body :global(.cm-content) {
665
+ color: var(--fd-foreground) !important;
666
+ caret-color: var(--fd-foreground) !important;
522
667
  }
523
668
 
524
- .form-markdown-editor :global(.editor-preview pre) {
525
- padding: 1rem;
526
- background-color: var(--fd-foreground);
527
- border-radius: var(--fd-radius-lg);
528
- overflow-x: auto;
669
+ .form-markdown-editor__body :global(.cm-line) {
670
+ color: var(--fd-foreground) !important;
529
671
  }
530
672
 
531
- .form-markdown-editor :global(.editor-preview pre code) {
532
- padding: 0;
533
- background-color: transparent;
534
- color: var(--fd-subtle);
673
+ .form-markdown-editor__body :global(.cm-gutters) {
674
+ background-color: var(--fd-subtle) !important;
675
+ border-right: 1px solid var(--fd-border);
535
676
  }
536
677
 
537
- .form-markdown-editor :global(.editor-preview blockquote) {
538
- margin: 1rem 0;
539
- padding: 0.5rem 1rem;
540
- border-left: 4px solid var(--fd-primary);
541
- background-color: var(--fd-primary-muted);
542
- color: var(--fd-foreground);
678
+ .form-markdown-editor__body :global(.cm-linenumber) {
679
+ color: var(--fd-muted-foreground) !important;
543
680
  }
544
681
 
545
- .form-markdown-editor :global(.editor-preview ul),
546
- .form-markdown-editor :global(.editor-preview ol) {
547
- margin: 0.75em 0;
548
- padding-left: 1.5rem;
682
+ .form-markdown-editor__body :global(.cm-cursor) {
683
+ border-left-color: var(--fd-muted-foreground) !important;
549
684
  }
550
685
 
551
- .form-markdown-editor :global(.editor-preview li) {
552
- margin: 0.25em 0;
686
+ .form-markdown-editor__body :global(.cm-activeLine) {
687
+ background-color: var(--fd-subtle) !important;
553
688
  }
554
689
 
555
- .form-markdown-editor :global(.editor-preview a) {
556
- color: var(--fd-primary-hover);
557
- text-decoration: underline;
690
+ .form-markdown-editor__body :global(.cm-activeLineGutter) {
691
+ background-color: var(--fd-subtle) !important;
558
692
  }
559
693
 
560
- .form-markdown-editor :global(.editor-preview a:hover) {
561
- color: var(--fd-primary);
562
- }
694
+ /* ── Markdown syntax styling ───────────────────── */
563
695
 
564
- .form-markdown-editor :global(.editor-preview table) {
565
- width: 100%;
566
- border-collapse: collapse;
567
- margin: 1rem 0;
696
+ .form-markdown-editor__body :global(.cm-header-1) {
697
+ font-size: 1.25rem;
698
+ line-height: 1.4;
568
699
  }
569
700
 
570
- .form-markdown-editor :global(.editor-preview th),
571
- .form-markdown-editor :global(.editor-preview td) {
572
- border: 1px solid var(--fd-border);
573
- padding: 0.5rem 0.75rem;
574
- text-align: left;
701
+ .form-markdown-editor__body :global(.cm-header-2) {
702
+ font-size: 1.125rem;
703
+ line-height: 1.4;
575
704
  }
576
705
 
577
- .form-markdown-editor :global(.editor-preview th) {
578
- background-color: var(--fd-subtle);
579
- font-weight: 600;
706
+ .form-markdown-editor__body :global(.cm-header-3) {
707
+ font-size: 1rem;
708
+ line-height: 1.4;
580
709
  }
581
710
 
582
- .form-markdown-editor :global(.editor-preview img) {
583
- max-width: 100%;
584
- border-radius: var(--fd-radius-lg);
711
+ .form-markdown-editor__body :global(.cm-header) {
712
+ font-weight: 600;
713
+ color: var(--fd-foreground) !important;
585
714
  }
586
715
 
587
- /* Side-by-side mode */
588
- .form-markdown-editor :global(.CodeMirror-sided) {
589
- width: 50% !important;
716
+ .form-markdown-editor__body :global(.cm-processingInstruction) {
717
+ color: var(--fd-success) !important;
590
718
  }
591
719
 
592
- .form-markdown-editor :global(.editor-preview-side) {
593
- width: 50%;
594
- border: 1px solid var(--fd-border);
595
- border-left: none;
596
- border-radius: 0 0 var(--fd-radius-lg) 0;
720
+ .form-markdown-editor__body :global(.cm-emphasis) {
721
+ color: var(--fd-foreground) !important;
722
+ font-style: italic;
597
723
  }
598
724
 
599
- /* Fullscreen mode adjustments */
600
- .form-markdown-editor :global(.editor-toolbar.fullscreen) {
601
- border-radius: 0;
725
+ .form-markdown-editor__body :global(.cm-strong) {
726
+ color: var(--fd-foreground) !important;
727
+ font-weight: 700;
602
728
  }
603
729
 
604
- .form-markdown-editor :global(.CodeMirror-fullscreen) {
605
- border-radius: 0;
730
+ .form-markdown-editor__body :global(.cm-strikethrough) {
731
+ color: var(--fd-muted-foreground) !important;
732
+ text-decoration: line-through;
606
733
  }
607
734
 
608
- /* Placeholder */
609
- .form-markdown-editor :global(.CodeMirror .CodeMirror-placeholder) {
610
- color: var(--fd-muted-foreground);
611
- font-style: italic;
735
+ .form-markdown-editor__body :global(.cm-url) {
736
+ color: var(--fd-primary) !important;
612
737
  }
613
738
 
614
- /* EasyMDE specific wrapper */
615
- .form-markdown-editor :global(.EasyMDEContainer) {
616
- width: 100%;
739
+ .form-markdown-editor__body :global(.cm-link) {
740
+ color: var(--fd-primary) !important;
741
+ text-decoration: underline;
617
742
  }
618
743
 
619
- /* When no status bar, CodeMirror gets bottom rounded corners */
620
- .form-markdown-editor :global(.EasyMDEContainer:not(:has(.editor-statusbar)) .CodeMirror) {
621
- border-radius: 0 0 0.5rem 0.5rem;
744
+ .form-markdown-editor__body :global(.cm-meta) {
745
+ color: var(--fd-muted-foreground) !important;
622
746
  }
623
747
 
624
- /* When status bar exists, it gets the bottom rounded corners */
625
- .form-markdown-editor :global(.EasyMDEContainer:has(.editor-statusbar) .CodeMirror) {
626
- border-bottom: none;
748
+ .form-markdown-editor__body :global(.cm-quote) {
749
+ color: var(--fd-success) !important;
750
+ font-style: italic;
627
751
  }
628
752
  </style>