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,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