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.
- checksums.yaml +7 -0
- data/.DS_Store +0 -0
- data/.rubocop.yml +8 -0
- data/.yardopts +11 -0
- data/CLAUDE.md +141 -0
- data/README.md +409 -0
- data/Rakefile +19 -0
- data/app/assets/javascripts/inkpen/controllers/editor_controller.js +2050 -0
- data/app/assets/javascripts/inkpen/controllers/sticky_toolbar_controller.js +667 -0
- data/app/assets/javascripts/inkpen/controllers/toolbar_controller.js +693 -0
- data/app/assets/javascripts/inkpen/export/html.js +637 -0
- data/app/assets/javascripts/inkpen/export/index.js +30 -0
- data/app/assets/javascripts/inkpen/export/markdown.js +697 -0
- data/app/assets/javascripts/inkpen/export/pdf.js +372 -0
- data/app/assets/javascripts/inkpen/extensions/advanced_table.js +640 -0
- data/app/assets/javascripts/inkpen/extensions/block_commands.js +300 -0
- data/app/assets/javascripts/inkpen/extensions/block_gutter.js +338 -0
- data/app/assets/javascripts/inkpen/extensions/callout.js +303 -0
- data/app/assets/javascripts/inkpen/extensions/columns.js +403 -0
- data/app/assets/javascripts/inkpen/extensions/database.js +990 -0
- data/app/assets/javascripts/inkpen/extensions/document_section.js +352 -0
- data/app/assets/javascripts/inkpen/extensions/drag_handle.js +407 -0
- data/app/assets/javascripts/inkpen/extensions/embed.js +629 -0
- data/app/assets/javascripts/inkpen/extensions/enhanced_image.js +566 -0
- data/app/assets/javascripts/inkpen/extensions/export_commands.js +271 -0
- data/app/assets/javascripts/inkpen/extensions/file_attachment.js +593 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/index.js +58 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table.js +638 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_cell.js +100 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_header.js +100 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_constants.js +152 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_helpers.js +254 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_menu.js +282 -0
- data/app/assets/javascripts/inkpen/extensions/preformatted.js +239 -0
- data/app/assets/javascripts/inkpen/extensions/section.js +281 -0
- data/app/assets/javascripts/inkpen/extensions/section_title.js +126 -0
- data/app/assets/javascripts/inkpen/extensions/slash_commands.js +439 -0
- data/app/assets/javascripts/inkpen/extensions/table_of_contents.js +474 -0
- data/app/assets/javascripts/inkpen/extensions/toggle_block.js +332 -0
- data/app/assets/javascripts/inkpen/index.js +87 -0
- data/app/assets/stylesheets/inkpen/advanced_table.css +514 -0
- data/app/assets/stylesheets/inkpen/animations.css +626 -0
- data/app/assets/stylesheets/inkpen/block_gutter.css +265 -0
- data/app/assets/stylesheets/inkpen/callout.css +359 -0
- data/app/assets/stylesheets/inkpen/columns.css +314 -0
- data/app/assets/stylesheets/inkpen/database.css +658 -0
- data/app/assets/stylesheets/inkpen/document_section.css +305 -0
- data/app/assets/stylesheets/inkpen/drag_drop.css +220 -0
- data/app/assets/stylesheets/inkpen/editor.css +652 -0
- data/app/assets/stylesheets/inkpen/embed.css +468 -0
- data/app/assets/stylesheets/inkpen/enhanced_image.css +453 -0
- data/app/assets/stylesheets/inkpen/export.css +499 -0
- data/app/assets/stylesheets/inkpen/file_attachment.css +347 -0
- data/app/assets/stylesheets/inkpen/footnotes.css +136 -0
- data/app/assets/stylesheets/inkpen/inkpen_table.css +608 -0
- data/app/assets/stylesheets/inkpen/preformatted.css +215 -0
- data/app/assets/stylesheets/inkpen/search_replace.css +58 -0
- data/app/assets/stylesheets/inkpen/section.css +236 -0
- data/app/assets/stylesheets/inkpen/slash_menu.css +252 -0
- data/app/assets/stylesheets/inkpen/sticky_toolbar.css +314 -0
- data/app/assets/stylesheets/inkpen/toc.css +386 -0
- data/app/assets/stylesheets/inkpen/toggle.css +260 -0
- data/app/helpers/inkpen/editor_helper.rb +114 -0
- data/app/views/inkpen/_editor.html.erb +139 -0
- data/config/importmap.rb +170 -0
- data/docs/.DS_Store +0 -0
- data/docs/CHANGELOG.md +571 -0
- data/docs/FEATURES.md +436 -0
- data/docs/ROADMAP.md +3029 -0
- data/docs/VISION.md +235 -0
- data/docs/extensions/INKPEN_TABLE.md +482 -0
- data/docs/thinking/CORRECTED_NO_VUE.md +756 -0
- data/docs/thinking/EXECUTIVE_SUMMARY.md +403 -0
- data/docs/thinking/INKPEN_CODE_SAMPLES.md +1479 -0
- data/docs/thinking/INKPEN_MASTER_GUIDE.md +891 -0
- data/docs/thinking/README_START_HERE.md +341 -0
- data/lib/inkpen/configuration.rb +175 -0
- data/lib/inkpen/editor.rb +204 -0
- data/lib/inkpen/engine.rb +32 -0
- data/lib/inkpen/extensions/base.rb +109 -0
- data/lib/inkpen/extensions/code_block_syntax.rb +177 -0
- data/lib/inkpen/extensions/document_section.rb +111 -0
- data/lib/inkpen/extensions/forced_document.rb +183 -0
- data/lib/inkpen/extensions/mention.rb +155 -0
- data/lib/inkpen/extensions/preformatted.rb +111 -0
- data/lib/inkpen/extensions/section.rb +139 -0
- data/lib/inkpen/extensions/slash_commands.rb +100 -0
- data/lib/inkpen/extensions/table.rb +182 -0
- data/lib/inkpen/extensions/task_list.rb +145 -0
- data/lib/inkpen/sticky_toolbar.rb +157 -0
- data/lib/inkpen/toolbar.rb +145 -0
- data/lib/inkpen/version.rb +5 -0
- data/lib/inkpen.rb +101 -0
- data/sig/inkpen.rbs +4 -0
- 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
|