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,303 @@
1
+ import { Node, mergeAttributes } from "@tiptap/core"
2
+
3
+ /**
4
+ * Callout Extension for TipTap
5
+ *
6
+ * Highlighted blocks for tips, warnings, notes, and other callouts.
7
+ * Similar to Notion callouts or GitHub admonitions.
8
+ *
9
+ * Features:
10
+ * - Multiple types: info, warning, tip, note, success, error
11
+ * - Default emojis per type (customizable)
12
+ * - Interactive type selector
13
+ * - Custom emoji support
14
+ * - Colored backgrounds and borders
15
+ *
16
+ * @example
17
+ * editor.commands.insertCallout({ type: 'warning' })
18
+ * editor.commands.setCalloutType('success')
19
+ *
20
+ * @since 0.3.3
21
+ */
22
+
23
+ // Default emoji icons for each callout type
24
+ const DEFAULT_EMOJIS = {
25
+ info: "ℹ️",
26
+ warning: "⚠️",
27
+ tip: "💡",
28
+ note: "📝",
29
+ success: "✅",
30
+ error: "❌"
31
+ }
32
+
33
+ // Callout type labels
34
+ const TYPE_LABELS = {
35
+ info: "Info",
36
+ warning: "Warning",
37
+ tip: "Tip",
38
+ note: "Note",
39
+ success: "Success",
40
+ error: "Error"
41
+ }
42
+
43
+ export const Callout = Node.create({
44
+ name: "callout",
45
+
46
+ group: "block",
47
+ content: "block+",
48
+ defining: true,
49
+
50
+ addOptions() {
51
+ return {
52
+ defaultType: "info",
53
+ types: ["info", "warning", "tip", "note", "success", "error"],
54
+ emojis: DEFAULT_EMOJIS,
55
+ labels: TYPE_LABELS,
56
+ showControls: true,
57
+ HTMLAttributes: {
58
+ class: "inkpen-callout"
59
+ }
60
+ }
61
+ },
62
+
63
+ addAttributes() {
64
+ return {
65
+ type: {
66
+ default: this.options.defaultType,
67
+ parseHTML: element => element.getAttribute("data-type") || this.options.defaultType,
68
+ renderHTML: attributes => ({ "data-type": attributes.type })
69
+ },
70
+ emoji: {
71
+ default: null,
72
+ parseHTML: element => element.getAttribute("data-emoji"),
73
+ renderHTML: attributes => attributes.emoji ? { "data-emoji": attributes.emoji } : {}
74
+ }
75
+ }
76
+ },
77
+
78
+ parseHTML() {
79
+ return [
80
+ { tag: "div.inkpen-callout" },
81
+ { tag: "div[data-callout]" },
82
+ { tag: "aside.inkpen-callout" }
83
+ ]
84
+ },
85
+
86
+ renderHTML({ HTMLAttributes }) {
87
+ const type = HTMLAttributes["data-type"] || this.options.defaultType
88
+
89
+ return [
90
+ "aside",
91
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
92
+ "data-callout": type,
93
+ class: `inkpen-callout inkpen-callout--${type}`
94
+ }),
95
+ 0
96
+ ]
97
+ },
98
+
99
+ addNodeView() {
100
+ return ({ node, getPos, editor }) => {
101
+ const type = node.attrs.type || this.options.defaultType
102
+ const emoji = node.attrs.emoji || this.options.emojis[type] || DEFAULT_EMOJIS.info
103
+
104
+ // Main container
105
+ const dom = document.createElement("aside")
106
+ dom.className = `inkpen-callout inkpen-callout--${type}`
107
+ dom.setAttribute("data-callout", type)
108
+ dom.setAttribute("data-type", type)
109
+
110
+ // Icon container
111
+ const iconWrapper = document.createElement("div")
112
+ iconWrapper.className = "inkpen-callout__icon"
113
+ iconWrapper.contentEditable = "false"
114
+
115
+ const iconSpan = document.createElement("span")
116
+ iconSpan.className = "inkpen-callout__emoji"
117
+ iconSpan.textContent = emoji
118
+ iconWrapper.appendChild(iconSpan)
119
+
120
+ // Type selector (shows on icon click)
121
+ if (this.options.showControls && editor.isEditable) {
122
+ iconWrapper.style.cursor = "pointer"
123
+ iconWrapper.title = "Click to change callout type"
124
+
125
+ iconWrapper.addEventListener("click", (e) => {
126
+ e.preventDefault()
127
+ e.stopPropagation()
128
+ this.showTypeSelector(iconWrapper, node, getPos, editor)
129
+ })
130
+ }
131
+
132
+ dom.appendChild(iconWrapper)
133
+
134
+ // Content container
135
+ const content = document.createElement("div")
136
+ content.className = "inkpen-callout__content"
137
+ dom.appendChild(content)
138
+
139
+ return {
140
+ dom,
141
+ contentDOM: content,
142
+ update: (updatedNode) => {
143
+ if (updatedNode.type.name !== "callout") return false
144
+
145
+ const newType = updatedNode.attrs.type || this.options.defaultType
146
+ const newEmoji = updatedNode.attrs.emoji || this.options.emojis[newType] || DEFAULT_EMOJIS.info
147
+
148
+ // Update classes
149
+ dom.className = `inkpen-callout inkpen-callout--${newType}`
150
+ dom.setAttribute("data-callout", newType)
151
+ dom.setAttribute("data-type", newType)
152
+
153
+ // Update emoji
154
+ iconSpan.textContent = newEmoji
155
+
156
+ return true
157
+ }
158
+ }
159
+ }
160
+ },
161
+
162
+ addCommands() {
163
+ return {
164
+ insertCallout: (attributes = {}) => ({ commands }) => {
165
+ const type = attributes.type || this.options.defaultType
166
+
167
+ return commands.insertContent({
168
+ type: this.name,
169
+ attrs: {
170
+ type,
171
+ emoji: attributes.emoji || null
172
+ },
173
+ content: [{ type: "paragraph" }]
174
+ })
175
+ },
176
+
177
+ setCallout: (attributes = {}) => ({ commands }) => {
178
+ return commands.wrapIn(this.name, attributes)
179
+ },
180
+
181
+ setCalloutType: (type) => ({ commands }) => {
182
+ return commands.updateAttributes(this.name, { type, emoji: null })
183
+ },
184
+
185
+ setCalloutEmoji: (emoji) => ({ commands }) => {
186
+ return commands.updateAttributes(this.name, { emoji })
187
+ },
188
+
189
+ toggleCallout: (type = "info") => ({ commands, state }) => {
190
+ const { $from } = state.selection
191
+
192
+ // Check if already in a callout
193
+ for (let d = $from.depth; d > 0; d--) {
194
+ if ($from.node(d).type.name === "callout") {
195
+ return commands.lift(this.name)
196
+ }
197
+ }
198
+
199
+ return commands.wrapIn(this.name, { type })
200
+ },
201
+
202
+ removeCallout: () => ({ commands }) => {
203
+ return commands.lift(this.name)
204
+ }
205
+ }
206
+ },
207
+
208
+ addKeyboardShortcuts() {
209
+ return {
210
+ "Mod-Shift-o": () => this.editor.commands.insertCallout({ type: "info" })
211
+ }
212
+ },
213
+
214
+ // Private helpers
215
+
216
+ showTypeSelector(iconWrapper, node, getPos, editor) {
217
+ // Remove any existing selector
218
+ const existing = document.querySelector(".inkpen-callout-selector")
219
+ if (existing) {
220
+ existing.remove()
221
+ return
222
+ }
223
+
224
+ // Create selector dropdown
225
+ const selector = document.createElement("div")
226
+ selector.className = "inkpen-callout-selector"
227
+
228
+ this.options.types.forEach(type => {
229
+ const btn = document.createElement("button")
230
+ btn.type = "button"
231
+ btn.className = "inkpen-callout-selector__item"
232
+ if (node.attrs.type === type) {
233
+ btn.classList.add("is-active")
234
+ }
235
+
236
+ const emoji = document.createElement("span")
237
+ emoji.className = "inkpen-callout-selector__emoji"
238
+ emoji.textContent = this.options.emojis[type] || DEFAULT_EMOJIS[type]
239
+
240
+ const label = document.createElement("span")
241
+ label.className = "inkpen-callout-selector__label"
242
+ label.textContent = this.options.labels[type] || type
243
+
244
+ btn.appendChild(emoji)
245
+ btn.appendChild(label)
246
+
247
+ btn.addEventListener("mousedown", (e) => e.preventDefault())
248
+ btn.addEventListener("click", (e) => {
249
+ e.preventDefault()
250
+ e.stopPropagation()
251
+
252
+ if (typeof getPos === "function") {
253
+ const pos = getPos()
254
+ if (pos !== undefined) {
255
+ editor.chain()
256
+ .focus()
257
+ .command(({ tr }) => {
258
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, type, emoji: null })
259
+ return true
260
+ })
261
+ .run()
262
+ }
263
+ }
264
+
265
+ selector.remove()
266
+ })
267
+
268
+ selector.appendChild(btn)
269
+ })
270
+
271
+ // Position the selector
272
+ const rect = iconWrapper.getBoundingClientRect()
273
+ selector.style.position = "fixed"
274
+ selector.style.left = `${rect.left}px`
275
+ selector.style.top = `${rect.bottom + 4}px`
276
+ selector.style.zIndex = "10000"
277
+
278
+ document.body.appendChild(selector)
279
+
280
+ // Close on outside click
281
+ const closeHandler = (e) => {
282
+ if (!selector.contains(e.target) && !iconWrapper.contains(e.target)) {
283
+ selector.remove()
284
+ document.removeEventListener("mousedown", closeHandler)
285
+ }
286
+ }
287
+
288
+ setTimeout(() => {
289
+ document.addEventListener("mousedown", closeHandler)
290
+ }, 0)
291
+
292
+ // Close on escape
293
+ const escHandler = (e) => {
294
+ if (e.key === "Escape") {
295
+ selector.remove()
296
+ document.removeEventListener("keydown", escHandler)
297
+ }
298
+ }
299
+ document.addEventListener("keydown", escHandler)
300
+ }
301
+ })
302
+
303
+ export default Callout
@@ -0,0 +1,403 @@
1
+ import { Node, mergeAttributes } from "@tiptap/core"
2
+
3
+ /**
4
+ * Columns Extension for TipTap
5
+ *
6
+ * Multi-column layout blocks for side-by-side content.
7
+ * Supports 2-4 columns with various layout presets.
8
+ *
9
+ * Features:
10
+ * - 2, 3, or 4 column layouts
11
+ * - Layout presets (equal, 1:2, 2:1, 1:2:1, etc.)
12
+ * - Add/remove columns via controls
13
+ * - Responsive stacking on mobile
14
+ * - Drag to reorder columns (future)
15
+ *
16
+ * @example
17
+ * editor.commands.insertColumns({ count: 2 })
18
+ * editor.commands.setColumnLayout('1:2')
19
+ *
20
+ * @since 0.3.3
21
+ */
22
+
23
+ // Layout presets define column width ratios
24
+ const LAYOUT_PRESETS = {
25
+ // 2 columns
26
+ "equal-2": { columns: 2, widths: ["1fr", "1fr"], label: "Equal" },
27
+ "1:2": { columns: 2, widths: ["1fr", "2fr"], label: "1:2" },
28
+ "2:1": { columns: 2, widths: ["2fr", "1fr"], label: "2:1" },
29
+ "1:3": { columns: 2, widths: ["1fr", "3fr"], label: "1:3" },
30
+ "3:1": { columns: 2, widths: ["3fr", "1fr"], label: "3:1" },
31
+
32
+ // 3 columns
33
+ "equal-3": { columns: 3, widths: ["1fr", "1fr", "1fr"], label: "Equal" },
34
+ "1:2:1": { columns: 3, widths: ["1fr", "2fr", "1fr"], label: "1:2:1" },
35
+ "2:1:1": { columns: 3, widths: ["2fr", "1fr", "1fr"], label: "2:1:1" },
36
+ "1:1:2": { columns: 3, widths: ["1fr", "1fr", "2fr"], label: "1:1:2" },
37
+
38
+ // 4 columns
39
+ "equal-4": { columns: 4, widths: ["1fr", "1fr", "1fr", "1fr"], label: "Equal" }
40
+ }
41
+
42
+ // Column node - individual column container
43
+ export const Column = Node.create({
44
+ name: "column",
45
+
46
+ content: "block+",
47
+ defining: true,
48
+ isolating: true,
49
+
50
+ parseHTML() {
51
+ return [{ tag: "div.inkpen-column" }]
52
+ },
53
+
54
+ renderHTML({ HTMLAttributes }) {
55
+ return [
56
+ "div",
57
+ mergeAttributes(HTMLAttributes, { class: "inkpen-column" }),
58
+ 0
59
+ ]
60
+ }
61
+ })
62
+
63
+ // Columns node - container for multiple columns
64
+ export const Columns = Node.create({
65
+ name: "columns",
66
+
67
+ group: "block",
68
+ content: "column{2,4}",
69
+ defining: true,
70
+ isolating: true,
71
+
72
+ addOptions() {
73
+ return {
74
+ defaultCount: 2,
75
+ defaultLayout: "equal-2",
76
+ showControls: true,
77
+ layouts: LAYOUT_PRESETS,
78
+ HTMLAttributes: {
79
+ class: "inkpen-columns"
80
+ }
81
+ }
82
+ },
83
+
84
+ addAttributes() {
85
+ return {
86
+ layout: {
87
+ default: this.options.defaultLayout,
88
+ parseHTML: element => element.getAttribute("data-layout") || this.options.defaultLayout,
89
+ renderHTML: attributes => ({ "data-layout": attributes.layout })
90
+ }
91
+ }
92
+ },
93
+
94
+ parseHTML() {
95
+ return [
96
+ { tag: "div.inkpen-columns" },
97
+ { tag: "div[data-type='columns']" }
98
+ ]
99
+ },
100
+
101
+ renderHTML({ HTMLAttributes }) {
102
+ const layout = this.options.layouts[HTMLAttributes["data-layout"]] || this.options.layouts["equal-2"]
103
+ const gridTemplate = layout.widths.join(" ")
104
+
105
+ return [
106
+ "div",
107
+ mergeAttributes(this.options.HTMLAttributes, HTMLAttributes, {
108
+ "data-type": "columns",
109
+ style: `--inkpen-columns-template: ${gridTemplate}`
110
+ }),
111
+ 0
112
+ ]
113
+ },
114
+
115
+ addNodeView() {
116
+ return ({ node, getPos, editor }) => {
117
+ const dom = document.createElement("div")
118
+ dom.className = "inkpen-columns"
119
+ dom.setAttribute("data-type", "columns")
120
+ dom.setAttribute("data-layout", node.attrs.layout)
121
+
122
+ // Set grid template from layout
123
+ const layout = this.options.layouts[node.attrs.layout] || this.options.layouts["equal-2"]
124
+ dom.style.setProperty("--inkpen-columns-template", layout.widths.join(" "))
125
+
126
+ // Controls (only in edit mode)
127
+ let controls = null
128
+ if (this.options.showControls && editor.isEditable) {
129
+ controls = this.createControls(node, getPos, editor)
130
+ dom.appendChild(controls)
131
+ }
132
+
133
+ // Content container
134
+ const content = document.createElement("div")
135
+ content.className = "inkpen-columns__content"
136
+ dom.appendChild(content)
137
+
138
+ return {
139
+ dom,
140
+ contentDOM: content,
141
+ update: (updatedNode) => {
142
+ if (updatedNode.type.name !== "columns") return false
143
+
144
+ dom.setAttribute("data-layout", updatedNode.attrs.layout)
145
+
146
+ const newLayout = this.options.layouts[updatedNode.attrs.layout] || this.options.layouts["equal-2"]
147
+ dom.style.setProperty("--inkpen-columns-template", newLayout.widths.join(" "))
148
+
149
+ // Update controls
150
+ if (controls) {
151
+ this.updateControls(controls, updatedNode, getPos, editor)
152
+ }
153
+
154
+ return true
155
+ }
156
+ }
157
+ }
158
+ },
159
+
160
+ addCommands() {
161
+ return {
162
+ insertColumns: (attributes = {}) => ({ commands }) => {
163
+ const count = attributes.count || this.options.defaultCount
164
+ const layout = attributes.layout || `equal-${count}`
165
+
166
+ const columns = Array.from({ length: count }, () => ({
167
+ type: "column",
168
+ content: [{ type: "paragraph" }]
169
+ }))
170
+
171
+ return commands.insertContent({
172
+ type: this.name,
173
+ attrs: { layout },
174
+ content: columns
175
+ })
176
+ },
177
+
178
+ setColumnLayout: (layout) => ({ commands }) => {
179
+ return commands.updateAttributes(this.name, { layout })
180
+ },
181
+
182
+ addColumn: () => ({ state, dispatch, editor }) => {
183
+ const { $from } = state.selection
184
+
185
+ // Find the columns node
186
+ for (let d = $from.depth; d > 0; d--) {
187
+ const node = $from.node(d)
188
+ if (node.type.name === "columns") {
189
+ const pos = $from.before(d)
190
+ const columnsNode = state.doc.nodeAt(pos)
191
+
192
+ if (!columnsNode || columnsNode.childCount >= 4) return false
193
+
194
+ if (dispatch) {
195
+ // Create new column
196
+ const newColumn = state.schema.nodes.column.create(null, [
197
+ state.schema.nodes.paragraph.create()
198
+ ])
199
+
200
+ // Insert at end of columns
201
+ const insertPos = pos + columnsNode.nodeSize - 1
202
+ const tr = state.tr.insert(insertPos, newColumn)
203
+
204
+ // Update layout to match new count
205
+ const newCount = columnsNode.childCount + 1
206
+ const newLayout = `equal-${newCount}`
207
+ tr.setNodeMarkup(pos, undefined, { ...columnsNode.attrs, layout: newLayout })
208
+
209
+ dispatch(tr)
210
+ }
211
+ return true
212
+ }
213
+ }
214
+ return false
215
+ },
216
+
217
+ removeColumn: () => ({ state, dispatch }) => {
218
+ const { $from } = state.selection
219
+
220
+ // Find the column we're in
221
+ for (let d = $from.depth; d > 0; d--) {
222
+ const node = $from.node(d)
223
+ if (node.type.name === "column") {
224
+ const columnPos = $from.before(d)
225
+
226
+ // Find parent columns
227
+ const columnsDepth = d - 1
228
+ const columnsNode = $from.node(columnsDepth)
229
+ const columnsPos = $from.before(columnsDepth)
230
+
231
+ if (columnsNode.type.name !== "columns") continue
232
+ if (columnsNode.childCount <= 2) return false // Min 2 columns
233
+
234
+ if (dispatch) {
235
+ const columnNode = state.doc.nodeAt(columnPos)
236
+ const tr = state.tr.delete(columnPos, columnPos + columnNode.nodeSize)
237
+
238
+ // Update layout to match new count
239
+ const newCount = columnsNode.childCount - 1
240
+ const newLayout = `equal-${newCount}`
241
+
242
+ // Adjust position after deletion
243
+ const newColumnsPos = columnPos < columnsPos ? columnsPos - columnNode.nodeSize : columnsPos
244
+ tr.setNodeMarkup(newColumnsPos, undefined, { ...columnsNode.attrs, layout: newLayout })
245
+
246
+ dispatch(tr)
247
+ }
248
+ return true
249
+ }
250
+ }
251
+ return false
252
+ },
253
+
254
+ deleteColumns: () => ({ state, dispatch }) => {
255
+ const { $from } = state.selection
256
+
257
+ for (let d = $from.depth; d > 0; d--) {
258
+ const node = $from.node(d)
259
+ if (node.type.name === "columns") {
260
+ if (dispatch) {
261
+ const pos = $from.before(d)
262
+ const tr = state.tr.delete(pos, pos + node.nodeSize)
263
+ dispatch(tr)
264
+ }
265
+ return true
266
+ }
267
+ }
268
+ return false
269
+ }
270
+ }
271
+ },
272
+
273
+ addKeyboardShortcuts() {
274
+ return {
275
+ "Mod-Shift-c": () => this.editor.commands.insertColumns({ count: 2 })
276
+ }
277
+ },
278
+
279
+ // Private helpers
280
+
281
+ createControls(node, getPos, editor) {
282
+ const controls = document.createElement("div")
283
+ controls.className = "inkpen-columns-controls"
284
+ controls.contentEditable = "false"
285
+
286
+ // Layout selector
287
+ const layoutGroup = document.createElement("div")
288
+ layoutGroup.className = "inkpen-columns-controls__group"
289
+
290
+ const layoutLabel = document.createElement("span")
291
+ layoutLabel.className = "inkpen-columns-controls__label"
292
+ layoutLabel.textContent = "Layout"
293
+ layoutGroup.appendChild(layoutLabel)
294
+
295
+ // Get layouts for current column count
296
+ const currentLayout = this.options.layouts[node.attrs.layout]
297
+ const columnCount = currentLayout?.columns || 2
298
+
299
+ Object.entries(this.options.layouts)
300
+ .filter(([, preset]) => preset.columns === columnCount)
301
+ .forEach(([key, preset]) => {
302
+ const btn = document.createElement("button")
303
+ btn.type = "button"
304
+ btn.className = "inkpen-columns-controls__btn"
305
+ btn.dataset.layout = key
306
+ btn.textContent = preset.label
307
+ btn.title = `Set layout to ${preset.label}`
308
+
309
+ if (node.attrs.layout === key) {
310
+ btn.classList.add("is-active")
311
+ }
312
+
313
+ btn.addEventListener("mousedown", (e) => e.preventDefault())
314
+ btn.addEventListener("click", (e) => {
315
+ e.preventDefault()
316
+ e.stopPropagation()
317
+
318
+ if (typeof getPos === "function") {
319
+ const pos = getPos()
320
+ if (pos !== undefined) {
321
+ editor.chain()
322
+ .focus()
323
+ .command(({ tr }) => {
324
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, layout: key })
325
+ return true
326
+ })
327
+ .run()
328
+ }
329
+ }
330
+ })
331
+
332
+ layoutGroup.appendChild(btn)
333
+ })
334
+
335
+ controls.appendChild(layoutGroup)
336
+
337
+ // Add/remove column buttons
338
+ const columnGroup = document.createElement("div")
339
+ columnGroup.className = "inkpen-columns-controls__group"
340
+
341
+ const removeBtn = document.createElement("button")
342
+ removeBtn.type = "button"
343
+ removeBtn.className = "inkpen-columns-controls__btn inkpen-columns-controls__btn--icon"
344
+ removeBtn.innerHTML = "−"
345
+ removeBtn.title = "Remove column"
346
+ removeBtn.disabled = columnCount <= 2
347
+ removeBtn.addEventListener("mousedown", (e) => e.preventDefault())
348
+ removeBtn.addEventListener("click", (e) => {
349
+ e.preventDefault()
350
+ e.stopPropagation()
351
+ editor.commands.removeColumn()
352
+ })
353
+
354
+ const countLabel = document.createElement("span")
355
+ countLabel.className = "inkpen-columns-controls__count"
356
+ countLabel.textContent = `${columnCount} cols`
357
+
358
+ const addBtn = document.createElement("button")
359
+ addBtn.type = "button"
360
+ addBtn.className = "inkpen-columns-controls__btn inkpen-columns-controls__btn--icon"
361
+ addBtn.innerHTML = "+"
362
+ addBtn.title = "Add column"
363
+ addBtn.disabled = columnCount >= 4
364
+ addBtn.addEventListener("mousedown", (e) => e.preventDefault())
365
+ addBtn.addEventListener("click", (e) => {
366
+ e.preventDefault()
367
+ e.stopPropagation()
368
+ editor.commands.addColumn()
369
+ })
370
+
371
+ columnGroup.appendChild(removeBtn)
372
+ columnGroup.appendChild(countLabel)
373
+ columnGroup.appendChild(addBtn)
374
+ controls.appendChild(columnGroup)
375
+
376
+ return controls
377
+ },
378
+
379
+ updateControls(controls, node, getPos, editor) {
380
+ const currentLayout = this.options.layouts[node.attrs.layout]
381
+ const columnCount = currentLayout?.columns || 2
382
+
383
+ // Update layout buttons
384
+ controls.querySelectorAll("[data-layout]").forEach(btn => {
385
+ btn.classList.toggle("is-active", btn.dataset.layout === node.attrs.layout)
386
+ })
387
+
388
+ // Update count label
389
+ const countLabel = controls.querySelector(".inkpen-columns-controls__count")
390
+ if (countLabel) {
391
+ countLabel.textContent = `${columnCount} cols`
392
+ }
393
+
394
+ // Update add/remove button states
395
+ const removeBtn = controls.querySelector(".inkpen-columns-controls__btn--icon:first-child")
396
+ const addBtn = controls.querySelector(".inkpen-columns-controls__btn--icon:last-child")
397
+
398
+ if (removeBtn) removeBtn.disabled = columnCount <= 2
399
+ if (addBtn) addBtn.disabled = columnCount >= 4
400
+ }
401
+ })
402
+
403
+ export default Columns