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,300 @@
|
|
|
1
|
+
import { Extension } from "@tiptap/core"
|
|
2
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state"
|
|
3
|
+
import { Decoration, DecorationSet } from "@tiptap/pm/view"
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Block Commands Extension for TipTap
|
|
7
|
+
*
|
|
8
|
+
* Adds block-level operations like selection, duplication, and deletion.
|
|
9
|
+
* Part of Phase 3 polish for BlockNote-style editing experience.
|
|
10
|
+
*
|
|
11
|
+
* Features:
|
|
12
|
+
* - Block selection via gutter click
|
|
13
|
+
* - Multi-block selection with Shift+Click
|
|
14
|
+
* - Duplicate block (Cmd+D)
|
|
15
|
+
* - Delete empty block (Backspace)
|
|
16
|
+
* - Select all content in block (Cmd+A when block selected)
|
|
17
|
+
*
|
|
18
|
+
* @since 0.4.0
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
const BLOCK_COMMANDS_KEY = new PluginKey("blockCommands")
|
|
22
|
+
|
|
23
|
+
export const BlockCommands = Extension.create({
|
|
24
|
+
name: "blockCommands",
|
|
25
|
+
|
|
26
|
+
addOptions() {
|
|
27
|
+
return {
|
|
28
|
+
// Class for selected blocks
|
|
29
|
+
selectedClass: "is-selected",
|
|
30
|
+
// Enable block selection via gutter
|
|
31
|
+
enableGutterSelection: true
|
|
32
|
+
}
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
addCommands() {
|
|
36
|
+
return {
|
|
37
|
+
// Duplicate the current block
|
|
38
|
+
duplicateBlock: () => ({ state, dispatch }) => {
|
|
39
|
+
const { selection, doc } = state
|
|
40
|
+
const { $from } = selection
|
|
41
|
+
|
|
42
|
+
// Find the current block
|
|
43
|
+
let blockPos = null
|
|
44
|
+
let blockNode = null
|
|
45
|
+
|
|
46
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
47
|
+
const node = $from.node(d)
|
|
48
|
+
const parent = $from.node(d - 1)
|
|
49
|
+
|
|
50
|
+
if (parent.type.name === "doc") {
|
|
51
|
+
blockPos = $from.before(d)
|
|
52
|
+
blockNode = node
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (blockPos === null || !blockNode) return false
|
|
58
|
+
|
|
59
|
+
if (dispatch) {
|
|
60
|
+
const insertPos = blockPos + blockNode.nodeSize
|
|
61
|
+
const tr = state.tr.insert(insertPos, blockNode.copy(blockNode.content))
|
|
62
|
+
|
|
63
|
+
// Move cursor to the duplicated block
|
|
64
|
+
const newBlockStart = insertPos + 1
|
|
65
|
+
tr.setSelection(state.selection.constructor.near(tr.doc.resolve(newBlockStart)))
|
|
66
|
+
|
|
67
|
+
dispatch(tr.scrollIntoView())
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return true
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
// Delete the current block
|
|
74
|
+
deleteBlock: () => ({ state, dispatch }) => {
|
|
75
|
+
const { selection, doc } = state
|
|
76
|
+
const { $from } = selection
|
|
77
|
+
|
|
78
|
+
// Find the current block
|
|
79
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
80
|
+
const node = $from.node(d)
|
|
81
|
+
const parent = $from.node(d - 1)
|
|
82
|
+
|
|
83
|
+
if (parent.type.name === "doc") {
|
|
84
|
+
const pos = $from.before(d)
|
|
85
|
+
|
|
86
|
+
// Don't delete if it's the only block
|
|
87
|
+
if (doc.childCount <= 1) return false
|
|
88
|
+
|
|
89
|
+
if (dispatch) {
|
|
90
|
+
const tr = state.tr.delete(pos, pos + node.nodeSize)
|
|
91
|
+
dispatch(tr.scrollIntoView())
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return false
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
// Select the entire current block
|
|
102
|
+
selectBlock: () => ({ state, dispatch, editor }) => {
|
|
103
|
+
const { selection, doc } = state
|
|
104
|
+
const { $from } = selection
|
|
105
|
+
|
|
106
|
+
// Find the current block
|
|
107
|
+
for (let d = $from.depth; d > 0; d--) {
|
|
108
|
+
const node = $from.node(d)
|
|
109
|
+
const parent = $from.node(d - 1)
|
|
110
|
+
|
|
111
|
+
if (parent.type.name === "doc") {
|
|
112
|
+
const pos = $from.before(d)
|
|
113
|
+
|
|
114
|
+
if (dispatch) {
|
|
115
|
+
// Use NodeSelection to select the whole block
|
|
116
|
+
const NodeSelection = state.selection.constructor.name === "NodeSelection"
|
|
117
|
+
? state.selection.constructor
|
|
118
|
+
: require("@tiptap/pm/state").NodeSelection
|
|
119
|
+
|
|
120
|
+
const tr = state.tr.setSelection(
|
|
121
|
+
NodeSelection.create(doc, pos)
|
|
122
|
+
)
|
|
123
|
+
dispatch(tr)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return true
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return false
|
|
131
|
+
},
|
|
132
|
+
|
|
133
|
+
// Select block at a specific position
|
|
134
|
+
selectBlockAt: (pos) => ({ state, dispatch }) => {
|
|
135
|
+
const { doc } = state
|
|
136
|
+
const node = doc.nodeAt(pos)
|
|
137
|
+
|
|
138
|
+
if (!node) return false
|
|
139
|
+
|
|
140
|
+
if (dispatch) {
|
|
141
|
+
const { NodeSelection } = require("@tiptap/pm/state")
|
|
142
|
+
const tr = state.tr.setSelection(NodeSelection.create(doc, pos))
|
|
143
|
+
dispatch(tr)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return true
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
addKeyboardShortcuts() {
|
|
152
|
+
return {
|
|
153
|
+
// Duplicate block
|
|
154
|
+
"Mod-d": () => {
|
|
155
|
+
// Prevent default browser bookmark action
|
|
156
|
+
return this.editor.commands.duplicateBlock()
|
|
157
|
+
},
|
|
158
|
+
|
|
159
|
+
// Delete block when empty
|
|
160
|
+
Backspace: ({ editor }) => {
|
|
161
|
+
const { state } = editor
|
|
162
|
+
const { selection, doc } = state
|
|
163
|
+
const { $from, empty } = selection
|
|
164
|
+
|
|
165
|
+
// Only handle if selection is empty (just cursor)
|
|
166
|
+
if (!empty) return false
|
|
167
|
+
|
|
168
|
+
// Check if we're at the start of an empty block
|
|
169
|
+
const parentOffset = $from.parentOffset
|
|
170
|
+
|
|
171
|
+
if (parentOffset !== 0) return false
|
|
172
|
+
|
|
173
|
+
// Check if the parent is empty
|
|
174
|
+
const parent = $from.parent
|
|
175
|
+
if (parent.textContent !== "") return false
|
|
176
|
+
|
|
177
|
+
// Check if we're in a top-level block
|
|
178
|
+
const grandParent = $from.node($from.depth - 1)
|
|
179
|
+
if (grandParent && grandParent.type.name !== "doc") return false
|
|
180
|
+
|
|
181
|
+
// Don't delete the last remaining block
|
|
182
|
+
if (doc.childCount <= 1) return false
|
|
183
|
+
|
|
184
|
+
return editor.commands.deleteBlock()
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
addProseMirrorPlugins() {
|
|
190
|
+
const extension = this
|
|
191
|
+
|
|
192
|
+
return [
|
|
193
|
+
new Plugin({
|
|
194
|
+
key: BLOCK_COMMANDS_KEY,
|
|
195
|
+
|
|
196
|
+
state: {
|
|
197
|
+
init() {
|
|
198
|
+
return {
|
|
199
|
+
selectedBlocks: new Set()
|
|
200
|
+
}
|
|
201
|
+
},
|
|
202
|
+
apply(tr, value, oldState, newState) {
|
|
203
|
+
const meta = tr.getMeta(BLOCK_COMMANDS_KEY)
|
|
204
|
+
if (meta) {
|
|
205
|
+
return { ...value, ...meta }
|
|
206
|
+
}
|
|
207
|
+
// Clear selection on document change
|
|
208
|
+
if (tr.docChanged) {
|
|
209
|
+
return { selectedBlocks: new Set() }
|
|
210
|
+
}
|
|
211
|
+
return value
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
|
|
215
|
+
props: {
|
|
216
|
+
decorations(state) {
|
|
217
|
+
const pluginState = BLOCK_COMMANDS_KEY.getState(state)
|
|
218
|
+
if (!pluginState || pluginState.selectedBlocks.size === 0) {
|
|
219
|
+
return DecorationSet.empty
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const decorations = []
|
|
223
|
+
|
|
224
|
+
pluginState.selectedBlocks.forEach(pos => {
|
|
225
|
+
const node = state.doc.nodeAt(pos)
|
|
226
|
+
if (node) {
|
|
227
|
+
decorations.push(
|
|
228
|
+
Decoration.node(pos, pos + node.nodeSize, {
|
|
229
|
+
class: extension.options.selectedClass
|
|
230
|
+
})
|
|
231
|
+
)
|
|
232
|
+
}
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
return DecorationSet.create(state.doc, decorations)
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
handleClick(view, pos, event) {
|
|
239
|
+
// Handle gutter clicks for block selection
|
|
240
|
+
const target = event.target
|
|
241
|
+
|
|
242
|
+
if (!target.closest(".inkpen-block-gutter__drag")) {
|
|
243
|
+
return false
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const gutter = target.closest(".inkpen-block-gutter")
|
|
247
|
+
if (!gutter) return false
|
|
248
|
+
|
|
249
|
+
const blockPos = parseInt(gutter.dataset.pos)
|
|
250
|
+
if (isNaN(blockPos)) return false
|
|
251
|
+
|
|
252
|
+
const { state } = view
|
|
253
|
+
const pluginState = BLOCK_COMMANDS_KEY.getState(state)
|
|
254
|
+
const currentSelected = new Set(pluginState.selectedBlocks)
|
|
255
|
+
|
|
256
|
+
if (event.shiftKey) {
|
|
257
|
+
// Toggle selection for multi-select
|
|
258
|
+
if (currentSelected.has(blockPos)) {
|
|
259
|
+
currentSelected.delete(blockPos)
|
|
260
|
+
} else {
|
|
261
|
+
currentSelected.add(blockPos)
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
// Single select
|
|
265
|
+
currentSelected.clear()
|
|
266
|
+
currentSelected.add(blockPos)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
view.dispatch(
|
|
270
|
+
state.tr.setMeta(BLOCK_COMMANDS_KEY, {
|
|
271
|
+
selectedBlocks: currentSelected
|
|
272
|
+
})
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
return true
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
handleKeyDown(view, event) {
|
|
279
|
+
const { state } = view
|
|
280
|
+
const pluginState = BLOCK_COMMANDS_KEY.getState(state)
|
|
281
|
+
|
|
282
|
+
// Clear selection on Escape
|
|
283
|
+
if (event.key === "Escape" && pluginState.selectedBlocks.size > 0) {
|
|
284
|
+
view.dispatch(
|
|
285
|
+
state.tr.setMeta(BLOCK_COMMANDS_KEY, {
|
|
286
|
+
selectedBlocks: new Set()
|
|
287
|
+
})
|
|
288
|
+
)
|
|
289
|
+
return true
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return false
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
})
|
|
296
|
+
]
|
|
297
|
+
}
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
export default BlockCommands
|
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { Extension } from "@tiptap/core"
|
|
2
|
+
import { Plugin, PluginKey } from "@tiptap/pm/state"
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Block Gutter Extension for TipTap
|
|
6
|
+
*
|
|
7
|
+
* Adds a left-side gutter with drag handles and plus buttons for each block.
|
|
8
|
+
* Similar to Notion/Editor.js block handles.
|
|
9
|
+
*
|
|
10
|
+
* Features:
|
|
11
|
+
* - Drag handle (⋮⋮) for block reordering
|
|
12
|
+
* - Plus button (+) to insert new block below
|
|
13
|
+
* - Shows on hover, hides when not focused
|
|
14
|
+
* - Skips blocks inside tables
|
|
15
|
+
* - Integrates with slash commands
|
|
16
|
+
*
|
|
17
|
+
* @example
|
|
18
|
+
* // The gutter appears on block hover:
|
|
19
|
+
* // ⋮⋮ + │ Heading text
|
|
20
|
+
* // ⋮⋮ + │ Paragraph content...
|
|
21
|
+
*
|
|
22
|
+
* @since 0.3.1
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const BLOCK_GUTTER_KEY = new PluginKey("blockGutter")
|
|
26
|
+
|
|
27
|
+
export const BlockGutter = Extension.create({
|
|
28
|
+
name: "blockGutter",
|
|
29
|
+
|
|
30
|
+
addOptions() {
|
|
31
|
+
return {
|
|
32
|
+
// Show drag handle
|
|
33
|
+
showDragHandle: true,
|
|
34
|
+
// Show plus button
|
|
35
|
+
showPlusButton: true,
|
|
36
|
+
// Class for the gutter container
|
|
37
|
+
gutterClass: "inkpen-block-gutter",
|
|
38
|
+
// Class for the drag handle
|
|
39
|
+
dragClass: "inkpen-block-gutter__drag",
|
|
40
|
+
// Class for the plus button
|
|
41
|
+
plusClass: "inkpen-block-gutter__plus",
|
|
42
|
+
// Block types to skip (don't show gutter)
|
|
43
|
+
skipTypes: ["tableRow", "tableCell", "tableHeader", "listItem", "taskItem"],
|
|
44
|
+
// Parent types to skip (don't show gutter for children)
|
|
45
|
+
skipParentTypes: ["table", "tableRow"],
|
|
46
|
+
// Callback when plus button is clicked
|
|
47
|
+
onPlusClick: null,
|
|
48
|
+
// Callback when drag starts
|
|
49
|
+
onDragStart: null,
|
|
50
|
+
// Callback when drag ends
|
|
51
|
+
onDragEnd: null
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
addProseMirrorPlugins() {
|
|
56
|
+
const extension = this
|
|
57
|
+
const editor = this.editor
|
|
58
|
+
|
|
59
|
+
return [
|
|
60
|
+
new Plugin({
|
|
61
|
+
key: BLOCK_GUTTER_KEY,
|
|
62
|
+
|
|
63
|
+
view(editorView) {
|
|
64
|
+
// Container for all gutters
|
|
65
|
+
const container = document.createElement("div")
|
|
66
|
+
container.className = "inkpen-block-gutter-container"
|
|
67
|
+
container.setAttribute("aria-hidden", "true")
|
|
68
|
+
|
|
69
|
+
// Track current gutters
|
|
70
|
+
let gutters = new Map()
|
|
71
|
+
let hoveredPos = null
|
|
72
|
+
let isDragging = false
|
|
73
|
+
|
|
74
|
+
// Create gutter element for a block
|
|
75
|
+
const createGutter = (pos, node) => {
|
|
76
|
+
const gutter = document.createElement("div")
|
|
77
|
+
gutter.className = extension.options.gutterClass
|
|
78
|
+
gutter.dataset.pos = pos.toString()
|
|
79
|
+
gutter.dataset.nodeType = node.type.name
|
|
80
|
+
|
|
81
|
+
// Drag handle
|
|
82
|
+
if (extension.options.showDragHandle) {
|
|
83
|
+
const dragHandle = document.createElement("button")
|
|
84
|
+
dragHandle.type = "button"
|
|
85
|
+
dragHandle.className = extension.options.dragClass
|
|
86
|
+
dragHandle.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
|
87
|
+
<circle cx="4" cy="3" r="1.5"/>
|
|
88
|
+
<circle cx="10" cy="3" r="1.5"/>
|
|
89
|
+
<circle cx="4" cy="7" r="1.5"/>
|
|
90
|
+
<circle cx="10" cy="7" r="1.5"/>
|
|
91
|
+
<circle cx="4" cy="11" r="1.5"/>
|
|
92
|
+
<circle cx="10" cy="11" r="1.5"/>
|
|
93
|
+
</svg>`
|
|
94
|
+
dragHandle.setAttribute("aria-label", "Drag to reorder")
|
|
95
|
+
dragHandle.setAttribute("draggable", "true")
|
|
96
|
+
|
|
97
|
+
dragHandle.addEventListener("mousedown", (e) => {
|
|
98
|
+
e.preventDefault()
|
|
99
|
+
// Select the block when clicking drag handle
|
|
100
|
+
const blockPos = parseInt(gutter.dataset.pos)
|
|
101
|
+
const $pos = editorView.state.doc.resolve(blockPos)
|
|
102
|
+
const nodeAtPos = editorView.state.doc.nodeAt(blockPos)
|
|
103
|
+
if (nodeAtPos) {
|
|
104
|
+
const selection = editor.state.selection.constructor.near($pos)
|
|
105
|
+
editor.view.dispatch(editor.state.tr.setSelection(selection))
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
dragHandle.addEventListener("dragstart", (e) => {
|
|
110
|
+
isDragging = true
|
|
111
|
+
const blockPos = parseInt(gutter.dataset.pos)
|
|
112
|
+
const nodeAtPos = editorView.state.doc.nodeAt(blockPos)
|
|
113
|
+
|
|
114
|
+
// Set drag data
|
|
115
|
+
e.dataTransfer.effectAllowed = "move"
|
|
116
|
+
e.dataTransfer.setData("text/plain", blockPos.toString())
|
|
117
|
+
e.dataTransfer.setData("application/inkpen-block", JSON.stringify({
|
|
118
|
+
pos: blockPos,
|
|
119
|
+
type: nodeAtPos?.type.name
|
|
120
|
+
}))
|
|
121
|
+
|
|
122
|
+
// Create custom drag image (ghost)
|
|
123
|
+
const ghost = document.createElement("div")
|
|
124
|
+
ghost.className = "inkpen-drag-ghost"
|
|
125
|
+
ghost.textContent = nodeAtPos?.textContent?.slice(0, 50) || nodeAtPos?.type.name || "Block"
|
|
126
|
+
document.body.appendChild(ghost)
|
|
127
|
+
e.dataTransfer.setDragImage(ghost, 0, 0)
|
|
128
|
+
|
|
129
|
+
// Remove ghost after drag starts
|
|
130
|
+
requestAnimationFrame(() => {
|
|
131
|
+
ghost.remove()
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
// Add dragging class
|
|
135
|
+
gutter.classList.add("is-dragging")
|
|
136
|
+
editorView.dom.classList.add("is-block-dragging")
|
|
137
|
+
|
|
138
|
+
// Callback
|
|
139
|
+
extension.options.onDragStart?.(blockPos, nodeAtPos, e)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
dragHandle.addEventListener("dragend", (e) => {
|
|
143
|
+
isDragging = false
|
|
144
|
+
gutter.classList.remove("is-dragging")
|
|
145
|
+
editorView.dom.classList.remove("is-block-dragging")
|
|
146
|
+
extension.options.onDragEnd?.(e)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
gutter.appendChild(dragHandle)
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Plus button
|
|
153
|
+
if (extension.options.showPlusButton) {
|
|
154
|
+
const plusBtn = document.createElement("button")
|
|
155
|
+
plusBtn.type = "button"
|
|
156
|
+
plusBtn.className = extension.options.plusClass
|
|
157
|
+
plusBtn.innerHTML = `<svg width="14" height="14" viewBox="0 0 14 14" fill="currentColor">
|
|
158
|
+
<path d="M7 1v12M1 7h12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
|
159
|
+
</svg>`
|
|
160
|
+
plusBtn.setAttribute("aria-label", "Add block below")
|
|
161
|
+
|
|
162
|
+
plusBtn.addEventListener("click", (e) => {
|
|
163
|
+
e.preventDefault()
|
|
164
|
+
e.stopPropagation()
|
|
165
|
+
|
|
166
|
+
const blockPos = parseInt(gutter.dataset.pos)
|
|
167
|
+
const nodeAtPos = editorView.state.doc.nodeAt(blockPos)
|
|
168
|
+
const insertPos = blockPos + (nodeAtPos?.nodeSize || 1)
|
|
169
|
+
|
|
170
|
+
// Insert a new paragraph and focus it
|
|
171
|
+
editor.chain()
|
|
172
|
+
.focus()
|
|
173
|
+
.insertContentAt(insertPos, { type: "paragraph" })
|
|
174
|
+
.setTextSelection(insertPos + 1)
|
|
175
|
+
.run()
|
|
176
|
+
|
|
177
|
+
// If slash commands extension is active, trigger it
|
|
178
|
+
// by inserting "/" character
|
|
179
|
+
setTimeout(() => {
|
|
180
|
+
if (editor.extensionManager.extensions.find(ext => ext.name === "slashCommands")) {
|
|
181
|
+
editor.commands.insertContent("/")
|
|
182
|
+
}
|
|
183
|
+
}, 10)
|
|
184
|
+
|
|
185
|
+
// Callback
|
|
186
|
+
extension.options.onPlusClick?.(insertPos, e)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
gutter.appendChild(plusBtn)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return gutter
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Position a gutter element
|
|
196
|
+
const positionGutter = (gutter, pos) => {
|
|
197
|
+
try {
|
|
198
|
+
const coords = editorView.coordsAtPos(pos)
|
|
199
|
+
const editorRect = editorView.dom.getBoundingClientRect()
|
|
200
|
+
|
|
201
|
+
// Position relative to editor
|
|
202
|
+
gutter.style.top = `${coords.top - editorRect.top}px`
|
|
203
|
+
} catch (e) {
|
|
204
|
+
// Position might be invalid
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Update all gutters
|
|
209
|
+
const updateGutters = () => {
|
|
210
|
+
const { doc } = editorView.state
|
|
211
|
+
const newGutters = new Map()
|
|
212
|
+
const skipTypes = extension.options.skipTypes || []
|
|
213
|
+
const skipParentTypes = extension.options.skipParentTypes || []
|
|
214
|
+
|
|
215
|
+
// Find all top-level blocks
|
|
216
|
+
doc.forEach((node, pos) => {
|
|
217
|
+
// Skip certain node types
|
|
218
|
+
if (skipTypes.includes(node.type.name)) return
|
|
219
|
+
|
|
220
|
+
// Check if inside a skipped parent
|
|
221
|
+
const $pos = doc.resolve(pos)
|
|
222
|
+
for (let d = $pos.depth; d > 0; d--) {
|
|
223
|
+
if (skipParentTypes.includes($pos.node(d).type.name)) return
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Reuse existing gutter or create new one
|
|
227
|
+
let gutter = gutters.get(pos)
|
|
228
|
+
if (!gutter) {
|
|
229
|
+
gutter = createGutter(pos, node)
|
|
230
|
+
container.appendChild(gutter)
|
|
231
|
+
} else {
|
|
232
|
+
// Update data attributes
|
|
233
|
+
gutter.dataset.pos = pos.toString()
|
|
234
|
+
gutter.dataset.nodeType = node.type.name
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
positionGutter(gutter, pos)
|
|
238
|
+
newGutters.set(pos, gutter)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
// Remove old gutters
|
|
242
|
+
for (const [pos, gutter] of gutters) {
|
|
243
|
+
if (!newGutters.has(pos)) {
|
|
244
|
+
gutter.remove()
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
gutters = newGutters
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Handle mouse movement to show/hide gutters
|
|
252
|
+
const handleMouseMove = (e) => {
|
|
253
|
+
if (isDragging) return
|
|
254
|
+
|
|
255
|
+
const target = e.target
|
|
256
|
+
const editorRect = editorView.dom.getBoundingClientRect()
|
|
257
|
+
|
|
258
|
+
// Check if mouse is in gutter area (left side of editor)
|
|
259
|
+
const isInGutterArea = e.clientX < editorRect.left + 60
|
|
260
|
+
|
|
261
|
+
if (!isInGutterArea && !target.closest(".inkpen-block-gutter")) {
|
|
262
|
+
// Find which block we're hovering
|
|
263
|
+
const pos = editorView.posAtCoords({ left: editorRect.left + 10, top: e.clientY })
|
|
264
|
+
if (pos) {
|
|
265
|
+
try {
|
|
266
|
+
const $pos = editorView.state.doc.resolve(pos.pos)
|
|
267
|
+
// Get the top-level block position (guard against depth 0)
|
|
268
|
+
const blockPos = $pos.depth > 0 ? $pos.before($pos.depth) : 0
|
|
269
|
+
|
|
270
|
+
if (blockPos !== hoveredPos) {
|
|
271
|
+
hoveredPos = blockPos
|
|
272
|
+
// Show only the hovered gutter
|
|
273
|
+
for (const [gpos, gutter] of gutters) {
|
|
274
|
+
gutter.classList.toggle("is-visible", gpos === blockPos)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
} catch (e) {
|
|
278
|
+
// Invalid position, ignore
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Handle mouse enter on gutter area
|
|
285
|
+
const handleGutterEnter = () => {
|
|
286
|
+
// Show all gutters when in gutter area
|
|
287
|
+
for (const gutter of gutters.values()) {
|
|
288
|
+
gutter.classList.add("is-visible")
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Handle mouse leave from gutter area
|
|
293
|
+
const handleGutterLeave = () => {
|
|
294
|
+
if (isDragging) return
|
|
295
|
+
// Hide all gutters
|
|
296
|
+
for (const gutter of gutters.values()) {
|
|
297
|
+
gutter.classList.remove("is-visible")
|
|
298
|
+
}
|
|
299
|
+
hoveredPos = null
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Append container to editor wrapper
|
|
303
|
+
const editorWrapper = editorView.dom.parentElement
|
|
304
|
+
if (editorWrapper) {
|
|
305
|
+
editorWrapper.style.position = "relative"
|
|
306
|
+
editorWrapper.appendChild(container)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Set up event listeners
|
|
310
|
+
editorView.dom.addEventListener("mousemove", handleMouseMove)
|
|
311
|
+
container.addEventListener("mouseenter", handleGutterEnter)
|
|
312
|
+
container.addEventListener("mouseleave", handleGutterLeave)
|
|
313
|
+
|
|
314
|
+
// Initial render
|
|
315
|
+
updateGutters()
|
|
316
|
+
|
|
317
|
+
return {
|
|
318
|
+
update(view, prevState) {
|
|
319
|
+
// Only update if document changed
|
|
320
|
+
if (!prevState.doc.eq(view.state.doc)) {
|
|
321
|
+
updateGutters()
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
|
|
325
|
+
destroy() {
|
|
326
|
+
editorView.dom.removeEventListener("mousemove", handleMouseMove)
|
|
327
|
+
container.removeEventListener("mouseenter", handleGutterEnter)
|
|
328
|
+
container.removeEventListener("mouseleave", handleGutterLeave)
|
|
329
|
+
container.remove()
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
})
|
|
334
|
+
]
|
|
335
|
+
}
|
|
336
|
+
})
|
|
337
|
+
|
|
338
|
+
export default BlockGutter
|