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,990 @@
1
+ import { Node } from "@tiptap/core"
2
+
3
+ /**
4
+ * Database Block Extension for TipTap
5
+ *
6
+ * Notion-style inline databases with multiple views.
7
+ *
8
+ * Features:
9
+ * - Property types: Text, Number, Select, Multi-select, Date, Checkbox, URL
10
+ * - Views: Table, List, Gallery, Board (Kanban)
11
+ * - Filters with AND/OR logic
12
+ * - Sorting (single and multi-column)
13
+ * - Inline database (embedded in document)
14
+ * - Real-time updates
15
+ *
16
+ * @example
17
+ * editor.commands.insertDatabase()
18
+ * editor.commands.insertDatabase({ title: 'Tasks', view: 'board' })
19
+ *
20
+ * @since 0.6.0
21
+ */
22
+
23
+ // Property type definitions
24
+ const PROPERTY_TYPES = {
25
+ text: { icon: "Aa", label: "Text", default: "" },
26
+ number: { icon: "#", label: "Number", default: 0 },
27
+ select: { icon: "▼", label: "Select", default: null },
28
+ multiSelect: { icon: "☰", label: "Multi-select", default: [] },
29
+ date: { icon: "📅", label: "Date", default: null },
30
+ checkbox: { icon: "☑", label: "Checkbox", default: false },
31
+ url: { icon: "🔗", label: "URL", default: "" }
32
+ }
33
+
34
+ // View type definitions
35
+ const VIEW_TYPES = {
36
+ table: { icon: "⊞", label: "Table" },
37
+ list: { icon: "☰", label: "List" },
38
+ gallery: { icon: "⊟", label: "Gallery" },
39
+ board: { icon: "▣", label: "Board" }
40
+ }
41
+
42
+ // Default select options colors
43
+ const SELECT_COLORS = ["gray", "red", "orange", "yellow", "green", "blue", "purple", "pink"]
44
+
45
+ // Generate unique ID
46
+ function generateId() {
47
+ return `db-${Date.now().toString(36)}-${Math.random().toString(36).substr(2, 9)}`
48
+ }
49
+
50
+ export const Database = Node.create({
51
+ name: "database",
52
+
53
+ group: "block",
54
+ atom: true,
55
+ draggable: true,
56
+ selectable: true,
57
+
58
+ addOptions() {
59
+ return {
60
+ defaultTitle: "Untitled Database",
61
+ defaultView: "table",
62
+ propertyTypes: PROPERTY_TYPES,
63
+ viewTypes: VIEW_TYPES,
64
+ selectColors: SELECT_COLORS,
65
+ HTMLAttributes: {
66
+ class: "inkpen-database"
67
+ }
68
+ }
69
+ },
70
+
71
+ addAttributes() {
72
+ return {
73
+ id: {
74
+ default: null,
75
+ parseHTML: element => element.getAttribute("data-id") || generateId(),
76
+ renderHTML: attributes => ({ "data-id": attributes.id || generateId() })
77
+ },
78
+ title: {
79
+ default: this.options.defaultTitle,
80
+ parseHTML: element => element.getAttribute("data-title") || this.options.defaultTitle,
81
+ renderHTML: attributes => ({ "data-title": attributes.title })
82
+ },
83
+ properties: {
84
+ default: [
85
+ { id: "name", name: "Name", type: "text", width: 200 },
86
+ { id: "status", name: "Status", type: "select", options: [
87
+ { id: "todo", label: "To Do", color: "gray" },
88
+ { id: "progress", label: "In Progress", color: "blue" },
89
+ { id: "done", label: "Done", color: "green" }
90
+ ]}
91
+ ],
92
+ parseHTML: element => {
93
+ try {
94
+ return JSON.parse(element.getAttribute("data-properties") || "[]")
95
+ } catch {
96
+ return []
97
+ }
98
+ },
99
+ renderHTML: attributes => ({ "data-properties": JSON.stringify(attributes.properties) })
100
+ },
101
+ rows: {
102
+ default: [],
103
+ parseHTML: element => {
104
+ try {
105
+ return JSON.parse(element.getAttribute("data-rows") || "[]")
106
+ } catch {
107
+ return []
108
+ }
109
+ },
110
+ renderHTML: attributes => ({ "data-rows": JSON.stringify(attributes.rows) })
111
+ },
112
+ activeView: {
113
+ default: "table",
114
+ parseHTML: element => element.getAttribute("data-view") || "table",
115
+ renderHTML: attributes => ({ "data-view": attributes.activeView })
116
+ },
117
+ filters: {
118
+ default: [],
119
+ parseHTML: element => {
120
+ try {
121
+ return JSON.parse(element.getAttribute("data-filters") || "[]")
122
+ } catch {
123
+ return []
124
+ }
125
+ },
126
+ renderHTML: attributes => ({ "data-filters": JSON.stringify(attributes.filters) })
127
+ },
128
+ sorts: {
129
+ default: [],
130
+ parseHTML: element => {
131
+ try {
132
+ return JSON.parse(element.getAttribute("data-sorts") || "[]")
133
+ } catch {
134
+ return []
135
+ }
136
+ },
137
+ renderHTML: attributes => ({ "data-sorts": JSON.stringify(attributes.sorts) })
138
+ },
139
+ groupBy: {
140
+ default: null,
141
+ parseHTML: element => element.getAttribute("data-group-by"),
142
+ renderHTML: attributes => attributes.groupBy ? { "data-group-by": attributes.groupBy } : {}
143
+ }
144
+ }
145
+ },
146
+
147
+ parseHTML() {
148
+ return [
149
+ { tag: "div.inkpen-database" },
150
+ { tag: "[data-database]" }
151
+ ]
152
+ },
153
+
154
+ renderHTML({ HTMLAttributes }) {
155
+ return ["div", { ...this.options.HTMLAttributes, ...HTMLAttributes, "data-database": "" }, 0]
156
+ },
157
+
158
+ addNodeView() {
159
+ return ({ node, editor, getPos }) => {
160
+ const extension = this
161
+ let currentNode = node
162
+
163
+ // Main container
164
+ const dom = document.createElement("div")
165
+ dom.className = `inkpen-database inkpen-database--${node.attrs.activeView}`
166
+
167
+ // Render function
168
+ const render = () => {
169
+ dom.innerHTML = ""
170
+ dom.className = `inkpen-database inkpen-database--${currentNode.attrs.activeView}`
171
+
172
+ // Header
173
+ const header = extension.renderHeader(currentNode, editor, getPos)
174
+ dom.appendChild(header)
175
+
176
+ // Content based on view type
177
+ const content = document.createElement("div")
178
+ content.className = "inkpen-database__content"
179
+
180
+ switch (currentNode.attrs.activeView) {
181
+ case "table":
182
+ content.appendChild(extension.renderTableView(currentNode, editor, getPos))
183
+ break
184
+ case "board":
185
+ content.appendChild(extension.renderBoardView(currentNode, editor, getPos))
186
+ break
187
+ case "list":
188
+ content.appendChild(extension.renderListView(currentNode, editor, getPos))
189
+ break
190
+ case "gallery":
191
+ content.appendChild(extension.renderGalleryView(currentNode, editor, getPos))
192
+ break
193
+ }
194
+
195
+ dom.appendChild(content)
196
+ }
197
+
198
+ render()
199
+
200
+ return {
201
+ dom,
202
+ update: (updatedNode) => {
203
+ if (updatedNode.type.name !== "database") return false
204
+ currentNode = updatedNode
205
+ render()
206
+ return true
207
+ }
208
+ }
209
+ }
210
+ },
211
+
212
+ addCommands() {
213
+ return {
214
+ insertDatabase: (options = {}) => ({ commands }) => {
215
+ const id = generateId()
216
+ return commands.insertContent({
217
+ type: this.name,
218
+ attrs: {
219
+ id,
220
+ title: options.title || this.options.defaultTitle,
221
+ activeView: options.view || this.options.defaultView,
222
+ properties: options.properties || [
223
+ { id: "name", name: "Name", type: "text", width: 200 },
224
+ { id: "status", name: "Status", type: "select", options: [
225
+ { id: "todo", label: "To Do", color: "gray" },
226
+ { id: "progress", label: "In Progress", color: "blue" },
227
+ { id: "done", label: "Done", color: "green" }
228
+ ]}
229
+ ],
230
+ rows: options.rows || [],
231
+ groupBy: options.groupBy || "status"
232
+ }
233
+ })
234
+ },
235
+
236
+ setDatabaseTitle: (title) => ({ tr, state, dispatch }) => {
237
+ const node = findDatabaseNode(state.selection)
238
+ if (!node) return false
239
+ if (dispatch) {
240
+ tr.setNodeMarkup(node.pos, undefined, { ...node.node.attrs, title })
241
+ }
242
+ return true
243
+ },
244
+
245
+ setDatabaseView: (view) => ({ tr, state, dispatch }) => {
246
+ const node = findDatabaseNode(state.selection)
247
+ if (!node) return false
248
+ if (dispatch) {
249
+ tr.setNodeMarkup(node.pos, undefined, { ...node.node.attrs, activeView: view })
250
+ }
251
+ return true
252
+ },
253
+
254
+ addDatabaseRow: (rowData = {}) => ({ tr, state, dispatch }) => {
255
+ const node = findDatabaseNode(state.selection)
256
+ if (!node) return false
257
+
258
+ const newRow = {
259
+ id: generateId(),
260
+ ...rowData
261
+ }
262
+
263
+ // Set defaults for each property
264
+ node.node.attrs.properties.forEach(prop => {
265
+ if (newRow[prop.id] === undefined) {
266
+ newRow[prop.id] = PROPERTY_TYPES[prop.type]?.default ?? ""
267
+ }
268
+ })
269
+
270
+ if (dispatch) {
271
+ tr.setNodeMarkup(node.pos, undefined, {
272
+ ...node.node.attrs,
273
+ rows: [...node.node.attrs.rows, newRow]
274
+ })
275
+ }
276
+ return true
277
+ },
278
+
279
+ updateDatabaseRow: (rowId, updates) => ({ tr, state, dispatch }) => {
280
+ const node = findDatabaseNode(state.selection)
281
+ if (!node) return false
282
+
283
+ const rows = node.node.attrs.rows.map(row =>
284
+ row.id === rowId ? { ...row, ...updates } : row
285
+ )
286
+
287
+ if (dispatch) {
288
+ tr.setNodeMarkup(node.pos, undefined, { ...node.node.attrs, rows })
289
+ }
290
+ return true
291
+ },
292
+
293
+ deleteDatabaseRow: (rowId) => ({ tr, state, dispatch }) => {
294
+ const node = findDatabaseNode(state.selection)
295
+ if (!node) return false
296
+
297
+ const rows = node.node.attrs.rows.filter(row => row.id !== rowId)
298
+
299
+ if (dispatch) {
300
+ tr.setNodeMarkup(node.pos, undefined, { ...node.node.attrs, rows })
301
+ }
302
+ return true
303
+ },
304
+
305
+ addDatabaseProperty: (propertyDef) => ({ tr, state, dispatch }) => {
306
+ const node = findDatabaseNode(state.selection)
307
+ if (!node) return false
308
+
309
+ const newProp = {
310
+ id: generateId(),
311
+ name: propertyDef.name || "New Property",
312
+ type: propertyDef.type || "text",
313
+ ...propertyDef
314
+ }
315
+
316
+ if (dispatch) {
317
+ tr.setNodeMarkup(node.pos, undefined, {
318
+ ...node.node.attrs,
319
+ properties: [...node.node.attrs.properties, newProp]
320
+ })
321
+ }
322
+ return true
323
+ }
324
+ }
325
+ },
326
+
327
+ // Private: Render header
328
+
329
+ renderHeader(node, editor, getPos) {
330
+ const header = document.createElement("div")
331
+ header.className = "inkpen-database__header"
332
+
333
+ // Title input
334
+ const titleInput = document.createElement("input")
335
+ titleInput.type = "text"
336
+ titleInput.className = "inkpen-database__title"
337
+ titleInput.value = node.attrs.title
338
+ titleInput.placeholder = "Untitled"
339
+
340
+ if (editor.isEditable) {
341
+ titleInput.addEventListener("input", () => {
342
+ this.updateNodeAttrs(getPos, node, { title: titleInput.value }, editor)
343
+ })
344
+ } else {
345
+ titleInput.readOnly = true
346
+ }
347
+
348
+ header.appendChild(titleInput)
349
+
350
+ // View tabs
351
+ const viewTabs = document.createElement("div")
352
+ viewTabs.className = "inkpen-database__views"
353
+
354
+ Object.entries(VIEW_TYPES).forEach(([viewType, { icon, label }]) => {
355
+ const tab = document.createElement("button")
356
+ tab.type = "button"
357
+ tab.className = "inkpen-database__view-tab"
358
+ if (node.attrs.activeView === viewType) tab.classList.add("is-active")
359
+ tab.innerHTML = `<span class="inkpen-database__view-icon">${icon}</span>`
360
+ tab.title = label
361
+
362
+ tab.addEventListener("mousedown", (e) => e.preventDefault())
363
+ tab.addEventListener("click", () => {
364
+ this.updateNodeAttrs(getPos, node, { activeView: viewType }, editor)
365
+ })
366
+
367
+ viewTabs.appendChild(tab)
368
+ })
369
+
370
+ header.appendChild(viewTabs)
371
+
372
+ // Actions
373
+ if (editor.isEditable) {
374
+ const actions = document.createElement("div")
375
+ actions.className = "inkpen-database__actions"
376
+
377
+ const newRowBtn = document.createElement("button")
378
+ newRowBtn.type = "button"
379
+ newRowBtn.className = "inkpen-database__action-btn"
380
+ newRowBtn.textContent = "+ New"
381
+ newRowBtn.title = "Add new row"
382
+
383
+ newRowBtn.addEventListener("mousedown", (e) => e.preventDefault())
384
+ newRowBtn.addEventListener("click", () => {
385
+ const newRow = { id: generateId() }
386
+ node.attrs.properties.forEach(prop => {
387
+ newRow[prop.id] = PROPERTY_TYPES[prop.type]?.default ?? ""
388
+ })
389
+ this.updateNodeAttrs(getPos, node, {
390
+ rows: [...node.attrs.rows, newRow]
391
+ }, editor)
392
+ })
393
+
394
+ actions.appendChild(newRowBtn)
395
+ header.appendChild(actions)
396
+ }
397
+
398
+ return header
399
+ },
400
+
401
+ // Private: Render table view
402
+
403
+ renderTableView(node, editor, getPos) {
404
+ const { properties, rows } = node.attrs
405
+ const container = document.createElement("div")
406
+ container.className = "inkpen-database__table-wrapper"
407
+
408
+ const table = document.createElement("table")
409
+ table.className = "inkpen-database__table"
410
+
411
+ // Header row
412
+ const thead = document.createElement("thead")
413
+ const headerRow = document.createElement("tr")
414
+
415
+ properties.forEach(prop => {
416
+ const th = document.createElement("th")
417
+ th.innerHTML = `
418
+ <span class="inkpen-database__prop-icon">${PROPERTY_TYPES[prop.type]?.icon || ""}</span>
419
+ <span class="inkpen-database__prop-name">${escapeHtml(prop.name)}</span>
420
+ `
421
+ th.style.width = prop.width ? `${prop.width}px` : "auto"
422
+ headerRow.appendChild(th)
423
+ })
424
+
425
+ // Add property column
426
+ if (editor.isEditable) {
427
+ const addTh = document.createElement("th")
428
+ addTh.className = "inkpen-database__add-prop"
429
+ addTh.innerHTML = "+"
430
+ addTh.title = "Add property"
431
+
432
+ addTh.addEventListener("click", () => {
433
+ this.showAddPropertyMenu(addTh, node, editor, getPos)
434
+ })
435
+
436
+ headerRow.appendChild(addTh)
437
+ }
438
+
439
+ thead.appendChild(headerRow)
440
+ table.appendChild(thead)
441
+
442
+ // Body rows
443
+ const tbody = document.createElement("tbody")
444
+
445
+ rows.forEach(row => {
446
+ const tr = document.createElement("tr")
447
+ tr.dataset.rowId = row.id
448
+
449
+ properties.forEach(prop => {
450
+ const td = document.createElement("td")
451
+ td.dataset.propId = prop.id
452
+ td.dataset.type = prop.type
453
+
454
+ const cellContent = this.renderCell(prop, row[prop.id], row, node, editor, getPos)
455
+ td.appendChild(cellContent)
456
+ tr.appendChild(td)
457
+ })
458
+
459
+ // Row actions
460
+ if (editor.isEditable) {
461
+ const actionsTd = document.createElement("td")
462
+ actionsTd.className = "inkpen-database__row-actions"
463
+
464
+ const deleteBtn = document.createElement("button")
465
+ deleteBtn.type = "button"
466
+ deleteBtn.className = "inkpen-database__row-delete"
467
+ deleteBtn.innerHTML = "×"
468
+ deleteBtn.title = "Delete row"
469
+
470
+ deleteBtn.addEventListener("mousedown", (e) => e.preventDefault())
471
+ deleteBtn.addEventListener("click", () => {
472
+ this.updateNodeAttrs(getPos, node, {
473
+ rows: node.attrs.rows.filter(r => r.id !== row.id)
474
+ }, editor)
475
+ })
476
+
477
+ actionsTd.appendChild(deleteBtn)
478
+ tr.appendChild(actionsTd)
479
+ }
480
+
481
+ tbody.appendChild(tr)
482
+ })
483
+
484
+ // New row placeholder
485
+ if (editor.isEditable) {
486
+ const placeholderRow = document.createElement("tr")
487
+ placeholderRow.className = "inkpen-database__new-row"
488
+
489
+ const placeholderTd = document.createElement("td")
490
+ placeholderTd.colSpan = properties.length + 1
491
+ placeholderTd.innerHTML = "+ New row"
492
+
493
+ placeholderTd.addEventListener("click", () => {
494
+ const newRow = { id: generateId() }
495
+ properties.forEach(prop => {
496
+ newRow[prop.id] = PROPERTY_TYPES[prop.type]?.default ?? ""
497
+ })
498
+ this.updateNodeAttrs(getPos, node, {
499
+ rows: [...rows, newRow]
500
+ }, editor)
501
+ })
502
+
503
+ placeholderRow.appendChild(placeholderTd)
504
+ tbody.appendChild(placeholderRow)
505
+ }
506
+
507
+ table.appendChild(tbody)
508
+ container.appendChild(table)
509
+
510
+ return container
511
+ },
512
+
513
+ // Private: Render board view (Kanban)
514
+
515
+ renderBoardView(node, editor, getPos) {
516
+ const { properties, rows, groupBy } = node.attrs
517
+ const container = document.createElement("div")
518
+ container.className = "inkpen-database__board"
519
+
520
+ // Find the select property to group by
521
+ const groupProp = properties.find(p => p.id === groupBy && p.type === "select")
522
+ if (!groupProp) {
523
+ container.innerHTML = '<div class="inkpen-database__empty">Select a property to group by</div>'
524
+ return container
525
+ }
526
+
527
+ const groups = groupProp.options || []
528
+
529
+ groups.forEach(group => {
530
+ const column = document.createElement("div")
531
+ column.className = "inkpen-database__column"
532
+ column.dataset.group = group.id
533
+
534
+ // Column header
535
+ const columnHeader = document.createElement("div")
536
+ columnHeader.className = "inkpen-database__column-header"
537
+ columnHeader.innerHTML = `
538
+ <span class="inkpen-database__column-label" style="--select-color: var(--inkpen-select-${group.color})">
539
+ ${escapeHtml(group.label)}
540
+ </span>
541
+ <span class="inkpen-database__column-count">
542
+ ${rows.filter(r => r[groupBy] === group.id).length}
543
+ </span>
544
+ `
545
+ column.appendChild(columnHeader)
546
+
547
+ // Column items
548
+ const columnItems = document.createElement("div")
549
+ columnItems.className = "inkpen-database__column-items"
550
+
551
+ const groupRows = rows.filter(r => r[groupBy] === group.id)
552
+ groupRows.forEach(row => {
553
+ const card = this.renderCard(row, properties, node, editor, getPos)
554
+ columnItems.appendChild(card)
555
+ })
556
+
557
+ // Add card button
558
+ if (editor.isEditable) {
559
+ const addCard = document.createElement("button")
560
+ addCard.type = "button"
561
+ addCard.className = "inkpen-database__add-card"
562
+ addCard.innerHTML = "+ Add"
563
+
564
+ addCard.addEventListener("mousedown", (e) => e.preventDefault())
565
+ addCard.addEventListener("click", () => {
566
+ const newRow = { id: generateId(), [groupBy]: group.id }
567
+ properties.forEach(prop => {
568
+ if (newRow[prop.id] === undefined) {
569
+ newRow[prop.id] = PROPERTY_TYPES[prop.type]?.default ?? ""
570
+ }
571
+ })
572
+ this.updateNodeAttrs(getPos, node, {
573
+ rows: [...rows, newRow]
574
+ }, editor)
575
+ })
576
+
577
+ columnItems.appendChild(addCard)
578
+ }
579
+
580
+ column.appendChild(columnItems)
581
+ container.appendChild(column)
582
+ })
583
+
584
+ return container
585
+ },
586
+
587
+ // Private: Render list view
588
+
589
+ renderListView(node, editor, getPos) {
590
+ const { properties, rows } = node.attrs
591
+ const container = document.createElement("div")
592
+ container.className = "inkpen-database__list"
593
+
594
+ const nameProperty = properties.find(p => p.type === "text") || properties[0]
595
+
596
+ rows.forEach(row => {
597
+ const item = document.createElement("div")
598
+ item.className = "inkpen-database__list-item"
599
+ item.dataset.rowId = row.id
600
+
601
+ const name = document.createElement("div")
602
+ name.className = "inkpen-database__list-name"
603
+ name.textContent = row[nameProperty?.id] || "Untitled"
604
+ item.appendChild(name)
605
+
606
+ const props = document.createElement("div")
607
+ props.className = "inkpen-database__list-props"
608
+
609
+ properties.slice(1).forEach(prop => {
610
+ const propEl = document.createElement("span")
611
+ propEl.className = `inkpen-database__list-prop inkpen-database__list-prop--${prop.type}`
612
+ propEl.textContent = this.formatValue(prop, row[prop.id])
613
+ props.appendChild(propEl)
614
+ })
615
+
616
+ item.appendChild(props)
617
+
618
+ if (editor.isEditable) {
619
+ item.style.cursor = "pointer"
620
+ item.addEventListener("click", () => {
621
+ // Could open edit modal here
622
+ })
623
+ }
624
+
625
+ container.appendChild(item)
626
+ })
627
+
628
+ return container
629
+ },
630
+
631
+ // Private: Render gallery view
632
+
633
+ renderGalleryView(node, editor, getPos) {
634
+ const { properties, rows } = node.attrs
635
+ const container = document.createElement("div")
636
+ container.className = "inkpen-database__gallery"
637
+
638
+ const nameProperty = properties.find(p => p.type === "text") || properties[0]
639
+
640
+ rows.forEach(row => {
641
+ const card = document.createElement("div")
642
+ card.className = "inkpen-database__gallery-card"
643
+ card.dataset.rowId = row.id
644
+
645
+ const name = document.createElement("div")
646
+ name.className = "inkpen-database__gallery-name"
647
+ name.textContent = row[nameProperty?.id] || "Untitled"
648
+ card.appendChild(name)
649
+
650
+ const props = document.createElement("div")
651
+ props.className = "inkpen-database__gallery-props"
652
+
653
+ properties.slice(1, 4).forEach(prop => {
654
+ const propEl = document.createElement("div")
655
+ propEl.className = "inkpen-database__gallery-prop"
656
+ propEl.innerHTML = `
657
+ <span class="inkpen-database__gallery-prop-label">${escapeHtml(prop.name)}</span>
658
+ <span class="inkpen-database__gallery-prop-value">${this.formatValue(prop, row[prop.id])}</span>
659
+ `
660
+ props.appendChild(propEl)
661
+ })
662
+
663
+ card.appendChild(props)
664
+ container.appendChild(card)
665
+ })
666
+
667
+ return container
668
+ },
669
+
670
+ // Private: Render a single cell
671
+
672
+ renderCell(prop, value, row, node, editor, getPos) {
673
+ const cell = document.createElement("div")
674
+ cell.className = `inkpen-database__cell inkpen-database__cell--${prop.type}`
675
+
676
+ switch (prop.type) {
677
+ case "text":
678
+ case "url":
679
+ if (editor.isEditable) {
680
+ const input = document.createElement("input")
681
+ input.type = prop.type === "url" ? "url" : "text"
682
+ input.className = "inkpen-database__cell-input"
683
+ input.value = value || ""
684
+ input.placeholder = prop.type === "url" ? "https://..." : ""
685
+
686
+ input.addEventListener("change", () => {
687
+ this.updateRow(getPos, node, row.id, { [prop.id]: input.value }, editor)
688
+ })
689
+
690
+ cell.appendChild(input)
691
+ } else {
692
+ if (prop.type === "url" && value) {
693
+ const link = document.createElement("a")
694
+ link.href = value
695
+ link.textContent = value
696
+ link.target = "_blank"
697
+ link.rel = "noopener noreferrer"
698
+ cell.appendChild(link)
699
+ } else {
700
+ cell.textContent = value || ""
701
+ }
702
+ }
703
+ break
704
+
705
+ case "number":
706
+ if (editor.isEditable) {
707
+ const input = document.createElement("input")
708
+ input.type = "number"
709
+ input.className = "inkpen-database__cell-input"
710
+ input.value = value ?? ""
711
+
712
+ input.addEventListener("change", () => {
713
+ this.updateRow(getPos, node, row.id, { [prop.id]: parseFloat(input.value) || 0 }, editor)
714
+ })
715
+
716
+ cell.appendChild(input)
717
+ } else {
718
+ cell.textContent = value ?? ""
719
+ }
720
+ break
721
+
722
+ case "checkbox":
723
+ const checkbox = document.createElement("input")
724
+ checkbox.type = "checkbox"
725
+ checkbox.className = "inkpen-database__cell-checkbox"
726
+ checkbox.checked = !!value
727
+ checkbox.disabled = !editor.isEditable
728
+
729
+ if (editor.isEditable) {
730
+ checkbox.addEventListener("change", () => {
731
+ this.updateRow(getPos, node, row.id, { [prop.id]: checkbox.checked }, editor)
732
+ })
733
+ }
734
+
735
+ cell.appendChild(checkbox)
736
+ break
737
+
738
+ case "select":
739
+ const selectedOption = prop.options?.find(o => o.id === value)
740
+ if (selectedOption) {
741
+ const tag = document.createElement("span")
742
+ tag.className = `inkpen-database__tag inkpen-database__tag--${selectedOption.color}`
743
+ tag.textContent = selectedOption.label
744
+ cell.appendChild(tag)
745
+ }
746
+
747
+ if (editor.isEditable) {
748
+ cell.style.cursor = "pointer"
749
+ cell.addEventListener("click", (e) => {
750
+ e.stopPropagation()
751
+ this.showSelectMenu(cell, prop, row, node, editor, getPos)
752
+ })
753
+ }
754
+ break
755
+
756
+ case "date":
757
+ if (editor.isEditable) {
758
+ const input = document.createElement("input")
759
+ input.type = "date"
760
+ input.className = "inkpen-database__cell-input"
761
+ input.value = value || ""
762
+
763
+ input.addEventListener("change", () => {
764
+ this.updateRow(getPos, node, row.id, { [prop.id]: input.value }, editor)
765
+ })
766
+
767
+ cell.appendChild(input)
768
+ } else {
769
+ cell.textContent = value ? new Date(value).toLocaleDateString() : ""
770
+ }
771
+ break
772
+
773
+ default:
774
+ cell.textContent = value || ""
775
+ }
776
+
777
+ return cell
778
+ },
779
+
780
+ // Private: Render a board card
781
+
782
+ renderCard(row, properties, node, editor, getPos) {
783
+ const card = document.createElement("div")
784
+ card.className = "inkpen-database__card"
785
+ card.dataset.rowId = row.id
786
+
787
+ const nameProperty = properties.find(p => p.type === "text") || properties[0]
788
+
789
+ const title = document.createElement("div")
790
+ title.className = "inkpen-database__card-title"
791
+ title.textContent = row[nameProperty?.id] || "Untitled"
792
+ card.appendChild(title)
793
+
794
+ // Show a few properties
795
+ const propsContainer = document.createElement("div")
796
+ propsContainer.className = "inkpen-database__card-props"
797
+
798
+ properties.slice(0, 3).filter(p => p.type !== "text").forEach(prop => {
799
+ if (row[prop.id]) {
800
+ const propEl = document.createElement("span")
801
+ propEl.className = "inkpen-database__card-prop"
802
+ propEl.textContent = this.formatValue(prop, row[prop.id])
803
+ propsContainer.appendChild(propEl)
804
+ }
805
+ })
806
+
807
+ card.appendChild(propsContainer)
808
+
809
+ return card
810
+ },
811
+
812
+ // Private: Format value for display
813
+
814
+ formatValue(prop, value) {
815
+ if (value === null || value === undefined) return ""
816
+
817
+ switch (prop.type) {
818
+ case "checkbox":
819
+ return value ? "✓" : ""
820
+ case "select":
821
+ const option = prop.options?.find(o => o.id === value)
822
+ return option?.label || ""
823
+ case "date":
824
+ return value ? new Date(value).toLocaleDateString() : ""
825
+ case "number":
826
+ return value.toString()
827
+ default:
828
+ return String(value)
829
+ }
830
+ },
831
+
832
+ // Private: Show select menu
833
+
834
+ showSelectMenu(anchor, prop, row, node, editor, getPos) {
835
+ removeExistingDropdown()
836
+
837
+ const menu = document.createElement("div")
838
+ menu.className = "inkpen-database__select-menu"
839
+
840
+ prop.options?.forEach(option => {
841
+ const item = document.createElement("button")
842
+ item.type = "button"
843
+ item.className = "inkpen-database__select-item"
844
+ if (row[prop.id] === option.id) item.classList.add("is-active")
845
+
846
+ const tag = document.createElement("span")
847
+ tag.className = `inkpen-database__tag inkpen-database__tag--${option.color}`
848
+ tag.textContent = option.label
849
+ item.appendChild(tag)
850
+
851
+ item.addEventListener("mousedown", (e) => e.preventDefault())
852
+ item.addEventListener("click", () => {
853
+ this.updateRow(getPos, node, row.id, { [prop.id]: option.id }, editor)
854
+ menu.remove()
855
+ })
856
+
857
+ menu.appendChild(item)
858
+ })
859
+
860
+ positionDropdown(menu, anchor)
861
+ setupDropdownClose(menu, anchor)
862
+ },
863
+
864
+ // Private: Show add property menu
865
+
866
+ showAddPropertyMenu(anchor, node, editor, getPos) {
867
+ removeExistingDropdown()
868
+
869
+ const menu = document.createElement("div")
870
+ menu.className = "inkpen-database__prop-menu"
871
+
872
+ Object.entries(PROPERTY_TYPES).forEach(([type, { icon, label }]) => {
873
+ const item = document.createElement("button")
874
+ item.type = "button"
875
+ item.className = "inkpen-database__prop-menu-item"
876
+ item.innerHTML = `<span>${icon}</span> ${label}`
877
+
878
+ item.addEventListener("mousedown", (e) => e.preventDefault())
879
+ item.addEventListener("click", () => {
880
+ const newProp = {
881
+ id: generateId(),
882
+ name: label,
883
+ type
884
+ }
885
+
886
+ if (type === "select") {
887
+ newProp.options = [
888
+ { id: "option1", label: "Option 1", color: "gray" }
889
+ ]
890
+ }
891
+
892
+ this.updateNodeAttrs(getPos, node, {
893
+ properties: [...node.attrs.properties, newProp]
894
+ }, editor)
895
+
896
+ menu.remove()
897
+ })
898
+
899
+ menu.appendChild(item)
900
+ })
901
+
902
+ positionDropdown(menu, anchor)
903
+ setupDropdownClose(menu, anchor)
904
+ },
905
+
906
+ // Private: Update node attributes
907
+
908
+ updateNodeAttrs(getPos, node, updates, editor) {
909
+ if (typeof getPos !== "function") return
910
+
911
+ const pos = getPos()
912
+ if (pos === undefined) return
913
+
914
+ editor.chain().command(({ tr }) => {
915
+ tr.setNodeMarkup(pos, undefined, { ...node.attrs, ...updates })
916
+ return true
917
+ }).run()
918
+ },
919
+
920
+ // Private: Update a single row
921
+
922
+ updateRow(getPos, node, rowId, updates, editor) {
923
+ const rows = node.attrs.rows.map(row =>
924
+ row.id === rowId ? { ...row, ...updates } : row
925
+ )
926
+ this.updateNodeAttrs(getPos, node, { rows }, editor)
927
+ }
928
+ })
929
+
930
+ // Helper: Find database node in selection
931
+
932
+ function findDatabaseNode(selection) {
933
+ const { $from } = selection
934
+ for (let d = $from.depth; d >= 0; d--) {
935
+ const node = $from.node(d)
936
+ if (node.type.name === "database") {
937
+ return { node, pos: $from.before(d) }
938
+ }
939
+ }
940
+ return null
941
+ }
942
+
943
+ // Helper: Escape HTML
944
+
945
+ function escapeHtml(text) {
946
+ const div = document.createElement("div")
947
+ div.textContent = text
948
+ return div.innerHTML
949
+ }
950
+
951
+ // Helper: Remove existing dropdown
952
+
953
+ function removeExistingDropdown() {
954
+ document.querySelectorAll(".inkpen-database__select-menu, .inkpen-database__prop-menu").forEach(el => el.remove())
955
+ }
956
+
957
+ // Helper: Position dropdown
958
+
959
+ function positionDropdown(dropdown, anchor) {
960
+ const rect = anchor.getBoundingClientRect()
961
+ dropdown.style.position = "fixed"
962
+ dropdown.style.left = `${rect.left}px`
963
+ dropdown.style.top = `${rect.bottom + 4}px`
964
+ dropdown.style.zIndex = "10000"
965
+ document.body.appendChild(dropdown)
966
+ }
967
+
968
+ // Helper: Setup dropdown close
969
+
970
+ function setupDropdownClose(dropdown, anchor) {
971
+ const closeHandler = (e) => {
972
+ if (!dropdown.contains(e.target) && !anchor.contains(e.target)) {
973
+ dropdown.remove()
974
+ document.removeEventListener("mousedown", closeHandler)
975
+ }
976
+ }
977
+
978
+ setTimeout(() => document.addEventListener("mousedown", closeHandler), 0)
979
+
980
+ const escHandler = (e) => {
981
+ if (e.key === "Escape") {
982
+ dropdown.remove()
983
+ document.removeEventListener("keydown", escHandler)
984
+ }
985
+ }
986
+ document.addEventListener("keydown", escHandler)
987
+ }
988
+
989
+ export { PROPERTY_TYPES, VIEW_TYPES, SELECT_COLORS }
990
+ export default Database