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,667 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Inkpen Sticky Toolbar Controller
5
+ *
6
+ * Fixed-position toolbar for block/media/widget insertion.
7
+ * Supports horizontal (bottom) and vertical (left/right) layouts.
8
+ *
9
+ * Follows Fizzy patterns:
10
+ * - Single responsibility (toolbar positioning + commands)
11
+ * - Declarative data-* configuration
12
+ * - Event-driven communication with editor
13
+ *
14
+ * Usage:
15
+ * <div data-controller="inkpen--editor inkpen--sticky-toolbar"
16
+ * data-inkpen--sticky-toolbar-position-value="bottom"
17
+ * data-inkpen--sticky-toolbar-buttons-value='["table","code_block","image"]'>
18
+ * ...
19
+ * </div>
20
+ */
21
+ export default class extends Controller {
22
+ static targets = ["button", "widgetModal", "widgetList"]
23
+
24
+ static values = {
25
+ position: { type: String, default: "bottom" },
26
+ buttons: { type: Array, default: [] },
27
+ widgetTypes: { type: Array, default: [] },
28
+ vertical: { type: Boolean, default: false },
29
+ showExport: { type: Boolean, default: false },
30
+ exportFormats: { type: Array, default: ["markdown", "html", "pdf"] }
31
+ }
32
+
33
+ #exportMenuOpen = false
34
+ #boundCloseExportMenu = null
35
+
36
+ connect() {
37
+ this.editorController = null
38
+ this.findEditorController()
39
+
40
+ // Prevent duplicate toolbars on reconnect
41
+ if (!this.toolbarElement) {
42
+ this.createToolbarElement()
43
+ this.buildToolbar()
44
+ this.applyLayout()
45
+ }
46
+
47
+ // Bind document click to close export menu
48
+ this.#boundCloseExportMenu = this.#closeExportMenuOnClickOutside.bind(this)
49
+ document.addEventListener("click", this.#boundCloseExportMenu)
50
+ }
51
+
52
+ disconnect() {
53
+ this.closeWidgetModal()
54
+ this.#closeExportMenu()
55
+ this.removeToolbarElement()
56
+ this.removeModalElement()
57
+ document.removeEventListener("click", this.#boundCloseExportMenu)
58
+ }
59
+
60
+ // --- Toolbar Element Management ---
61
+
62
+ createToolbarElement() {
63
+ // Create the sticky toolbar container as a sibling to the editor
64
+ this.toolbarElement = document.createElement("div")
65
+ this.toolbarElement.className = "inkpen-sticky-toolbar"
66
+
67
+ // Insert after the editor element
68
+ this.element.parentNode.insertBefore(this.toolbarElement, this.element.nextSibling)
69
+ }
70
+
71
+ removeToolbarElement() {
72
+ if (this.toolbarElement && this.toolbarElement.parentNode) {
73
+ this.toolbarElement.parentNode.removeChild(this.toolbarElement)
74
+ }
75
+ }
76
+
77
+ // Create modal element and append to body (avoids transform containing block issues)
78
+ createModalElement() {
79
+ // Prevent duplicate modals
80
+ if (this.modalElement) return
81
+
82
+ const wrapper = document.createElement("div")
83
+ wrapper.innerHTML = this.renderWidgetModal()
84
+ this.modalElement = wrapper.firstElementChild
85
+ document.body.appendChild(this.modalElement)
86
+
87
+ // Bind modal event handlers
88
+ this.bindModalEvents()
89
+ }
90
+
91
+ removeModalElement() {
92
+ if (this.modalElement && this.modalElement.parentNode) {
93
+ this.modalElement.parentNode.removeChild(this.modalElement)
94
+ }
95
+ }
96
+
97
+ bindModalEvents() {
98
+ if (!this.modalElement) return
99
+
100
+ const backdrop = this.modalElement.querySelector(".inkpen-widget-modal__backdrop")
101
+ if (backdrop) {
102
+ backdrop.addEventListener("click", () => this.closeWidgetModal())
103
+ }
104
+
105
+ const closeBtn = this.modalElement.querySelector(".inkpen-widget-modal__close")
106
+ if (closeBtn) {
107
+ closeBtn.addEventListener("click", () => this.closeWidgetModal())
108
+ }
109
+
110
+ this.modalElement.querySelectorAll("[data-widget-type]").forEach(btn => {
111
+ btn.addEventListener("click", (e) => this.insertWidget(e))
112
+ })
113
+ }
114
+
115
+ // --- Layout Management ---
116
+
117
+ applyLayout() {
118
+ if (!this.toolbarElement) return
119
+
120
+ this.toolbarElement.classList.toggle("inkpen-sticky-toolbar--vertical", this.verticalValue)
121
+ this.toolbarElement.classList.toggle("inkpen-sticky-toolbar--horizontal", !this.verticalValue)
122
+ this.toolbarElement.dataset.position = this.positionValue
123
+ }
124
+
125
+ positionValueChanged() {
126
+ this.verticalValue = ["left", "right"].includes(this.positionValue)
127
+ this.applyLayout()
128
+ }
129
+
130
+ // --- Toolbar Building ---
131
+
132
+ buildToolbar() {
133
+ if (!this.toolbarElement) return
134
+
135
+ const buttons = this.buttonsValue.length > 0 ? this.buttonsValue : this.defaultButtons()
136
+
137
+ // Build toolbar buttons only (modal is appended to body separately)
138
+ this.toolbarElement.innerHTML = `
139
+ <div class="inkpen-sticky-toolbar__buttons">
140
+ ${buttons.map(btn => this.renderButton(btn)).join("")}
141
+ ${this.showExportValue ? this.#renderExportButton() : ""}
142
+ </div>
143
+ `
144
+
145
+ // Bind toolbar button handlers
146
+ this.bindToolbarEvents()
147
+
148
+ // Create modal as child of body to avoid transform containing block issues
149
+ this.createModalElement()
150
+ }
151
+
152
+ bindToolbarEvents() {
153
+ if (!this.toolbarElement) return
154
+
155
+ // Button click handlers with mousedown prevention to preserve editor selection
156
+ this.toolbarElement.querySelectorAll("[data-command]").forEach(btn => {
157
+ btn.addEventListener("mousedown", (e) => e.preventDefault())
158
+ btn.addEventListener("click", (e) => this.executeCommand(e))
159
+ })
160
+
161
+ // Export menu toggle
162
+ const exportToggle = this.toolbarElement.querySelector("[data-export-toggle]")
163
+ if (exportToggle) {
164
+ exportToggle.addEventListener("mousedown", (e) => e.preventDefault())
165
+ exportToggle.addEventListener("click", (e) => {
166
+ e.stopPropagation()
167
+ this.#toggleExportMenu()
168
+ })
169
+ }
170
+
171
+ // Export menu item handlers
172
+ this.toolbarElement.querySelectorAll("[data-export-format]").forEach(btn => {
173
+ btn.addEventListener("mousedown", (e) => e.preventDefault())
174
+ btn.addEventListener("click", (e) => this.#handleExport(e))
175
+ })
176
+ }
177
+
178
+ defaultButtons() {
179
+ return ["table", "code_block", "divider", "image", "youtube", "divider", "widget"]
180
+ }
181
+
182
+ renderButton(btn) {
183
+ if (btn === "divider") {
184
+ return '<span class="inkpen-sticky-toolbar__divider"></span>'
185
+ }
186
+
187
+ const config = this.buttonConfig(btn)
188
+ return `
189
+ <button type="button"
190
+ class="inkpen-sticky-toolbar__btn"
191
+ data-command="${btn}"
192
+ title="${config.title}">
193
+ ${config.icon}
194
+ </button>
195
+ `
196
+ }
197
+
198
+ buttonConfig(name) {
199
+ const configs = {
200
+ table: {
201
+ title: "Insert Table",
202
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/><line x1="9" y1="3" x2="9" y2="21"/><line x1="15" y1="3" x2="15" y2="21"/></svg>'
203
+ },
204
+ code_block: {
205
+ title: "Code Block",
206
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>'
207
+ },
208
+ blockquote: {
209
+ title: "Quote Block",
210
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 21c3 0 7-1 7-8V5c0-1.25-.756-2.017-2-2H4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2 1 0 1 0 1 1v1c0 1-1 2-2 2s-1 .008-1 1.031V21z"/><path d="M15 21c3 0 7-1 7-8V5c0-1.25-.757-2.017-2-2h-4c-1.25 0-2 .75-2 1.972V11c0 1.25.75 2 2 2h.75c0 2.25.25 4-2.75 4v3z"/></svg>'
211
+ },
212
+ horizontal_rule: {
213
+ title: "Divider Line",
214
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="12" x2="21" y2="12"/></svg>'
215
+ },
216
+ task_list: {
217
+ title: "Task List",
218
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="5" width="6" height="6" rx="1"/><path d="m3.5 8 2 2 3-3"/><line x1="13" y1="8" x2="21" y2="8"/><rect x="3" y="14" width="6" height="6" rx="1"/><line x1="13" y1="17" x2="21" y2="17"/></svg>'
219
+ },
220
+ image: {
221
+ title: "Insert Image",
222
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><path d="m21 15-5-5L5 21"/></svg>'
223
+ },
224
+ youtube: {
225
+ title: "YouTube Video",
226
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2.5 17a24.12 24.12 0 0 1 0-10 2 2 0 0 1 1.4-1.4 49.56 49.56 0 0 1 16.2 0A2 2 0 0 1 21.5 7a24.12 24.12 0 0 1 0 10 2 2 0 0 1-1.4 1.4 49.55 49.55 0 0 1-16.2 0A2 2 0 0 1 2.5 17"/><path d="m10 15 5-3-5-3z"/></svg>'
227
+ },
228
+ embed: {
229
+ title: "Embed Content",
230
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m18 16 4-4-4-4"/><path d="m6 8-4 4 4 4"/><path d="m14.5 4-5 16"/></svg>'
231
+ },
232
+ widget: {
233
+ title: "Insert Widget",
234
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>'
235
+ },
236
+ section: {
237
+ title: "Insert Section",
238
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="3" y1="9" x2="21" y2="9"/><line x1="3" y1="15" x2="21" y2="15"/></svg>'
239
+ },
240
+ preformatted: {
241
+ title: "Preformatted Text (ASCII)",
242
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><text x="6" y="10" font-size="5" font-family="monospace" fill="currentColor">┌──┐</text><text x="6" y="15" font-size="5" font-family="monospace" fill="currentColor">│ │</text><text x="6" y="20" font-size="5" font-family="monospace" fill="currentColor">└──┘</text></svg>'
243
+ },
244
+ export: {
245
+ title: "Export",
246
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>'
247
+ },
248
+ callout: {
249
+ title: "Callout Block",
250
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2z"/><path d="M12 16v-4"/><path d="M12 8h.01"/></svg>'
251
+ },
252
+ toggle: {
253
+ title: "Toggle Block",
254
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 18 6-6-6-6"/><line x1="4" y1="12" x2="15" y2="12"/><line x1="4" y1="6" x2="11" y2="6"/><line x1="4" y1="18" x2="11" y2="18"/></svg>'
255
+ },
256
+ columns: {
257
+ title: "Multi-Column Layout",
258
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg>'
259
+ },
260
+ table_of_contents: {
261
+ title: "Table of Contents",
262
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="4" y1="6" x2="20" y2="6"/><line x1="4" y1="12" x2="14" y2="12"/><line x1="4" y1="18" x2="18" y2="18"/><circle cx="4" cy="6" r="1" fill="currentColor"/><circle cx="4" cy="12" r="1" fill="currentColor"/><circle cx="4" cy="18" r="1" fill="currentColor"/></svg>'
263
+ },
264
+ file_attachment: {
265
+ title: "Attach File",
266
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m21.44 11.05-9.19 9.19a6 6 0 0 1-8.49-8.49l8.57-8.57a4 4 0 1 1 5.66 5.66l-8.58 8.58a2 2 0 0 1-2.83-2.83l8.49-8.48"/></svg>'
267
+ }
268
+ }
269
+
270
+ return configs[name] || { title: name, icon: name }
271
+ }
272
+
273
+ #exportFormatConfig(format) {
274
+ const configs = {
275
+ markdown: {
276
+ label: "Markdown",
277
+ description: "Export as .md file",
278
+ icon: "M↓",
279
+ shortcut: "⌘⌥M"
280
+ },
281
+ html: {
282
+ label: "HTML",
283
+ description: "Export as .html file",
284
+ icon: "<>",
285
+ shortcut: "⌘⌥H"
286
+ },
287
+ pdf: {
288
+ label: "PDF",
289
+ description: "Export as .pdf file",
290
+ icon: "📄",
291
+ shortcut: "⌘⌥P"
292
+ },
293
+ copy_markdown: {
294
+ label: "Copy as Markdown",
295
+ description: "Copy content to clipboard",
296
+ icon: "📋",
297
+ shortcut: "⌘⌥⇧M"
298
+ },
299
+ copy_html: {
300
+ label: "Copy as HTML",
301
+ description: "Copy HTML to clipboard",
302
+ icon: "📋",
303
+ shortcut: "⌘⌥⇧H"
304
+ }
305
+ }
306
+
307
+ return configs[format] || { label: format, description: "", icon: "", shortcut: "" }
308
+ }
309
+
310
+ // --- Widget Modal ---
311
+
312
+ renderWidgetModal() {
313
+ const widgets = this.widgetTypesValue.length > 0 ? this.widgetTypesValue : ["form", "gallery", "poll"]
314
+
315
+ return `
316
+ <div class="inkpen-widget-modal hidden" role="dialog" aria-modal="true">
317
+ <div class="inkpen-widget-modal__backdrop"></div>
318
+ <div class="inkpen-widget-modal__content">
319
+ <div class="inkpen-widget-modal__header">
320
+ <h3>Insert Widget</h3>
321
+ <button type="button" class="inkpen-widget-modal__close">
322
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
323
+ <line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
324
+ </svg>
325
+ </button>
326
+ </div>
327
+ <div class="inkpen-widget-modal__list">
328
+ ${widgets.map(w => this.renderWidgetOption(w)).join("")}
329
+ </div>
330
+ </div>
331
+ </div>
332
+ `
333
+ }
334
+
335
+ renderWidgetOption(type) {
336
+ const config = this.widgetConfig(type)
337
+ return `
338
+ <button type="button" class="inkpen-widget-modal__option" data-widget-type="${type}">
339
+ <span class="inkpen-widget-modal__option-icon">${config.icon}</span>
340
+ <div class="inkpen-widget-modal__option-text">
341
+ <span class="inkpen-widget-modal__option-label">${config.label}</span>
342
+ <span class="inkpen-widget-modal__option-desc">${config.description}</span>
343
+ </div>
344
+ </button>
345
+ `
346
+ }
347
+
348
+ widgetConfig(type) {
349
+ const configs = {
350
+ form: {
351
+ label: "Form",
352
+ description: "Embed a contact or signup form",
353
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><line x1="7" y1="8" x2="17" y2="8"/><line x1="7" y1="12" x2="17" y2="12"/><line x1="7" y1="16" x2="12" y2="16"/></svg>'
354
+ },
355
+ gallery: {
356
+ label: "Gallery",
357
+ description: "Display an image gallery",
358
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><rect x="7" y="7" width="3" height="3"/><rect x="14" y="7" width="3" height="3"/><rect x="7" y="14" width="3" height="3"/><rect x="14" y="14" width="3" height="3"/></svg>'
359
+ },
360
+ poll: {
361
+ label: "Poll",
362
+ description: "Create an interactive poll",
363
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><rect x="7" y="8" width="10" height="2"/><rect x="7" y="14" width="6" height="2"/></svg>'
364
+ }
365
+ }
366
+
367
+ return configs[type] || { label: type, description: "", icon: "" }
368
+ }
369
+
370
+ openWidgetModal() {
371
+ if (this.modalElement) {
372
+ this.modalElement.classList.remove("hidden")
373
+ document.body.style.overflow = "hidden"
374
+ }
375
+ }
376
+
377
+ closeWidgetModal() {
378
+ if (this.modalElement) {
379
+ this.modalElement.classList.add("hidden")
380
+ document.body.style.overflow = ""
381
+ }
382
+ }
383
+
384
+ insertWidget(event) {
385
+ const type = event.currentTarget.dataset.widgetType
386
+ this.closeWidgetModal()
387
+
388
+ // Dispatch event for host app to handle widget insertion
389
+ this.element.dispatchEvent(
390
+ new CustomEvent("inkpen:insert-widget", {
391
+ bubbles: true,
392
+ detail: { type, controller: this }
393
+ })
394
+ )
395
+ }
396
+
397
+ // --- Command Execution ---
398
+
399
+ findEditorController() {
400
+ // The sticky toolbar controller is on the same element as the editor controller
401
+ this.editorController = this.application.getControllerForElementAndIdentifier(
402
+ this.element,
403
+ "inkpen--editor"
404
+ )
405
+ }
406
+
407
+ executeCommand(event) {
408
+ const command = event.currentTarget.dataset.command
409
+ if (!this.editorController) {
410
+ this.findEditorController()
411
+ }
412
+ if (!this.editorController) return
413
+
414
+ switch (command) {
415
+ case "table":
416
+ this.editorController.insertTable(3, 3, true)
417
+ break
418
+ case "code_block":
419
+ this.editorController.toggleCodeBlock()
420
+ break
421
+ case "blockquote":
422
+ this.editorController.toggleBlockquote()
423
+ break
424
+ case "horizontal_rule":
425
+ this.editorController.insertHorizontalRule()
426
+ break
427
+ case "task_list":
428
+ this.editorController.toggleTaskList()
429
+ break
430
+ case "image":
431
+ this.promptForImage()
432
+ break
433
+ case "youtube":
434
+ this.promptForYoutubeUrl()
435
+ break
436
+ case "embed":
437
+ this.promptForEmbed()
438
+ break
439
+ case "widget":
440
+ this.openWidgetModal()
441
+ break
442
+ case "section":
443
+ this.editorController.insertSection()
444
+ break
445
+ case "preformatted":
446
+ this.editorController.insertPreformatted()
447
+ break
448
+ case "callout":
449
+ this.editorController.insertCallout()
450
+ break
451
+ case "toggle":
452
+ this.editorController.insertToggle()
453
+ break
454
+ case "columns":
455
+ this.editorController.insertColumns()
456
+ break
457
+ case "table_of_contents":
458
+ this.editorController.insertTableOfContents()
459
+ break
460
+ case "file_attachment":
461
+ this.promptForFileAttachment()
462
+ break
463
+ }
464
+ }
465
+
466
+ promptForFileAttachment() {
467
+ // Dispatch event for host app to handle file attachment
468
+ this.element.dispatchEvent(
469
+ new CustomEvent("inkpen:request-file", {
470
+ bubbles: true,
471
+ detail: { controller: this }
472
+ })
473
+ )
474
+ }
475
+
476
+ promptForImage() {
477
+ // Dispatch event for host app to handle image upload
478
+ this.element.dispatchEvent(
479
+ new CustomEvent("inkpen:request-image", {
480
+ bubbles: true,
481
+ detail: { controller: this }
482
+ })
483
+ )
484
+ }
485
+
486
+ promptForYoutubeUrl() {
487
+ const url = prompt("Enter YouTube URL:", "https://www.youtube.com/watch?v=")
488
+ if (url && url !== "https://www.youtube.com/watch?v=") {
489
+ this.editorController.insertYoutubeVideo(url)
490
+ }
491
+ }
492
+
493
+ promptForEmbed() {
494
+ // Dispatch event for host app to handle embed
495
+ this.element.dispatchEvent(
496
+ new CustomEvent("inkpen:request-embed", {
497
+ bubbles: true,
498
+ detail: { controller: this }
499
+ })
500
+ )
501
+ }
502
+
503
+ // --- Export Menu ---
504
+
505
+ #renderExportButton() {
506
+ const config = this.buttonConfig("export")
507
+ const formats = this.exportFormatsValue
508
+
509
+ return `
510
+ <span class="inkpen-sticky-toolbar__divider"></span>
511
+ <div class="inkpen-export-dropdown">
512
+ <button type="button"
513
+ class="inkpen-sticky-toolbar__btn inkpen-export-dropdown__toggle"
514
+ data-export-toggle
515
+ title="${config.title}">
516
+ ${config.icon}
517
+ <span class="inkpen-export-dropdown__caret">▾</span>
518
+ </button>
519
+ <div class="inkpen-export-dropdown__menu">
520
+ <div class="inkpen-export-dropdown__header">Download</div>
521
+ ${formats.map(format => this.#renderExportMenuItem(format)).join("")}
522
+ <div class="inkpen-export-dropdown__divider"></div>
523
+ <div class="inkpen-export-dropdown__header">Copy</div>
524
+ ${this.#renderExportMenuItem("copy_markdown")}
525
+ ${this.#renderExportMenuItem("copy_html")}
526
+ </div>
527
+ </div>
528
+ `
529
+ }
530
+
531
+ #renderExportMenuItem(format) {
532
+ const config = this.#exportFormatConfig(format)
533
+ return `
534
+ <button type="button"
535
+ class="inkpen-export-dropdown__item"
536
+ data-export-format="${format}">
537
+ <span class="inkpen-export-dropdown__icon">${config.icon}</span>
538
+ <span class="inkpen-export-dropdown__label">${config.label}</span>
539
+ ${config.shortcut ? `<span class="inkpen-export-dropdown__shortcut">${config.shortcut}</span>` : ""}
540
+ </button>
541
+ `
542
+ }
543
+
544
+ #toggleExportMenu() {
545
+ this.#exportMenuOpen = !this.#exportMenuOpen
546
+ const menu = this.toolbarElement?.querySelector(".inkpen-export-dropdown__menu")
547
+ const toggle = this.toolbarElement?.querySelector("[data-export-toggle]")
548
+
549
+ if (menu) {
550
+ menu.classList.toggle("is-open", this.#exportMenuOpen)
551
+ }
552
+ if (toggle) {
553
+ toggle.classList.toggle("is-active", this.#exportMenuOpen)
554
+ }
555
+ }
556
+
557
+ #closeExportMenu() {
558
+ this.#exportMenuOpen = false
559
+ const menu = this.toolbarElement?.querySelector(".inkpen-export-dropdown__menu")
560
+ const toggle = this.toolbarElement?.querySelector("[data-export-toggle]")
561
+
562
+ if (menu) {
563
+ menu.classList.remove("is-open")
564
+ }
565
+ if (toggle) {
566
+ toggle.classList.remove("is-active")
567
+ }
568
+ }
569
+
570
+ #closeExportMenuOnClickOutside(event) {
571
+ if (!this.#exportMenuOpen) return
572
+
573
+ const dropdown = this.toolbarElement?.querySelector(".inkpen-export-dropdown")
574
+ if (dropdown && !dropdown.contains(event.target)) {
575
+ this.#closeExportMenu()
576
+ }
577
+ }
578
+
579
+ async #handleExport(event) {
580
+ const format = event.currentTarget.dataset.exportFormat
581
+ this.#closeExportMenu()
582
+
583
+ if (!this.editorController) {
584
+ this.findEditorController()
585
+ }
586
+ if (!this.editorController) return
587
+
588
+ try {
589
+ switch (format) {
590
+ case "markdown":
591
+ this.editorController.downloadAsMarkdown()
592
+ this.#showExportSuccess("Downloaded as Markdown")
593
+ break
594
+ case "html":
595
+ this.editorController.downloadAsHTML()
596
+ this.#showExportSuccess("Downloaded as HTML")
597
+ break
598
+ case "pdf":
599
+ await this.editorController.downloadAsPDF()
600
+ this.#showExportSuccess("Downloaded as PDF")
601
+ break
602
+ case "copy_markdown":
603
+ const mdSuccess = await this.editorController.copyAsMarkdown()
604
+ if (mdSuccess) {
605
+ this.#showExportSuccess("Copied as Markdown")
606
+ }
607
+ break
608
+ case "copy_html":
609
+ const htmlSuccess = await this.editorController.copyAsHTML()
610
+ if (htmlSuccess) {
611
+ this.#showExportSuccess("Copied as HTML")
612
+ }
613
+ break
614
+ }
615
+ } catch (error) {
616
+ console.error("Export failed:", error)
617
+ this.#showExportError("Export failed")
618
+ }
619
+ }
620
+
621
+ #showExportSuccess(message) {
622
+ this.element.dispatchEvent(
623
+ new CustomEvent("inkpen:export-success", {
624
+ bubbles: true,
625
+ detail: { message, controller: this }
626
+ })
627
+ )
628
+ }
629
+
630
+ #showExportError(message) {
631
+ this.element.dispatchEvent(
632
+ new CustomEvent("inkpen:export-error", {
633
+ bubbles: true,
634
+ detail: { message, controller: this }
635
+ })
636
+ )
637
+ }
638
+
639
+ // --- Public API ---
640
+
641
+ /**
642
+ * Insert an image at the current cursor position.
643
+ * Call this from host app after uploading an image.
644
+ * @param {string} src - The image URL
645
+ * @param {string} alt - Alt text for the image
646
+ */
647
+ insertImage(src, alt = "") {
648
+ if (this.editorController?.editor) {
649
+ this.editorController.editor.chain().focus().setImage({ src, alt }).run()
650
+ }
651
+ }
652
+
653
+ /**
654
+ * Insert a widget placeholder at the current cursor position.
655
+ * @param {string} type - Widget type (form, gallery, poll)
656
+ * @param {object} data - Widget configuration data
657
+ */
658
+ insertWidgetBlock(type, data = {}) {
659
+ // Dispatch event with widget data for host app to insert custom node
660
+ this.element.dispatchEvent(
661
+ new CustomEvent("inkpen:widget-inserted", {
662
+ bubbles: true,
663
+ detail: { type, data, controller: this }
664
+ })
665
+ )
666
+ }
667
+ }