inkpen 0.7.1

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 (95) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/.rubocop.yml +8 -0
  4. data/.yardopts +11 -0
  5. data/CLAUDE.md +141 -0
  6. data/README.md +409 -0
  7. data/Rakefile +19 -0
  8. data/app/assets/javascripts/inkpen/controllers/editor_controller.js +2050 -0
  9. data/app/assets/javascripts/inkpen/controllers/sticky_toolbar_controller.js +667 -0
  10. data/app/assets/javascripts/inkpen/controllers/toolbar_controller.js +693 -0
  11. data/app/assets/javascripts/inkpen/export/html.js +637 -0
  12. data/app/assets/javascripts/inkpen/export/index.js +30 -0
  13. data/app/assets/javascripts/inkpen/export/markdown.js +697 -0
  14. data/app/assets/javascripts/inkpen/export/pdf.js +372 -0
  15. data/app/assets/javascripts/inkpen/extensions/advanced_table.js +640 -0
  16. data/app/assets/javascripts/inkpen/extensions/block_commands.js +300 -0
  17. data/app/assets/javascripts/inkpen/extensions/block_gutter.js +338 -0
  18. data/app/assets/javascripts/inkpen/extensions/callout.js +303 -0
  19. data/app/assets/javascripts/inkpen/extensions/columns.js +403 -0
  20. data/app/assets/javascripts/inkpen/extensions/database.js +990 -0
  21. data/app/assets/javascripts/inkpen/extensions/document_section.js +352 -0
  22. data/app/assets/javascripts/inkpen/extensions/drag_handle.js +407 -0
  23. data/app/assets/javascripts/inkpen/extensions/embed.js +629 -0
  24. data/app/assets/javascripts/inkpen/extensions/enhanced_image.js +566 -0
  25. data/app/assets/javascripts/inkpen/extensions/export_commands.js +271 -0
  26. data/app/assets/javascripts/inkpen/extensions/file_attachment.js +593 -0
  27. data/app/assets/javascripts/inkpen/extensions/inkpen_table/index.js +58 -0
  28. data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table.js +638 -0
  29. data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_cell.js +100 -0
  30. data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_header.js +100 -0
  31. data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_constants.js +152 -0
  32. data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_helpers.js +254 -0
  33. data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_menu.js +282 -0
  34. data/app/assets/javascripts/inkpen/extensions/preformatted.js +239 -0
  35. data/app/assets/javascripts/inkpen/extensions/section.js +281 -0
  36. data/app/assets/javascripts/inkpen/extensions/section_title.js +126 -0
  37. data/app/assets/javascripts/inkpen/extensions/slash_commands.js +439 -0
  38. data/app/assets/javascripts/inkpen/extensions/table_of_contents.js +474 -0
  39. data/app/assets/javascripts/inkpen/extensions/toggle_block.js +332 -0
  40. data/app/assets/javascripts/inkpen/index.js +87 -0
  41. data/app/assets/stylesheets/inkpen/advanced_table.css +514 -0
  42. data/app/assets/stylesheets/inkpen/animations.css +626 -0
  43. data/app/assets/stylesheets/inkpen/block_gutter.css +265 -0
  44. data/app/assets/stylesheets/inkpen/callout.css +359 -0
  45. data/app/assets/stylesheets/inkpen/columns.css +314 -0
  46. data/app/assets/stylesheets/inkpen/database.css +658 -0
  47. data/app/assets/stylesheets/inkpen/document_section.css +305 -0
  48. data/app/assets/stylesheets/inkpen/drag_drop.css +220 -0
  49. data/app/assets/stylesheets/inkpen/editor.css +652 -0
  50. data/app/assets/stylesheets/inkpen/embed.css +468 -0
  51. data/app/assets/stylesheets/inkpen/enhanced_image.css +453 -0
  52. data/app/assets/stylesheets/inkpen/export.css +499 -0
  53. data/app/assets/stylesheets/inkpen/file_attachment.css +347 -0
  54. data/app/assets/stylesheets/inkpen/footnotes.css +136 -0
  55. data/app/assets/stylesheets/inkpen/inkpen_table.css +608 -0
  56. data/app/assets/stylesheets/inkpen/preformatted.css +215 -0
  57. data/app/assets/stylesheets/inkpen/search_replace.css +58 -0
  58. data/app/assets/stylesheets/inkpen/section.css +236 -0
  59. data/app/assets/stylesheets/inkpen/slash_menu.css +252 -0
  60. data/app/assets/stylesheets/inkpen/sticky_toolbar.css +314 -0
  61. data/app/assets/stylesheets/inkpen/toc.css +386 -0
  62. data/app/assets/stylesheets/inkpen/toggle.css +260 -0
  63. data/app/helpers/inkpen/editor_helper.rb +114 -0
  64. data/app/views/inkpen/_editor.html.erb +139 -0
  65. data/config/importmap.rb +170 -0
  66. data/docs/.DS_Store +0 -0
  67. data/docs/CHANGELOG.md +571 -0
  68. data/docs/FEATURES.md +436 -0
  69. data/docs/ROADMAP.md +3029 -0
  70. data/docs/VISION.md +235 -0
  71. data/docs/extensions/INKPEN_TABLE.md +482 -0
  72. data/docs/thinking/CORRECTED_NO_VUE.md +756 -0
  73. data/docs/thinking/EXECUTIVE_SUMMARY.md +403 -0
  74. data/docs/thinking/INKPEN_CODE_SAMPLES.md +1479 -0
  75. data/docs/thinking/INKPEN_MASTER_GUIDE.md +891 -0
  76. data/docs/thinking/README_START_HERE.md +341 -0
  77. data/lib/inkpen/configuration.rb +175 -0
  78. data/lib/inkpen/editor.rb +204 -0
  79. data/lib/inkpen/engine.rb +32 -0
  80. data/lib/inkpen/extensions/base.rb +109 -0
  81. data/lib/inkpen/extensions/code_block_syntax.rb +177 -0
  82. data/lib/inkpen/extensions/document_section.rb +111 -0
  83. data/lib/inkpen/extensions/forced_document.rb +183 -0
  84. data/lib/inkpen/extensions/mention.rb +155 -0
  85. data/lib/inkpen/extensions/preformatted.rb +111 -0
  86. data/lib/inkpen/extensions/section.rb +139 -0
  87. data/lib/inkpen/extensions/slash_commands.rb +100 -0
  88. data/lib/inkpen/extensions/table.rb +182 -0
  89. data/lib/inkpen/extensions/task_list.rb +145 -0
  90. data/lib/inkpen/sticky_toolbar.rb +157 -0
  91. data/lib/inkpen/toolbar.rb +145 -0
  92. data/lib/inkpen/version.rb +5 -0
  93. data/lib/inkpen.rb +101 -0
  94. data/sig/inkpen.rbs +4 -0
  95. metadata +165 -0
@@ -0,0 +1,2050 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ // ============================================
4
+ // LAZY LOADING
5
+ // ============================================
6
+ // All TipTap/ProseMirror modules are lazy-loaded when the editor connects.
7
+ // This prevents 50+ CDN requests on pages that don't use the editor.
8
+ // The modules are cached after first load for subsequent editor instances.
9
+
10
+ let cachedModules = null
11
+
12
+ async function loadEditorModules() {
13
+ if (cachedModules) return cachedModules
14
+
15
+ // Load all core modules in parallel
16
+ const [
17
+ tiptapCore,
18
+ tiptapPmModel,
19
+ documentExt,
20
+ starterKit,
21
+ linkExt,
22
+ placeholderExt,
23
+ imageExt,
24
+ tableExt,
25
+ tableRowExt,
26
+ tableCellExt,
27
+ tableHeaderExt,
28
+ taskListExt,
29
+ taskItemExt,
30
+ mentionExt,
31
+ codeBlockLowlightExt,
32
+ lowlightMod,
33
+ typographyExt,
34
+ highlightExt,
35
+ underlineExt,
36
+ subscriptExt,
37
+ superscriptExt,
38
+ youtubeExt,
39
+ characterCountExt,
40
+ bubbleMenuExt,
41
+ // Inkpen custom extensions
42
+ sectionMod,
43
+ preformattedMod,
44
+ slashCommandsMod,
45
+ blockGutterMod,
46
+ dragHandleMod,
47
+ toggleBlockMod,
48
+ columnsMod,
49
+ calloutMod,
50
+ blockCommandsMod,
51
+ enhancedImageMod,
52
+ fileAttachmentMod,
53
+ embedMod,
54
+ advancedTableMod,
55
+ tableOfContentsMod,
56
+ databaseMod,
57
+ documentSectionMod,
58
+ contentEmbedMod
59
+ ] = await Promise.all([
60
+ import("@tiptap/core"),
61
+ import("@tiptap/pm/model"),
62
+ import("@tiptap/extension-document"),
63
+ import("@tiptap/starter-kit"),
64
+ import("@tiptap/extension-link"),
65
+ import("@tiptap/extension-placeholder"),
66
+ import("@tiptap/extension-image"),
67
+ import("@tiptap/extension-table"),
68
+ import("@tiptap/extension-table-row"),
69
+ import("@tiptap/extension-table-cell"),
70
+ import("@tiptap/extension-table-header"),
71
+ import("@tiptap/extension-task-list"),
72
+ import("@tiptap/extension-task-item"),
73
+ import("@tiptap/extension-mention"),
74
+ import("@tiptap/extension-code-block-lowlight"),
75
+ import("lowlight"),
76
+ import("@tiptap/extension-typography"),
77
+ import("@tiptap/extension-highlight"),
78
+ import("@tiptap/extension-underline"),
79
+ import("@tiptap/extension-subscript"),
80
+ import("@tiptap/extension-superscript"),
81
+ import("@tiptap/extension-youtube"),
82
+ import("@tiptap/extension-character-count"),
83
+ import("@tiptap/extension-bubble-menu"),
84
+ // Inkpen custom extensions
85
+ import("inkpen/extensions/section"),
86
+ import("inkpen/extensions/preformatted"),
87
+ import("inkpen/extensions/slash_commands"),
88
+ import("inkpen/extensions/block_gutter"),
89
+ import("inkpen/extensions/drag_handle"),
90
+ import("inkpen/extensions/toggle_block"),
91
+ import("inkpen/extensions/columns"),
92
+ import("inkpen/extensions/callout"),
93
+ import("inkpen/extensions/block_commands"),
94
+ import("inkpen/extensions/enhanced_image"),
95
+ import("inkpen/extensions/file_attachment"),
96
+ import("inkpen/extensions/embed"),
97
+ import("inkpen/extensions/advanced_table"),
98
+ import("inkpen/extensions/table_of_contents"),
99
+ import("inkpen/extensions/database"),
100
+ import("inkpen/extensions/document_section"),
101
+ import("inkpen/extensions/content_embed")
102
+ ])
103
+
104
+ cachedModules = {
105
+ Editor: tiptapCore.Editor,
106
+ DOMSerializer: tiptapPmModel.DOMSerializer,
107
+ Document: documentExt.default,
108
+ StarterKit: starterKit.default,
109
+ Link: linkExt.default,
110
+ Placeholder: placeholderExt.default,
111
+ Image: imageExt.default,
112
+ Table: tableExt.default,
113
+ TableRow: tableRowExt.default,
114
+ TableCell: tableCellExt.default,
115
+ TableHeader: tableHeaderExt.default,
116
+ TaskList: taskListExt.default,
117
+ TaskItem: taskItemExt.default,
118
+ Mention: mentionExt.default,
119
+ CodeBlockLowlight: codeBlockLowlightExt.default,
120
+ common: lowlightMod.common,
121
+ createLowlight: lowlightMod.createLowlight,
122
+ Typography: typographyExt.default,
123
+ Highlight: highlightExt.default,
124
+ Underline: underlineExt.default,
125
+ Subscript: subscriptExt.default,
126
+ Superscript: superscriptExt.default,
127
+ Youtube: youtubeExt.default,
128
+ CharacterCount: characterCountExt.default,
129
+ BubbleMenu: bubbleMenuExt.default,
130
+ // Inkpen custom extensions
131
+ Section: sectionMod.Section,
132
+ Preformatted: preformattedMod.Preformatted,
133
+ SlashCommands: slashCommandsMod.SlashCommands,
134
+ BlockGutter: blockGutterMod.BlockGutter,
135
+ DragHandle: dragHandleMod.DragHandle,
136
+ ToggleBlock: toggleBlockMod.ToggleBlock,
137
+ ToggleSummary: toggleBlockMod.ToggleSummary,
138
+ Columns: columnsMod.Columns,
139
+ Column: columnsMod.Column,
140
+ Callout: calloutMod.Callout,
141
+ BlockCommands: blockCommandsMod.BlockCommands,
142
+ EnhancedImage: enhancedImageMod.EnhancedImage,
143
+ FileAttachment: fileAttachmentMod.FileAttachment,
144
+ Embed: embedMod.Embed,
145
+ AdvancedTable: advancedTableMod.AdvancedTable,
146
+ AdvancedTableRow: advancedTableMod.AdvancedTableRow,
147
+ AdvancedTableCell: advancedTableMod.AdvancedTableCell,
148
+ AdvancedTableHeader: advancedTableMod.AdvancedTableHeader,
149
+ TableOfContents: tableOfContentsMod.TableOfContents,
150
+ Database: databaseMod.Database,
151
+ DocumentSection: documentSectionMod.DocumentSection,
152
+ ContentEmbed: contentEmbedMod.ContentEmbed
153
+ }
154
+
155
+ return cachedModules
156
+ }
157
+
158
+ // Extensions loaded lazily to prevent import failures from breaking the editor
159
+ let EmojiReplacer = null
160
+ let SearchNReplace = null
161
+ let FootnotesModule = null
162
+ let InkpenTableModule = null
163
+
164
+ async function loadEmojiReplacer() {
165
+ if (EmojiReplacer) return EmojiReplacer
166
+ try {
167
+ const module = await import("@tiptap-extend/emoji-replacer")
168
+ EmojiReplacer = module.EmojiReplacer
169
+ return EmojiReplacer
170
+ } catch (e) {
171
+ console.warn("Inkpen: EmojiReplacer extension not available:", e.message)
172
+ return null
173
+ }
174
+ }
175
+
176
+ async function loadSearchNReplace() {
177
+ if (SearchNReplace) return SearchNReplace
178
+ try {
179
+ const module = await import("@sereneinserenade/tiptap-search-and-replace")
180
+ SearchNReplace = module.default
181
+ return SearchNReplace
182
+ } catch (e) {
183
+ console.warn("Inkpen: SearchNReplace extension not available:", e.message)
184
+ return null
185
+ }
186
+ }
187
+
188
+ async function loadFootnotes() {
189
+ if (FootnotesModule) return FootnotesModule
190
+ try {
191
+ FootnotesModule = await import("tiptap-footnotes")
192
+ return FootnotesModule
193
+ } catch (e) {
194
+ console.warn("Inkpen: Footnotes extension not available:", e.message)
195
+ return null
196
+ }
197
+ }
198
+
199
+ async function loadInkpenTable() {
200
+ if (InkpenTableModule) return InkpenTableModule
201
+ try {
202
+ InkpenTableModule = await import("inkpen/extensions/inkpen_table")
203
+ return InkpenTableModule
204
+ } catch (e) {
205
+ console.warn("Inkpen: InkpenTable extension not available:", e.message)
206
+ return null
207
+ }
208
+ }
209
+
210
+ // Export modules are loaded lazily when export_commands extension is enabled
211
+ // This prevents 404 errors for apps that don't use export functionality
212
+ let ExportCommands = null
213
+ let exportModules = null
214
+
215
+ async function loadExportModules() {
216
+ if (exportModules) return exportModules
217
+ try {
218
+ exportModules = await import("inkpen/export")
219
+ return exportModules
220
+ } catch (e) {
221
+ console.warn("Inkpen export modules not available:", e.message)
222
+ return null
223
+ }
224
+ }
225
+
226
+ async function loadExportCommands() {
227
+ if (ExportCommands) return ExportCommands
228
+ try {
229
+ const module = await import("inkpen/extensions/export_commands")
230
+ ExportCommands = module.ExportCommands
231
+ return ExportCommands
232
+ } catch (e) {
233
+ console.warn("Inkpen ExportCommands extension not available:", e.message)
234
+ return null
235
+ }
236
+ }
237
+
238
+ /**
239
+ * Inkpen Editor Controller
240
+ *
241
+ * Main Stimulus controller for the TipTap editor.
242
+ * Handles initialization, content sync, and editor lifecycle.
243
+ *
244
+ * Supports extensions:
245
+ * - StarterKit (bold, italic, strike, heading, lists, blockquote, code, etc.)
246
+ * - Link, Image
247
+ * - Table (with rows, cells, headers)
248
+ * - TaskList (checkboxes)
249
+ * - Mention (@mentions)
250
+ * - CodeBlockLowlight (syntax highlighting)
251
+ * - Typography (smart quotes, markdown shortcuts)
252
+ * - Highlight, Underline, Subscript, Superscript
253
+ * - YouTube (video embeds)
254
+ * - CharacterCount
255
+ */
256
+ export default class extends Controller {
257
+ static targets = ["input", "content", "toolbar", "markdownEditor", "modeToggle", "hintsPanel"]
258
+
259
+ static values = {
260
+ extensions: { type: Array, default: [] },
261
+ extensionConfig: { type: Object, default: {} },
262
+ toolbar: { type: String, default: "floating" },
263
+ placeholder: { type: String, default: "Start writing..." },
264
+ autosave: { type: Boolean, default: false },
265
+ autosaveInterval: { type: Number, default: 5000 },
266
+ // Markdown mode values
267
+ markdownEnabled: { type: Boolean, default: false },
268
+ markdownMode: { type: String, default: "wysiwyg" },
269
+ markdownShowToggle: { type: Boolean, default: true },
270
+ markdownToolbarButton: { type: Boolean, default: false },
271
+ markdownSyncDelay: { type: Number, default: 300 },
272
+ markdownShortcuts: { type: Boolean, default: true }
273
+ }
274
+
275
+ connect() {
276
+ this.initializeEditor()
277
+ .then(() => {
278
+ this.setupFormSubmitSync()
279
+ // Initialize markdown mode AFTER editor is ready
280
+ if (this.markdownEnabledValue) {
281
+ this.initializeMarkdownMode()
282
+ }
283
+ })
284
+ .catch(error => {
285
+ console.error("Inkpen: Failed to initialize editor:", error)
286
+ this.dispatchEvent("error", { error })
287
+ // Show fallback UI
288
+ this.showFallbackEditor()
289
+ })
290
+ }
291
+
292
+ disconnect() {
293
+ this.destroyEditor()
294
+ }
295
+
296
+ /**
297
+ * Show a basic textarea fallback if the rich editor fails to load.
298
+ * This ensures users can still edit content even if TipTap fails.
299
+ */
300
+ showFallbackEditor() {
301
+ if (!this.hasContentTarget || !this.hasInputTarget) return
302
+
303
+ // Create a textarea with the same content
304
+ const textarea = document.createElement("textarea")
305
+ textarea.className = "inkpen-editor__fallback"
306
+ textarea.value = this.inputTarget.value || ""
307
+ textarea.placeholder = this.placeholderValue
308
+ textarea.style.cssText = `
309
+ width: 100%;
310
+ min-height: 200px;
311
+ padding: 1rem;
312
+ border: 1px solid #e5e7eb;
313
+ border-radius: 0.375rem;
314
+ font-family: inherit;
315
+ font-size: 1rem;
316
+ line-height: 1.6;
317
+ resize: vertical;
318
+ `
319
+
320
+ // Sync textarea changes to hidden input
321
+ textarea.addEventListener("input", () => {
322
+ this.inputTarget.value = textarea.value
323
+ })
324
+
325
+ // Replace the content target with the textarea
326
+ this.contentTarget.innerHTML = ""
327
+ this.contentTarget.appendChild(textarea)
328
+
329
+ // Focus the textarea
330
+ textarea.focus()
331
+ }
332
+
333
+ async initializeEditor() {
334
+ // Lazy-load all TipTap modules
335
+ this.modules = await loadEditorModules()
336
+ const { Editor } = this.modules
337
+
338
+ const extensions = await this.buildExtensions()
339
+
340
+ this.editor = new Editor({
341
+ element: this.contentTarget,
342
+ extensions,
343
+ content: this.inputTarget.value || "",
344
+ editorProps: {
345
+ attributes: {
346
+ class: "inkpen-editor__content"
347
+ }
348
+ },
349
+ onUpdate: ({ editor }) => {
350
+ this.syncContent(editor)
351
+ this.dispatchChangeEvent()
352
+ },
353
+ onSelectionUpdate: ({ editor }) => {
354
+ this.handleSelectionChange(editor)
355
+ },
356
+ onFocus: () => {
357
+ this.element.classList.add("is-focused")
358
+ this.dispatchEvent("focus")
359
+ },
360
+ onBlur: () => {
361
+ this.element.classList.remove("is-focused")
362
+ this.dispatchEvent("blur")
363
+ }
364
+ })
365
+
366
+ // Set up autosave if enabled
367
+ if (this.autosaveValue) {
368
+ this.setupAutosave()
369
+ }
370
+
371
+ // Dispatch ready event
372
+ this.dispatchEvent("ready", { editor: this.editor })
373
+ }
374
+
375
+ destroyEditor() {
376
+ if (this.autosaveTimer) {
377
+ clearInterval(this.autosaveTimer)
378
+ }
379
+
380
+ this.clearMarkdownModeSync()
381
+ this.clearSplitSync()
382
+ this.teardownFormSubmitSync()
383
+
384
+ if (this.editor) {
385
+ this.editor.destroy()
386
+ this.editor = null
387
+ }
388
+ }
389
+
390
+ async buildExtensions() {
391
+ const config = this.extensionConfigValue
392
+ const enabledExtensions = this.extensionsValue
393
+
394
+ // Destructure all modules from lazy-loaded cache
395
+ const {
396
+ StarterKit, Document, Placeholder, BubbleMenu, Link, Image,
397
+ Table, TableRow, TableCell, TableHeader,
398
+ TaskList, TaskItem, Mention,
399
+ CodeBlockLowlight, common, createLowlight,
400
+ Typography, Highlight, Underline, Subscript, Superscript,
401
+ Youtube, CharacterCount,
402
+ Section, Preformatted, SlashCommands, BlockGutter, DragHandle,
403
+ ToggleBlock, ToggleSummary, Columns, Column, Callout, BlockCommands,
404
+ EnhancedImage, FileAttachment, Embed,
405
+ AdvancedTable, AdvancedTableRow, AdvancedTableCell, AdvancedTableHeader,
406
+ TableOfContents, Database, DocumentSection, ContentEmbed
407
+ } = this.modules
408
+
409
+ // Base StarterKit - disable codeBlock if we're using CodeBlockLowlight
410
+ const starterKitConfig = {
411
+ heading: { levels: [1, 2, 3, 4] }
412
+ }
413
+
414
+ if (enabledExtensions.includes("code_block_syntax")) {
415
+ starterKitConfig.codeBlock = false
416
+ }
417
+
418
+ // If forced_document is enabled, we need to use a custom Document
419
+ // and disable the default document in StarterKit
420
+ if (enabledExtensions.includes("forced_document")) {
421
+ starterKitConfig.document = false
422
+ }
423
+
424
+ const extensions = [
425
+ StarterKit.configure(starterKitConfig)
426
+ ]
427
+
428
+ // BubbleMenu (floating toolbar on text selection) - TipTap's native extension
429
+ // Only use BubbleMenu for "floating" mode, not for "fixed" mode
430
+ if (this.hasToolbarTarget && this.toolbarValue === "floating") {
431
+ const toolbarEl = this.toolbarTarget
432
+ extensions.push(
433
+ BubbleMenu.configure({
434
+ element: toolbarEl,
435
+ tippyOptions: {
436
+ duration: 100,
437
+ placement: "top",
438
+ zIndex: 9999,
439
+ },
440
+ shouldShow: ({ editor, state }) => {
441
+ // Only show when there's a text selection (not just cursor)
442
+ return !state.selection.empty && editor.isEditable
443
+ }
444
+ })
445
+ )
446
+ }
447
+
448
+ // Fixed toolbar - always visible, no BubbleMenu needed
449
+ // The toolbar is rendered in the DOM and controlled by toolbar_controller.js
450
+
451
+ // Forced Document Structure (title + optional subtitle)
452
+ if (enabledExtensions.includes("forced_document")) {
453
+ const docConfig = config.forced_document || {}
454
+ const contentExpression = docConfig.contentExpression || "heading block*"
455
+
456
+ // Create custom Document with forced structure
457
+ const CustomDocument = Document.extend({
458
+ content: contentExpression
459
+ })
460
+
461
+ extensions.unshift(CustomDocument)
462
+
463
+ // Store config for placeholder use
464
+ this.forcedDocConfig = docConfig
465
+ }
466
+
467
+ // Placeholder extension with support for forced document structure
468
+ extensions.push(
469
+ Placeholder.configure({
470
+ includeChildren: true,
471
+ placeholder: ({ node, pos, editor }) => {
472
+ // If forced_document is enabled, show specific placeholders
473
+ if (this.forcedDocConfig && node.type.name === "heading") {
474
+ const docConfig = this.forcedDocConfig
475
+ const titleLevel = docConfig.titleLevel || 1
476
+ const subtitleLevel = docConfig.subtitleLevel || 2
477
+
478
+ // First heading (title) - position 0 and matching title level
479
+ if (pos === 0 && node.attrs.level === titleLevel) {
480
+ return docConfig.titlePlaceholder || "Untitled"
481
+ }
482
+
483
+ // Second heading (subtitle) - after title, matching subtitle level
484
+ if (docConfig.subtitle && node.attrs.level === subtitleLevel) {
485
+ // Check if this is the second node in the document
486
+ const doc = editor.state.doc
487
+ let isSecondHeading = false
488
+ let nodeIndex = 0
489
+ doc.forEach((n, offset) => {
490
+ if (offset === pos) {
491
+ isSecondHeading = nodeIndex === 1
492
+ }
493
+ nodeIndex++
494
+ })
495
+ if (isSecondHeading) {
496
+ return docConfig.subtitlePlaceholder || "Add a subtitle..."
497
+ }
498
+ }
499
+ }
500
+
501
+ // Default placeholder for paragraphs
502
+ if (node.type.name === "paragraph") {
503
+ return this.placeholderValue || "Start writing..."
504
+ }
505
+
506
+ return ""
507
+ }
508
+ })
509
+ )
510
+
511
+ // Link extension
512
+ if (enabledExtensions.includes("link")) {
513
+ extensions.push(
514
+ Link.configure({
515
+ openOnClick: false,
516
+ HTMLAttributes: {
517
+ rel: "noopener noreferrer",
518
+ target: "_blank"
519
+ }
520
+ })
521
+ )
522
+ }
523
+
524
+ // Image extension
525
+ if (enabledExtensions.includes("image")) {
526
+ extensions.push(
527
+ Image.configure({
528
+ inline: false,
529
+ allowBase64: true
530
+ })
531
+ )
532
+ }
533
+
534
+ // Table extension (use InkpenTable for enhanced Notion-style tables)
535
+ // Loaded lazily to prevent import failures from breaking the editor
536
+ if (enabledExtensions.includes("inkpen_table")) {
537
+ const inkpenTableMod = await loadInkpenTable()
538
+ if (inkpenTableMod) {
539
+ const { InkpenTable, InkpenTableRow, InkpenTableCell, InkpenTableHeader } = inkpenTableMod
540
+ const tableConfig = config.inkpen_table || config.table || {}
541
+ extensions.push(
542
+ InkpenTable.configure({
543
+ resizable: tableConfig.resizable !== false,
544
+ showHandles: tableConfig.showHandles !== false,
545
+ showAddButtons: tableConfig.showAddButtons !== false,
546
+ showCaption: tableConfig.showCaption !== false,
547
+ stickyHeader: tableConfig.stickyHeader || false,
548
+ defaultVariant: tableConfig.defaultVariant || "default",
549
+ HTMLAttributes: {
550
+ class: "inkpen-table"
551
+ }
552
+ }),
553
+ InkpenTableRow,
554
+ InkpenTableHeader,
555
+ InkpenTableCell
556
+ )
557
+ }
558
+ } else if (enabledExtensions.includes("advanced_table")) {
559
+ // Legacy: Use AdvancedTable (deprecated, use inkpen_table instead)
560
+ const tableConfig = config.advanced_table || config.table || {}
561
+ extensions.push(
562
+ AdvancedTable.configure({
563
+ resizable: tableConfig.resizable !== false,
564
+ showControls: tableConfig.showControls !== false,
565
+ defaultVariant: tableConfig.defaultVariant || "default",
566
+ HTMLAttributes: {
567
+ class: "inkpen-table"
568
+ }
569
+ }),
570
+ AdvancedTableRow,
571
+ AdvancedTableHeader,
572
+ AdvancedTableCell
573
+ )
574
+ } else if (enabledExtensions.includes("table")) {
575
+ const tableConfig = config.table || {}
576
+ extensions.push(
577
+ Table.configure({
578
+ resizable: tableConfig.resizable !== false,
579
+ HTMLAttributes: {
580
+ class: "inkpen-table"
581
+ }
582
+ }),
583
+ TableRow,
584
+ TableHeader,
585
+ TableCell
586
+ )
587
+ }
588
+
589
+ // TaskList extension (checkboxes)
590
+ if (enabledExtensions.includes("task_list")) {
591
+ const taskConfig = config.task_list || {}
592
+ extensions.push(
593
+ TaskList.configure({
594
+ HTMLAttributes: {
595
+ class: taskConfig.listClass || "inkpen-task-list"
596
+ }
597
+ }),
598
+ TaskItem.configure({
599
+ nested: taskConfig.nested !== false,
600
+ HTMLAttributes: {
601
+ class: taskConfig.itemClass || "inkpen-task-item"
602
+ }
603
+ })
604
+ )
605
+ }
606
+
607
+ // Mention extension
608
+ if (enabledExtensions.includes("mention")) {
609
+ const mentionConfig = config.mention || {}
610
+ extensions.push(
611
+ Mention.configure({
612
+ HTMLAttributes: {
613
+ class: mentionConfig.HTMLAttributes?.class || "inkpen-mention"
614
+ },
615
+ renderLabel({ node }) {
616
+ // Use label as-is if it starts with @, otherwise prepend @
617
+ const label = node.attrs.label || node.attrs.id || ""
618
+ return label.startsWith("@") ? label : `@${label}`
619
+ },
620
+ suggestion: this.buildMentionSuggestion(mentionConfig)
621
+ })
622
+ )
623
+ }
624
+
625
+ // CodeBlockLowlight (syntax highlighting)
626
+ if (enabledExtensions.includes("code_block_syntax")) {
627
+ const codeConfig = config.code_block_syntax || {}
628
+ const lowlight = createLowlight(common)
629
+
630
+ extensions.push(
631
+ CodeBlockLowlight.configure({
632
+ lowlight,
633
+ defaultLanguage: codeConfig.defaultLanguage || null,
634
+ HTMLAttributes: {
635
+ class: "inkpen-code-block"
636
+ }
637
+ })
638
+ )
639
+ }
640
+
641
+ // Typography (smart quotes, markdown shortcuts like ## for headings)
642
+ if (enabledExtensions.includes("typography")) {
643
+ extensions.push(Typography)
644
+ }
645
+
646
+ // Highlight mark (text highlighting)
647
+ if (enabledExtensions.includes("highlight")) {
648
+ extensions.push(
649
+ Highlight.configure({
650
+ multicolor: true,
651
+ HTMLAttributes: {
652
+ class: "inkpen-highlight"
653
+ }
654
+ })
655
+ )
656
+ }
657
+
658
+ // Underline mark
659
+ if (enabledExtensions.includes("underline")) {
660
+ extensions.push(Underline)
661
+ }
662
+
663
+ // Subscript/Superscript marks
664
+ if (enabledExtensions.includes("subscript")) {
665
+ extensions.push(Subscript)
666
+ }
667
+ if (enabledExtensions.includes("superscript")) {
668
+ extensions.push(Superscript)
669
+ }
670
+
671
+ // YouTube video embeds
672
+ if (enabledExtensions.includes("youtube")) {
673
+ const youtubeConfig = config.youtube || {}
674
+ extensions.push(
675
+ Youtube.configure({
676
+ inline: false,
677
+ width: youtubeConfig.width || 640,
678
+ height: youtubeConfig.height || 360,
679
+ HTMLAttributes: {
680
+ class: "inkpen-youtube"
681
+ }
682
+ })
683
+ )
684
+ }
685
+
686
+ // Character count
687
+ if (enabledExtensions.includes("character_count")) {
688
+ const charConfig = config.character_count || {}
689
+ extensions.push(
690
+ CharacterCount.configure({
691
+ limit: charConfig.limit || null
692
+ })
693
+ )
694
+ }
695
+
696
+ // Emoji replacer (auto-converts :emoji: shortcodes to emojis)
697
+ // Loaded lazily to prevent CDN failures from breaking editor
698
+ if (enabledExtensions.includes("emoji")) {
699
+ const EmojiExt = await loadEmojiReplacer()
700
+ if (EmojiExt) {
701
+ const emojiConfig = config.emoji || {}
702
+ extensions.push(
703
+ EmojiExt.configure({
704
+ ruleConfigs: emojiConfig.customEmojis || [],
705
+ shouldUseExtraLookupSpace: emojiConfig.requireSpace !== false,
706
+ shouldUseExtraReplacementSpace: emojiConfig.addSpaceAfter !== false
707
+ })
708
+ )
709
+ }
710
+ }
711
+
712
+ // Search and Replace extension (find & replace text)
713
+ // Loaded lazily to prevent CDN failures from breaking editor
714
+ if (enabledExtensions.includes("search_replace")) {
715
+ const SearchExt = await loadSearchNReplace()
716
+ if (SearchExt) {
717
+ const searchConfig = config.search_replace || {}
718
+ extensions.push(
719
+ SearchExt.configure({
720
+ searchResultClass: searchConfig.resultClass || "inkpen-search-result",
721
+ caseSensitive: searchConfig.caseSensitive || false,
722
+ disableRegex: searchConfig.disableRegex || false
723
+ })
724
+ )
725
+ }
726
+ }
727
+
728
+ // Footnotes extension (academic footnotes)
729
+ // Loaded lazily to prevent CDN failures from breaking editor
730
+ if (enabledExtensions.includes("footnotes")) {
731
+ const FootnotesExt = await loadFootnotes()
732
+ if (FootnotesExt) {
733
+ extensions.push(FootnotesExt.Footnotes, FootnotesExt.Footnote, FootnotesExt.FootnoteReference)
734
+ }
735
+ }
736
+
737
+ // Section extension (page-builder style width/spacing control)
738
+ if (enabledExtensions.includes("section")) {
739
+ const sectionConfig = config.section || {}
740
+ extensions.push(
741
+ Section.configure({
742
+ defaultWidth: sectionConfig.defaultWidth || "default",
743
+ defaultSpacing: sectionConfig.defaultSpacing || "normal",
744
+ showControls: sectionConfig.showControls !== false,
745
+ widthPresets: sectionConfig.widthPresets || undefined,
746
+ spacingPresets: sectionConfig.spacingPresets || undefined
747
+ })
748
+ )
749
+ }
750
+
751
+ // Document Section extension (collapsible content-grouping with semantic title)
752
+ if (enabledExtensions.includes("document_section")) {
753
+ const docSectionConfig = config.document_section || {}
754
+ extensions.push(
755
+ DocumentSection.configure({
756
+ maxDepth: docSectionConfig.maxDepth || 3,
757
+ showControls: docSectionConfig.showControls !== false,
758
+ defaultCollapsed: docSectionConfig.defaultCollapsed || false,
759
+ allowNesting: docSectionConfig.allowNesting !== false
760
+ })
761
+ )
762
+ }
763
+
764
+ // Preformatted extension (ASCII art, tables, diagrams)
765
+ if (enabledExtensions.includes("preformatted")) {
766
+ const preConfig = config.preformatted || {}
767
+ extensions.push(
768
+ Preformatted.configure({
769
+ showLineNumbers: preConfig.showLineNumbers || false,
770
+ wrapLines: preConfig.wrapLines || false,
771
+ tabSize: preConfig.tabSize || 4,
772
+ showLabel: preConfig.showLabel !== false,
773
+ labelText: preConfig.labelText || "Plain Text"
774
+ })
775
+ )
776
+ }
777
+
778
+ // Slash Commands extension (Notion-style "/" menu)
779
+ if (enabledExtensions.includes("slash_commands")) {
780
+ const slashConfig = config.slash_commands || {}
781
+ extensions.push(
782
+ SlashCommands.configure({
783
+ commands: slashConfig.commands || undefined,
784
+ groups: slashConfig.groups || undefined,
785
+ maxSuggestions: slashConfig.maxSuggestions || 10,
786
+ char: slashConfig.trigger || "/",
787
+ startOfLine: slashConfig.startOfLine !== undefined ? slashConfig.startOfLine : false,
788
+ allowSpaces: slashConfig.allowSpaces || false,
789
+ suggestionClass: slashConfig.suggestionClass || "inkpen-slash-menu"
790
+ })
791
+ )
792
+ }
793
+
794
+ // Block Gutter extension (drag handles and plus buttons)
795
+ if (enabledExtensions.includes("block_gutter")) {
796
+ const gutterConfig = config.block_gutter || {}
797
+ extensions.push(
798
+ BlockGutter.configure({
799
+ showDragHandle: gutterConfig.showDragHandle !== false,
800
+ showPlusButton: gutterConfig.showPlusButton !== false,
801
+ skipTypes: gutterConfig.skipTypes || undefined,
802
+ skipParentTypes: gutterConfig.skipParentTypes || undefined
803
+ })
804
+ )
805
+ }
806
+
807
+ // Drag Handle extension (block reordering via drag & drop)
808
+ if (enabledExtensions.includes("drag_handle")) {
809
+ const dragConfig = config.drag_handle || {}
810
+ extensions.push(
811
+ DragHandle.configure({
812
+ scrollSpeed: dragConfig.scrollSpeed || 10,
813
+ scrollThreshold: dragConfig.scrollThreshold || 80
814
+ })
815
+ )
816
+ }
817
+
818
+ // Toggle Block extension (collapsible content)
819
+ if (enabledExtensions.includes("toggle_block")) {
820
+ const toggleConfig = config.toggle_block || {}
821
+ extensions.push(
822
+ ToggleSummary,
823
+ ToggleBlock.configure({
824
+ defaultOpen: toggleConfig.defaultOpen !== false
825
+ })
826
+ )
827
+ }
828
+
829
+ // Columns extension (multi-column layouts)
830
+ if (enabledExtensions.includes("columns")) {
831
+ const columnsConfig = config.columns || {}
832
+ extensions.push(
833
+ Column,
834
+ Columns.configure({
835
+ defaultCount: columnsConfig.defaultCount || 2,
836
+ defaultLayout: columnsConfig.defaultLayout || "equal-2",
837
+ showControls: columnsConfig.showControls !== false
838
+ })
839
+ )
840
+ }
841
+
842
+ // Callout extension (highlighted blocks)
843
+ if (enabledExtensions.includes("callout")) {
844
+ const calloutConfig = config.callout || {}
845
+ extensions.push(
846
+ Callout.configure({
847
+ defaultType: calloutConfig.defaultType || "info",
848
+ showControls: calloutConfig.showControls !== false
849
+ })
850
+ )
851
+ }
852
+
853
+ // Block Commands extension (duplicate, delete, select blocks)
854
+ if (enabledExtensions.includes("block_commands")) {
855
+ const blockConfig = config.block_commands || {}
856
+ extensions.push(
857
+ BlockCommands.configure({
858
+ selectedClass: blockConfig.selectedClass || "is-selected",
859
+ enableGutterSelection: blockConfig.enableGutterSelection !== false
860
+ })
861
+ )
862
+ }
863
+
864
+ // Enhanced Image extension (resizable, alignable images with captions)
865
+ if (enabledExtensions.includes("enhanced_image")) {
866
+ const imageConfig = config.enhanced_image || {}
867
+ extensions.push(
868
+ EnhancedImage.configure({
869
+ resizable: imageConfig.resizable !== false,
870
+ lightbox: imageConfig.lightbox !== false,
871
+ defaultAlignment: imageConfig.defaultAlignment || "center",
872
+ minWidth: imageConfig.minWidth || 100,
873
+ maxWidth: imageConfig.maxWidth || null,
874
+ lockAspectRatio: imageConfig.lockAspectRatio !== false,
875
+ showAlignmentControls: imageConfig.showAlignmentControls !== false,
876
+ allowCaption: imageConfig.allowCaption !== false
877
+ })
878
+ )
879
+ }
880
+
881
+ // File Attachment extension (file uploads with icons and download)
882
+ if (enabledExtensions.includes("file_attachment")) {
883
+ const fileConfig = config.file_attachment || {}
884
+ extensions.push(
885
+ FileAttachment.configure({
886
+ uploadUrl: fileConfig.uploadUrl || null,
887
+ uploadHeaders: fileConfig.uploadHeaders || {},
888
+ uploadFieldName: fileConfig.uploadFieldName || "file",
889
+ allowedTypes: fileConfig.allowedTypes || null,
890
+ maxSize: fileConfig.maxSize || 10 * 1024 * 1024,
891
+ uploadHandler: fileConfig.uploadHandler || null
892
+ })
893
+ )
894
+ }
895
+
896
+ // Embed extension (social media and website embeds)
897
+ if (enabledExtensions.includes("embed")) {
898
+ const embedConfig = config.embed || {}
899
+ extensions.push(
900
+ Embed.configure({
901
+ allowedProviders: embedConfig.allowedProviders || null,
902
+ privacyMode: embedConfig.privacyMode !== false,
903
+ enableLinkCards: embedConfig.enableLinkCards !== false,
904
+ linkCardFetcher: embedConfig.linkCardFetcher || null
905
+ })
906
+ )
907
+ }
908
+
909
+ // Table of Contents extension (auto-generated navigation)
910
+ if (enabledExtensions.includes("table_of_contents")) {
911
+ const tocConfig = config.table_of_contents || {}
912
+ extensions.push(
913
+ TableOfContents.configure({
914
+ defaultMaxDepth: tocConfig.maxDepth || 3,
915
+ defaultStyle: tocConfig.style || "numbered",
916
+ defaultTitle: tocConfig.title || "Table of Contents",
917
+ scrollOffset: tocConfig.scrollOffset || 100
918
+ })
919
+ )
920
+ }
921
+
922
+ // Database extension (Notion-style inline databases)
923
+ if (enabledExtensions.includes("database")) {
924
+ const dbConfig = config.database || {}
925
+ extensions.push(
926
+ Database.configure({
927
+ defaultTitle: dbConfig.title || "Untitled Database",
928
+ defaultView: dbConfig.view || "table"
929
+ })
930
+ )
931
+ }
932
+
933
+ // Export Commands extension (keyboard shortcuts for export)
934
+ // Loaded lazily to avoid 404s for apps that don't use export
935
+ if (enabledExtensions.includes("export_commands")) {
936
+ const ExportCommandsExt = await loadExportCommands()
937
+ if (ExportCommandsExt) {
938
+ const exportConfig = config.export_commands || {}
939
+ extensions.push(
940
+ ExportCommandsExt.configure({
941
+ defaultFilename: exportConfig.defaultFilename || "document",
942
+ markdownOptions: exportConfig.markdownOptions || {},
943
+ htmlOptions: exportConfig.htmlOptions || {},
944
+ pdfOptions: exportConfig.pdfOptions || {},
945
+ onExportSuccess: exportConfig.onExportSuccess || null,
946
+ onExportError: exportConfig.onExportError || null
947
+ })
948
+ )
949
+ // Pre-load export modules for methods
950
+ await loadExportModules()
951
+ }
952
+ }
953
+
954
+ // Content Embed extension (rich embed cards for app content)
955
+ if (enabledExtensions.includes("content_embed")) {
956
+ extensions.push(ContentEmbed)
957
+ }
958
+
959
+ return extensions
960
+ }
961
+
962
+ /**
963
+ * Build mention suggestion configuration
964
+ * Handles both static items and async search
965
+ */
966
+ buildMentionSuggestion(config) {
967
+ const items = config.items || []
968
+ const searchUrl = config.searchUrl
969
+ const minChars = config.minChars || 1
970
+ let debounceTimer = null
971
+ let abortController = null
972
+ const cache = new Map() // query → results cache
973
+
974
+ return {
975
+ char: config.trigger || "@",
976
+ items: ({ query }) => {
977
+ if (query.length < minChars) return []
978
+
979
+ // If search URL provided, fetch with cache + short debounce
980
+ if (searchUrl) {
981
+ // Return cached results instantly if available
982
+ const cached = cache.get(query)
983
+ if (cached) return cached
984
+
985
+ return new Promise((resolve) => {
986
+ clearTimeout(debounceTimer)
987
+ if (abortController) abortController.abort()
988
+
989
+ debounceTimer = setTimeout(async () => {
990
+ abortController = new AbortController()
991
+ try {
992
+ const response = await fetch(
993
+ `${searchUrl}?query=${encodeURIComponent(query)}`,
994
+ { signal: abortController.signal }
995
+ )
996
+ if (response.ok) {
997
+ const results = await response.json()
998
+ cache.set(query, results)
999
+ // Keep cache small (last 20 queries)
1000
+ if (cache.size > 20) cache.delete(cache.keys().next().value)
1001
+ resolve(results)
1002
+ } else {
1003
+ resolve([])
1004
+ }
1005
+ } catch (error) {
1006
+ if (error.name !== "AbortError") {
1007
+ console.error("Mention search failed:", error)
1008
+ }
1009
+ resolve([])
1010
+ }
1011
+ }, 120)
1012
+ })
1013
+ }
1014
+
1015
+ // Otherwise filter static items
1016
+ return items
1017
+ .filter(item =>
1018
+ item.label.toLowerCase().includes(query.toLowerCase())
1019
+ )
1020
+ .slice(0, 5)
1021
+ },
1022
+ render: () => {
1023
+ let component
1024
+ let popup
1025
+
1026
+ return {
1027
+ onStart: props => {
1028
+ component = this.createMentionPopup(props, config)
1029
+ popup = component.element
1030
+ document.body.appendChild(popup)
1031
+ this.updateMentionPopupPosition(popup, props.clientRect)
1032
+ },
1033
+ onUpdate: props => {
1034
+ this.updateMentionItems(component, props.items, props.command)
1035
+ this.updateMentionPopupPosition(popup, props.clientRect)
1036
+ },
1037
+ onKeyDown: props => {
1038
+ if (props.event.key === "Escape") {
1039
+ popup?.remove()
1040
+ return true
1041
+ }
1042
+ return component?.onKeyDown?.(props.event) || false
1043
+ },
1044
+ onExit: () => {
1045
+ popup?.remove()
1046
+ }
1047
+ }
1048
+ }
1049
+ }
1050
+ }
1051
+
1052
+ createMentionPopup(props, config) {
1053
+ const popup = document.createElement("div")
1054
+ popup.className = config.suggestionClass || "inkpen-mention-suggestions"
1055
+ popup.setAttribute("role", "listbox")
1056
+
1057
+ const component = {
1058
+ element: popup,
1059
+ selectedIndex: 0,
1060
+ items: props.items,
1061
+ command: props.command,
1062
+ onKeyDown: (event) => {
1063
+ if (event.key === "ArrowUp") {
1064
+ component.selectedIndex = Math.max(0, component.selectedIndex - 1)
1065
+ this.updateMentionSelection(popup, component.selectedIndex)
1066
+ return true
1067
+ }
1068
+ if (event.key === "ArrowDown") {
1069
+ component.selectedIndex = Math.min(component.items.length - 1, component.selectedIndex + 1)
1070
+ this.updateMentionSelection(popup, component.selectedIndex)
1071
+ return true
1072
+ }
1073
+ if (event.key === "Enter") {
1074
+ const item = component.items[component.selectedIndex]
1075
+ if (item) {
1076
+ component.command(item)
1077
+ }
1078
+ return true
1079
+ }
1080
+ return false
1081
+ }
1082
+ }
1083
+
1084
+ this.updateMentionItems(component, props.items, props.command)
1085
+ return component
1086
+ }
1087
+
1088
+ updateMentionItems(component, items, command) {
1089
+ component.items = items
1090
+ component.command = command
1091
+ component.selectedIndex = 0
1092
+
1093
+ const popup = component.element
1094
+ popup.innerHTML = ""
1095
+
1096
+ if (items.length === 0) {
1097
+ popup.innerHTML = '<div class="inkpen-mention-empty">No results</div>'
1098
+ return
1099
+ }
1100
+
1101
+ items.forEach((item, index) => {
1102
+ const button = document.createElement("button")
1103
+ button.className = `inkpen-mention-item ${index === 0 ? "is-selected" : ""}`
1104
+ button.setAttribute("role", "option")
1105
+
1106
+ // Rich rendering: avatar + handle + name (backwards compatible)
1107
+ if (item.avatar_url || item.handle || item.name) {
1108
+ // Avatar or placeholder
1109
+ if (item.avatar_url) {
1110
+ const avatar = document.createElement("img")
1111
+ avatar.className = "inkpen-mention-item__avatar"
1112
+ avatar.src = item.avatar_url
1113
+ avatar.alt = item.label || item.handle || ""
1114
+ avatar.loading = "lazy"
1115
+ button.appendChild(avatar)
1116
+ } else {
1117
+ const placeholder = document.createElement("span")
1118
+ placeholder.className = "inkpen-mention-item__avatar-placeholder"
1119
+ placeholder.textContent = (item.handle || item.label || "?").charAt(0)
1120
+ button.appendChild(placeholder)
1121
+ }
1122
+
1123
+ // Info column: handle + name
1124
+ const info = document.createElement("span")
1125
+ info.className = "inkpen-mention-item__info"
1126
+
1127
+ if (item.handle) {
1128
+ const handle = document.createElement("span")
1129
+ handle.className = "inkpen-mention-item__handle"
1130
+ handle.textContent = `@${item.handle}`
1131
+ info.appendChild(handle)
1132
+ }
1133
+
1134
+ if (item.name) {
1135
+ const name = document.createElement("span")
1136
+ name.className = "inkpen-mention-item__name"
1137
+ name.textContent = item.name
1138
+ info.appendChild(name)
1139
+ }
1140
+
1141
+ // Fallback: if only handle, show label as name
1142
+ if (item.handle && !item.name && item.label && item.label !== item.handle) {
1143
+ const name = document.createElement("span")
1144
+ name.className = "inkpen-mention-item__name"
1145
+ name.textContent = item.label
1146
+ info.appendChild(name)
1147
+ }
1148
+
1149
+ button.appendChild(info)
1150
+ } else {
1151
+ // Plain text fallback for backwards compatibility
1152
+ button.textContent = item.label
1153
+ }
1154
+
1155
+ button.addEventListener("click", () => command(item))
1156
+ popup.appendChild(button)
1157
+ })
1158
+ }
1159
+
1160
+ updateMentionSelection(popup, selectedIndex) {
1161
+ const items = popup.querySelectorAll(".inkpen-mention-item")
1162
+ items.forEach((item, index) => {
1163
+ item.classList.toggle("is-selected", index === selectedIndex)
1164
+ })
1165
+ }
1166
+
1167
+ updateMentionPopupPosition(popup, clientRect) {
1168
+ if (!clientRect) return
1169
+
1170
+ const rect = clientRect()
1171
+ if (!rect) return
1172
+
1173
+ popup.style.position = "fixed"
1174
+ popup.style.left = `${rect.left}px`
1175
+ popup.style.top = `${rect.bottom + 8}px`
1176
+ popup.style.zIndex = "9999"
1177
+ }
1178
+
1179
+ syncContent(editor) {
1180
+ // If forced_document is enabled, only store body content (not title/subtitle)
1181
+ // Title and subtitle are stored in separate hidden fields
1182
+ if (this.forcedDocConfig) {
1183
+ const body = this.getBody()
1184
+ this.inputTarget.value = body
1185
+ } else {
1186
+ const html = editor.getHTML()
1187
+ this.inputTarget.value = html
1188
+ }
1189
+ }
1190
+
1191
+ handleSelectionChange(editor) {
1192
+ // Dispatch event for toolbar to handle
1193
+ this.dispatchEvent("selection-change", {
1194
+ editor,
1195
+ isEmpty: editor.state.selection.empty,
1196
+ from: editor.state.selection.from,
1197
+ to: editor.state.selection.to
1198
+ })
1199
+ }
1200
+
1201
+ toggleHints() {
1202
+ if (!this.hasHintsPanelTarget) return
1203
+ const panel = this.hintsPanelTarget
1204
+ panel.hidden = !panel.hidden
1205
+ }
1206
+
1207
+ setupAutosave() {
1208
+ this.autosaveTimer = setInterval(() => {
1209
+ this.dispatchEvent("autosave", {
1210
+ content: this.inputTarget.value
1211
+ })
1212
+ }, this.autosaveIntervalValue)
1213
+ }
1214
+
1215
+ // Public API methods
1216
+ getContent() {
1217
+ return this.editor?.getHTML() || ""
1218
+ }
1219
+
1220
+ getJSON() {
1221
+ return this.editor?.getJSON() || null
1222
+ }
1223
+
1224
+ setContent(content) {
1225
+ this.editor?.commands.setContent(content)
1226
+ }
1227
+
1228
+ focus() {
1229
+ this.editor?.commands.focus()
1230
+ }
1231
+
1232
+ blur() {
1233
+ this.editor?.commands.blur()
1234
+ }
1235
+
1236
+ // Formatting commands
1237
+ toggleBold() {
1238
+ this.editor?.chain().focus().toggleBold().run()
1239
+ }
1240
+
1241
+ toggleItalic() {
1242
+ this.editor?.chain().focus().toggleItalic().run()
1243
+ }
1244
+
1245
+ toggleStrike() {
1246
+ this.editor?.chain().focus().toggleStrike().run()
1247
+ }
1248
+
1249
+ toggleHeading(level) {
1250
+ this.editor?.chain().focus().toggleHeading({ level }).run()
1251
+ }
1252
+
1253
+ toggleBulletList() {
1254
+ this.editor?.chain().focus().toggleBulletList().run()
1255
+ }
1256
+
1257
+ toggleOrderedList() {
1258
+ this.editor?.chain().focus().toggleOrderedList().run()
1259
+ }
1260
+
1261
+ toggleBlockquote() {
1262
+ this.editor?.chain().focus().toggleBlockquote().run()
1263
+ }
1264
+
1265
+ toggleCodeBlock() {
1266
+ this.editor?.chain().focus().toggleCodeBlock().run()
1267
+ }
1268
+
1269
+ toggleCode() {
1270
+ this.editor?.chain().focus().toggleCode().run()
1271
+ }
1272
+
1273
+ setLink(url) {
1274
+ if (url) {
1275
+ this.editor?.chain().focus().setLink({ href: url }).run()
1276
+ } else {
1277
+ this.editor?.chain().focus().unsetLink().run()
1278
+ }
1279
+ }
1280
+
1281
+ insertHorizontalRule() {
1282
+ this.editor?.chain().focus().setHorizontalRule().run()
1283
+ }
1284
+
1285
+ // Table commands
1286
+ insertTable(rows = 3, cols = 3, withHeaderRow = true) {
1287
+ this.editor?.chain().focus().insertTable({ rows, cols, withHeaderRow }).run()
1288
+ }
1289
+
1290
+ addTableRowBefore() {
1291
+ this.editor?.chain().focus().addRowBefore().run()
1292
+ }
1293
+
1294
+ addTableRowAfter() {
1295
+ this.editor?.chain().focus().addRowAfter().run()
1296
+ }
1297
+
1298
+ deleteTableRow() {
1299
+ this.editor?.chain().focus().deleteRow().run()
1300
+ }
1301
+
1302
+ addTableColumnBefore() {
1303
+ this.editor?.chain().focus().addColumnBefore().run()
1304
+ }
1305
+
1306
+ addTableColumnAfter() {
1307
+ this.editor?.chain().focus().addColumnAfter().run()
1308
+ }
1309
+
1310
+ deleteTableColumn() {
1311
+ this.editor?.chain().focus().deleteColumn().run()
1312
+ }
1313
+
1314
+ deleteTable() {
1315
+ this.editor?.chain().focus().deleteTable().run()
1316
+ }
1317
+
1318
+ mergeCells() {
1319
+ this.editor?.chain().focus().mergeCells().run()
1320
+ }
1321
+
1322
+ splitCell() {
1323
+ this.editor?.chain().focus().splitCell().run()
1324
+ }
1325
+
1326
+ // TaskList commands
1327
+ toggleTaskList() {
1328
+ this.editor?.chain().focus().toggleTaskList().run()
1329
+ }
1330
+
1331
+ // Highlight commands
1332
+ toggleHighlight(color = null) {
1333
+ if (color) {
1334
+ this.editor?.chain().focus().toggleHighlight({ color }).run()
1335
+ } else {
1336
+ this.editor?.chain().focus().toggleHighlight().run()
1337
+ }
1338
+ }
1339
+
1340
+ // Underline command
1341
+ toggleUnderline() {
1342
+ this.editor?.chain().focus().toggleUnderline().run()
1343
+ }
1344
+
1345
+ // Subscript/Superscript commands
1346
+ toggleSubscript() {
1347
+ this.editor?.chain().focus().toggleSubscript().run()
1348
+ }
1349
+
1350
+ toggleSuperscript() {
1351
+ this.editor?.chain().focus().toggleSuperscript().run()
1352
+ }
1353
+
1354
+ // YouTube embed command
1355
+ insertYoutubeVideo(url) {
1356
+ if (url) {
1357
+ this.editor?.chain().focus().setYoutubeVideo({ src: url }).run()
1358
+ }
1359
+ }
1360
+
1361
+ // Section commands
1362
+ insertSection(width = "default", spacing = "normal") {
1363
+ this.editor?.chain().focus().insertSection({ width, spacing }).run()
1364
+ }
1365
+
1366
+ setSectionWidth(width) {
1367
+ this.editor?.chain().focus().setSectionWidth(width).run()
1368
+ }
1369
+
1370
+ setSectionSpacing(spacing) {
1371
+ this.editor?.chain().focus().setSectionSpacing(spacing).run()
1372
+ }
1373
+
1374
+ wrapInSection(width = "default", spacing = "normal") {
1375
+ this.editor?.chain().focus().wrapInSection({ width, spacing }).run()
1376
+ }
1377
+
1378
+ // Preformatted commands
1379
+ insertPreformatted(content = "") {
1380
+ this.editor?.chain().focus().insertPreformatted(content).run()
1381
+ }
1382
+
1383
+ togglePreformatted() {
1384
+ this.editor?.chain().focus().togglePreformatted().run()
1385
+ }
1386
+
1387
+ // Callout commands
1388
+ insertCallout(type = "info") {
1389
+ this.editor?.chain().focus().insertCallout({ type }).run()
1390
+ }
1391
+
1392
+ setCalloutType(type) {
1393
+ this.editor?.chain().focus().setCalloutType(type).run()
1394
+ }
1395
+
1396
+ // Toggle block commands
1397
+ insertToggle() {
1398
+ this.editor?.chain().focus().insertToggle().run()
1399
+ }
1400
+
1401
+ // Columns commands
1402
+ insertColumns(count = 2) {
1403
+ this.editor?.chain().focus().setColumns(count).run()
1404
+ }
1405
+
1406
+ // Table of Contents commands
1407
+ insertTableOfContents() {
1408
+ this.editor?.chain().focus().insertTableOfContents().run()
1409
+ }
1410
+
1411
+ // Character count methods
1412
+ getCharacterCount() {
1413
+ return this.editor?.storage.characterCount?.characters() || 0
1414
+ }
1415
+
1416
+ getWordCount() {
1417
+ return this.editor?.storage.characterCount?.words() || 0
1418
+ }
1419
+
1420
+ // Check if format is active
1421
+ isActive(name, attributes = {}) {
1422
+ return this.editor?.isActive(name, attributes) || false
1423
+ }
1424
+
1425
+ // Export Methods (require export_commands extension to be enabled)
1426
+
1427
+ /**
1428
+ * Export document to Markdown
1429
+ * @param {Object} options - Export options
1430
+ * @returns {Promise<string>} Markdown content
1431
+ */
1432
+ async exportMarkdown(options = {}) {
1433
+ if (!this.editor) return ""
1434
+ const modules = await loadExportModules()
1435
+ if (!modules) {
1436
+ console.warn("Export modules not available. Enable export_commands extension.")
1437
+ return ""
1438
+ }
1439
+ return modules.exportToMarkdown(this.editor.getJSON(), options)
1440
+ }
1441
+
1442
+ /**
1443
+ * Download document as Markdown file
1444
+ * @param {string} filename - Filename for download
1445
+ * @param {Object} options - Export options
1446
+ */
1447
+ async downloadAsMarkdown(filename = "document.md", options = {}) {
1448
+ const modules = await loadExportModules()
1449
+ if (!modules) return
1450
+ const markdown = await this.exportMarkdown(options)
1451
+ modules.downloadMarkdown(markdown, filename)
1452
+ }
1453
+
1454
+ /**
1455
+ * Copy document as Markdown to clipboard
1456
+ * @param {Object} options - Export options
1457
+ * @returns {Promise<boolean>} Success status
1458
+ */
1459
+ async copyAsMarkdown(options = {}) {
1460
+ const modules = await loadExportModules()
1461
+ if (!modules) return false
1462
+ const markdown = await this.exportMarkdown(options)
1463
+ return modules.copyMarkdownToClipboard(markdown)
1464
+ }
1465
+
1466
+ /**
1467
+ * Import Markdown content into editor
1468
+ * @param {string} markdown - Markdown content
1469
+ * @param {Object} options - Import options
1470
+ * @returns {Promise<Object>} { frontmatter } - Parsed frontmatter if present
1471
+ */
1472
+ async importMarkdown(markdown, options = {}) {
1473
+ if (!this.editor) return {}
1474
+ const modules = await loadExportModules()
1475
+ if (!modules) return {}
1476
+ const result = modules.importFromMarkdown(markdown, this.editor.schema, options)
1477
+ if (result.html) {
1478
+ this.editor.commands.setContent(result.html)
1479
+ }
1480
+ return { frontmatter: result.frontmatter }
1481
+ }
1482
+
1483
+ /**
1484
+ * Export document to HTML
1485
+ * @param {Object} options - Export options
1486
+ * @returns {Promise<string>} HTML content
1487
+ */
1488
+ async exportHTML(options = {}) {
1489
+ if (!this.editor) return ""
1490
+ const modules = await loadExportModules()
1491
+ if (!modules) {
1492
+ console.warn("Export modules not available. Enable export_commands extension.")
1493
+ return ""
1494
+ }
1495
+ return modules.exportToHTML(this.editor, options)
1496
+ }
1497
+
1498
+ /**
1499
+ * Download document as HTML file
1500
+ * @param {string} filename - Filename for download
1501
+ * @param {Object} options - Export options
1502
+ */
1503
+ async downloadAsHTML(filename = "document.html", options = {}) {
1504
+ const modules = await loadExportModules()
1505
+ if (!modules) return
1506
+ const html = await this.exportHTML(options)
1507
+ modules.downloadHTML(html, filename)
1508
+ }
1509
+
1510
+ /**
1511
+ * Copy document as HTML to clipboard
1512
+ * @param {Object} options - Export options
1513
+ * @returns {Promise<boolean>} Success status
1514
+ */
1515
+ async copyAsHTML(options = {}) {
1516
+ const modules = await loadExportModules()
1517
+ if (!modules) return false
1518
+ const html = await this.exportHTML({ ...options, includeWrapper: false, includeStyles: false })
1519
+ return modules.copyHTMLToClipboard(html)
1520
+ }
1521
+
1522
+ /**
1523
+ * Export document to PDF
1524
+ * @param {Object} options - Export options
1525
+ * @returns {Promise<void>}
1526
+ */
1527
+ async exportPDF(options = {}) {
1528
+ if (!this.editor) return
1529
+ const modules = await loadExportModules()
1530
+ if (!modules) return
1531
+ await modules.exportToPDF(this.editor, options)
1532
+ }
1533
+
1534
+ /**
1535
+ * Download document as PDF file
1536
+ * @param {string} filename - Filename for download
1537
+ * @param {Object} options - Export options
1538
+ * @returns {Promise<void>}
1539
+ */
1540
+ async downloadAsPDF(filename = "document.pdf", options = {}) {
1541
+ await this.exportPDF({ ...options, filename })
1542
+ }
1543
+
1544
+ /**
1545
+ * Load html2pdf.js library for better PDF support
1546
+ * @returns {Promise<boolean>} Whether library was loaded
1547
+ */
1548
+ async loadPDFLibrary() {
1549
+ const modules = await loadExportModules()
1550
+ if (!modules) return false
1551
+ return modules.loadHtml2Pdf()
1552
+ }
1553
+
1554
+ /**
1555
+ * Check if PDF export with full features is available
1556
+ * @returns {Promise<boolean>}
1557
+ */
1558
+ async isPDFExportAvailable() {
1559
+ const modules = await loadExportModules()
1560
+ if (!modules) return false
1561
+ return modules.isPDFExportAvailable()
1562
+ }
1563
+
1564
+ // Forced Document helpers
1565
+
1566
+ /**
1567
+ * Get the title text from the first heading.
1568
+ * Only works when forced_document extension is enabled.
1569
+ * @returns {string} The title text
1570
+ */
1571
+ getTitle() {
1572
+ if (!this.editor || !this.forcedDocConfig) return ""
1573
+
1574
+ const doc = this.editor.state.doc
1575
+ const firstChild = doc.firstChild
1576
+
1577
+ if (firstChild && firstChild.type.name === "heading") {
1578
+ return firstChild.textContent || ""
1579
+ }
1580
+ return ""
1581
+ }
1582
+
1583
+ /**
1584
+ * Get the subtitle text from the second heading.
1585
+ * Only works when forced_document with subtitle is enabled.
1586
+ * @returns {string} The subtitle text
1587
+ */
1588
+ getSubtitle() {
1589
+ if (!this.editor || !this.forcedDocConfig || !this.forcedDocConfig.subtitle) return ""
1590
+
1591
+ const doc = this.editor.state.doc
1592
+ const subtitleLevel = this.forcedDocConfig.subtitleLevel || 2
1593
+
1594
+ // Look for second heading with matching level
1595
+ let found = false
1596
+ let subtitle = ""
1597
+
1598
+ doc.forEach((node, offset, index) => {
1599
+ if (found) return
1600
+ if (index === 1 && node.type.name === "heading" && node.attrs.level === subtitleLevel) {
1601
+ subtitle = node.textContent || ""
1602
+ found = true
1603
+ }
1604
+ })
1605
+
1606
+ return subtitle
1607
+ }
1608
+
1609
+ /**
1610
+ * Set the title text in the first heading.
1611
+ * @param {string} title The title text to set
1612
+ */
1613
+ setTitle(title) {
1614
+ if (!this.editor) return
1615
+
1616
+ const { state, view } = this.editor
1617
+ const firstChild = state.doc.firstChild
1618
+
1619
+ if (firstChild && firstChild.type.name === "heading") {
1620
+ const tr = state.tr.replaceWith(
1621
+ 1, // Start of first heading content
1622
+ 1 + firstChild.content.size, // End of first heading content
1623
+ state.schema.text(title)
1624
+ )
1625
+ view.dispatch(tr)
1626
+ }
1627
+ }
1628
+
1629
+ /**
1630
+ * Set the subtitle text in the second heading.
1631
+ * @param {string} subtitle The subtitle text to set
1632
+ */
1633
+ setSubtitle(subtitle) {
1634
+ if (!this.editor || !this.forcedDocConfig || !this.forcedDocConfig.subtitle) return
1635
+
1636
+ const { state, view } = this.editor
1637
+ const doc = state.doc
1638
+ const subtitleLevel = this.forcedDocConfig.subtitleLevel || 2
1639
+
1640
+ // Find the second heading position
1641
+ let subtitlePos = null
1642
+ let subtitleNode = null
1643
+
1644
+ doc.forEach((node, pos, index) => {
1645
+ if (subtitlePos !== null) return
1646
+ if (index === 1 && node.type.name === "heading" && node.attrs.level === subtitleLevel) {
1647
+ subtitlePos = pos
1648
+ subtitleNode = node
1649
+ }
1650
+ })
1651
+
1652
+ if (subtitlePos !== null && subtitleNode) {
1653
+ const tr = state.tr.replaceWith(
1654
+ subtitlePos + 1, // Start of subtitle content
1655
+ subtitlePos + 1 + subtitleNode.content.size, // End of subtitle content
1656
+ state.schema.text(subtitle)
1657
+ )
1658
+ view.dispatch(tr)
1659
+ }
1660
+ }
1661
+
1662
+ /**
1663
+ * Get body content (everything after title/subtitle).
1664
+ * @returns {string} HTML content of the body
1665
+ */
1666
+ getBody() {
1667
+ if (!this.editor) return ""
1668
+
1669
+ const doc = this.editor.state.doc
1670
+ const hasSubtitle = this.forcedDocConfig?.subtitle
1671
+
1672
+ // Skip first node (title) and optionally second (subtitle)
1673
+ const startIndex = hasSubtitle ? 2 : 1
1674
+ const bodyNodes = []
1675
+
1676
+ doc.forEach((node, offset, index) => {
1677
+ if (index >= startIndex) {
1678
+ bodyNodes.push(node)
1679
+ }
1680
+ })
1681
+
1682
+ if (bodyNodes.length === 0) return ""
1683
+
1684
+ // Create a temporary document fragment and serialize
1685
+ const fragment = this.editor.state.schema.nodes.doc.create(null, bodyNodes)
1686
+ const serializer = this.modules.DOMSerializer.fromSchema(this.editor.state.schema)
1687
+ const dom = serializer.serializeFragment(fragment.content)
1688
+ const div = document.createElement("div")
1689
+ div.appendChild(dom)
1690
+
1691
+ return div.innerHTML
1692
+ }
1693
+
1694
+ // Helper to dispatch custom events
1695
+ dispatchEvent(name, detail = {}) {
1696
+ this.element.dispatchEvent(
1697
+ new CustomEvent(`inkpen:${name}`, {
1698
+ bubbles: true,
1699
+ detail: { ...detail, controller: this }
1700
+ })
1701
+ )
1702
+ }
1703
+
1704
+ dispatchChangeEvent() {
1705
+ this.dispatchEvent("change", {
1706
+ content: this.inputTarget.value,
1707
+ title: this.getTitle(),
1708
+ subtitle: this.getSubtitle(),
1709
+ body: this.getBody(),
1710
+ wordCount: this.getWordCount(),
1711
+ characterCount: this.getCharacterCount()
1712
+ })
1713
+ }
1714
+
1715
+ // ============================================
1716
+ // MARKDOWN MODE
1717
+ // ============================================
1718
+
1719
+ /**
1720
+ * Initialize markdown mode if enabled.
1721
+ * Sets up the initial mode and keyboard shortcuts.
1722
+ * Called AFTER editor is fully initialized.
1723
+ */
1724
+ initializeMarkdownMode() {
1725
+ // Set up keyboard shortcuts first
1726
+ if (this.markdownShortcutsValue) {
1727
+ this.setupMarkdownShortcuts()
1728
+ }
1729
+
1730
+ // Set initial mode if not wysiwyg
1731
+ if (this.markdownModeValue !== "wysiwyg") {
1732
+ this.setMode(this.markdownModeValue)
1733
+ }
1734
+
1735
+ // Update toggle button states
1736
+ this.updateModeToggle()
1737
+ }
1738
+
1739
+ /**
1740
+ * Set up keyboard shortcuts for markdown mode.
1741
+ */
1742
+ setupMarkdownShortcuts() {
1743
+ this.element.addEventListener("keydown", (e) => {
1744
+ // Cmd/Ctrl + Shift + M = Toggle markdown mode
1745
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "m") {
1746
+ e.preventDefault()
1747
+ this.toggleMarkdownMode()
1748
+ }
1749
+ // Cmd/Ctrl + Shift + V = Toggle split view
1750
+ if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "v") {
1751
+ e.preventDefault()
1752
+ if (this.markdownModeValue === "split") {
1753
+ this.setMode("wysiwyg")
1754
+ } else {
1755
+ this.setMode("split")
1756
+ }
1757
+ }
1758
+ // Escape in markdown mode = return to WYSIWYG
1759
+ if (e.key === "Escape" && this.markdownModeValue === "markdown") {
1760
+ e.preventDefault()
1761
+ this.setMode("wysiwyg")
1762
+ }
1763
+ })
1764
+ }
1765
+
1766
+ /**
1767
+ * Get current editing mode.
1768
+ * @returns {string} "wysiwyg" | "markdown" | "split"
1769
+ */
1770
+ getMode() {
1771
+ return this.markdownModeValue
1772
+ }
1773
+
1774
+ /**
1775
+ * Set editing mode.
1776
+ * Can be called programmatically with a string, or from a Stimulus action with an Event.
1777
+ * @param {string|Event} modeOrEvent - "wysiwyg" | "markdown" | "split" OR click event
1778
+ */
1779
+ setMode(modeOrEvent) {
1780
+ // Handle Stimulus action call (receives Event)
1781
+ let mode
1782
+ if (modeOrEvent instanceof Event) {
1783
+ mode = modeOrEvent.currentTarget.dataset.mode
1784
+ if (!mode) return
1785
+ } else {
1786
+ mode = modeOrEvent
1787
+ }
1788
+
1789
+ const validModes = ["wysiwyg", "markdown", "split"]
1790
+ if (!validModes.includes(mode)) return
1791
+
1792
+ const previousMode = this.markdownModeValue
1793
+ if (mode === previousMode) return // No change needed
1794
+
1795
+ this.markdownModeValue = mode
1796
+
1797
+ switch (mode) {
1798
+ case "wysiwyg":
1799
+ this.switchToWysiwyg(previousMode)
1800
+ break
1801
+ case "markdown":
1802
+ this.switchToMarkdown()
1803
+ break
1804
+ case "split":
1805
+ this.switchToSplit()
1806
+ break
1807
+ }
1808
+
1809
+ this.updateModeToggle()
1810
+ this.dispatchEvent("mode-change", { mode, previousMode })
1811
+ }
1812
+
1813
+ /**
1814
+ * Toggle between WYSIWYG and markdown mode.
1815
+ */
1816
+ toggleMarkdownMode() {
1817
+ if (this.markdownModeValue === "wysiwyg") {
1818
+ this.setMode("markdown")
1819
+ } else {
1820
+ this.setMode("wysiwyg")
1821
+ }
1822
+ }
1823
+
1824
+ /**
1825
+ * Switch to WYSIWYG mode.
1826
+ * @param {string} previousMode - The mode we're switching from
1827
+ */
1828
+ switchToWysiwyg(previousMode) {
1829
+ // Import markdown content if coming from markdown mode
1830
+ if (previousMode === "markdown" && this.hasMarkdownEditorTarget) {
1831
+ const markdown = this.markdownEditorTarget.value
1832
+ this.importMarkdown(markdown)
1833
+ }
1834
+
1835
+ // Hide markdown editor
1836
+ if (this.hasMarkdownEditorTarget) {
1837
+ this.markdownEditorTarget.classList.add("hidden")
1838
+ }
1839
+
1840
+ // Show WYSIWYG editor
1841
+ this.contentTarget.classList.remove("hidden")
1842
+
1843
+ // Remove split class
1844
+ this.element.classList.remove("inkpen-editor--split")
1845
+
1846
+ // Clear markdown-only sync if active
1847
+ this.clearMarkdownModeSync()
1848
+
1849
+ // Clear split sync if active
1850
+ this.clearSplitSync()
1851
+
1852
+ // Focus WYSIWYG editor
1853
+ this.editor?.commands.focus()
1854
+ }
1855
+
1856
+ /**
1857
+ * Switch to markdown mode.
1858
+ */
1859
+ async switchToMarkdown() {
1860
+ // Export current content to markdown
1861
+ const markdown = await this.exportMarkdown()
1862
+
1863
+ // Show markdown editor
1864
+ if (this.hasMarkdownEditorTarget) {
1865
+ this.markdownEditorTarget.value = markdown
1866
+ this.markdownEditorTarget.classList.remove("hidden")
1867
+ }
1868
+
1869
+ // Hide WYSIWYG editor
1870
+ this.contentTarget.classList.add("hidden")
1871
+
1872
+ // Remove split class
1873
+ this.element.classList.remove("inkpen-editor--split")
1874
+
1875
+ // Keep hidden input synced while typing in markdown-only mode
1876
+ this.setupMarkdownModeSync()
1877
+
1878
+ // Clear split sync if active
1879
+ this.clearSplitSync()
1880
+
1881
+ // Focus markdown editor
1882
+ if (this.hasMarkdownEditorTarget) {
1883
+ this.markdownEditorTarget.focus()
1884
+ }
1885
+ }
1886
+
1887
+ /**
1888
+ * Switch to split view mode.
1889
+ */
1890
+ async switchToSplit() {
1891
+ // Export current content to markdown
1892
+ const markdown = await this.exportMarkdown()
1893
+
1894
+ // Show both editors
1895
+ if (this.hasMarkdownEditorTarget) {
1896
+ this.markdownEditorTarget.value = markdown
1897
+ this.markdownEditorTarget.classList.remove("hidden")
1898
+ }
1899
+ this.contentTarget.classList.remove("hidden")
1900
+
1901
+ // Add split class for layout
1902
+ this.element.classList.add("inkpen-editor--split")
1903
+
1904
+ // Split mode has its own bidirectional sync handlers
1905
+ this.clearMarkdownModeSync()
1906
+
1907
+ // Set up sync between editors
1908
+ this.setupSplitSync()
1909
+ }
1910
+
1911
+ setupMarkdownModeSync() {
1912
+ this.clearMarkdownModeSync()
1913
+ if (!this.hasMarkdownEditorTarget) return
1914
+
1915
+ const debounce = (fn, delay) => {
1916
+ let timeoutId
1917
+ return (...args) => {
1918
+ clearTimeout(timeoutId)
1919
+ timeoutId = setTimeout(() => fn.apply(this, args), delay)
1920
+ }
1921
+ }
1922
+
1923
+ this._markdownModeInputHandler = debounce(() => {
1924
+ if (this.markdownModeValue !== "markdown") return
1925
+ this.importMarkdown(this.markdownEditorTarget.value)
1926
+ }, this.markdownSyncDelayValue)
1927
+
1928
+ this.markdownEditorTarget.addEventListener("input", this._markdownModeInputHandler)
1929
+ }
1930
+
1931
+ clearMarkdownModeSync() {
1932
+ if (this._markdownModeInputHandler && this.hasMarkdownEditorTarget) {
1933
+ this.markdownEditorTarget.removeEventListener("input", this._markdownModeInputHandler)
1934
+ this._markdownModeInputHandler = null
1935
+ }
1936
+ }
1937
+
1938
+ /**
1939
+ * Set up sync between markdown and WYSIWYG in split mode.
1940
+ */
1941
+ setupSplitSync() {
1942
+ // Clear any existing sync
1943
+ this.clearSplitSync()
1944
+
1945
+ if (!this.hasMarkdownEditorTarget) return
1946
+
1947
+ // Debounce function
1948
+ const debounce = (fn, delay) => {
1949
+ let timeoutId
1950
+ return (...args) => {
1951
+ clearTimeout(timeoutId)
1952
+ timeoutId = setTimeout(() => fn.apply(this, args), delay)
1953
+ }
1954
+ }
1955
+
1956
+ // Markdown → WYSIWYG (debounced)
1957
+ this._markdownInputHandler = debounce(() => {
1958
+ if (this.markdownModeValue !== "split") return
1959
+ this._syncingFromMarkdown = true
1960
+ this.importMarkdown(this.markdownEditorTarget.value)
1961
+ this._syncingFromMarkdown = false
1962
+ }, this.markdownSyncDelayValue)
1963
+
1964
+ this.markdownEditorTarget.addEventListener("input", this._markdownInputHandler)
1965
+
1966
+ // WYSIWYG → Markdown (on update, but not when syncing from markdown)
1967
+ this._wysiwygUpdateHandler = async () => {
1968
+ if (this.markdownModeValue !== "split") return
1969
+ if (this._syncingFromMarkdown) return
1970
+ const markdown = await this.exportMarkdown()
1971
+ this.markdownEditorTarget.value = markdown
1972
+ }
1973
+
1974
+ this.editor?.on("update", this._wysiwygUpdateHandler)
1975
+ }
1976
+
1977
+ /**
1978
+ * Clear split sync handlers.
1979
+ */
1980
+ clearSplitSync() {
1981
+ if (this._markdownInputHandler && this.hasMarkdownEditorTarget) {
1982
+ this.markdownEditorTarget.removeEventListener("input", this._markdownInputHandler)
1983
+ this._markdownInputHandler = null
1984
+ }
1985
+
1986
+ if (this._wysiwygUpdateHandler && this.editor) {
1987
+ this.editor.off("update", this._wysiwygUpdateHandler)
1988
+ this._wysiwygUpdateHandler = null
1989
+ }
1990
+ }
1991
+
1992
+ setupFormSubmitSync() {
1993
+ this.formElement = this.element.closest("form")
1994
+ if (!this.formElement) return
1995
+
1996
+ this._formSubmitHandler = () => {
1997
+ if (!this.markdownEnabledValue || !this.hasMarkdownEditorTarget) return
1998
+
1999
+ if (this.markdownModeValue === "markdown" || this.markdownModeValue === "split") {
2000
+ this.importMarkdown(this.markdownEditorTarget.value)
2001
+ }
2002
+ }
2003
+
2004
+ this.formElement.addEventListener("submit", this._formSubmitHandler)
2005
+ }
2006
+
2007
+ teardownFormSubmitSync() {
2008
+ if (!this.formElement || !this._formSubmitHandler) return
2009
+
2010
+ this.formElement.removeEventListener("submit", this._formSubmitHandler)
2011
+ this._formSubmitHandler = null
2012
+ this.formElement = null
2013
+ }
2014
+
2015
+ /**
2016
+ * Update mode toggle button states.
2017
+ */
2018
+ updateModeToggle() {
2019
+ if (!this.hasModeToggleTarget) return
2020
+
2021
+ const buttons = this.modeToggleTarget.querySelectorAll("[data-mode]")
2022
+ buttons.forEach((button) => {
2023
+ const mode = button.dataset.mode
2024
+ button.classList.toggle("active", mode === this.markdownModeValue)
2025
+ })
2026
+ }
2027
+
2028
+ /**
2029
+ * Get markdown content.
2030
+ * Always available regardless of current mode.
2031
+ * @returns {Promise<string>} Markdown content
2032
+ */
2033
+ async getMarkdown() {
2034
+ if (this.markdownModeValue === "markdown" && this.hasMarkdownEditorTarget) {
2035
+ return this.markdownEditorTarget.value
2036
+ }
2037
+ return this.exportMarkdown()
2038
+ }
2039
+
2040
+ /**
2041
+ * Set content from markdown.
2042
+ * @param {string} markdown - Markdown content
2043
+ */
2044
+ async setMarkdown(markdown) {
2045
+ if (this.hasMarkdownEditorTarget) {
2046
+ this.markdownEditorTarget.value = markdown
2047
+ }
2048
+ await this.importMarkdown(markdown)
2049
+ }
2050
+ }