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,693 @@
1
+ import { Controller } from "@hotwired/stimulus"
2
+
3
+ /**
4
+ * Inkpen Toolbar Controller
5
+ *
6
+ * Context-aware floating toolbar that adapts its buttons based on the current selection.
7
+ * Shows different actions when selecting text vs. inside a table vs. on an image.
8
+ *
9
+ * Visibility and positioning are handled by TipTap's BubbleMenu extension.
10
+ *
11
+ * Important: BubbleMenu uses Tippy.js which moves the toolbar element to the
12
+ * document body. This triggers Stimulus to disconnect and reconnect the
13
+ * controller. We store the editor element ID on the DOM element itself
14
+ * so we can find it after reconnection.
15
+ *
16
+ * Context Types:
17
+ * - text: Regular text selection (formatting options)
18
+ * - table: Inside a table cell (table operations)
19
+ * - image: Image selected (alignment, delete)
20
+ * - code: Inside code block (language selector)
21
+ */
22
+ export default class extends Controller {
23
+ static targets = ["button", "contextMenu"]
24
+
25
+ static values = {
26
+ buttons: { type: Array, default: [] },
27
+ contextAware: { type: Boolean, default: true }
28
+ }
29
+
30
+ connect() {
31
+ this.editorController = null
32
+ this.currentContext = "text"
33
+ this.modeChangeHandler = null
34
+
35
+ // Store editor element ID on the DOM element before BubbleMenu moves us to body
36
+ // Using a data attribute persists across Stimulus disconnect/reconnect cycles
37
+ if (!this.element.dataset.inkpenEditorId) {
38
+ const editorElement = this.element.closest("[data-controller*='inkpen--editor']")
39
+ if (editorElement) {
40
+ // Generate a unique ID if the editor doesn't have one
41
+ if (!editorElement.id) {
42
+ editorElement.id = `inkpen-editor-${Date.now()}`
43
+ }
44
+ this.element.dataset.inkpenEditorId = editorElement.id
45
+ }
46
+ }
47
+
48
+ // Find parent editor controller (with retry for timing)
49
+ this.findEditorController()
50
+ if (!this.editorController) {
51
+ this.retryFindEditor()
52
+ }
53
+
54
+ // Build initial toolbar buttons
55
+ this.buildToolbar()
56
+
57
+ // Keep toolbar active states in sync when markdown mode changes via shortcuts.
58
+ const editorElement = this.getEditorElement()
59
+ if (editorElement) {
60
+ this.modeChangeHandler = () => this.updateActiveStates()
61
+ editorElement.addEventListener("inkpen:mode-change", this.modeChangeHandler)
62
+ }
63
+
64
+ // Listen for selection changes to update context
65
+ if (this.contextAwareValue) {
66
+ this.setupContextDetection()
67
+ }
68
+ }
69
+
70
+ disconnect() {
71
+ if (this.retryTimer) {
72
+ clearTimeout(this.retryTimer)
73
+ }
74
+ if (this.selectionObserver) {
75
+ this.selectionObserver = null
76
+ }
77
+
78
+ const editorElement = this.getEditorElement()
79
+ if (editorElement && this.modeChangeHandler) {
80
+ editorElement.removeEventListener("inkpen:mode-change", this.modeChangeHandler)
81
+ }
82
+ }
83
+
84
+ // --- Context Detection ---
85
+
86
+ setupContextDetection() {
87
+ // Watch for editor selection events
88
+ const editorElement = this.getEditorElement()
89
+ if (editorElement) {
90
+ editorElement.addEventListener("inkpen:selection-change", this.handleSelectionChange.bind(this))
91
+ }
92
+ }
93
+
94
+ handleSelectionChange(event) {
95
+ const newContext = this.detectContext()
96
+ if (newContext !== this.currentContext) {
97
+ this.currentContext = newContext
98
+ this.buildToolbar()
99
+ }
100
+ this.updateActiveStates()
101
+ }
102
+
103
+ detectContext() {
104
+ if (!this.editorController?.editor) return "text"
105
+
106
+ const editor = this.editorController.editor
107
+ const { selection } = editor.state
108
+ const { $from } = selection
109
+
110
+ // Check if inside a table cell
111
+ for (let depth = $from.depth; depth > 0; depth--) {
112
+ const node = $from.node(depth)
113
+ if (node.type.name === "tableCell" || node.type.name === "tableHeader") {
114
+ return "table"
115
+ }
116
+ if (node.type.name === "codeBlock") {
117
+ return "code"
118
+ }
119
+ }
120
+
121
+ // Check if an image is selected
122
+ if (selection.node?.type?.name === "image") {
123
+ return "image"
124
+ }
125
+
126
+ return "text"
127
+ }
128
+
129
+ getEditorElement() {
130
+ const editorId = this.element.dataset.inkpenEditorId
131
+ if (editorId) {
132
+ return document.getElementById(editorId)
133
+ }
134
+ return null
135
+ }
136
+
137
+ retryFindEditor() {
138
+ let attempts = 0
139
+ const maxAttempts = 10
140
+
141
+ const tryFind = () => {
142
+ attempts++
143
+ this.findEditorController()
144
+
145
+ if (!this.editorController && attempts < maxAttempts) {
146
+ this.retryTimer = setTimeout(tryFind, 100)
147
+ }
148
+ }
149
+
150
+ this.retryTimer = setTimeout(tryFind, 50)
151
+ }
152
+
153
+ findEditorController() {
154
+ let editorElement = null
155
+
156
+ // First, try using the stored editor ID (needed after BubbleMenu moves us to body)
157
+ const editorId = this.element.dataset.inkpenEditorId
158
+ if (editorId) {
159
+ editorElement = document.getElementById(editorId)
160
+ }
161
+
162
+ // Fallback: try closest (works if we haven't been moved yet)
163
+ if (!editorElement) {
164
+ editorElement = this.element.closest("[data-controller*='inkpen--editor']")
165
+ if (editorElement) {
166
+ if (!editorElement.id) {
167
+ editorElement.id = `inkpen-editor-${Date.now()}`
168
+ }
169
+ this.element.dataset.inkpenEditorId = editorElement.id
170
+ }
171
+ }
172
+
173
+ if (editorElement) {
174
+ this.editorController = this.application.getControllerForElementAndIdentifier(
175
+ editorElement,
176
+ "inkpen--editor"
177
+ )
178
+ }
179
+ }
180
+
181
+ buildToolbar() {
182
+ const buttons = this.getButtonsForContext()
183
+
184
+ this.element.innerHTML = buttons.map(btn => {
185
+ if (btn === "divider") {
186
+ return '<span class="inkpen-toolbar__divider"></span>'
187
+ }
188
+
189
+ const config = this.buttonConfig(btn)
190
+ // Use mousedown to prevent focus loss + click to execute command
191
+ return `
192
+ <button type="button"
193
+ class="inkpen-toolbar__button"
194
+ data-action="mousedown->inkpen--toolbar#preventFocusLoss click->inkpen--toolbar#executeCommand"
195
+ data-command="${btn}"
196
+ title="${config.title}">
197
+ ${config.icon}
198
+ </button>
199
+ `
200
+ }).join("")
201
+
202
+ // Update data attribute for CSS styling
203
+ this.element.dataset.context = this.currentContext
204
+ }
205
+
206
+ /**
207
+ * Get buttons based on current context
208
+ */
209
+ getButtonsForContext() {
210
+ // If buttons are explicitly set, use those
211
+ if (this.buttonsValue.length > 0) {
212
+ return this.buttonsValue
213
+ }
214
+
215
+ // Otherwise, return context-appropriate buttons
216
+ switch (this.currentContext) {
217
+ case "table":
218
+ return this.tableButtons()
219
+ case "code":
220
+ return this.codeButtons()
221
+ case "image":
222
+ return this.imageButtons()
223
+ default:
224
+ return this.defaultButtons()
225
+ }
226
+ }
227
+
228
+ /**
229
+ * Prevent focus from moving to the button when clicked.
230
+ * This preserves the editor's text selection so formatting commands work correctly.
231
+ */
232
+ preventFocusLoss(event) {
233
+ event.preventDefault()
234
+ }
235
+
236
+ // --- Button Sets by Context ---
237
+
238
+ defaultButtons() {
239
+ return this.appendMarkdownToggle([
240
+ "bold", "italic", "underline", "strike",
241
+ "divider", "code", "highlight", "link",
242
+ "divider", "heading", "callout"
243
+ ])
244
+ }
245
+
246
+ tableButtons() {
247
+ return this.appendMarkdownToggle([
248
+ "bold", "italic", "divider",
249
+ "addRowBefore", "addRowAfter", "deleteRow", "divider",
250
+ "addColumnBefore", "addColumnAfter", "deleteColumn", "divider",
251
+ "mergeCells", "splitCell", "deleteTable"
252
+ ])
253
+ }
254
+
255
+ codeButtons() {
256
+ return this.appendMarkdownToggle(["languageSelector"])
257
+ }
258
+
259
+ imageButtons() {
260
+ return this.appendMarkdownToggle(["alignLeft", "alignCenter", "alignRight", "divider", "deleteImage"])
261
+ }
262
+
263
+ markdownToggleAvailable() {
264
+ return this.editorController?.toolbarValue === "fixed" &&
265
+ this.editorController?.markdownEnabledValue &&
266
+ this.editorController?.markdownToolbarButtonValue
267
+ }
268
+
269
+ appendMarkdownToggle(buttons) {
270
+ if (!this.markdownToggleAvailable()) return buttons
271
+ if (buttons.includes("markdownMode")) return buttons
272
+
273
+ return [...buttons, "divider", "markdownMode"]
274
+ }
275
+
276
+ buttonConfig(name) {
277
+ const configs = {
278
+ bold: {
279
+ title: "Bold (Cmd+B)",
280
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/><path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z"/></svg>'
281
+ },
282
+ italic: {
283
+ title: "Italic (Cmd+I)",
284
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="19" y1="4" x2="10" y2="4"/><line x1="14" y1="20" x2="5" y2="20"/><line x1="15" y1="4" x2="9" y2="20"/></svg>'
285
+ },
286
+ underline: {
287
+ title: "Underline (Cmd+U)",
288
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 4v6a6 6 0 0 0 6 6 6 6 0 0 0 6-6V4"/><line x1="4" y1="20" x2="20" y2="20"/></svg>'
289
+ },
290
+ strike: {
291
+ title: "Strikethrough",
292
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M16 4H9a3 3 0 0 0-2.83 4"/><path d="M14 12a4 4 0 0 1 0 8H6"/><line x1="4" y1="12" x2="20" y2="12"/></svg>'
293
+ },
294
+ code: {
295
+ title: "Inline Code",
296
+ 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>'
297
+ },
298
+ highlight: {
299
+ title: "Highlight",
300
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m9 11-6 6v3h9l3-3"/><path d="m22 12-4.6 4.6a2 2 0 0 1-2.8 0l-5.2-5.2a2 2 0 0 1 0-2.8L14 4"/></svg>'
301
+ },
302
+ link: {
303
+ title: "Link (Cmd+K)",
304
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"/><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"/></svg>'
305
+ },
306
+ heading: {
307
+ title: "Heading",
308
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 12h16"/><path d="M4 6v12"/><path d="M20 6v12"/></svg>'
309
+ },
310
+ bulletList: {
311
+ title: "Bullet List",
312
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="9" y1="6" x2="20" y2="6"/><line x1="9" y1="12" x2="20" y2="12"/><line x1="9" y1="18" x2="20" 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>'
313
+ },
314
+ orderedList: {
315
+ title: "Numbered List",
316
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="10" y1="6" x2="21" y2="6"/><line x1="10" y1="12" x2="21" y2="12"/><line x1="10" y1="18" x2="21" y2="18"/><text x="4" y="7" font-size="6" fill="currentColor">1</text><text x="4" y="13" font-size="6" fill="currentColor">2</text><text x="4" y="19" font-size="6" fill="currentColor">3</text></svg>'
317
+ },
318
+ blockquote: {
319
+ title: "Quote",
320
+ 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>'
321
+ },
322
+ codeBlock: {
323
+ title: "Code Block",
324
+ 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>'
325
+ },
326
+ youtube: {
327
+ title: "YouTube Video",
328
+ 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>'
329
+ },
330
+
331
+ // --- Table Operations ---
332
+ addRowBefore: {
333
+ title: "Add Row Above",
334
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="8" width="18" height="13" rx="2"/><path d="M12 2v4"/><path d="M9 4h6"/></svg>'
335
+ },
336
+ addRowAfter: {
337
+ title: "Add Row Below",
338
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="13" rx="2"/><path d="M12 18v4"/><path d="M9 20h6"/></svg>'
339
+ },
340
+ deleteRow: {
341
+ title: "Delete Row",
342
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="6" width="18" height="12" rx="2"/><path d="M9 12h6"/></svg>'
343
+ },
344
+ addColumnBefore: {
345
+ title: "Add Column Left",
346
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="8" y="3" width="13" height="18" rx="2"/><path d="M2 12h4"/><path d="M4 9v6"/></svg>'
347
+ },
348
+ addColumnAfter: {
349
+ title: "Add Column Right",
350
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="13" height="18" rx="2"/><path d="M18 12h4"/><path d="M20 9v6"/></svg>'
351
+ },
352
+ deleteColumn: {
353
+ title: "Delete Column",
354
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="6" y="3" width="12" height="18" rx="2"/><path d="M9 12h6"/></svg>'
355
+ },
356
+ mergeCells: {
357
+ title: "Merge Cells",
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"/><path d="M9 3v18"/><path d="M15 3v18"/><path d="M9 12h6"/></svg>'
359
+ },
360
+ splitCell: {
361
+ title: "Split Cell",
362
+ 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"/><path d="M12 3v18"/><path d="M3 12h7"/><path d="M14 12h7"/></svg>'
363
+ },
364
+ deleteTable: {
365
+ title: "Delete Table",
366
+ 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"/><line x1="4" y1="4" x2="20" y2="20" stroke-width="2.5"/></svg>'
367
+ },
368
+
369
+ // --- Advanced HTML Features ---
370
+ callout: {
371
+ title: "Callout Box",
372
+ 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="12" cy="10" r="1" fill="currentColor"/><path d="M12 14v3"/></svg>'
373
+ },
374
+ htmlBlock: {
375
+ title: "HTML Block",
376
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="m16 18 2-2v-3"/><path d="m8 18-2-2v-3"/><path d="M12 2v6"/><path d="m16 6-2-2v-3"/><path d="m8 6 2-2V2"/><rect x="4" y="10" width="16" height="10" rx="2"/></svg>'
377
+ },
378
+
379
+ // --- Image Operations ---
380
+ alignLeft: {
381
+ title: "Align Left",
382
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="15" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>'
383
+ },
384
+ alignCenter: {
385
+ title: "Align Center",
386
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="6" y1="12" x2="18" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>'
387
+ },
388
+ alignRight: {
389
+ title: "Align Right",
390
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="9" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>'
391
+ },
392
+ deleteImage: {
393
+ title: "Delete Image",
394
+ 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"/><line x1="5" y1="5" x2="19" y2="19" stroke-width="2.5"/></svg>'
395
+ },
396
+
397
+ // --- Code Block ---
398
+ languageSelector: {
399
+ title: "Select Language",
400
+ 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"/><text x="9" y="17" font-size="8" fill="currentColor">JS</text></svg>'
401
+ },
402
+ markdownMode: {
403
+ title: "Toggle Markdown",
404
+ icon: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19V5h2l4 6 4-6h2v14h-2V9l-4 6-4-6v10z"/><path d="M20 8v8"/><path d="M18 10l2-2 2 2"/><path d="m18 14 2 2 2-2"/></svg>'
405
+ }
406
+ }
407
+
408
+ return configs[name] || { title: name, icon: name }
409
+ }
410
+
411
+ executeCommand(event) {
412
+ const command = event.currentTarget.dataset.command
413
+ if (!this.editorController) {
414
+ this.findEditorController()
415
+ }
416
+ if (!this.editorController) return
417
+
418
+ switch (command) {
419
+ // --- Text Formatting ---
420
+ case "bold":
421
+ this.editorController.toggleBold()
422
+ break
423
+ case "italic":
424
+ this.editorController.toggleItalic()
425
+ break
426
+ case "underline":
427
+ this.editorController.toggleUnderline()
428
+ break
429
+ case "strike":
430
+ this.editorController.toggleStrike()
431
+ break
432
+ case "highlight":
433
+ this.editorController.toggleHighlight()
434
+ break
435
+ case "link":
436
+ this.promptForLink()
437
+ break
438
+ case "heading":
439
+ this.editorController.toggleHeading(2)
440
+ break
441
+ case "bulletList":
442
+ this.editorController.toggleBulletList()
443
+ break
444
+ case "orderedList":
445
+ this.editorController.toggleOrderedList()
446
+ break
447
+ case "blockquote":
448
+ this.editorController.toggleBlockquote()
449
+ break
450
+ case "codeBlock":
451
+ this.editorController.toggleCodeBlock()
452
+ break
453
+ case "code":
454
+ this.editorController.toggleCode()
455
+ break
456
+ case "youtube":
457
+ this.promptForYoutubeUrl()
458
+ break
459
+
460
+ // --- Table Operations ---
461
+ case "addRowBefore":
462
+ this.editorController.addTableRowBefore()
463
+ break
464
+ case "addRowAfter":
465
+ this.editorController.addTableRowAfter()
466
+ break
467
+ case "deleteRow":
468
+ this.editorController.deleteTableRow()
469
+ break
470
+ case "addColumnBefore":
471
+ this.editorController.addTableColumnBefore()
472
+ break
473
+ case "addColumnAfter":
474
+ this.editorController.addTableColumnAfter()
475
+ break
476
+ case "deleteColumn":
477
+ this.editorController.deleteTableColumn()
478
+ break
479
+ case "mergeCells":
480
+ this.editorController.mergeCells()
481
+ break
482
+ case "splitCell":
483
+ this.editorController.splitCell()
484
+ break
485
+ case "deleteTable":
486
+ if (confirm("Delete this table?")) {
487
+ this.editorController.deleteTable()
488
+ }
489
+ break
490
+
491
+ // --- Advanced HTML Features ---
492
+ case "callout":
493
+ this.insertCallout()
494
+ break
495
+ case "htmlBlock":
496
+ this.insertHtmlBlock()
497
+ break
498
+
499
+ // --- Image Operations ---
500
+ case "alignLeft":
501
+ this.setImageAlignment("left")
502
+ break
503
+ case "alignCenter":
504
+ this.setImageAlignment("center")
505
+ break
506
+ case "alignRight":
507
+ this.setImageAlignment("right")
508
+ break
509
+ case "deleteImage":
510
+ this.deleteSelectedNode()
511
+ break
512
+
513
+ // --- Code Block ---
514
+ case "languageSelector":
515
+ this.showLanguageSelector()
516
+ break
517
+ case "markdownMode":
518
+ this.editorController.toggleMarkdownMode()
519
+ break
520
+ }
521
+
522
+ this.updateActiveStates()
523
+ }
524
+
525
+ promptForYoutubeUrl() {
526
+ const url = prompt("Enter YouTube URL:", "https://www.youtube.com/watch?v=")
527
+ if (url && url !== "https://www.youtube.com/watch?v=") {
528
+ this.editorController.insertYoutubeVideo(url)
529
+ }
530
+ }
531
+
532
+ promptForLink() {
533
+ const url = prompt("Enter URL:", "https://")
534
+ if (url && url !== "https://") {
535
+ this.editorController.setLink(url)
536
+ }
537
+ }
538
+
539
+ updateActiveStates() {
540
+ if (!this.editorController) return
541
+
542
+ this.element.querySelectorAll(".inkpen-toolbar__button").forEach(btn => {
543
+ const command = btn.dataset.command
544
+ const isActive = this.isCommandActive(command)
545
+ btn.classList.toggle("is-active", isActive)
546
+ })
547
+ }
548
+
549
+ isCommandActive(command) {
550
+ if (command === "markdownMode") {
551
+ return this.editorController?.markdownEnabledValue &&
552
+ this.editorController?.markdownModeValue !== "wysiwyg"
553
+ }
554
+
555
+ return this.editorController.isActive(this.commandToNodeName(command))
556
+ }
557
+
558
+ commandToNodeName(command) {
559
+ const mapping = {
560
+ bold: "bold",
561
+ italic: "italic",
562
+ strike: "strike",
563
+ underline: "underline",
564
+ link: "link",
565
+ heading: "heading",
566
+ bulletList: "bulletList",
567
+ orderedList: "orderedList",
568
+ blockquote: "blockquote",
569
+ codeBlock: "codeBlock",
570
+ code: "code",
571
+ highlight: "highlight"
572
+ }
573
+ return mapping[command] || command
574
+ }
575
+
576
+ // --- Advanced HTML Features ---
577
+
578
+ /**
579
+ * Insert a callout/alert box
580
+ * Types: info, warning, tip, note
581
+ */
582
+ insertCallout() {
583
+ const type = this.promptCalloutType()
584
+ if (!type) return
585
+
586
+ const calloutHtml = `
587
+ <div class="inkpen-callout inkpen-callout--${type}" data-callout-type="${type}">
588
+ <div class="inkpen-callout__icon">${this.getCalloutIcon(type)}</div>
589
+ <div class="inkpen-callout__content"><p>Type your ${type} message here...</p></div>
590
+ </div>
591
+ `
592
+ this.editorController?.editor?.chain().focus().insertContent(calloutHtml).run()
593
+ }
594
+
595
+ promptCalloutType() {
596
+ const type = prompt("Callout type (info, warning, tip, note):", "info")
597
+ if (!type) return null
598
+
599
+ const validTypes = ["info", "warning", "tip", "note"]
600
+ const normalizedType = type.toLowerCase().trim()
601
+
602
+ if (!validTypes.includes(normalizedType)) {
603
+ alert("Invalid callout type. Please use: info, warning, tip, or note.")
604
+ return null
605
+ }
606
+
607
+ return normalizedType
608
+ }
609
+
610
+ getCalloutIcon(type) {
611
+ const icons = {
612
+ info: "ℹ️",
613
+ warning: "⚠️",
614
+ tip: "💡",
615
+ note: "📝"
616
+ }
617
+ return icons[type] || "ℹ️"
618
+ }
619
+
620
+ /**
621
+ * Insert a raw HTML block
622
+ */
623
+ insertHtmlBlock() {
624
+ const html = prompt("Enter HTML code:", "<div></div>")
625
+ if (!html) return
626
+
627
+ // Wrap in a container with editing disabled for the raw HTML
628
+ const wrappedHtml = `<div class="inkpen-html-block" contenteditable="false">${html}</div>`
629
+ this.editorController?.editor?.chain().focus().insertContent(wrappedHtml).run()
630
+ }
631
+
632
+ // --- Image Operations ---
633
+
634
+ setImageAlignment(alignment) {
635
+ const editor = this.editorController?.editor
636
+ if (!editor) return
637
+
638
+ // Get the selected image node
639
+ const { selection } = editor.state
640
+ if (selection.node?.type?.name !== "image") return
641
+
642
+ // Update the image with alignment attribute
643
+ editor.chain().focus().updateAttributes("image", {
644
+ style: `display: block; margin: ${alignment === "center" ? "0 auto" : alignment === "right" ? "0 0 0 auto" : "0"};`
645
+ }).run()
646
+ }
647
+
648
+ deleteSelectedNode() {
649
+ this.editorController?.editor?.chain().focus().deleteSelection().run()
650
+ }
651
+
652
+ // --- Code Block Language ---
653
+
654
+ showLanguageSelector() {
655
+ const languages = [
656
+ "javascript", "typescript", "python", "ruby", "go",
657
+ "rust", "java", "c", "cpp", "csharp",
658
+ "html", "css", "json", "yaml", "sql",
659
+ "bash", "shell", "markdown", "plaintext"
660
+ ]
661
+
662
+ const current = this.getCurrentCodeLanguage() || "plaintext"
663
+ const selected = prompt(
664
+ `Select language (current: ${current}):\n\n${languages.join(", ")}`,
665
+ current
666
+ )
667
+
668
+ if (selected && languages.includes(selected.toLowerCase())) {
669
+ this.setCodeLanguage(selected.toLowerCase())
670
+ }
671
+ }
672
+
673
+ getCurrentCodeLanguage() {
674
+ const editor = this.editorController?.editor
675
+ if (!editor) return null
676
+
677
+ const { selection } = editor.state
678
+ const { $from } = selection
679
+
680
+ for (let depth = $from.depth; depth > 0; depth--) {
681
+ const node = $from.node(depth)
682
+ if (node.type.name === "codeBlock") {
683
+ return node.attrs.language || null
684
+ }
685
+ }
686
+
687
+ return null
688
+ }
689
+
690
+ setCodeLanguage(language) {
691
+ this.editorController?.editor?.chain().focus().updateAttributes("codeBlock", { language }).run()
692
+ }
693
+ }