@joinezco/markdown-editor 0.0.3 → 0.0.4

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 (59) hide show
  1. package/.turbo/turbo-build.log +4 -5
  2. package/dist/editor/extensions/codeblock.js +70 -29
  3. package/dist/editor/index.js +23 -0
  4. package/dist/editor/styles.js +8 -0
  5. package/package.json +3 -3
  6. package/public/fonts/UbuntuMonoNerdFont-Regular.ttf +0 -0
  7. package/public/snapshot.bin +0 -0
  8. package/src/lib/editor/extensions/codeblock.ts +77 -32
  9. package/src/lib/editor/index.ts +23 -0
  10. package/src/lib/editor/styles.ts +8 -0
  11. package/src/test/multiview-sync.test.ts +137 -0
  12. package/TEST_README.md +0 -359
  13. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Basic-Editor-Functionality-should-be-focusable-1.png +0 -0
  14. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Basic-Editor-Functionality-should-create-an-editor-instance-1.png +0 -0
  15. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Basic-Editor-Functionality-should-have-initial-content-1.png +0 -0
  16. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Basic-Editor-Functionality-should-render-in-the-DOM-1.png +0 -0
  17. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Browser-specific-Features-should-handle-copy-and-paste-operations-1.png +0 -0
  18. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Browser-specific-Features-should-handle-undo-and-redo-1.png +0 -0
  19. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Editor-State-and-Updates-should-maintain-state-across-content-changes-1.png +0 -0
  20. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Editor-State-and-Updates-should-trigger-update-callbacks-1.png +0 -0
  21. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Error-Handling-should-handle-invalid-markdown-gracefully-1.png +0 -0
  22. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Error-Handling-should-handle-very-long-content-1.png +0 -0
  23. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Keyboard-Shortcuts-should-handle-Ctrl-B-for-bold-1.png +0 -0
  24. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Keyboard-Shortcuts-should-handle-Ctrl-I-for-italic-1.png +0 -0
  25. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Markdown-Content-Management-should-get-markdown-content-1.png +0 -0
  26. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Markdown-Content-Management-should-handle-empty-content-1.png +0 -0
  27. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Markdown-Content-Management-should-set-markdown-content-1.png +0 -0
  28. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Markdown-Formatting-should-handle-bold-text-1.png +0 -0
  29. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Markdown-Formatting-should-handle-code-blocks-1.png +0 -0
  30. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Markdown-Formatting-should-handle-headings-1.png +0 -0
  31. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Markdown-Formatting-should-handle-inline-code-1.png +0 -0
  32. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Markdown-Formatting-should-handle-italic-text-1.png +0 -0
  33. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Markdown-Formatting-should-handle-lists-1.png +0 -0
  34. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Markdown-Formatting-should-handle-task-lists-1.png +0 -0
  35. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Selection-and-Cursor-Management-should-handle-cursor-positioning-1.png +0 -0
  36. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Selection-and-Cursor-Management-should-set-and-get-selection-1.png +0 -0
  37. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Text-Input-and-Editing-should-handle-line-breaks-1.png +0 -0
  38. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Text-Input-and-Editing-should-handle-typing-at-different-positions-1.png +0 -0
  39. package/src/test/__screenshots__/editor.test.ts/MarkdownEditor-Text-Input-and-Editing-should-insert-text-at-cursor-position-1.png +0 -0
  40. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Code-Block-Extension-should-handle-code-blocks-without-language-specification-1.png +0 -0
  41. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Code-Block-Extension-should-handle-different-programming-languages-1.png +0 -0
  42. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Code-Block-Extension-should-handle-inline-code-1.png +0 -0
  43. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Code-Block-Extension-should-render-code-blocks-with-syntax-highlighting-1.png +0 -0
  44. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Extension-Interactions-should-handle-multiple-extensions-working-together-1.png +0 -0
  45. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Extension-Interactions-should-maintain-editor-state-across-complex-operations-1.png +0 -0
  46. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-File-System-Integration-should-handle-file-references-in-code-blocks-1.png +0 -0
  47. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-File-System-Integration-should-maintain-file-system-state-1.png +0 -0
  48. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Link-Extension-should-auto-detect-URLs-1.png +0 -0
  49. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Link-Extension-should-handle-email-links-1.png +0 -0
  50. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Link-Extension-should-render-links-correctly-1.png +0 -0
  51. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Markdown-Storage-should-provide-markdown-storage-interface-1.png +0 -0
  52. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Markdown-Storage-should-sync-markdown-content-with-storage-1.png +0 -0
  53. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Slash-Commands-Extension-should-handle-heading-commands-1.png +0 -0
  54. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Slash-Commands-Extension-should-trigger-slash-commands-1.png +0 -0
  55. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Table-Extension-should-handle-table-navigation-1.png +0 -0
  56. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Table-Extension-should-render-tables-correctly-1.png +0 -0
  57. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Task-List-Extension-should-handle-nested-task-lists-1.png +0 -0
  58. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Task-List-Extension-should-render-task-lists-correctly-1.png +0 -0
  59. package/src/test/__screenshots__/extensions.test.ts/MarkdownEditor-Extensions-Task-List-Extension-should-toggle-task-completion-1.png +0 -0
@@ -1,5 +1,4 @@
1
-
2
-
3
- > @joinezco/markdown-editor@0.0.2 build /home/theo/dev/mono/src/typescript/markdown-editor
4
- > tsc -p tsconfig.lib.json
5
-
1
+
2
+ > @joinezco/markdown-editor@0.0.3 build /home/theo/dev/mono/src/typescript/markdown-editor
3
+ > tsc -p tsconfig.lib.json
4
+
@@ -147,36 +147,36 @@ export const ExtendedCodeblock = Node.create({
147
147
  },
148
148
  // Register input rules (e.g., ``` or ~~~ at the start of a line)
149
149
  addInputRules() {
150
+ const parseLanguageAttributes = (input) => {
151
+ if (!input)
152
+ return { language: 'markdown' };
153
+ // If input contains a dot, treat it as a filename
154
+ if (input.includes('.')) {
155
+ const ext = input.split('.').pop()?.toLowerCase() || '';
156
+ const lang = extOrLanguageToLanguageId[ext] || 'markdown';
157
+ return { file: input, language: lang };
158
+ }
159
+ // Otherwise, check if it's a language name
160
+ const matchingLanguage = Object.entries(extOrLanguageToLanguageId).find(([ext, lang]) => {
161
+ return lang.includes(input) || ext === input;
162
+ });
163
+ if (matchingLanguage) {
164
+ return { language: matchingLanguage[1], file: null };
165
+ }
166
+ return { language: 'markdown' };
167
+ };
150
168
  return [
169
+ // ```language + space — more specific, checked first
151
170
  textblockTypeInputRule({
152
- find: /^```([^\s`]+)?\s$/,
171
+ find: /^```([^\s`]+)\s$/,
153
172
  type: this.type,
154
- getAttributes: match => {
155
- const input = match[1]?.trim();
156
- if (!input)
157
- return { language: 'markdown' };
158
- // If input contains a dot, treat it as a filename
159
- if (input.includes('.')) {
160
- const ext = input.split('.').pop()?.toLowerCase() || '';
161
- const lang = extOrLanguageToLanguageId[ext] || 'markdown';
162
- return {
163
- file: input,
164
- language: lang,
165
- };
166
- }
167
- // Otherwise, check if it's a language name
168
- const matchingLanguage = Object.entries(extOrLanguageToLanguageId).find(([ext, lang]) => {
169
- return lang.includes(input) || ext === input;
170
- });
171
- if (matchingLanguage) {
172
- return {
173
- language: matchingLanguage[1],
174
- file: null,
175
- };
176
- }
177
- // If no language match found, default to markdown
178
- return { language: 'markdown' };
179
- },
173
+ getAttributes: match => parseLanguageAttributes(match[1]?.trim()),
174
+ }),
175
+ // ``` alone — triggers immediately on the third backtick
176
+ textblockTypeInputRule({
177
+ find: /^```$/,
178
+ type: this.type,
179
+ getAttributes: () => ({ language: '' }),
180
180
  }),
181
181
  ];
182
182
  },
@@ -236,6 +236,14 @@ export const ExtendedCodeblock = Node.create({
236
236
  main = state.doc.lineAt(main.head);
237
237
  if (dir < 0 ? main.from > 0 : main.to < state.doc.length)
238
238
  return false;
239
+ // ArrowUp from first line: focus toolbar instead of escaping to ProseMirror
240
+ if (dir < 0) {
241
+ const toolbarInput = cm.dom.querySelector('.cm-toolbar-input');
242
+ if (toolbarInput) {
243
+ toolbarInput.focus();
244
+ return true;
245
+ }
246
+ }
239
247
  // @ts-ignore
240
248
  let targetPos = getPos() + (dir < 0 ? 0 : node.nodeSize);
241
249
  let selection = Selection.near(view.state.doc.resolve(targetPos), dir);
@@ -302,10 +310,34 @@ export const ExtendedCodeblock = Node.create({
302
310
  // Reassign z-indexes for all codeblocks whenever a new one is created
303
311
  // This ensures proper stacking order based on DOM position
304
312
  reassignZIndexes();
313
+ // Handle ArrowUp from toolbar to escape to ProseMirror above
314
+ dom.addEventListener('keydown', (e) => {
315
+ const target = e.target;
316
+ if (target.classList.contains('cm-toolbar-input') && e.key === 'ArrowUp') {
317
+ // Check if the dropdown is open — if so, let toolbar handle it
318
+ const dropdown = dom.querySelector('.cm-search-results');
319
+ if (dropdown && dropdown.children.length > 0)
320
+ return;
321
+ e.preventDefault();
322
+ e.stopPropagation();
323
+ // @ts-ignore
324
+ const pos = getPos();
325
+ if (pos !== undefined) {
326
+ let selection = Selection.near(view.state.doc.resolve(pos), -1);
327
+ let tr = view.state.tr.setSelection(selection).scrollIntoView();
328
+ view.dispatch(tr);
329
+ view.focus();
330
+ }
331
+ }
332
+ }, true);
305
333
  // Track whether this codeblock was created empty (e.g. via ``` input rule)
306
334
  const wasCreatedEmpty = !node.textContent && !node.attrs.file;
307
- // Initialize filesystem worker and update extensions asynchronously
308
- getFileSystemWorker().then(fs => {
335
+ // Use the editor's filesystem if available (so codeblocks can resolve
336
+ // file references seeded into the same filesystem), otherwise fall back
337
+ // to a standalone worker.
338
+ const editorFs = editor.storage.persistence?.options?.fs;
339
+ const fsPromise = editorFs ? Promise.resolve(editorFs) : getFileSystemWorker();
340
+ fsPromise.then(fs => {
309
341
  fsWorker = fs;
310
342
  SearchIndex.get(fsWorker, '.codeblock/index.json').then(index => {
311
343
  // Reconfigure with codeblock extension once fs is ready
@@ -344,6 +376,15 @@ export const ExtendedCodeblock = Node.create({
344
376
  return {
345
377
  dom,
346
378
  setSelection(anchor, head) {
379
+ // If the codeblock wasn't focused (entering from outside),
380
+ // direct to the toolbar input for keyboard navigation
381
+ if (!cm.hasFocus) {
382
+ const toolbarInput = cm.dom.querySelector('.cm-toolbar-input');
383
+ if (toolbarInput) {
384
+ toolbarInput.focus();
385
+ return;
386
+ }
387
+ }
347
388
  cm.focus();
348
389
  updating = true;
349
390
  cm.dispatch({ selection: { anchor, head } });
@@ -11,6 +11,28 @@ import { ExtendedLink } from './extensions/link';
11
11
  import { SlashCommands } from './extensions/slash-commands';
12
12
  import { defaultSlashCommands } from './commands';
13
13
  import { StyleModule } from 'style-mod';
14
+ // Override native caret blink speed on browsers that support caret-animation (Firefox 130+/Zen)
15
+ let caretBlinkInjected = false;
16
+ function injectCaretBlink() {
17
+ if (caretBlinkInjected)
18
+ return;
19
+ caretBlinkInjected = true;
20
+ const style = document.createElement('style');
21
+ style.textContent = `
22
+ @supports (caret-animation: manual) {
23
+ .ezco-mde .ProseMirror {
24
+ caret-animation: manual;
25
+ }
26
+ .ezco-mde .ProseMirror:focus {
27
+ animation: ezco-mde-caret-blink 1s step-end infinite;
28
+ }
29
+ @keyframes ezco-mde-caret-blink {
30
+ from, 50% { caret-color: currentColor; }
31
+ 50.1%, to { caret-color: transparent; }
32
+ }
33
+ }`;
34
+ document.head.appendChild(style);
35
+ }
14
36
  /**
15
37
  * Create a Markdown-ready Tiptap Editor with default extensions and options.
16
38
  *
@@ -63,6 +85,7 @@ export function createEditor(options = {}) {
63
85
  editor.view.dom.classList.add('ezco-mde');
64
86
  if (typeof document !== 'undefined') {
65
87
  StyleModule.mount(document, styleModule);
88
+ injectCaretBlink();
66
89
  }
67
90
  return editor;
68
91
  }
@@ -230,5 +230,13 @@ export const styleModule = new StyleModule({
230
230
  flex: 1
231
231
  }
232
232
  },
233
+ // Make task checkboxes visible when selected (Ctrl-A)
234
+ // Checkboxes don't natively show selection highlighting,
235
+ // so add an outline using the system Highlight color
236
+ '& ul[data-type="taskList"] li > label > input[type="checkbox"]': {
237
+ '&::selection': {
238
+ background: 'Highlight',
239
+ },
240
+ },
233
241
  }
234
242
  });
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@joinezco/markdown-editor",
3
3
  "private": false,
4
- "version": "0.0.3",
4
+ "version": "0.0.4",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
@@ -15,7 +15,7 @@
15
15
  },
16
16
  "dependencies": {
17
17
  "@codemirror/state": "^6.5.2",
18
- "@codemirror/view": "6.36.7",
18
+ "@codemirror/view": "^6.36.7",
19
19
  "@tiptap/core": "^3.4.1",
20
20
  "@tiptap/extension-link": "^3.4.1",
21
21
  "@tiptap/extension-table": "^3.4.1",
@@ -35,7 +35,7 @@
35
35
  "tippy.js": "^6.3.7",
36
36
  "tiptap-markdown": "^0.8.10",
37
37
  "vite-plugin-node-polyfills": "^0.24.0",
38
- "@joinezco/codeblock": "0.0.9"
38
+ "@joinezco/codeblock": "0.0.10"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@eslint/js": "^9.22.0",
Binary file
@@ -170,39 +170,40 @@ export const ExtendedCodeblock = Node.create({
170
170
 
171
171
  // Register input rules (e.g., ``` or ~~~ at the start of a line)
172
172
  addInputRules() {
173
- return [
174
- textblockTypeInputRule({
175
- find: /^```([^\s`]+)?\s$/,
176
- type: this.type,
177
- getAttributes: match => {
178
- const input = match[1]?.trim();
179
- if (!input) return { language: 'markdown' };
180
-
181
- // If input contains a dot, treat it as a filename
182
- if (input.includes('.')) {
183
- const ext = input.split('.').pop()?.toLowerCase() || '';
184
- const lang = extOrLanguageToLanguageId[ext as ExtensionOrLanguage] || 'markdown'
185
- return {
186
- file: input,
187
- language: lang,
188
- };
189
- }
173
+ const parseLanguageAttributes = (input: string | undefined) => {
174
+ if (!input) return { language: 'markdown' };
175
+
176
+ // If input contains a dot, treat it as a filename
177
+ if (input.includes('.')) {
178
+ const ext = input.split('.').pop()?.toLowerCase() || '';
179
+ const lang = extOrLanguageToLanguageId[ext as ExtensionOrLanguage] || 'markdown'
180
+ return { file: input, language: lang };
181
+ }
190
182
 
191
- // Otherwise, check if it's a language name
192
- const matchingLanguage = Object.entries(extOrLanguageToLanguageId).find(([ext, lang]) => {
193
- return lang.includes(input) || ext === input;
194
- })
183
+ // Otherwise, check if it's a language name
184
+ const matchingLanguage = Object.entries(extOrLanguageToLanguageId).find(([ext, lang]) => {
185
+ return lang.includes(input) || ext === input;
186
+ })
195
187
 
196
- if (matchingLanguage) {
197
- return {
198
- language: matchingLanguage[1],
199
- file: null,
200
- };
201
- }
188
+ if (matchingLanguage) {
189
+ return { language: matchingLanguage[1], file: null };
190
+ }
202
191
 
203
- // If no language match found, default to markdown
204
- return { language: 'markdown' };
205
- },
192
+ return { language: 'markdown' };
193
+ };
194
+
195
+ return [
196
+ // ```language + space — more specific, checked first
197
+ textblockTypeInputRule({
198
+ find: /^```([^\s`]+)\s$/,
199
+ type: this.type,
200
+ getAttributes: match => parseLanguageAttributes(match[1]?.trim()),
201
+ }),
202
+ // ``` alone — triggers immediately on the third backtick
203
+ textblockTypeInputRule({
204
+ find: /^```$/,
205
+ type: this.type,
206
+ getAttributes: () => ({ language: '' }),
206
207
  }),
207
208
  ];
208
209
  },
@@ -269,6 +270,16 @@ export const ExtendedCodeblock = Node.create({
269
270
  if (!main.empty) return false
270
271
  if (unit == "line") main = state.doc.lineAt(main.head)
271
272
  if (dir < 0 ? main.from > 0 : main.to < state.doc.length) return false
273
+
274
+ // ArrowUp from first line: focus toolbar instead of escaping to ProseMirror
275
+ if (dir < 0) {
276
+ const toolbarInput = cm.dom.querySelector<HTMLInputElement>('.cm-toolbar-input');
277
+ if (toolbarInput) {
278
+ toolbarInput.focus();
279
+ return true;
280
+ }
281
+ }
282
+
272
283
  // @ts-ignore
273
284
  let targetPos = getPos() + (dir < 0 ? 0 : node.nodeSize)
274
285
  let selection = Selection.near(view.state.doc.resolve(targetPos), dir)
@@ -342,11 +353,36 @@ export const ExtendedCodeblock = Node.create({
342
353
  // This ensures proper stacking order based on DOM position
343
354
  reassignZIndexes();
344
355
 
356
+ // Handle ArrowUp from toolbar to escape to ProseMirror above
357
+ dom.addEventListener('keydown', (e) => {
358
+ const target = e.target as HTMLElement;
359
+ if (target.classList.contains('cm-toolbar-input') && e.key === 'ArrowUp') {
360
+ // Check if the dropdown is open — if so, let toolbar handle it
361
+ const dropdown = dom.querySelector('.cm-search-results');
362
+ if (dropdown && dropdown.children.length > 0) return;
363
+
364
+ e.preventDefault();
365
+ e.stopPropagation();
366
+ // @ts-ignore
367
+ const pos = getPos();
368
+ if (pos !== undefined) {
369
+ let selection = Selection.near(view.state.doc.resolve(pos), -1);
370
+ let tr = view.state.tr.setSelection(selection).scrollIntoView();
371
+ view.dispatch(tr);
372
+ view.focus();
373
+ }
374
+ }
375
+ }, true);
376
+
345
377
  // Track whether this codeblock was created empty (e.g. via ``` input rule)
346
378
  const wasCreatedEmpty = !node.textContent && !node.attrs.file;
347
379
 
348
- // Initialize filesystem worker and update extensions asynchronously
349
- getFileSystemWorker().then(fs => {
380
+ // Use the editor's filesystem if available (so codeblocks can resolve
381
+ // file references seeded into the same filesystem), otherwise fall back
382
+ // to a standalone worker.
383
+ const editorFs = editor.storage.persistence?.options?.fs;
384
+ const fsPromise = editorFs ? Promise.resolve(editorFs) : getFileSystemWorker();
385
+ fsPromise.then(fs => {
350
386
  fsWorker = fs;
351
387
  SearchIndex.get(fsWorker, '.codeblock/index.json').then(index => {
352
388
  // Reconfigure with codeblock extension once fs is ready
@@ -388,6 +424,15 @@ export const ExtendedCodeblock = Node.create({
388
424
  return {
389
425
  dom,
390
426
  setSelection(anchor, head) {
427
+ // If the codeblock wasn't focused (entering from outside),
428
+ // direct to the toolbar input for keyboard navigation
429
+ if (!cm.hasFocus) {
430
+ const toolbarInput = cm.dom.querySelector<HTMLInputElement>('.cm-toolbar-input');
431
+ if (toolbarInput) {
432
+ toolbarInput.focus();
433
+ return;
434
+ }
435
+ }
391
436
  cm.focus()
392
437
  updating = true
393
438
  cm.dispatch({ selection: { anchor, head } })
@@ -13,6 +13,28 @@ import { SlashCommands } from './extensions/slash-commands';
13
13
  import { defaultSlashCommands } from './commands';
14
14
  import { StyleModule } from 'style-mod';
15
15
 
16
+ // Override native caret blink speed on browsers that support caret-animation (Firefox 130+/Zen)
17
+ let caretBlinkInjected = false;
18
+ function injectCaretBlink() {
19
+ if (caretBlinkInjected) return;
20
+ caretBlinkInjected = true;
21
+ const style = document.createElement('style');
22
+ style.textContent = `
23
+ @supports (caret-animation: manual) {
24
+ .ezco-mde .ProseMirror {
25
+ caret-animation: manual;
26
+ }
27
+ .ezco-mde .ProseMirror:focus {
28
+ animation: ezco-mde-caret-blink 1s step-end infinite;
29
+ }
30
+ @keyframes ezco-mde-caret-blink {
31
+ from, 50% { caret-color: currentColor; }
32
+ 50.1%, to { caret-color: transparent; }
33
+ }
34
+ }`;
35
+ document.head.appendChild(style);
36
+ }
37
+
16
38
  export type MarkdownEditorOptions = Partial<EditorOptions> & {
17
39
  extensions?: Extension[];
18
40
  fs?: FileSystemOptions;
@@ -77,6 +99,7 @@ export function createEditor(options: MarkdownEditorOptions = {}): MarkdownEdito
77
99
 
78
100
  if (typeof document !== 'undefined') {
79
101
  StyleModule.mount(document, styleModule);
102
+ injectCaretBlink();
80
103
  }
81
104
  return editor as MarkdownEditor;
82
105
  }
@@ -247,5 +247,13 @@ export const styleModule: StyleModule = new StyleModule({
247
247
  flex: 1
248
248
  }
249
249
  },
250
+ // Make task checkboxes visible when selected (Ctrl-A)
251
+ // Checkboxes don't natively show selection highlighting,
252
+ // so add an outline using the system Highlight color
253
+ '& ul[data-type="taskList"] li > label > input[type="checkbox"]': {
254
+ '&::selection': {
255
+ background: 'Highlight',
256
+ },
257
+ },
250
258
  }
251
259
  })
@@ -0,0 +1,137 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
2
+ import { fileChangeBus } from '@joinezco/codeblock'
3
+ import { EditorView } from '@codemirror/view'
4
+ import { EditorState } from '@codemirror/state'
5
+
6
+ describe('FileChangeBus', () => {
7
+ let viewA: EditorView
8
+ let viewB: EditorView
9
+ let containerA: HTMLElement
10
+ let containerB: HTMLElement
11
+
12
+ beforeEach(() => {
13
+ containerA = document.createElement('div')
14
+ containerB = document.createElement('div')
15
+ document.body.appendChild(containerA)
16
+ document.body.appendChild(containerB)
17
+
18
+ viewA = new EditorView({
19
+ state: EditorState.create({ doc: 'initial' }),
20
+ parent: containerA,
21
+ })
22
+ viewB = new EditorView({
23
+ state: EditorState.create({ doc: 'initial' }),
24
+ parent: containerB,
25
+ })
26
+ })
27
+
28
+ afterEach(() => {
29
+ viewA.destroy()
30
+ viewB.destroy()
31
+ containerA.remove()
32
+ containerB.remove()
33
+ })
34
+
35
+ it('should notify other subscribers but not the source', () => {
36
+ const received: { view: string; content: string }[] = []
37
+
38
+ fileChangeBus.subscribe('test.txt', viewA, (content) => {
39
+ received.push({ view: 'A', content })
40
+ })
41
+ fileChangeBus.subscribe('test.txt', viewB, (content) => {
42
+ received.push({ view: 'B', content })
43
+ })
44
+
45
+ // Notify from view A — only B should receive
46
+ fileChangeBus.notify('test.txt', 'hello from A', viewA)
47
+
48
+ expect(received).toEqual([{ view: 'B', content: 'hello from A' }])
49
+ })
50
+
51
+ it('should not notify after unsubscribe', () => {
52
+ const received: string[] = []
53
+
54
+ const unsub = fileChangeBus.subscribe('test.txt', viewA, (content) => {
55
+ received.push(content)
56
+ })
57
+ fileChangeBus.subscribe('test.txt', viewB, () => {})
58
+
59
+ unsub()
60
+ fileChangeBus.notify('test.txt', 'hello', viewB)
61
+
62
+ expect(received).toEqual([])
63
+ })
64
+
65
+ it('should handle multiple files independently', () => {
66
+ const received: string[] = []
67
+
68
+ fileChangeBus.subscribe('a.txt', viewA, (content) => {
69
+ received.push('a:' + content)
70
+ })
71
+ fileChangeBus.subscribe('b.txt', viewA, (content) => {
72
+ received.push('b:' + content)
73
+ })
74
+
75
+ fileChangeBus.notify('a.txt', 'one', viewB)
76
+ fileChangeBus.notify('b.txt', 'two', viewB)
77
+
78
+ expect(received).toEqual(['a:one', 'b:two'])
79
+ })
80
+
81
+ it('should sync document content between views via the bus', () => {
82
+ // Simulate two views on the same file using the bus to sync
83
+
84
+ const unsubA = fileChangeBus.subscribe('shared.txt', viewA, (content) => {
85
+ if (viewA.state.doc.toString() !== content) {
86
+ viewA.dispatch({ changes: { from: 0, to: viewA.state.doc.length, insert: content } })
87
+ }
88
+ })
89
+ const unsubB = fileChangeBus.subscribe('shared.txt', viewB, (content) => {
90
+ if (viewB.state.doc.toString() !== content) {
91
+ viewB.dispatch({ changes: { from: 0, to: viewB.state.doc.length, insert: content } })
92
+ }
93
+ })
94
+
95
+ // Edit view A and "save" (notify the bus)
96
+ viewA.dispatch({ changes: { from: 0, to: viewA.state.doc.length, insert: 'updated content' } })
97
+ fileChangeBus.notify('shared.txt', 'updated content', viewA)
98
+
99
+ // View B should have received the update
100
+ expect(viewB.state.doc.toString()).toBe('updated content')
101
+ // View A should NOT have been re-dispatched (it was the source)
102
+ expect(viewA.state.doc.toString()).toBe('updated content')
103
+
104
+ unsubA()
105
+ unsubB()
106
+ })
107
+
108
+ it('should not create infinite loops when both views subscribe', () => {
109
+ let dispatchCountA = 0
110
+ let dispatchCountB = 0
111
+
112
+ fileChangeBus.subscribe('shared.txt', viewA, (content) => {
113
+ if (viewA.state.doc.toString() !== content) {
114
+ dispatchCountA++
115
+ viewA.dispatch({ changes: { from: 0, to: viewA.state.doc.length, insert: content } })
116
+ // In the real codeblockView, this dispatch would NOT trigger save because
117
+ // receivingExternalUpdate is true. So we do NOT re-notify.
118
+ }
119
+ })
120
+ fileChangeBus.subscribe('shared.txt', viewB, (content) => {
121
+ if (viewB.state.doc.toString() !== content) {
122
+ dispatchCountB++
123
+ viewB.dispatch({ changes: { from: 0, to: viewB.state.doc.length, insert: content } })
124
+ }
125
+ })
126
+
127
+ // Simulate save from A
128
+ viewA.dispatch({ changes: { from: 0, to: viewA.state.doc.length, insert: 'final' } })
129
+ fileChangeBus.notify('shared.txt', 'final', viewA)
130
+
131
+ // Only B should have dispatched once
132
+ expect(dispatchCountA).toBe(0)
133
+ expect(dispatchCountB).toBe(1)
134
+ expect(viewA.state.doc.toString()).toBe('final')
135
+ expect(viewB.state.doc.toString()).toBe('final')
136
+ })
137
+ })
package/TEST_README.md DELETED
@@ -1,359 +0,0 @@
1
- # Markdown Editor Testing Guide
2
-
3
- This document explains how to use the testing setup for the `@joinezco/markdown-editor` library.
4
-
5
- ## Overview
6
-
7
- The testing setup uses **Vitest** with **browser testing capabilities** to test the markdown editor in a real browser environment. This allows us to test DOM interactions, keyboard events, and the actual rendering behavior of the editor.
8
-
9
- ## Setup
10
-
11
- ### Dependencies
12
-
13
- The following testing dependencies are included:
14
-
15
- - `vitest` - Fast unit test framework
16
- - `@vitest/browser` - Browser testing support
17
- - `@vitest/ui` - Web UI for test results
18
- - `playwright` - Browser automation for testing
19
- - `jsdom` - DOM implementation for Node.js
20
- - `webdriverio` - WebDriver implementation
21
-
22
- ### Configuration
23
-
24
- The testing is configured in [`vitest.config.ts`](./vitest.config.ts) with:
25
-
26
- - **Browser testing enabled** using Playwright with Chromium
27
- - **Test environment** set up with proper DOM mocking
28
- - **Coverage reporting** with v8 provider
29
- - **Custom setup file** for browser environment preparation
30
-
31
- ## Running Tests
32
-
33
- ### Available Scripts
34
-
35
- ```bash
36
- # Run tests in watch mode
37
- npm run test
38
-
39
- # Run tests with UI
40
- npm run test:ui
41
-
42
- # Run tests in browser mode
43
- npm run test:browser
44
-
45
- # Run tests once and exit
46
- npm run test:run
47
-
48
- # Run tests with coverage
49
- npm run test:coverage
50
- ```
51
-
52
- ### Test Files
53
-
54
- Tests are located in the `src/test/` directory:
55
-
56
- - [`setup.ts`](./src/test/setup.ts) - Global test setup and browser mocks
57
- - [`utils.ts`](./src/test/utils.ts) - Testing utilities for markdown editor
58
- - [`editor.test.ts`](./src/test/editor.test.ts) - Core editor functionality tests
59
- - [`extensions.test.ts`](./src/test/extensions.test.ts) - Extension-specific tests
60
-
61
- ## Testing Utilities
62
-
63
- The [`utils.ts`](./src/test/utils.ts) file provides comprehensive utilities for testing the markdown editor:
64
-
65
- ### DOM Management
66
-
67
- ```typescript
68
- // Create a test container
69
- const container = createTestContainer()
70
-
71
- // Create and initialize editor
72
- const editor = await createTestEditor(container)
73
- await waitForEditor(editor)
74
-
75
- // Cleanup after test
76
- cleanupEditor(editor, container)
77
- ```
78
-
79
- ### Content Management
80
-
81
- ```typescript
82
- // Get current markdown content
83
- const content = getMarkdownContent(editor)
84
-
85
- // Set new markdown content
86
- setMarkdownContent(editor, '# New Content')
87
-
88
- // Get HTML output
89
- const html = getHTMLContent(editor)
90
- ```
91
-
92
- ### User Interactions
93
-
94
- ```typescript
95
- // Focus the editor
96
- focusEditor(editor)
97
-
98
- // Type text
99
- typeText(editor, 'Hello, World!')
100
-
101
- // Simulate key presses
102
- pressKey(editor, 'b', { ctrl: true }) // Ctrl+B for bold
103
-
104
- // Set cursor position
105
- setSelection(editor, 0, 10) // Select characters 0-10
106
- ```
107
-
108
- ### Async Operations
109
-
110
- ```typescript
111
- // Wait for conditions
112
- await waitFor(() => editor.isFocused, 5000)
113
-
114
- // Wait for editor to be ready
115
- await waitForEditor(editor)
116
- ```
117
-
118
- ## Test Categories
119
-
120
- ### Basic Editor Functionality
121
-
122
- Tests core editor features:
123
- - Editor initialization and DOM rendering
124
- - Content getting/setting
125
- - Focus management
126
- - Selection handling
127
-
128
- ### Markdown Content Management
129
-
130
- Tests markdown processing:
131
- - Content conversion between markdown and HTML
132
- - Handling of various markdown syntax
133
- - Content validation and error handling
134
-
135
- ### Text Input and Editing
136
-
137
- Tests user input:
138
- - Text insertion at cursor position
139
- - Line breaks and formatting
140
- - Keyboard shortcuts (Ctrl+B, Ctrl+I, etc.)
141
-
142
- ### Extension Testing
143
-
144
- Tests specific editor extensions:
145
- - **Task Lists** - Checkbox rendering and interaction
146
- - **Tables** - Table rendering and navigation
147
- - **Links** - Link detection and rendering
148
- - **Code Blocks** - Syntax highlighting and language support
149
- - **Slash Commands** - Command menu functionality
150
-
151
- ### Browser-specific Features
152
-
153
- Tests browser interactions:
154
- - Copy/paste operations
155
- - Undo/redo functionality
156
- - Keyboard event handling
157
- - DOM event simulation
158
-
159
- ## Writing New Tests
160
-
161
- ### Basic Test Structure
162
-
163
- ```typescript
164
- import { describe, it, expect, beforeEach, afterEach } from 'vitest'
165
- import { MarkdownEditor } from '../lib/editor'
166
- import {
167
- createTestContainer,
168
- createTestEditor,
169
- waitForEditor,
170
- cleanupEditor,
171
- } from './utils'
172
-
173
- describe('My Feature', () => {
174
- let container: HTMLElement
175
- let editor: MarkdownEditor
176
-
177
- beforeEach(async () => {
178
- container = createTestContainer()
179
- editor = await createTestEditor(container)
180
- await waitForEditor(editor)
181
- })
182
-
183
- afterEach(() => {
184
- cleanupEditor(editor, container)
185
- })
186
-
187
- it('should do something', () => {
188
- // Your test code here
189
- expect(editor).toBeDefined()
190
- })
191
- })
192
- ```
193
-
194
- ### Testing Editor State
195
-
196
- ```typescript
197
- it('should update content correctly', () => {
198
- setMarkdownContent(editor, '# Test Heading')
199
- const content = getMarkdownContent(editor)
200
- expect(content).toContain('# Test Heading')
201
-
202
- const html = getHTMLContent(editor)
203
- expect(html).toContain('<h1>Test Heading</h1>')
204
- })
205
- ```
206
-
207
- ### Testing User Interactions
208
-
209
- ```typescript
210
- it('should handle keyboard shortcuts', () => {
211
- focusEditor(editor)
212
- typeText(editor, 'bold text')
213
- setSelection(editor, 0, 9) // Select "bold text"
214
-
215
- pressKey(editor, 'b', { ctrl: true })
216
-
217
- const content = getMarkdownContent(editor)
218
- expect(content).toContain('**bold text**')
219
- })
220
- ```
221
-
222
- ### Testing Async Operations
223
-
224
- ```typescript
225
- it('should handle async updates', async () => {
226
- let updateTriggered = false
227
-
228
- const testEditor = await createTestEditor(container, {
229
- onUpdate: () => { updateTriggered = true }
230
- })
231
-
232
- typeText(testEditor, 'New content')
233
-
234
- await waitFor(() => updateTriggered, 2000)
235
- expect(updateTriggered).toBe(true)
236
- })
237
- ```
238
-
239
- ## Browser Testing Features
240
-
241
- ### Real DOM Environment
242
-
243
- Tests run in a real browser environment (Chromium via Playwright), providing:
244
- - Accurate DOM rendering
245
- - Real event handling
246
- - Proper CSS layout
247
- - Browser-specific behaviors
248
-
249
- ### Visual Testing
250
-
251
- While not implemented in the current setup, the browser environment supports:
252
- - Screenshot comparison testing
253
- - Visual regression testing
254
- - Layout testing
255
-
256
- ### Performance Testing
257
-
258
- The browser environment allows for:
259
- - Measuring render times
260
- - Testing with large documents
261
- - Memory usage monitoring
262
-
263
- ## Debugging Tests
264
-
265
- ### Using the UI
266
-
267
- Run tests with the UI for better debugging:
268
-
269
- ```bash
270
- npm run test:ui
271
- ```
272
-
273
- This opens a web interface showing:
274
- - Test results and failures
275
- - Test execution timeline
276
- - Code coverage reports
277
- - Interactive test running
278
-
279
- ### Browser DevTools
280
-
281
- When running browser tests, you can:
282
- - Set `headless: false` in `vitest.config.ts`
283
- - Use browser DevTools for debugging
284
- - Inspect the actual DOM during tests
285
-
286
- ### Console Logging
287
-
288
- Add debug logging in tests:
289
-
290
- ```typescript
291
- it('should debug editor state', () => {
292
- console.log('Editor state:', editor.state)
293
- console.log('Current content:', getMarkdownContent(editor))
294
- // ... test code
295
- })
296
- ```
297
-
298
- ## Best Practices
299
-
300
- ### Test Isolation
301
-
302
- - Always use `beforeEach`/`afterEach` for setup/cleanup
303
- - Create fresh editor instances for each test
304
- - Clean up DOM elements after tests
305
-
306
- ### Async Handling
307
-
308
- - Use `await waitForEditor()` after creating editors
309
- - Use `waitFor()` for conditional waiting
310
- - Handle async operations properly
311
-
312
- ### Realistic Testing
313
-
314
- - Test actual user interactions (typing, clicking)
315
- - Use real markdown content in tests
316
- - Test edge cases and error conditions
317
-
318
- ### Performance
319
-
320
- - Keep tests focused and fast
321
- - Use appropriate timeouts
322
- - Clean up resources properly
323
-
324
- ## Troubleshooting
325
-
326
- ### Common Issues
327
-
328
- 1. **Editor not ready**: Always use `await waitForEditor(editor)` after creation
329
- 2. **DOM not found**: Ensure container is created and editor is initialized
330
- 3. **Async timing**: Use `waitFor()` for conditions that may take time
331
- 4. **Memory leaks**: Always call `cleanupEditor()` in `afterEach`
332
-
333
- ### Browser Issues
334
-
335
- 1. **Headless failures**: Set `headless: false` for debugging
336
- 2. **Timeout errors**: Increase timeout values in config
337
- 3. **Worker issues**: Check that worker files are accessible
338
-
339
- ## Contributing
340
-
341
- When adding new tests:
342
-
343
- 1. Follow the existing test structure
344
- 2. Add utilities to `utils.ts` for reusable functionality
345
- 3. Group related tests in describe blocks
346
- 4. Use descriptive test names
347
- 5. Include both positive and negative test cases
348
- 6. Test browser-specific behaviors when relevant
349
-
350
- ## Future Enhancements
351
-
352
- Potential improvements to the testing setup:
353
-
354
- - Visual regression testing with screenshot comparison
355
- - Performance benchmarking tests
356
- - Accessibility testing integration
357
- - Cross-browser testing support
358
- - Integration with CI/CD pipelines
359
- - Test data generation utilities