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
data/docs/ROADMAP.md
ADDED
|
@@ -0,0 +1,3029 @@
|
|
|
1
|
+
# Inkpen: Notion-like Block Editor Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **Vision**: Transform Inkpen into a world-class Notion/Editor.js style block editor while maintaining Rails-native simplicity.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Table of Contents
|
|
8
|
+
|
|
9
|
+
1. [Architecture Overview](#architecture-overview)
|
|
10
|
+
2. [Completed Extensions](#completed-extensions)
|
|
11
|
+
3. [Phase 1: Drag & Drop](#phase-1-drag--drop-v032)
|
|
12
|
+
4. [Phase 2: Enhanced Blocks](#phase-2-enhanced-blocks-v033)
|
|
13
|
+
5. [Phase 3: BlockNote-Style Polish](#phase-3-blocknote-style-polish-v040)
|
|
14
|
+
6. [Phase 4: Markdown Mode](#phase-4-markdown-mode-v050)
|
|
15
|
+
7. [Technical References](#technical-references)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## Upcoming: Markdown Mode (v0.5.0)
|
|
20
|
+
|
|
21
|
+
Toggle between WYSIWYG, raw markdown, and split view editing.
|
|
22
|
+
|
|
23
|
+
**See full plan:** [docs/plans/MARKDOWN_MODE.md](plans/MARKDOWN_MODE.md)
|
|
24
|
+
|
|
25
|
+
**Key features:**
|
|
26
|
+
- Mode toggle: WYSIWYG / Markdown / Split
|
|
27
|
+
- Live sync between modes (debounced)
|
|
28
|
+
- Ruby PORO configuration
|
|
29
|
+
- Keyboard shortcuts (`Cmd+Shift+M`)
|
|
30
|
+
|
|
31
|
+
**Status:** 📋 Planned
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Architecture Overview
|
|
36
|
+
|
|
37
|
+
### Current Stack
|
|
38
|
+
```
|
|
39
|
+
┌─────────────────────────────────────────────────────┐
|
|
40
|
+
│ Rails Application (mademysite.com) │
|
|
41
|
+
├─────────────────────────────────────────────────────┤
|
|
42
|
+
│ Inkpen Gem (Rails Engine) │
|
|
43
|
+
│ ├── Ruby POROs (Editor, Toolbar, Extensions) │
|
|
44
|
+
│ ├── Stimulus Controllers (editor, toolbar, sticky) │
|
|
45
|
+
│ └── TipTap/ProseMirror Core │
|
|
46
|
+
└─────────────────────────────────────────────────────┘
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### Target Architecture
|
|
50
|
+
```
|
|
51
|
+
┌─────────────────────────────────────────────────────┐
|
|
52
|
+
│ Rails Application │
|
|
53
|
+
├─────────────────────────────────────────────────────┤
|
|
54
|
+
│ Inkpen Gem v0.4.0+ │
|
|
55
|
+
│ ├── Ruby POROs │
|
|
56
|
+
│ ├── Stimulus Controllers │
|
|
57
|
+
│ │ ├── editor_controller.js │
|
|
58
|
+
│ │ ├── toolbar_controller.js │
|
|
59
|
+
│ │ ├── sticky_toolbar_controller.js │
|
|
60
|
+
│ │ ├── slash_menu_controller.js ← NEW │
|
|
61
|
+
│ │ ├── block_gutter_controller.js ← NEW │
|
|
62
|
+
│ │ └── drag_handle_controller.js ← NEW │
|
|
63
|
+
│ ├── TipTap Extensions │
|
|
64
|
+
│ │ ├── SlashCommands ← ENHANCED │
|
|
65
|
+
│ │ ├── BlockGutter ← NEW │
|
|
66
|
+
│ │ ├── DragHandle ← NEW │
|
|
67
|
+
│ │ └── UniqueID ← NEW │
|
|
68
|
+
│ └── Helpers │
|
|
69
|
+
│ ├── position_helpers.js ← NEW │
|
|
70
|
+
│ ├── block_helpers.js ← NEW │
|
|
71
|
+
│ └── drag_helpers.js ← NEW │
|
|
72
|
+
└─────────────────────────────────────────────────────┘
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
---
|
|
76
|
+
|
|
77
|
+
## Completed Extensions
|
|
78
|
+
|
|
79
|
+
### Section Extension (v0.2.2) ✅
|
|
80
|
+
|
|
81
|
+
Page-builder style layout blocks with configurable width and spacing.
|
|
82
|
+
|
|
83
|
+
**Features:**
|
|
84
|
+
- Width presets: narrow (560px), default (680px), wide (900px), full (100%)
|
|
85
|
+
- Spacing presets: none, small (1rem), medium (2rem), large (4rem)
|
|
86
|
+
- Interactive NodeView with hover controls
|
|
87
|
+
- Keyboard shortcut: `Cmd+Shift+S`
|
|
88
|
+
|
|
89
|
+
**Files:**
|
|
90
|
+
```
|
|
91
|
+
lib/inkpen/extensions/section.rb
|
|
92
|
+
app/assets/javascripts/inkpen/extensions/section.js
|
|
93
|
+
app/assets/stylesheets/inkpen/section.css
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
**Commands:**
|
|
97
|
+
- `insertSection()` - Insert new section block
|
|
98
|
+
- `setSectionWidth(width)` - Change width preset
|
|
99
|
+
- `setSectionSpacing(spacing)` - Change spacing preset
|
|
100
|
+
- `wrapInSection()` - Wrap selection in section
|
|
101
|
+
|
|
102
|
+
---
|
|
103
|
+
|
|
104
|
+
### Preformatted Text Extension (v0.3.0) ✅
|
|
105
|
+
|
|
106
|
+
Plain text block for ASCII art, tables, and diagrams with strict whitespace preservation.
|
|
107
|
+
|
|
108
|
+
**Features:**
|
|
109
|
+
- Strict monospace font (no ligatures)
|
|
110
|
+
- Whitespace preservation (`white-space: pre`)
|
|
111
|
+
- Tab key inserts actual tabs
|
|
112
|
+
- No formatting marks allowed (bold, italic disabled)
|
|
113
|
+
- "Plain Text" label badge
|
|
114
|
+
- Paste handling preserves whitespace
|
|
115
|
+
|
|
116
|
+
**Files:**
|
|
117
|
+
```
|
|
118
|
+
lib/inkpen/extensions/preformatted.rb
|
|
119
|
+
app/assets/javascripts/inkpen/extensions/preformatted.js
|
|
120
|
+
app/assets/stylesheets/inkpen/preformatted.css
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
**Commands:**
|
|
124
|
+
- `setPreformatted()` - Convert to preformatted
|
|
125
|
+
- `togglePreformatted()` - Toggle preformatted/paragraph
|
|
126
|
+
- `insertPreformatted(content)` - Insert with content
|
|
127
|
+
|
|
128
|
+
**Keyboard Shortcuts:**
|
|
129
|
+
| Shortcut | Action |
|
|
130
|
+
|----------|--------|
|
|
131
|
+
| `Cmd+Shift+P` | Toggle preformatted |
|
|
132
|
+
| `Tab` | Insert tab character |
|
|
133
|
+
| `Shift+Tab` | Remove leading tab |
|
|
134
|
+
| `Enter` | Insert newline (not new block) |
|
|
135
|
+
| `Backspace` (empty) | Exit to paragraph |
|
|
136
|
+
|
|
137
|
+
---
|
|
138
|
+
|
|
139
|
+
### Slash Commands Extension (v0.3.0) ✅
|
|
140
|
+
|
|
141
|
+
Notion-style "/" command palette for rapid block insertion.
|
|
142
|
+
|
|
143
|
+
**Features:**
|
|
144
|
+
- Type "/" to open menu, then type to filter
|
|
145
|
+
- Keyboard navigation (arrows, Enter, Escape)
|
|
146
|
+
- Grouped commands: Basic, Lists, Blocks, Media, Advanced
|
|
147
|
+
- Fuzzy search across title, keywords, description
|
|
148
|
+
- Customizable command list
|
|
149
|
+
|
|
150
|
+
**Files:**
|
|
151
|
+
```
|
|
152
|
+
lib/inkpen/extensions/slash_commands.rb
|
|
153
|
+
app/assets/javascripts/inkpen/extensions/slash_commands.js
|
|
154
|
+
app/assets/stylesheets/inkpen/slash_menu.css
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
**Default Commands:**
|
|
158
|
+
- Basic: paragraph, heading1, heading2, heading3
|
|
159
|
+
- Lists: bulletList, orderedList, taskList
|
|
160
|
+
- Blocks: blockquote, codeBlock, preformatted, divider
|
|
161
|
+
- Media: image, youtube, table
|
|
162
|
+
- Advanced: section
|
|
163
|
+
|
|
164
|
+
---
|
|
165
|
+
|
|
166
|
+
### Block Gutter Extension (v0.3.1) ✅
|
|
167
|
+
|
|
168
|
+
Left-side gutter with drag handles and plus buttons for each block.
|
|
169
|
+
|
|
170
|
+
**Features:**
|
|
171
|
+
- Drag handle (⋮⋮) for block reordering
|
|
172
|
+
- Plus button (+) to insert new block below
|
|
173
|
+
- Integrates with slash commands
|
|
174
|
+
- Shows on hover, hides when not focused
|
|
175
|
+
- Skips blocks inside tables
|
|
176
|
+
- Mobile-optimized
|
|
177
|
+
|
|
178
|
+
**Files:**
|
|
179
|
+
```
|
|
180
|
+
app/assets/javascripts/inkpen/extensions/block_gutter.js
|
|
181
|
+
app/assets/stylesheets/inkpen/block_gutter.css
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
**Visual Design:**
|
|
185
|
+
```
|
|
186
|
+
┌────────────────────────────────────────┐
|
|
187
|
+
│ │
|
|
188
|
+
⋮⋮ + │ Heading 1 │
|
|
189
|
+
│ │
|
|
190
|
+
├────────────────────────────────────────┤
|
|
191
|
+
│ │
|
|
192
|
+
⋮⋮ + │ Paragraph text goes here... │
|
|
193
|
+
│ │
|
|
194
|
+
├────────────────────────────────────────┤
|
|
195
|
+
│ │
|
|
196
|
+
⋮⋮ + │ • List item 1 │
|
|
197
|
+
│ • List item 2 │
|
|
198
|
+
│ │
|
|
199
|
+
└────────────────────────────────────────┘
|
|
200
|
+
|
|
201
|
+
Legend:
|
|
202
|
+
⋮⋮ = Drag handle (appears on hover)
|
|
203
|
+
+ = Plus button (opens slash menu or quick insert)
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Phase 1: Drag & Drop (v0.3.2) ✅
|
|
209
|
+
|
|
210
|
+
### Goal
|
|
211
|
+
Enable visual block reordering via drag and drop.
|
|
212
|
+
|
|
213
|
+
**Status:** Complete
|
|
214
|
+
|
|
215
|
+
### Components
|
|
216
|
+
|
|
217
|
+
#### 3.1 TipTap Extension: DragHandle
|
|
218
|
+
```javascript
|
|
219
|
+
// app/assets/javascripts/inkpen/extensions/drag_handle.js
|
|
220
|
+
|
|
221
|
+
import { Extension } from '@tiptap/core'
|
|
222
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
223
|
+
|
|
224
|
+
export const DragHandle = Extension.create({
|
|
225
|
+
name: 'dragHandle',
|
|
226
|
+
|
|
227
|
+
addOptions() {
|
|
228
|
+
return {
|
|
229
|
+
scrollThreshold: 100,
|
|
230
|
+
scrollSpeed: 10
|
|
231
|
+
}
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
addProseMirrorPlugins() {
|
|
235
|
+
const extension = this
|
|
236
|
+
let draggedPos = null
|
|
237
|
+
let dropIndicator = null
|
|
238
|
+
|
|
239
|
+
return [
|
|
240
|
+
new Plugin({
|
|
241
|
+
key: new PluginKey('dragHandle'),
|
|
242
|
+
props: {
|
|
243
|
+
handleDOMEvents: {
|
|
244
|
+
dragstart(view, event) {
|
|
245
|
+
const target = event.target.closest('.inkpen-block-gutter__drag')
|
|
246
|
+
if (!target) return false
|
|
247
|
+
|
|
248
|
+
const pos = parseInt(target.closest('.inkpen-block-gutter').dataset.pos)
|
|
249
|
+
draggedPos = pos
|
|
250
|
+
|
|
251
|
+
// Set drag data
|
|
252
|
+
event.dataTransfer.effectAllowed = 'move'
|
|
253
|
+
event.dataTransfer.setData('text/plain', pos.toString())
|
|
254
|
+
|
|
255
|
+
// Add dragging class
|
|
256
|
+
view.dom.classList.add('is-dragging')
|
|
257
|
+
|
|
258
|
+
return true
|
|
259
|
+
},
|
|
260
|
+
|
|
261
|
+
dragover(view, event) {
|
|
262
|
+
if (draggedPos === null) return false
|
|
263
|
+
|
|
264
|
+
event.preventDefault()
|
|
265
|
+
event.dataTransfer.dropEffect = 'move'
|
|
266
|
+
|
|
267
|
+
// Calculate drop position
|
|
268
|
+
const coords = { left: event.clientX, top: event.clientY }
|
|
269
|
+
const dropPos = view.posAtCoords(coords)
|
|
270
|
+
|
|
271
|
+
if (dropPos) {
|
|
272
|
+
extension.showDropIndicator(view, dropPos.pos)
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
return true
|
|
276
|
+
},
|
|
277
|
+
|
|
278
|
+
drop(view, event) {
|
|
279
|
+
if (draggedPos === null) return false
|
|
280
|
+
|
|
281
|
+
event.preventDefault()
|
|
282
|
+
|
|
283
|
+
const coords = { left: event.clientX, top: event.clientY }
|
|
284
|
+
const dropPos = view.posAtCoords(coords)
|
|
285
|
+
|
|
286
|
+
if (dropPos) {
|
|
287
|
+
extension.moveBlock(view, draggedPos, dropPos.pos)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
extension.cleanup(view)
|
|
291
|
+
return true
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
dragend(view) {
|
|
295
|
+
extension.cleanup(view)
|
|
296
|
+
return true
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
})
|
|
301
|
+
]
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
showDropIndicator(view, pos) {
|
|
305
|
+
// Remove existing indicator
|
|
306
|
+
this.hideDropIndicator()
|
|
307
|
+
|
|
308
|
+
// Create new indicator
|
|
309
|
+
const indicator = document.createElement('div')
|
|
310
|
+
indicator.className = 'inkpen-drop-indicator'
|
|
311
|
+
|
|
312
|
+
const coords = view.coordsAtPos(pos)
|
|
313
|
+
indicator.style.top = `${coords.top}px`
|
|
314
|
+
indicator.style.left = `${view.dom.getBoundingClientRect().left}px`
|
|
315
|
+
indicator.style.width = `${view.dom.clientWidth}px`
|
|
316
|
+
|
|
317
|
+
view.dom.parentNode.appendChild(indicator)
|
|
318
|
+
this.dropIndicator = indicator
|
|
319
|
+
},
|
|
320
|
+
|
|
321
|
+
hideDropIndicator() {
|
|
322
|
+
if (this.dropIndicator) {
|
|
323
|
+
this.dropIndicator.remove()
|
|
324
|
+
this.dropIndicator = null
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
|
|
328
|
+
moveBlock(view, from, to) {
|
|
329
|
+
const { state, dispatch } = view
|
|
330
|
+
const node = state.doc.nodeAt(from)
|
|
331
|
+
|
|
332
|
+
if (!node) return
|
|
333
|
+
|
|
334
|
+
const tr = state.tr
|
|
335
|
+
const nodeSize = node.nodeSize
|
|
336
|
+
|
|
337
|
+
// Delete from original position
|
|
338
|
+
tr.delete(from, from + nodeSize)
|
|
339
|
+
|
|
340
|
+
// Adjust target position if needed
|
|
341
|
+
const adjustedTo = to > from ? to - nodeSize : to
|
|
342
|
+
|
|
343
|
+
// Insert at new position
|
|
344
|
+
tr.insert(adjustedTo, node)
|
|
345
|
+
|
|
346
|
+
dispatch(tr)
|
|
347
|
+
},
|
|
348
|
+
|
|
349
|
+
cleanup(view) {
|
|
350
|
+
this.hideDropIndicator()
|
|
351
|
+
view.dom.classList.remove('is-dragging')
|
|
352
|
+
this.draggedPos = null
|
|
353
|
+
}
|
|
354
|
+
})
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
#### 3.2 CSS for Drag & Drop
|
|
358
|
+
```css
|
|
359
|
+
/* app/assets/stylesheets/inkpen/drag_drop.css */
|
|
360
|
+
|
|
361
|
+
.inkpen-editor.is-dragging {
|
|
362
|
+
cursor: grabbing;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.inkpen-editor.is-dragging .ProseMirror > * {
|
|
366
|
+
transition: transform 150ms;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
.inkpen-drop-indicator {
|
|
370
|
+
position: absolute;
|
|
371
|
+
height: 3px;
|
|
372
|
+
background: var(--inkpen-color-primary);
|
|
373
|
+
border-radius: 1.5px;
|
|
374
|
+
pointer-events: none;
|
|
375
|
+
z-index: 100;
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
.inkpen-drop-indicator::before,
|
|
379
|
+
.inkpen-drop-indicator::after {
|
|
380
|
+
content: '';
|
|
381
|
+
position: absolute;
|
|
382
|
+
top: 50%;
|
|
383
|
+
width: 8px;
|
|
384
|
+
height: 8px;
|
|
385
|
+
background: var(--inkpen-color-primary);
|
|
386
|
+
border-radius: 50%;
|
|
387
|
+
transform: translateY(-50%);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
.inkpen-drop-indicator::before {
|
|
391
|
+
left: -4px;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
.inkpen-drop-indicator::after {
|
|
395
|
+
right: -4px;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* Ghost element during drag */
|
|
399
|
+
.inkpen-drag-ghost {
|
|
400
|
+
position: fixed;
|
|
401
|
+
padding: 0.5rem 1rem;
|
|
402
|
+
background: var(--inkpen-toolbar-bg);
|
|
403
|
+
border: 1px solid var(--inkpen-color-border);
|
|
404
|
+
border-radius: var(--inkpen-radius);
|
|
405
|
+
box-shadow: var(--inkpen-shadow);
|
|
406
|
+
opacity: 0.9;
|
|
407
|
+
pointer-events: none;
|
|
408
|
+
z-index: 9999;
|
|
409
|
+
max-width: 300px;
|
|
410
|
+
overflow: hidden;
|
|
411
|
+
text-overflow: ellipsis;
|
|
412
|
+
white-space: nowrap;
|
|
413
|
+
}
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
### References
|
|
417
|
+
- [Plate DnD Examples](https://platejs.org/docs/examples/dnd)
|
|
418
|
+
- [React DnD](https://react-dnd.github.io/react-dnd/about)
|
|
419
|
+
- [Editor.js Drag Plugin](https://github.com/kommitters/editorjs-drag-drop)
|
|
420
|
+
|
|
421
|
+
---
|
|
422
|
+
|
|
423
|
+
## Phase 2: Enhanced Blocks (v0.3.3)
|
|
424
|
+
|
|
425
|
+
### Goal
|
|
426
|
+
Add Notion-style blocks: toggles, columns, callouts with variants.
|
|
427
|
+
|
|
428
|
+
### 4.1 Toggle/Collapsible Block ✅
|
|
429
|
+
|
|
430
|
+
**Status:** Complete
|
|
431
|
+
|
|
432
|
+
**Features:**
|
|
433
|
+
- Collapsible/expandable blocks with clickable header
|
|
434
|
+
- Native HTML5 `<details>` and `<summary>` elements
|
|
435
|
+
- Editable summary text
|
|
436
|
+
- Nested block content support
|
|
437
|
+
- Smooth expand/collapse animations
|
|
438
|
+
- Keyboard shortcuts: `Cmd+Shift+T`, `Cmd+Enter`
|
|
439
|
+
- Commands: `insertToggle`, `toggleOpen`, `expandToggle`, `collapseToggle`
|
|
440
|
+
|
|
441
|
+
**Files:**
|
|
442
|
+
```
|
|
443
|
+
app/assets/javascripts/inkpen/extensions/toggle_block.js
|
|
444
|
+
app/assets/stylesheets/inkpen/toggle.css
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
### 4.1 Toggle/Collapsible Block
|
|
448
|
+
```javascript
|
|
449
|
+
// app/assets/javascripts/inkpen/extensions/toggle_block.js
|
|
450
|
+
|
|
451
|
+
import { Node, mergeAttributes } from '@tiptap/core'
|
|
452
|
+
|
|
453
|
+
export const ToggleBlock = Node.create({
|
|
454
|
+
name: 'toggleBlock',
|
|
455
|
+
group: 'block',
|
|
456
|
+
content: 'block+',
|
|
457
|
+
|
|
458
|
+
addAttributes() {
|
|
459
|
+
return {
|
|
460
|
+
open: { default: true }
|
|
461
|
+
}
|
|
462
|
+
},
|
|
463
|
+
|
|
464
|
+
parseHTML() {
|
|
465
|
+
return [{ tag: 'details' }]
|
|
466
|
+
},
|
|
467
|
+
|
|
468
|
+
renderHTML({ HTMLAttributes }) {
|
|
469
|
+
return ['details', mergeAttributes(HTMLAttributes, {
|
|
470
|
+
class: 'inkpen-toggle',
|
|
471
|
+
open: HTMLAttributes.open ? '' : null
|
|
472
|
+
}), 0]
|
|
473
|
+
},
|
|
474
|
+
|
|
475
|
+
addCommands() {
|
|
476
|
+
return {
|
|
477
|
+
setToggleBlock: () => ({ commands }) => {
|
|
478
|
+
return commands.wrapIn(this.name)
|
|
479
|
+
},
|
|
480
|
+
toggleOpen: () => ({ tr, state }) => {
|
|
481
|
+
// Toggle open attribute
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
})
|
|
486
|
+
```
|
|
487
|
+
|
|
488
|
+
### 4.2 Column Layout ✅
|
|
489
|
+
|
|
490
|
+
**Status:** Complete
|
|
491
|
+
|
|
492
|
+
**Features:**
|
|
493
|
+
- Multi-column layouts (2, 3, or 4 columns)
|
|
494
|
+
- Layout presets: equal, 1:2, 2:1, 1:3, 3:1, 1:2:1, etc.
|
|
495
|
+
- Interactive controls to change layout and add/remove columns
|
|
496
|
+
- Responsive stacking on mobile
|
|
497
|
+
- Keyboard shortcut: `Cmd+Shift+C`
|
|
498
|
+
- Commands: `insertColumns`, `setColumnLayout`, `addColumn`, `removeColumn`
|
|
499
|
+
|
|
500
|
+
**Files:**
|
|
501
|
+
```
|
|
502
|
+
app/assets/javascripts/inkpen/extensions/columns.js
|
|
503
|
+
app/assets/stylesheets/inkpen/columns.css
|
|
504
|
+
```
|
|
505
|
+
|
|
506
|
+
### 4.3 Callout ✅
|
|
507
|
+
|
|
508
|
+
**Status:** Complete
|
|
509
|
+
|
|
510
|
+
**Features:**
|
|
511
|
+
- Highlighted blocks for tips, warnings, notes, and other callouts
|
|
512
|
+
- Six types: info, warning, tip, note, success, error
|
|
513
|
+
- Default emoji icons per type (customizable)
|
|
514
|
+
- Click emoji to change callout type via dropdown
|
|
515
|
+
- Colored backgrounds and left borders per type
|
|
516
|
+
- Keyboard shortcut: `Cmd+Shift+O`
|
|
517
|
+
- Commands: `insertCallout`, `setCalloutType`, `setCalloutEmoji`, `toggleCallout`
|
|
518
|
+
|
|
519
|
+
**Files:**
|
|
520
|
+
```
|
|
521
|
+
app/assets/javascripts/inkpen/extensions/callout.js
|
|
522
|
+
app/assets/stylesheets/inkpen/callout.css
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
### CSS for Enhanced Blocks
|
|
526
|
+
```css
|
|
527
|
+
/* Toggle */
|
|
528
|
+
.inkpen-toggle {
|
|
529
|
+
border: 1px solid var(--inkpen-color-border);
|
|
530
|
+
border-radius: var(--inkpen-radius);
|
|
531
|
+
margin: 1rem 0;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
.inkpen-toggle > summary {
|
|
535
|
+
padding: 0.75rem 1rem;
|
|
536
|
+
cursor: pointer;
|
|
537
|
+
font-weight: 500;
|
|
538
|
+
list-style: none;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
.inkpen-toggle > summary::before {
|
|
542
|
+
content: '▶';
|
|
543
|
+
display: inline-block;
|
|
544
|
+
margin-right: 0.5rem;
|
|
545
|
+
transition: transform 150ms;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
.inkpen-toggle[open] > summary::before {
|
|
549
|
+
transform: rotate(90deg);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
.inkpen-toggle > *:not(summary) {
|
|
553
|
+
padding: 0 1rem 1rem;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/* Columns */
|
|
557
|
+
.inkpen-columns {
|
|
558
|
+
display: grid;
|
|
559
|
+
grid-template-columns: repeat(var(--column-count, 2), 1fr);
|
|
560
|
+
gap: 1.5rem;
|
|
561
|
+
margin: 1rem 0;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
.inkpen-column {
|
|
565
|
+
min-width: 0;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/* Callout variants */
|
|
569
|
+
.inkpen-callout--success {
|
|
570
|
+
background: rgba(16, 185, 129, 0.1);
|
|
571
|
+
border-left-color: #10b981;
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
.inkpen-callout--error {
|
|
575
|
+
background: rgba(239, 68, 68, 0.1);
|
|
576
|
+
border-left-color: #ef4444;
|
|
577
|
+
}
|
|
578
|
+
```
|
|
579
|
+
|
|
580
|
+
---
|
|
581
|
+
|
|
582
|
+
## Phase 3: BlockNote-Style Polish (v0.4.0) ✅
|
|
583
|
+
|
|
584
|
+
### Goal
|
|
585
|
+
Add the finishing touches that make the editor feel polished and professional.
|
|
586
|
+
|
|
587
|
+
**Status:** Complete
|
|
588
|
+
|
|
589
|
+
### 5.1 Block Commands Extension ✅
|
|
590
|
+
|
|
591
|
+
**Features:**
|
|
592
|
+
- Block selection via gutter click
|
|
593
|
+
- Multi-block selection with Shift+Click
|
|
594
|
+
- Duplicate block (`Cmd+D`)
|
|
595
|
+
- Delete empty block (`Backspace`)
|
|
596
|
+
- Select all content in block (`Cmd+A` when block selected)
|
|
597
|
+
- Commands: `duplicateBlock`, `deleteBlock`, `selectBlock`, `selectBlockAt`
|
|
598
|
+
|
|
599
|
+
**Files:**
|
|
600
|
+
```
|
|
601
|
+
app/assets/javascripts/inkpen/extensions/block_commands.js
|
|
602
|
+
```
|
|
603
|
+
|
|
604
|
+
### 5.2 Smooth Animations ✅
|
|
605
|
+
|
|
606
|
+
**Features:**
|
|
607
|
+
- Block entry animations (fade + slide)
|
|
608
|
+
- Block focus ring
|
|
609
|
+
- Block selection highlighting
|
|
610
|
+
- Menu entrance animations (slash menu, bubble menu, dropdowns)
|
|
611
|
+
- Gutter fade in/out
|
|
612
|
+
- Toggle block expand/collapse
|
|
613
|
+
- Cursor and placeholder animations
|
|
614
|
+
- Table cell selection
|
|
615
|
+
- Image selection
|
|
616
|
+
- Scrollbar styling
|
|
617
|
+
|
|
618
|
+
**Files:**
|
|
619
|
+
```
|
|
620
|
+
app/assets/stylesheets/inkpen/animations.css
|
|
621
|
+
```
|
|
622
|
+
|
|
623
|
+
### 5.3 Keyboard Shortcuts
|
|
624
|
+
| Shortcut | Action |
|
|
625
|
+
|----------|--------|
|
|
626
|
+
| `/` | Open slash menu |
|
|
627
|
+
| `Cmd+Shift+↑` | Move block up |
|
|
628
|
+
| `Cmd+Shift+↓` | Move block down |
|
|
629
|
+
| `Cmd+D` | Duplicate block |
|
|
630
|
+
| `Backspace` (empty block) | Delete block |
|
|
631
|
+
| `Enter` (end of block) | Create new paragraph |
|
|
632
|
+
| `Tab` | Indent list item |
|
|
633
|
+
| `Shift+Tab` | Outdent list item |
|
|
634
|
+
|
|
635
|
+
### 5.4 Mobile Touch Optimizations ✅
|
|
636
|
+
|
|
637
|
+
**Features:**
|
|
638
|
+
- Larger touch targets (32px)
|
|
639
|
+
- Touch-friendly tap feedback
|
|
640
|
+
- Always-visible gutter on mobile
|
|
641
|
+
- Faster animations for snappy mobile feel
|
|
642
|
+
- Touch-friendly block selection with box-shadow
|
|
643
|
+
- Prevent text selection during drag
|
|
644
|
+
- Smooth scrolling (`-webkit-overflow-scrolling: touch`)
|
|
645
|
+
- Reduced motion support (`prefers-reduced-motion`)
|
|
646
|
+
- Print styles (animations disabled)
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
650
|
+
## Phase 4: Media & Embeds (v0.5.0) ✅
|
|
651
|
+
|
|
652
|
+
### Goal
|
|
653
|
+
Transform Inkpen into a rich media editor with enhanced image handling, file attachments, and social embeds.
|
|
654
|
+
|
|
655
|
+
**Status:** Complete
|
|
656
|
+
|
|
657
|
+
### 4.1 Enhanced Image Extension (v0.5.0-alpha) ✅
|
|
658
|
+
|
|
659
|
+
**Features:**
|
|
660
|
+
- Resizable images with drag handles
|
|
661
|
+
- Alignment options: left, center, right, full-width
|
|
662
|
+
- Image captions (editable text below image)
|
|
663
|
+
- Lightbox preview on click
|
|
664
|
+
- Lazy loading with blur placeholder
|
|
665
|
+
- Alt text editing
|
|
666
|
+
- Link wrapping (make image clickable)
|
|
667
|
+
|
|
668
|
+
**Implementation:**
|
|
669
|
+
```javascript
|
|
670
|
+
// app/assets/javascripts/inkpen/extensions/enhanced_image.js
|
|
671
|
+
|
|
672
|
+
import { Node, mergeAttributes } from '@tiptap/core'
|
|
673
|
+
import { Plugin } from '@tiptap/pm/state'
|
|
674
|
+
|
|
675
|
+
export const EnhancedImage = Node.create({
|
|
676
|
+
name: 'enhancedImage',
|
|
677
|
+
group: 'block',
|
|
678
|
+
|
|
679
|
+
addAttributes() {
|
|
680
|
+
return {
|
|
681
|
+
src: { default: null },
|
|
682
|
+
alt: { default: null },
|
|
683
|
+
title: { default: null },
|
|
684
|
+
width: { default: null },
|
|
685
|
+
alignment: { default: 'center' }, // left, center, right, full
|
|
686
|
+
caption: { default: null },
|
|
687
|
+
link: { default: null }
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
|
|
691
|
+
addNodeView() {
|
|
692
|
+
// Interactive NodeView with:
|
|
693
|
+
// - Resize handles (corners)
|
|
694
|
+
// - Alignment toolbar on selection
|
|
695
|
+
// - Caption input below image
|
|
696
|
+
// - Lightbox trigger
|
|
697
|
+
},
|
|
698
|
+
|
|
699
|
+
addCommands() {
|
|
700
|
+
return {
|
|
701
|
+
setImageAlignment: (alignment) => ({ commands }) => {...},
|
|
702
|
+
setImageWidth: (width) => ({ commands }) => {...},
|
|
703
|
+
setImageCaption: (caption) => ({ commands }) => {...},
|
|
704
|
+
setImageLink: (url) => ({ commands }) => {...}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
})
|
|
708
|
+
```
|
|
709
|
+
|
|
710
|
+
**CSS:**
|
|
711
|
+
```css
|
|
712
|
+
/* app/assets/stylesheets/inkpen/enhanced_image.css */
|
|
713
|
+
|
|
714
|
+
.inkpen-image {
|
|
715
|
+
position: relative;
|
|
716
|
+
display: inline-block;
|
|
717
|
+
max-width: 100%;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
.inkpen-image--left { margin-right: auto; }
|
|
721
|
+
.inkpen-image--center { margin: 0 auto; }
|
|
722
|
+
.inkpen-image--right { margin-left: auto; }
|
|
723
|
+
.inkpen-image--full { width: 100%; }
|
|
724
|
+
|
|
725
|
+
.inkpen-image__resize-handle {
|
|
726
|
+
position: absolute;
|
|
727
|
+
width: 12px;
|
|
728
|
+
height: 12px;
|
|
729
|
+
background: var(--inkpen-color-primary);
|
|
730
|
+
border: 2px solid white;
|
|
731
|
+
border-radius: 50%;
|
|
732
|
+
cursor: nwse-resize;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
.inkpen-image__caption {
|
|
736
|
+
text-align: center;
|
|
737
|
+
font-size: 0.875rem;
|
|
738
|
+
color: var(--inkpen-color-text-muted);
|
|
739
|
+
margin-top: 0.5rem;
|
|
740
|
+
}
|
|
741
|
+
```
|
|
742
|
+
|
|
743
|
+
**Keyboard Shortcuts:**
|
|
744
|
+
| Shortcut | Action |
|
|
745
|
+
|----------|--------|
|
|
746
|
+
| `Enter` on image | Edit caption |
|
|
747
|
+
| `Delete` on image | Remove image |
|
|
748
|
+
| `Cmd+Shift+L` | Align left |
|
|
749
|
+
| `Cmd+Shift+E` | Align center |
|
|
750
|
+
| `Cmd+Shift+R` | Align right |
|
|
751
|
+
|
|
752
|
+
---
|
|
753
|
+
|
|
754
|
+
### 4.2 File Attachment Extension (v0.5.0-beta)
|
|
755
|
+
|
|
756
|
+
**Features:**
|
|
757
|
+
- Upload any file type via drag & drop or button
|
|
758
|
+
- File type icons (PDF, Word, Excel, ZIP, etc.)
|
|
759
|
+
- File size display
|
|
760
|
+
- Download button
|
|
761
|
+
- Inline PDF preview (optional)
|
|
762
|
+
- Progress indicator during upload
|
|
763
|
+
- Configurable upload endpoint
|
|
764
|
+
|
|
765
|
+
**Implementation:**
|
|
766
|
+
```javascript
|
|
767
|
+
// app/assets/javascripts/inkpen/extensions/file_attachment.js
|
|
768
|
+
|
|
769
|
+
export const FileAttachment = Node.create({
|
|
770
|
+
name: 'fileAttachment',
|
|
771
|
+
group: 'block',
|
|
772
|
+
|
|
773
|
+
addAttributes() {
|
|
774
|
+
return {
|
|
775
|
+
url: { default: null },
|
|
776
|
+
filename: { default: null },
|
|
777
|
+
filesize: { default: null },
|
|
778
|
+
filetype: { default: null },
|
|
779
|
+
uploadProgress: { default: null }
|
|
780
|
+
}
|
|
781
|
+
},
|
|
782
|
+
|
|
783
|
+
addOptions() {
|
|
784
|
+
return {
|
|
785
|
+
uploadUrl: '/uploads',
|
|
786
|
+
allowedTypes: '*',
|
|
787
|
+
maxSize: 10 * 1024 * 1024, // 10MB
|
|
788
|
+
onUpload: null, // Custom upload handler
|
|
789
|
+
onError: null
|
|
790
|
+
}
|
|
791
|
+
},
|
|
792
|
+
|
|
793
|
+
addNodeView() {
|
|
794
|
+
// File card with:
|
|
795
|
+
// - Icon based on file type
|
|
796
|
+
// - Filename + size
|
|
797
|
+
// - Download button
|
|
798
|
+
// - Upload progress bar
|
|
799
|
+
},
|
|
800
|
+
|
|
801
|
+
addCommands() {
|
|
802
|
+
return {
|
|
803
|
+
insertFile: (file) => ({ commands }) => {...},
|
|
804
|
+
uploadFile: (file) => ({ commands }) => {...}
|
|
805
|
+
}
|
|
806
|
+
},
|
|
807
|
+
|
|
808
|
+
addProseMirrorPlugins() {
|
|
809
|
+
return [
|
|
810
|
+
// Drop handler for file uploads
|
|
811
|
+
new Plugin({
|
|
812
|
+
props: {
|
|
813
|
+
handleDrop(view, event) {
|
|
814
|
+
const files = event.dataTransfer?.files
|
|
815
|
+
if (files?.length) {
|
|
816
|
+
// Handle file upload
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
})
|
|
821
|
+
]
|
|
822
|
+
}
|
|
823
|
+
})
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
**File Type Icons:**
|
|
827
|
+
```
|
|
828
|
+
📄 PDF, DOC, DOCX, TXT
|
|
829
|
+
📊 XLS, XLSX, CSV
|
|
830
|
+
📁 ZIP, RAR, 7Z
|
|
831
|
+
🎵 MP3, WAV, OGG
|
|
832
|
+
🎬 MP4, MOV, AVI
|
|
833
|
+
🖼️ Image files (fallback)
|
|
834
|
+
📎 Other files
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
**CSS:**
|
|
838
|
+
```css
|
|
839
|
+
.inkpen-file {
|
|
840
|
+
display: flex;
|
|
841
|
+
align-items: center;
|
|
842
|
+
gap: 0.75rem;
|
|
843
|
+
padding: 0.75rem 1rem;
|
|
844
|
+
background: var(--inkpen-color-border);
|
|
845
|
+
border-radius: var(--inkpen-radius);
|
|
846
|
+
margin: 1rem 0;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
.inkpen-file__icon {
|
|
850
|
+
font-size: 1.5rem;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
.inkpen-file__info {
|
|
854
|
+
flex: 1;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
.inkpen-file__name {
|
|
858
|
+
font-weight: 500;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
.inkpen-file__size {
|
|
862
|
+
font-size: 0.75rem;
|
|
863
|
+
color: var(--inkpen-color-text-muted);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
.inkpen-file__download {
|
|
867
|
+
padding: 0.5rem;
|
|
868
|
+
border-radius: var(--inkpen-radius);
|
|
869
|
+
background: var(--inkpen-color-primary);
|
|
870
|
+
color: white;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
.inkpen-file__progress {
|
|
874
|
+
height: 4px;
|
|
875
|
+
background: var(--inkpen-color-border);
|
|
876
|
+
border-radius: 2px;
|
|
877
|
+
overflow: hidden;
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
.inkpen-file__progress-bar {
|
|
881
|
+
height: 100%;
|
|
882
|
+
background: var(--inkpen-color-primary);
|
|
883
|
+
transition: width 150ms;
|
|
884
|
+
}
|
|
885
|
+
```
|
|
886
|
+
|
|
887
|
+
---
|
|
888
|
+
|
|
889
|
+
### 4.3 Social Embeds Extension (v0.5.0-rc)
|
|
890
|
+
|
|
891
|
+
**Features:**
|
|
892
|
+
- Paste URL to auto-embed
|
|
893
|
+
- Supported platforms:
|
|
894
|
+
- Twitter/X posts
|
|
895
|
+
- Instagram posts
|
|
896
|
+
- TikTok videos
|
|
897
|
+
- Figma designs
|
|
898
|
+
- Loom videos
|
|
899
|
+
- CodePen pens
|
|
900
|
+
- GitHub Gists
|
|
901
|
+
- Spotify tracks/playlists
|
|
902
|
+
- Responsive embeds
|
|
903
|
+
- Fallback link card for unsupported URLs
|
|
904
|
+
- Privacy-aware (no tracking until clicked)
|
|
905
|
+
|
|
906
|
+
**Implementation:**
|
|
907
|
+
```javascript
|
|
908
|
+
// app/assets/javascripts/inkpen/extensions/embed.js
|
|
909
|
+
|
|
910
|
+
const EMBED_PROVIDERS = {
|
|
911
|
+
twitter: {
|
|
912
|
+
regex: /https?:\/\/(twitter|x)\.com\/\w+\/status\/(\d+)/,
|
|
913
|
+
template: (id) => `<blockquote class="twitter-tweet" data-id="${id}"></blockquote>`,
|
|
914
|
+
script: 'https://platform.twitter.com/widgets.js'
|
|
915
|
+
},
|
|
916
|
+
instagram: {
|
|
917
|
+
regex: /https?:\/\/www\.instagram\.com\/(p|reel)\/([A-Za-z0-9_-]+)/,
|
|
918
|
+
template: (id) => `<blockquote class="instagram-media" data-instgrm-permalink="https://www.instagram.com/p/${id}/"></blockquote>`,
|
|
919
|
+
script: 'https://www.instagram.com/embed.js'
|
|
920
|
+
},
|
|
921
|
+
figma: {
|
|
922
|
+
regex: /https?:\/\/www\.figma\.com\/(file|proto)\/([A-Za-z0-9]+)/,
|
|
923
|
+
template: (url) => `<iframe src="https://www.figma.com/embed?embed_host=inkpen&url=${encodeURIComponent(url)}"></iframe>`
|
|
924
|
+
},
|
|
925
|
+
loom: {
|
|
926
|
+
regex: /https?:\/\/www\.loom\.com\/share\/([a-zA-Z0-9]+)/,
|
|
927
|
+
template: (id) => `<iframe src="https://www.loom.com/embed/${id}"></iframe>`
|
|
928
|
+
},
|
|
929
|
+
codepen: {
|
|
930
|
+
regex: /https?:\/\/codepen\.io\/([^\/]+)\/pen\/([A-Za-z0-9]+)/,
|
|
931
|
+
template: (user, id) => `<iframe src="https://codepen.io/${user}/embed/${id}?default-tab=result"></iframe>`
|
|
932
|
+
},
|
|
933
|
+
gist: {
|
|
934
|
+
regex: /https?:\/\/gist\.github\.com\/([^\/]+)\/([a-f0-9]+)/,
|
|
935
|
+
template: (user, id) => `<script src="https://gist.github.com/${user}/${id}.js"></script>`
|
|
936
|
+
},
|
|
937
|
+
spotify: {
|
|
938
|
+
regex: /https?:\/\/open\.spotify\.com\/(track|album|playlist)\/([A-Za-z0-9]+)/,
|
|
939
|
+
template: (type, id) => `<iframe src="https://open.spotify.com/embed/${type}/${id}"></iframe>`
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
export const Embed = Node.create({
|
|
944
|
+
name: 'embed',
|
|
945
|
+
group: 'block',
|
|
946
|
+
|
|
947
|
+
addAttributes() {
|
|
948
|
+
return {
|
|
949
|
+
url: { default: null },
|
|
950
|
+
provider: { default: null },
|
|
951
|
+
embedId: { default: null },
|
|
952
|
+
loaded: { default: false }
|
|
953
|
+
}
|
|
954
|
+
},
|
|
955
|
+
|
|
956
|
+
addOptions() {
|
|
957
|
+
return {
|
|
958
|
+
providers: EMBED_PROVIDERS,
|
|
959
|
+
allowedProviders: null, // null = all, or ['twitter', 'youtube']
|
|
960
|
+
privacyMode: true // Show placeholder until clicked
|
|
961
|
+
}
|
|
962
|
+
},
|
|
963
|
+
|
|
964
|
+
addNodeView() {
|
|
965
|
+
// Privacy-first embed:
|
|
966
|
+
// 1. Show preview card with provider logo
|
|
967
|
+
// 2. "Click to load" button
|
|
968
|
+
// 3. Load actual embed on click
|
|
969
|
+
},
|
|
970
|
+
|
|
971
|
+
addPasteRules() {
|
|
972
|
+
// Auto-detect and embed URLs on paste
|
|
973
|
+
},
|
|
974
|
+
|
|
975
|
+
addCommands() {
|
|
976
|
+
return {
|
|
977
|
+
insertEmbed: (url) => ({ commands }) => {...},
|
|
978
|
+
loadEmbed: () => ({ commands }) => {...}
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
})
|
|
982
|
+
```
|
|
983
|
+
|
|
984
|
+
**Link Card Fallback:**
|
|
985
|
+
```css
|
|
986
|
+
.inkpen-link-card {
|
|
987
|
+
display: flex;
|
|
988
|
+
border: 1px solid var(--inkpen-color-border);
|
|
989
|
+
border-radius: var(--inkpen-radius);
|
|
990
|
+
overflow: hidden;
|
|
991
|
+
text-decoration: none;
|
|
992
|
+
color: inherit;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
.inkpen-link-card__image {
|
|
996
|
+
width: 120px;
|
|
997
|
+
height: 80px;
|
|
998
|
+
object-fit: cover;
|
|
999
|
+
flex-shrink: 0;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
.inkpen-link-card__content {
|
|
1003
|
+
padding: 0.75rem;
|
|
1004
|
+
flex: 1;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
.inkpen-link-card__title {
|
|
1008
|
+
font-weight: 500;
|
|
1009
|
+
margin-bottom: 0.25rem;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
.inkpen-link-card__description {
|
|
1013
|
+
font-size: 0.875rem;
|
|
1014
|
+
color: var(--inkpen-color-text-muted);
|
|
1015
|
+
display: -webkit-box;
|
|
1016
|
+
-webkit-line-clamp: 2;
|
|
1017
|
+
-webkit-box-orient: vertical;
|
|
1018
|
+
overflow: hidden;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
.inkpen-link-card__domain {
|
|
1022
|
+
font-size: 0.75rem;
|
|
1023
|
+
color: var(--inkpen-color-text-muted);
|
|
1024
|
+
margin-top: 0.5rem;
|
|
1025
|
+
}
|
|
1026
|
+
```
|
|
1027
|
+
|
|
1028
|
+
---
|
|
1029
|
+
|
|
1030
|
+
### 4.4 Slash Commands Updates
|
|
1031
|
+
|
|
1032
|
+
Add new commands to slash menu:
|
|
1033
|
+
- `/image` - Insert image (upload or URL)
|
|
1034
|
+
- `/file` - Upload file attachment
|
|
1035
|
+
- `/embed` - Paste URL to embed
|
|
1036
|
+
- `/twitter` - Embed tweet
|
|
1037
|
+
- `/figma` - Embed Figma design
|
|
1038
|
+
- `/loom` - Embed Loom video
|
|
1039
|
+
- `/codepen` - Embed CodePen
|
|
1040
|
+
|
|
1041
|
+
---
|
|
1042
|
+
|
|
1043
|
+
### Implementation Priority
|
|
1044
|
+
|
|
1045
|
+
| Feature | Priority | Complexity | Files |
|
|
1046
|
+
|---------|----------|------------|-------|
|
|
1047
|
+
| Enhanced Image | High | Medium | enhanced_image.js, enhanced_image.css |
|
|
1048
|
+
| File Attachment | High | High | file_attachment.js, file_attachment.css |
|
|
1049
|
+
| Social Embeds | Medium | Medium | embed.js, embed.css |
|
|
1050
|
+
| Link Cards | Medium | Low | link_card.js, link_card.css |
|
|
1051
|
+
| Slash Menu Updates | Low | Low | slash_commands.js |
|
|
1052
|
+
|
|
1053
|
+
---
|
|
1054
|
+
|
|
1055
|
+
### Files to Create (v0.5.0)
|
|
1056
|
+
|
|
1057
|
+
```
|
|
1058
|
+
app/assets/javascripts/inkpen/extensions/
|
|
1059
|
+
├── enhanced_image.js ← v0.5.0-alpha
|
|
1060
|
+
├── file_attachment.js ← v0.5.0-beta
|
|
1061
|
+
├── embed.js ← v0.5.0-rc
|
|
1062
|
+
└── link_card.js ← v0.5.0-rc
|
|
1063
|
+
|
|
1064
|
+
app/assets/stylesheets/inkpen/
|
|
1065
|
+
├── enhanced_image.css ← v0.5.0-alpha
|
|
1066
|
+
├── file_attachment.css ← v0.5.0-beta
|
|
1067
|
+
├── embed.css ← v0.5.0-rc
|
|
1068
|
+
└── link_card.css ← v0.5.0-rc
|
|
1069
|
+
|
|
1070
|
+
lib/inkpen/extensions/
|
|
1071
|
+
├── enhanced_image.rb ← v0.5.0-alpha
|
|
1072
|
+
├── file_attachment.rb ← v0.5.0-beta
|
|
1073
|
+
└── embed.rb ← v0.5.0-rc
|
|
1074
|
+
```
|
|
1075
|
+
|
|
1076
|
+
---
|
|
1077
|
+
|
|
1078
|
+
## Phase 5: Tables & Data (v0.6.0) ✅
|
|
1079
|
+
|
|
1080
|
+
### Goal
|
|
1081
|
+
Transform Inkpen into a data-rich editor with advanced table features, Notion-style database blocks, and automatic table of contents generation.
|
|
1082
|
+
|
|
1083
|
+
**Status:** Complete
|
|
1084
|
+
|
|
1085
|
+
---
|
|
1086
|
+
|
|
1087
|
+
### 5.1 Advanced Tables Extension (v0.6.0-alpha) ✅
|
|
1088
|
+
|
|
1089
|
+
Enhance the existing TipTap table with professional features.
|
|
1090
|
+
|
|
1091
|
+
**Features:**
|
|
1092
|
+
- Column resizing with visual handles (existing)
|
|
1093
|
+
- Cell merging and splitting (existing)
|
|
1094
|
+
- Header rows with sticky behavior
|
|
1095
|
+
- **NEW:** Column alignment (left, center, right)
|
|
1096
|
+
- **NEW:** Table caption/title
|
|
1097
|
+
- **NEW:** Striped rows option
|
|
1098
|
+
- **NEW:** Border style variants (default, borderless, minimal)
|
|
1099
|
+
- **NEW:** Table toolbar on cell selection
|
|
1100
|
+
- **NEW:** Cell background colors
|
|
1101
|
+
- **NEW:** Row/column drag reordering
|
|
1102
|
+
|
|
1103
|
+
**Implementation:**
|
|
1104
|
+
```javascript
|
|
1105
|
+
// app/assets/javascripts/inkpen/extensions/advanced_table.js
|
|
1106
|
+
|
|
1107
|
+
import { Table } from '@tiptap/extension-table'
|
|
1108
|
+
import { TableRow } from '@tiptap/extension-table-row'
|
|
1109
|
+
import { TableCell } from '@tiptap/extension-table-cell'
|
|
1110
|
+
import { TableHeader } from '@tiptap/extension-table-header'
|
|
1111
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
1112
|
+
|
|
1113
|
+
// Extended TableCell with new attributes
|
|
1114
|
+
export const AdvancedTableCell = TableCell.extend({
|
|
1115
|
+
addAttributes() {
|
|
1116
|
+
return {
|
|
1117
|
+
...this.parent?.(),
|
|
1118
|
+
align: {
|
|
1119
|
+
default: 'left',
|
|
1120
|
+
parseHTML: element => element.style.textAlign || 'left',
|
|
1121
|
+
renderHTML: attributes => ({
|
|
1122
|
+
style: `text-align: ${attributes.align}`
|
|
1123
|
+
})
|
|
1124
|
+
},
|
|
1125
|
+
background: {
|
|
1126
|
+
default: null,
|
|
1127
|
+
parseHTML: element => element.getAttribute('data-background'),
|
|
1128
|
+
renderHTML: attributes => attributes.background
|
|
1129
|
+
? { 'data-background': attributes.background, style: `background: var(--inkpen-table-bg-${attributes.background})` }
|
|
1130
|
+
: {}
|
|
1131
|
+
}
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
})
|
|
1135
|
+
|
|
1136
|
+
// Extended Table with caption and style variants
|
|
1137
|
+
export const AdvancedTable = Table.extend({
|
|
1138
|
+
addAttributes() {
|
|
1139
|
+
return {
|
|
1140
|
+
...this.parent?.(),
|
|
1141
|
+
caption: { default: null },
|
|
1142
|
+
variant: { default: 'default' }, // default, striped, borderless, minimal
|
|
1143
|
+
stickyHeader: { default: false }
|
|
1144
|
+
}
|
|
1145
|
+
},
|
|
1146
|
+
|
|
1147
|
+
addNodeView() {
|
|
1148
|
+
return ({ node, editor, getPos }) => {
|
|
1149
|
+
const wrapper = document.createElement('div')
|
|
1150
|
+
wrapper.className = 'inkpen-table-wrapper'
|
|
1151
|
+
|
|
1152
|
+
// Caption above table
|
|
1153
|
+
if (node.attrs.caption) {
|
|
1154
|
+
const caption = document.createElement('div')
|
|
1155
|
+
caption.className = 'inkpen-table__caption'
|
|
1156
|
+
caption.textContent = node.attrs.caption
|
|
1157
|
+
caption.contentEditable = 'true'
|
|
1158
|
+
wrapper.appendChild(caption)
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
// Table controls toolbar
|
|
1162
|
+
if (editor.isEditable) {
|
|
1163
|
+
const controls = document.createElement('div')
|
|
1164
|
+
controls.className = 'inkpen-table__controls'
|
|
1165
|
+
controls.innerHTML = `
|
|
1166
|
+
<button data-action="align-left" title="Align Left">⬅</button>
|
|
1167
|
+
<button data-action="align-center" title="Align Center">⬌</button>
|
|
1168
|
+
<button data-action="align-right" title="Align Right">➡</button>
|
|
1169
|
+
<span class="divider"></span>
|
|
1170
|
+
<button data-action="toggle-striped" title="Striped Rows">≡</button>
|
|
1171
|
+
<button data-action="cell-color" title="Cell Color">🎨</button>
|
|
1172
|
+
<span class="divider"></span>
|
|
1173
|
+
<button data-action="add-row" title="Add Row">+↓</button>
|
|
1174
|
+
<button data-action="add-col" title="Add Column">+→</button>
|
|
1175
|
+
`
|
|
1176
|
+
wrapper.appendChild(controls)
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
// Table element
|
|
1180
|
+
const tableContainer = document.createElement('div')
|
|
1181
|
+
tableContainer.className = 'inkpen-table__container'
|
|
1182
|
+
wrapper.appendChild(tableContainer)
|
|
1183
|
+
|
|
1184
|
+
return {
|
|
1185
|
+
dom: wrapper,
|
|
1186
|
+
contentDOM: tableContainer
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
},
|
|
1190
|
+
|
|
1191
|
+
addCommands() {
|
|
1192
|
+
return {
|
|
1193
|
+
...this.parent?.(),
|
|
1194
|
+
setTableCaption: (caption) => ({ tr, state, dispatch }) => {
|
|
1195
|
+
// Set caption on table node
|
|
1196
|
+
},
|
|
1197
|
+
setTableVariant: (variant) => ({ tr, state, dispatch }) => {
|
|
1198
|
+
// Set variant (default, striped, borderless, minimal)
|
|
1199
|
+
},
|
|
1200
|
+
setCellAlignment: (align) => ({ tr, state, dispatch }) => {
|
|
1201
|
+
// Set alignment for selected cells
|
|
1202
|
+
},
|
|
1203
|
+
setCellBackground: (color) => ({ tr, state, dispatch }) => {
|
|
1204
|
+
// Set background for selected cells
|
|
1205
|
+
},
|
|
1206
|
+
toggleStickyHeader: () => ({ tr, state, dispatch }) => {
|
|
1207
|
+
// Toggle sticky header behavior
|
|
1208
|
+
}
|
|
1209
|
+
}
|
|
1210
|
+
},
|
|
1211
|
+
|
|
1212
|
+
addKeyboardShortcuts() {
|
|
1213
|
+
return {
|
|
1214
|
+
...this.parent?.(),
|
|
1215
|
+
'Tab': () => this.editor.commands.goToNextCell(),
|
|
1216
|
+
'Shift-Tab': () => this.editor.commands.goToPreviousCell()
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
})
|
|
1220
|
+
```
|
|
1221
|
+
|
|
1222
|
+
**CSS:**
|
|
1223
|
+
```css
|
|
1224
|
+
/* app/assets/stylesheets/inkpen/advanced_table.css */
|
|
1225
|
+
|
|
1226
|
+
.inkpen-table-wrapper {
|
|
1227
|
+
margin: 1.5rem 0;
|
|
1228
|
+
position: relative;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
.inkpen-table__caption {
|
|
1232
|
+
font-size: 0.875rem;
|
|
1233
|
+
color: var(--inkpen-color-text-muted);
|
|
1234
|
+
margin-bottom: 0.5rem;
|
|
1235
|
+
font-style: italic;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
.inkpen-table__controls {
|
|
1239
|
+
position: absolute;
|
|
1240
|
+
top: -36px;
|
|
1241
|
+
left: 50%;
|
|
1242
|
+
transform: translateX(-50%);
|
|
1243
|
+
display: flex;
|
|
1244
|
+
gap: 0.25rem;
|
|
1245
|
+
padding: 0.25rem;
|
|
1246
|
+
background: var(--inkpen-toolbar-bg);
|
|
1247
|
+
border: 1px solid var(--inkpen-color-border);
|
|
1248
|
+
border-radius: var(--inkpen-radius);
|
|
1249
|
+
box-shadow: var(--inkpen-shadow);
|
|
1250
|
+
opacity: 0;
|
|
1251
|
+
transition: opacity 150ms;
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
.inkpen-table-wrapper:focus-within .inkpen-table__controls,
|
|
1255
|
+
.inkpen-table-wrapper:hover .inkpen-table__controls {
|
|
1256
|
+
opacity: 1;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
/* Table Variants */
|
|
1260
|
+
.inkpen-table {
|
|
1261
|
+
width: 100%;
|
|
1262
|
+
border-collapse: collapse;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
.inkpen-table--default td,
|
|
1266
|
+
.inkpen-table--default th {
|
|
1267
|
+
border: 1px solid var(--inkpen-color-border);
|
|
1268
|
+
padding: 0.5rem 0.75rem;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
.inkpen-table--striped tr:nth-child(even) {
|
|
1272
|
+
background: var(--inkpen-color-bg-subtle);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
.inkpen-table--borderless td,
|
|
1276
|
+
.inkpen-table--borderless th {
|
|
1277
|
+
border: none;
|
|
1278
|
+
border-bottom: 1px solid var(--inkpen-color-border);
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
.inkpen-table--minimal td,
|
|
1282
|
+
.inkpen-table--minimal th {
|
|
1283
|
+
border: none;
|
|
1284
|
+
padding: 0.5rem 1rem;
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
/* Sticky Header */
|
|
1288
|
+
.inkpen-table--sticky-header thead {
|
|
1289
|
+
position: sticky;
|
|
1290
|
+
top: 0;
|
|
1291
|
+
background: var(--inkpen-toolbar-bg);
|
|
1292
|
+
z-index: 10;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
/* Cell Colors */
|
|
1296
|
+
.inkpen-table [data-background="gray"] { background: var(--inkpen-color-bg-subtle); }
|
|
1297
|
+
.inkpen-table [data-background="red"] { background: rgba(239, 68, 68, 0.15); }
|
|
1298
|
+
.inkpen-table [data-background="orange"] { background: rgba(249, 115, 22, 0.15); }
|
|
1299
|
+
.inkpen-table [data-background="yellow"] { background: rgba(234, 179, 8, 0.15); }
|
|
1300
|
+
.inkpen-table [data-background="green"] { background: rgba(34, 197, 94, 0.15); }
|
|
1301
|
+
.inkpen-table [data-background="blue"] { background: rgba(59, 130, 246, 0.15); }
|
|
1302
|
+
.inkpen-table [data-background="purple"] { background: rgba(168, 85, 247, 0.15); }
|
|
1303
|
+
|
|
1304
|
+
/* Resize Handles */
|
|
1305
|
+
.inkpen-table .column-resize-handle {
|
|
1306
|
+
position: absolute;
|
|
1307
|
+
right: -2px;
|
|
1308
|
+
top: 0;
|
|
1309
|
+
bottom: 0;
|
|
1310
|
+
width: 4px;
|
|
1311
|
+
background: var(--inkpen-color-primary);
|
|
1312
|
+
cursor: col-resize;
|
|
1313
|
+
opacity: 0;
|
|
1314
|
+
transition: opacity 150ms;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
.inkpen-table td:hover .column-resize-handle,
|
|
1318
|
+
.inkpen-table th:hover .column-resize-handle {
|
|
1319
|
+
opacity: 1;
|
|
1320
|
+
}
|
|
1321
|
+
```
|
|
1322
|
+
|
|
1323
|
+
---
|
|
1324
|
+
|
|
1325
|
+
### 5.2 Database Block Extension (v0.6.0-beta)
|
|
1326
|
+
|
|
1327
|
+
Notion-style inline databases with multiple views.
|
|
1328
|
+
|
|
1329
|
+
**Features:**
|
|
1330
|
+
- Property types: Text, Number, Select, Multi-select, Date, Checkbox, URL, Email, Person
|
|
1331
|
+
- Views: Table, List, Gallery, Board (Kanban)
|
|
1332
|
+
- Filters with AND/OR logic
|
|
1333
|
+
- Sorting (single and multi-column)
|
|
1334
|
+
- Inline database (embedded in document)
|
|
1335
|
+
- Full-page database option
|
|
1336
|
+
- Template rows
|
|
1337
|
+
- Formulas (basic: SUM, COUNT, AVG)
|
|
1338
|
+
- Linked databases (reference same data)
|
|
1339
|
+
|
|
1340
|
+
**Implementation:**
|
|
1341
|
+
```javascript
|
|
1342
|
+
// app/assets/javascripts/inkpen/extensions/database.js
|
|
1343
|
+
|
|
1344
|
+
import { Node, mergeAttributes } from '@tiptap/core'
|
|
1345
|
+
|
|
1346
|
+
const PROPERTY_TYPES = {
|
|
1347
|
+
text: { icon: 'Aa', default: '' },
|
|
1348
|
+
number: { icon: '#', default: 0 },
|
|
1349
|
+
select: { icon: '▼', default: null, options: [] },
|
|
1350
|
+
multiSelect: { icon: '☰', default: [], options: [] },
|
|
1351
|
+
date: { icon: '📅', default: null },
|
|
1352
|
+
checkbox: { icon: '☑', default: false },
|
|
1353
|
+
url: { icon: '🔗', default: '' },
|
|
1354
|
+
email: { icon: '✉', default: '' },
|
|
1355
|
+
person: { icon: '👤', default: null },
|
|
1356
|
+
formula: { icon: 'ƒ', default: null, formula: '' }
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const VIEWS = {
|
|
1360
|
+
table: { icon: '⊞', name: 'Table' },
|
|
1361
|
+
list: { icon: '☰', name: 'List' },
|
|
1362
|
+
gallery: { icon: '⊟', name: 'Gallery' },
|
|
1363
|
+
board: { icon: '▣', name: 'Board' }
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
export const Database = Node.create({
|
|
1367
|
+
name: 'database',
|
|
1368
|
+
group: 'block',
|
|
1369
|
+
atom: true,
|
|
1370
|
+
draggable: true,
|
|
1371
|
+
|
|
1372
|
+
addAttributes() {
|
|
1373
|
+
return {
|
|
1374
|
+
title: { default: 'Untitled Database' },
|
|
1375
|
+
properties: {
|
|
1376
|
+
default: [
|
|
1377
|
+
{ id: 'name', name: 'Name', type: 'text' },
|
|
1378
|
+
{ id: 'status', name: 'Status', type: 'select', options: ['To Do', 'In Progress', 'Done'] }
|
|
1379
|
+
]
|
|
1380
|
+
},
|
|
1381
|
+
rows: {
|
|
1382
|
+
default: []
|
|
1383
|
+
},
|
|
1384
|
+
views: {
|
|
1385
|
+
default: [
|
|
1386
|
+
{ id: 'default', type: 'table', name: 'Table View', filters: [], sorts: [] }
|
|
1387
|
+
]
|
|
1388
|
+
},
|
|
1389
|
+
activeView: { default: 'default' },
|
|
1390
|
+
isInline: { default: true },
|
|
1391
|
+
linkedFrom: { default: null } // ID of source database if linked
|
|
1392
|
+
}
|
|
1393
|
+
},
|
|
1394
|
+
|
|
1395
|
+
addNodeView() {
|
|
1396
|
+
return ({ node, editor, getPos, HTMLAttributes }) => {
|
|
1397
|
+
const { title, properties, rows, views, activeView, isInline } = node.attrs
|
|
1398
|
+
const currentView = views.find(v => v.id === activeView) || views[0]
|
|
1399
|
+
|
|
1400
|
+
const wrapper = document.createElement('div')
|
|
1401
|
+
wrapper.className = `inkpen-database inkpen-database--${currentView.type}`
|
|
1402
|
+
if (isInline) wrapper.classList.add('inkpen-database--inline')
|
|
1403
|
+
|
|
1404
|
+
// Header with title and view tabs
|
|
1405
|
+
const header = document.createElement('div')
|
|
1406
|
+
header.className = 'inkpen-database__header'
|
|
1407
|
+
header.innerHTML = `
|
|
1408
|
+
<input type="text" class="inkpen-database__title" value="${title}" placeholder="Untitled" />
|
|
1409
|
+
<div class="inkpen-database__views">
|
|
1410
|
+
${views.map(v => `
|
|
1411
|
+
<button class="inkpen-database__view-tab ${v.id === activeView ? 'is-active' : ''}"
|
|
1412
|
+
data-view-id="${v.id}">
|
|
1413
|
+
${VIEWS[v.type].icon} ${v.name}
|
|
1414
|
+
</button>
|
|
1415
|
+
`).join('')}
|
|
1416
|
+
<button class="inkpen-database__add-view">+ Add View</button>
|
|
1417
|
+
</div>
|
|
1418
|
+
<div class="inkpen-database__actions">
|
|
1419
|
+
<button class="inkpen-database__filter">Filter</button>
|
|
1420
|
+
<button class="inkpen-database__sort">Sort</button>
|
|
1421
|
+
<button class="inkpen-database__new-row">+ New</button>
|
|
1422
|
+
</div>
|
|
1423
|
+
`
|
|
1424
|
+
wrapper.appendChild(header)
|
|
1425
|
+
|
|
1426
|
+
// Render view based on type
|
|
1427
|
+
const content = document.createElement('div')
|
|
1428
|
+
content.className = 'inkpen-database__content'
|
|
1429
|
+
|
|
1430
|
+
switch (currentView.type) {
|
|
1431
|
+
case 'table':
|
|
1432
|
+
content.innerHTML = this.renderTableView(properties, rows, currentView)
|
|
1433
|
+
break
|
|
1434
|
+
case 'list':
|
|
1435
|
+
content.innerHTML = this.renderListView(properties, rows, currentView)
|
|
1436
|
+
break
|
|
1437
|
+
case 'gallery':
|
|
1438
|
+
content.innerHTML = this.renderGalleryView(properties, rows, currentView)
|
|
1439
|
+
break
|
|
1440
|
+
case 'board':
|
|
1441
|
+
content.innerHTML = this.renderBoardView(properties, rows, currentView)
|
|
1442
|
+
break
|
|
1443
|
+
}
|
|
1444
|
+
wrapper.appendChild(content)
|
|
1445
|
+
|
|
1446
|
+
return { dom: wrapper }
|
|
1447
|
+
}
|
|
1448
|
+
},
|
|
1449
|
+
|
|
1450
|
+
renderTableView(properties, rows, view) {
|
|
1451
|
+
return `
|
|
1452
|
+
<table class="inkpen-database__table">
|
|
1453
|
+
<thead>
|
|
1454
|
+
<tr>
|
|
1455
|
+
${properties.map(p => `
|
|
1456
|
+
<th data-prop-id="${p.id}">
|
|
1457
|
+
${PROPERTY_TYPES[p.type].icon} ${p.name}
|
|
1458
|
+
</th>
|
|
1459
|
+
`).join('')}
|
|
1460
|
+
<th class="inkpen-database__add-prop">+</th>
|
|
1461
|
+
</tr>
|
|
1462
|
+
</thead>
|
|
1463
|
+
<tbody>
|
|
1464
|
+
${rows.map(row => `
|
|
1465
|
+
<tr data-row-id="${row.id}">
|
|
1466
|
+
${properties.map(p => `
|
|
1467
|
+
<td data-prop-id="${p.id}" data-type="${p.type}">
|
|
1468
|
+
${this.renderCell(p, row[p.id])}
|
|
1469
|
+
</td>
|
|
1470
|
+
`).join('')}
|
|
1471
|
+
</tr>
|
|
1472
|
+
`).join('')}
|
|
1473
|
+
<tr class="inkpen-database__new-row-placeholder">
|
|
1474
|
+
<td colspan="${properties.length + 1}">+ New row</td>
|
|
1475
|
+
</tr>
|
|
1476
|
+
</tbody>
|
|
1477
|
+
</table>
|
|
1478
|
+
`
|
|
1479
|
+
},
|
|
1480
|
+
|
|
1481
|
+
renderBoardView(properties, rows, view) {
|
|
1482
|
+
const groupBy = view.groupBy || properties.find(p => p.type === 'select')?.id
|
|
1483
|
+
const groupProp = properties.find(p => p.id === groupBy)
|
|
1484
|
+
const groups = groupProp?.options || ['No Status']
|
|
1485
|
+
|
|
1486
|
+
return `
|
|
1487
|
+
<div class="inkpen-database__board">
|
|
1488
|
+
${groups.map(group => `
|
|
1489
|
+
<div class="inkpen-database__column" data-group="${group}">
|
|
1490
|
+
<div class="inkpen-database__column-header">
|
|
1491
|
+
<span>${group}</span>
|
|
1492
|
+
<span class="inkpen-database__column-count">
|
|
1493
|
+
${rows.filter(r => r[groupBy] === group).length}
|
|
1494
|
+
</span>
|
|
1495
|
+
</div>
|
|
1496
|
+
<div class="inkpen-database__column-items">
|
|
1497
|
+
${rows.filter(r => r[groupBy] === group).map(row => `
|
|
1498
|
+
<div class="inkpen-database__card" data-row-id="${row.id}">
|
|
1499
|
+
${this.renderCard(properties, row)}
|
|
1500
|
+
</div>
|
|
1501
|
+
`).join('')}
|
|
1502
|
+
<button class="inkpen-database__add-card">+ Add</button>
|
|
1503
|
+
</div>
|
|
1504
|
+
</div>
|
|
1505
|
+
`).join('')}
|
|
1506
|
+
</div>
|
|
1507
|
+
`
|
|
1508
|
+
},
|
|
1509
|
+
|
|
1510
|
+
addCommands() {
|
|
1511
|
+
return {
|
|
1512
|
+
insertDatabase: (options = {}) => ({ commands }) => {
|
|
1513
|
+
return commands.insertContent({
|
|
1514
|
+
type: this.name,
|
|
1515
|
+
attrs: options
|
|
1516
|
+
})
|
|
1517
|
+
},
|
|
1518
|
+
addDatabaseProperty: (propertyDef) => ({ tr, state }) => {
|
|
1519
|
+
// Add new property column
|
|
1520
|
+
},
|
|
1521
|
+
addDatabaseRow: (rowData) => ({ tr, state }) => {
|
|
1522
|
+
// Add new row
|
|
1523
|
+
},
|
|
1524
|
+
setDatabaseView: (viewId) => ({ tr, state }) => {
|
|
1525
|
+
// Switch active view
|
|
1526
|
+
},
|
|
1527
|
+
addDatabaseView: (viewDef) => ({ tr, state }) => {
|
|
1528
|
+
// Create new view
|
|
1529
|
+
},
|
|
1530
|
+
setDatabaseFilter: (filters) => ({ tr, state }) => {
|
|
1531
|
+
// Update view filters
|
|
1532
|
+
},
|
|
1533
|
+
setDatabaseSort: (sorts) => ({ tr, state }) => {
|
|
1534
|
+
// Update view sorts
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
})
|
|
1539
|
+
```
|
|
1540
|
+
|
|
1541
|
+
**CSS:**
|
|
1542
|
+
```css
|
|
1543
|
+
/* app/assets/stylesheets/inkpen/database.css */
|
|
1544
|
+
|
|
1545
|
+
.inkpen-database {
|
|
1546
|
+
margin: 1.5rem 0;
|
|
1547
|
+
border: 1px solid var(--inkpen-color-border);
|
|
1548
|
+
border-radius: var(--inkpen-radius);
|
|
1549
|
+
overflow: hidden;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
.inkpen-database--inline {
|
|
1553
|
+
max-height: 400px;
|
|
1554
|
+
overflow-y: auto;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
/* Header */
|
|
1558
|
+
.inkpen-database__header {
|
|
1559
|
+
display: flex;
|
|
1560
|
+
align-items: center;
|
|
1561
|
+
gap: 1rem;
|
|
1562
|
+
padding: 0.75rem 1rem;
|
|
1563
|
+
border-bottom: 1px solid var(--inkpen-color-border);
|
|
1564
|
+
background: var(--inkpen-color-bg-subtle);
|
|
1565
|
+
}
|
|
1566
|
+
|
|
1567
|
+
.inkpen-database__title {
|
|
1568
|
+
font-size: 1rem;
|
|
1569
|
+
font-weight: 600;
|
|
1570
|
+
border: none;
|
|
1571
|
+
background: transparent;
|
|
1572
|
+
flex: 1;
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
.inkpen-database__views {
|
|
1576
|
+
display: flex;
|
|
1577
|
+
gap: 0.25rem;
|
|
1578
|
+
}
|
|
1579
|
+
|
|
1580
|
+
.inkpen-database__view-tab {
|
|
1581
|
+
padding: 0.375rem 0.75rem;
|
|
1582
|
+
border-radius: var(--inkpen-radius);
|
|
1583
|
+
border: none;
|
|
1584
|
+
background: transparent;
|
|
1585
|
+
cursor: pointer;
|
|
1586
|
+
}
|
|
1587
|
+
|
|
1588
|
+
.inkpen-database__view-tab.is-active {
|
|
1589
|
+
background: var(--inkpen-toolbar-bg);
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/* Table View */
|
|
1593
|
+
.inkpen-database__table {
|
|
1594
|
+
width: 100%;
|
|
1595
|
+
border-collapse: collapse;
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
.inkpen-database__table th {
|
|
1599
|
+
text-align: left;
|
|
1600
|
+
padding: 0.5rem 0.75rem;
|
|
1601
|
+
border-bottom: 1px solid var(--inkpen-color-border);
|
|
1602
|
+
font-weight: 500;
|
|
1603
|
+
font-size: 0.875rem;
|
|
1604
|
+
color: var(--inkpen-color-text-muted);
|
|
1605
|
+
white-space: nowrap;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
.inkpen-database__table td {
|
|
1609
|
+
padding: 0.5rem 0.75rem;
|
|
1610
|
+
border-bottom: 1px solid var(--inkpen-color-border);
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
.inkpen-database__table td:hover {
|
|
1614
|
+
background: var(--inkpen-color-bg-subtle);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/* Board View (Kanban) */
|
|
1618
|
+
.inkpen-database__board {
|
|
1619
|
+
display: flex;
|
|
1620
|
+
gap: 1rem;
|
|
1621
|
+
padding: 1rem;
|
|
1622
|
+
overflow-x: auto;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
.inkpen-database__column {
|
|
1626
|
+
flex: 0 0 280px;
|
|
1627
|
+
background: var(--inkpen-color-bg-subtle);
|
|
1628
|
+
border-radius: var(--inkpen-radius);
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
.inkpen-database__column-header {
|
|
1632
|
+
display: flex;
|
|
1633
|
+
justify-content: space-between;
|
|
1634
|
+
padding: 0.75rem;
|
|
1635
|
+
font-weight: 500;
|
|
1636
|
+
}
|
|
1637
|
+
|
|
1638
|
+
.inkpen-database__column-items {
|
|
1639
|
+
padding: 0.5rem;
|
|
1640
|
+
display: flex;
|
|
1641
|
+
flex-direction: column;
|
|
1642
|
+
gap: 0.5rem;
|
|
1643
|
+
}
|
|
1644
|
+
|
|
1645
|
+
.inkpen-database__card {
|
|
1646
|
+
padding: 0.75rem;
|
|
1647
|
+
background: var(--inkpen-toolbar-bg);
|
|
1648
|
+
border-radius: var(--inkpen-radius);
|
|
1649
|
+
border: 1px solid var(--inkpen-color-border);
|
|
1650
|
+
cursor: pointer;
|
|
1651
|
+
}
|
|
1652
|
+
|
|
1653
|
+
.inkpen-database__card:hover {
|
|
1654
|
+
box-shadow: var(--inkpen-shadow);
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/* Gallery View */
|
|
1658
|
+
.inkpen-database__gallery {
|
|
1659
|
+
display: grid;
|
|
1660
|
+
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
|
1661
|
+
gap: 1rem;
|
|
1662
|
+
padding: 1rem;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
/* Property Cells */
|
|
1666
|
+
.inkpen-database [data-type="checkbox"] {
|
|
1667
|
+
text-align: center;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
.inkpen-database [data-type="select"] .inkpen-tag {
|
|
1671
|
+
display: inline-block;
|
|
1672
|
+
padding: 0.125rem 0.5rem;
|
|
1673
|
+
border-radius: 1rem;
|
|
1674
|
+
font-size: 0.75rem;
|
|
1675
|
+
font-weight: 500;
|
|
1676
|
+
}
|
|
1677
|
+
|
|
1678
|
+
.inkpen-database [data-type="date"] {
|
|
1679
|
+
font-family: var(--inkpen-font-mono);
|
|
1680
|
+
font-size: 0.875rem;
|
|
1681
|
+
}
|
|
1682
|
+
```
|
|
1683
|
+
|
|
1684
|
+
---
|
|
1685
|
+
|
|
1686
|
+
### 5.3 Table of Contents Extension (v0.6.0-rc)
|
|
1687
|
+
|
|
1688
|
+
Auto-generated navigation from document headings.
|
|
1689
|
+
|
|
1690
|
+
**Features:**
|
|
1691
|
+
- Auto-detect headings (H1-H6)
|
|
1692
|
+
- Clickable links with smooth scroll
|
|
1693
|
+
- Configurable max depth (e.g., H1-H3 only)
|
|
1694
|
+
- Numbered or bulleted style
|
|
1695
|
+
- Collapsible sections
|
|
1696
|
+
- Sticky positioning option
|
|
1697
|
+
- Real-time updates as document changes
|
|
1698
|
+
- Click heading to scroll into view
|
|
1699
|
+
|
|
1700
|
+
**Implementation:**
|
|
1701
|
+
```javascript
|
|
1702
|
+
// app/assets/javascripts/inkpen/extensions/table_of_contents.js
|
|
1703
|
+
|
|
1704
|
+
import { Node } from '@tiptap/core'
|
|
1705
|
+
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
|
1706
|
+
|
|
1707
|
+
export const TableOfContents = Node.create({
|
|
1708
|
+
name: 'tableOfContents',
|
|
1709
|
+
group: 'block',
|
|
1710
|
+
atom: true,
|
|
1711
|
+
draggable: true,
|
|
1712
|
+
|
|
1713
|
+
addAttributes() {
|
|
1714
|
+
return {
|
|
1715
|
+
maxDepth: { default: 3 }, // Max heading level (1-6)
|
|
1716
|
+
style: { default: 'numbered' }, // numbered, bulleted, plain
|
|
1717
|
+
title: { default: 'Table of Contents' },
|
|
1718
|
+
collapsible: { default: false },
|
|
1719
|
+
sticky: { default: false }
|
|
1720
|
+
}
|
|
1721
|
+
},
|
|
1722
|
+
|
|
1723
|
+
addNodeView() {
|
|
1724
|
+
return ({ node, editor }) => {
|
|
1725
|
+
const wrapper = document.createElement('div')
|
|
1726
|
+
wrapper.className = 'inkpen-toc'
|
|
1727
|
+
if (node.attrs.sticky) wrapper.classList.add('inkpen-toc--sticky')
|
|
1728
|
+
|
|
1729
|
+
const render = () => {
|
|
1730
|
+
const headings = this.getHeadings(editor, node.attrs.maxDepth)
|
|
1731
|
+
wrapper.innerHTML = this.renderTOC(headings, node.attrs)
|
|
1732
|
+
this.attachClickHandlers(wrapper, editor)
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
render()
|
|
1736
|
+
|
|
1737
|
+
// Update on document change
|
|
1738
|
+
const updateHandler = () => render()
|
|
1739
|
+
editor.on('update', updateHandler)
|
|
1740
|
+
|
|
1741
|
+
return {
|
|
1742
|
+
dom: wrapper,
|
|
1743
|
+
destroy: () => editor.off('update', updateHandler)
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
},
|
|
1747
|
+
|
|
1748
|
+
getHeadings(editor, maxDepth) {
|
|
1749
|
+
const headings = []
|
|
1750
|
+
let index = 0
|
|
1751
|
+
|
|
1752
|
+
editor.state.doc.descendants((node, pos) => {
|
|
1753
|
+
if (node.type.name === 'heading' && node.attrs.level <= maxDepth) {
|
|
1754
|
+
headings.push({
|
|
1755
|
+
id: `heading-${index++}`,
|
|
1756
|
+
level: node.attrs.level,
|
|
1757
|
+
text: node.textContent,
|
|
1758
|
+
pos
|
|
1759
|
+
})
|
|
1760
|
+
}
|
|
1761
|
+
})
|
|
1762
|
+
|
|
1763
|
+
return headings
|
|
1764
|
+
},
|
|
1765
|
+
|
|
1766
|
+
renderTOC(headings, attrs) {
|
|
1767
|
+
const { title, style, collapsible } = attrs
|
|
1768
|
+
|
|
1769
|
+
if (headings.length === 0) {
|
|
1770
|
+
return `
|
|
1771
|
+
<div class="inkpen-toc__empty">
|
|
1772
|
+
No headings found. Add headings to generate a table of contents.
|
|
1773
|
+
</div>
|
|
1774
|
+
`
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
const listTag = style === 'numbered' ? 'ol' : 'ul'
|
|
1778
|
+
|
|
1779
|
+
return `
|
|
1780
|
+
<div class="inkpen-toc__header">
|
|
1781
|
+
<span class="inkpen-toc__title">${title}</span>
|
|
1782
|
+
${collapsible ? '<button class="inkpen-toc__toggle">▼</button>' : ''}
|
|
1783
|
+
</div>
|
|
1784
|
+
<nav class="inkpen-toc__nav">
|
|
1785
|
+
<${listTag} class="inkpen-toc__list inkpen-toc__list--${style}">
|
|
1786
|
+
${headings.map(h => `
|
|
1787
|
+
<li class="inkpen-toc__item inkpen-toc__item--level-${h.level}"
|
|
1788
|
+
style="--toc-indent: ${(h.level - 1) * 1}rem">
|
|
1789
|
+
<a href="#${h.id}" data-pos="${h.pos}">${h.text}</a>
|
|
1790
|
+
</li>
|
|
1791
|
+
`).join('')}
|
|
1792
|
+
</${listTag}>
|
|
1793
|
+
</nav>
|
|
1794
|
+
`
|
|
1795
|
+
},
|
|
1796
|
+
|
|
1797
|
+
attachClickHandlers(wrapper, editor) {
|
|
1798
|
+
wrapper.querySelectorAll('.inkpen-toc__item a').forEach(link => {
|
|
1799
|
+
link.addEventListener('click', (e) => {
|
|
1800
|
+
e.preventDefault()
|
|
1801
|
+
const pos = parseInt(link.dataset.pos)
|
|
1802
|
+
|
|
1803
|
+
// Scroll to heading
|
|
1804
|
+
const coords = editor.view.coordsAtPos(pos)
|
|
1805
|
+
window.scrollTo({
|
|
1806
|
+
top: coords.top - 100, // Offset for sticky headers
|
|
1807
|
+
behavior: 'smooth'
|
|
1808
|
+
})
|
|
1809
|
+
|
|
1810
|
+
// Focus editor at heading
|
|
1811
|
+
editor.commands.setTextSelection(pos)
|
|
1812
|
+
})
|
|
1813
|
+
})
|
|
1814
|
+
},
|
|
1815
|
+
|
|
1816
|
+
addCommands() {
|
|
1817
|
+
return {
|
|
1818
|
+
insertTableOfContents: (options = {}) => ({ commands }) => {
|
|
1819
|
+
return commands.insertContent({
|
|
1820
|
+
type: this.name,
|
|
1821
|
+
attrs: options
|
|
1822
|
+
})
|
|
1823
|
+
},
|
|
1824
|
+
setTocMaxDepth: (depth) => ({ tr, state }) => {
|
|
1825
|
+
// Update max depth
|
|
1826
|
+
},
|
|
1827
|
+
setTocStyle: (style) => ({ tr, state }) => {
|
|
1828
|
+
// Update style (numbered, bulleted, plain)
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
}
|
|
1832
|
+
})
|
|
1833
|
+
```
|
|
1834
|
+
|
|
1835
|
+
**CSS:**
|
|
1836
|
+
```css
|
|
1837
|
+
/* app/assets/stylesheets/inkpen/toc.css */
|
|
1838
|
+
|
|
1839
|
+
.inkpen-toc {
|
|
1840
|
+
margin: 1.5rem 0;
|
|
1841
|
+
padding: 1rem 1.5rem;
|
|
1842
|
+
background: var(--inkpen-color-bg-subtle);
|
|
1843
|
+
border-radius: var(--inkpen-radius);
|
|
1844
|
+
border: 1px solid var(--inkpen-color-border);
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
.inkpen-toc--sticky {
|
|
1848
|
+
position: sticky;
|
|
1849
|
+
top: 1rem;
|
|
1850
|
+
max-height: calc(100vh - 2rem);
|
|
1851
|
+
overflow-y: auto;
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
.inkpen-toc__header {
|
|
1855
|
+
display: flex;
|
|
1856
|
+
justify-content: space-between;
|
|
1857
|
+
align-items: center;
|
|
1858
|
+
margin-bottom: 0.75rem;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
.inkpen-toc__title {
|
|
1862
|
+
font-weight: 600;
|
|
1863
|
+
font-size: 0.875rem;
|
|
1864
|
+
text-transform: uppercase;
|
|
1865
|
+
letter-spacing: 0.05em;
|
|
1866
|
+
color: var(--inkpen-color-text-muted);
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
.inkpen-toc__toggle {
|
|
1870
|
+
border: none;
|
|
1871
|
+
background: none;
|
|
1872
|
+
cursor: pointer;
|
|
1873
|
+
padding: 0.25rem;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
.inkpen-toc__list {
|
|
1877
|
+
margin: 0;
|
|
1878
|
+
padding: 0;
|
|
1879
|
+
list-style: none;
|
|
1880
|
+
}
|
|
1881
|
+
|
|
1882
|
+
.inkpen-toc__list--numbered {
|
|
1883
|
+
counter-reset: toc;
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
.inkpen-toc__list--numbered .inkpen-toc__item::before {
|
|
1887
|
+
counter-increment: toc;
|
|
1888
|
+
content: counters(toc, ".") ". ";
|
|
1889
|
+
color: var(--inkpen-color-text-muted);
|
|
1890
|
+
margin-right: 0.5rem;
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
.inkpen-toc__list--bulleted .inkpen-toc__item::before {
|
|
1894
|
+
content: "•";
|
|
1895
|
+
color: var(--inkpen-color-text-muted);
|
|
1896
|
+
margin-right: 0.5rem;
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
.inkpen-toc__item {
|
|
1900
|
+
padding: 0.375rem 0;
|
|
1901
|
+
padding-left: var(--toc-indent, 0);
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
.inkpen-toc__item a {
|
|
1905
|
+
color: var(--inkpen-color-text);
|
|
1906
|
+
text-decoration: none;
|
|
1907
|
+
transition: color 150ms;
|
|
1908
|
+
}
|
|
1909
|
+
|
|
1910
|
+
.inkpen-toc__item a:hover {
|
|
1911
|
+
color: var(--inkpen-color-primary);
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
/* Level styling */
|
|
1915
|
+
.inkpen-toc__item--level-1 { font-weight: 600; }
|
|
1916
|
+
.inkpen-toc__item--level-2 { font-weight: 500; }
|
|
1917
|
+
.inkpen-toc__item--level-3,
|
|
1918
|
+
.inkpen-toc__item--level-4,
|
|
1919
|
+
.inkpen-toc__item--level-5,
|
|
1920
|
+
.inkpen-toc__item--level-6 {
|
|
1921
|
+
font-size: 0.875rem;
|
|
1922
|
+
color: var(--inkpen-color-text-muted);
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
.inkpen-toc__empty {
|
|
1926
|
+
font-size: 0.875rem;
|
|
1927
|
+
color: var(--inkpen-color-text-muted);
|
|
1928
|
+
font-style: italic;
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
/* Collapsible */
|
|
1932
|
+
.inkpen-toc.is-collapsed .inkpen-toc__nav {
|
|
1933
|
+
display: none;
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
.inkpen-toc.is-collapsed .inkpen-toc__toggle {
|
|
1937
|
+
transform: rotate(-90deg);
|
|
1938
|
+
}
|
|
1939
|
+
|
|
1940
|
+
/* Dark mode */
|
|
1941
|
+
@media (prefers-color-scheme: dark) {
|
|
1942
|
+
.inkpen-toc {
|
|
1943
|
+
background: var(--inkpen-color-bg-subtle);
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
```
|
|
1947
|
+
|
|
1948
|
+
---
|
|
1949
|
+
|
|
1950
|
+
### 5.4 Slash Commands Updates
|
|
1951
|
+
|
|
1952
|
+
Add new commands to slash menu:
|
|
1953
|
+
|
|
1954
|
+
```javascript
|
|
1955
|
+
// Added to DEFAULT_COMMANDS in slash_commands.js
|
|
1956
|
+
|
|
1957
|
+
// Data group
|
|
1958
|
+
{ id: "table", title: "Table", description: "Insert a table", icon: "⊞", group: "Data" },
|
|
1959
|
+
{ id: "database", title: "Database", description: "Create an inline database", icon: "🗃️", group: "Data", keywords: ["notion", "airtable", "spreadsheet"] },
|
|
1960
|
+
{ id: "databaseTable", title: "Database - Table View", description: "Database with table view", icon: "⊞", group: "Data" },
|
|
1961
|
+
{ id: "databaseBoard", title: "Database - Board View", description: "Kanban-style board", icon: "▣", group: "Data", keywords: ["kanban", "trello"] },
|
|
1962
|
+
{ id: "databaseGallery", title: "Database - Gallery", description: "Card gallery view", icon: "⊟", group: "Data" },
|
|
1963
|
+
|
|
1964
|
+
// Navigation group
|
|
1965
|
+
{ id: "toc", title: "Table of Contents", description: "Auto-generated navigation", icon: "📑", group: "Navigation", keywords: ["contents", "navigation", "index"] },
|
|
1966
|
+
```
|
|
1967
|
+
|
|
1968
|
+
---
|
|
1969
|
+
|
|
1970
|
+
### Implementation Priority
|
|
1971
|
+
|
|
1972
|
+
| Feature | Priority | Complexity | Files |
|
|
1973
|
+
|---------|----------|------------|-------|
|
|
1974
|
+
| Advanced Tables | High | Medium | advanced_table.js, advanced_table.css |
|
|
1975
|
+
| Table of Contents | High | Low | table_of_contents.js, toc.css |
|
|
1976
|
+
| Database Blocks | Medium | High | database.js, database.css |
|
|
1977
|
+
| Slash Menu Updates | Low | Low | slash_commands.js |
|
|
1978
|
+
|
|
1979
|
+
---
|
|
1980
|
+
|
|
1981
|
+
### Files to Create (v0.6.0)
|
|
1982
|
+
|
|
1983
|
+
```
|
|
1984
|
+
app/assets/javascripts/inkpen/extensions/
|
|
1985
|
+
├── advanced_table.js ← v0.6.0-alpha
|
|
1986
|
+
├── database.js ← v0.6.0-beta
|
|
1987
|
+
└── table_of_contents.js ← v0.6.0-rc
|
|
1988
|
+
|
|
1989
|
+
app/assets/stylesheets/inkpen/
|
|
1990
|
+
├── advanced_table.css ← v0.6.0-alpha
|
|
1991
|
+
├── database.css ← v0.6.0-beta
|
|
1992
|
+
└── toc.css ← v0.6.0-rc
|
|
1993
|
+
```
|
|
1994
|
+
|
|
1995
|
+
---
|
|
1996
|
+
|
|
1997
|
+
## Phase 6: Export & Import (v0.7.0) ✅
|
|
1998
|
+
|
|
1999
|
+
### Goal
|
|
2000
|
+
Enable seamless content portability with Markdown import/export, clean HTML export, and PDF generation.
|
|
2001
|
+
|
|
2002
|
+
**Status:** Complete
|
|
2003
|
+
|
|
2004
|
+
---
|
|
2005
|
+
|
|
2006
|
+
### 6.1 Markdown Export/Import (v0.7.0-alpha) ✅
|
|
2007
|
+
|
|
2008
|
+
Convert editor content to/from Markdown with full fidelity.
|
|
2009
|
+
|
|
2010
|
+
**Features:**
|
|
2011
|
+
- Export to GitHub-Flavored Markdown (GFM)
|
|
2012
|
+
- Import from Markdown files
|
|
2013
|
+
- Frontmatter support (YAML metadata)
|
|
2014
|
+
- Table conversion (GFM tables)
|
|
2015
|
+
- Code block language preservation
|
|
2016
|
+
- Image handling (inline or reference style)
|
|
2017
|
+
- Task list conversion
|
|
2018
|
+
- Callout to blockquote mapping
|
|
2019
|
+
- Custom block fallbacks (HTML comments)
|
|
2020
|
+
|
|
2021
|
+
**Implementation:**
|
|
2022
|
+
```javascript
|
|
2023
|
+
// app/assets/javascripts/inkpen/export/markdown.js
|
|
2024
|
+
|
|
2025
|
+
/**
|
|
2026
|
+
* Markdown Exporter
|
|
2027
|
+
*
|
|
2028
|
+
* Converts TipTap/ProseMirror document to Markdown.
|
|
2029
|
+
* Uses custom serializers for Inkpen-specific nodes.
|
|
2030
|
+
*/
|
|
2031
|
+
|
|
2032
|
+
const NODE_SERIALIZERS = {
|
|
2033
|
+
paragraph: (node) => node.textContent + '\n\n',
|
|
2034
|
+
heading: (node) => '#'.repeat(node.attrs.level) + ' ' + node.textContent + '\n\n',
|
|
2035
|
+
bulletList: (node, serialize) => serializeList(node, serialize, '-'),
|
|
2036
|
+
orderedList: (node, serialize) => serializeList(node, serialize, '1.'),
|
|
2037
|
+
taskList: (node, serialize) => serializeTaskList(node, serialize),
|
|
2038
|
+
blockquote: (node, serialize) => node.content.map(n => '> ' + serialize(n)).join(''),
|
|
2039
|
+
codeBlock: (node) => '```' + (node.attrs.language || '') + '\n' + node.textContent + '\n```\n\n',
|
|
2040
|
+
horizontalRule: () => '---\n\n',
|
|
2041
|
+
image: (node) => `\n\n`,
|
|
2042
|
+
table: (node, serialize) => serializeTable(node, serialize),
|
|
2043
|
+
callout: (node, serialize) => serializeCallout(node, serialize),
|
|
2044
|
+
toggleBlock: (node, serialize) => serializeToggle(node, serialize),
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
const MARK_SERIALIZERS = {
|
|
2048
|
+
bold: (text) => `**${text}**`,
|
|
2049
|
+
italic: (text) => `_${text}_`,
|
|
2050
|
+
strike: (text) => `~~${text}~~`,
|
|
2051
|
+
code: (text) => `\`${text}\``,
|
|
2052
|
+
link: (text, mark) => `[${text}](${mark.attrs.href})`,
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
export function exportToMarkdown(doc, options = {}) {
|
|
2056
|
+
const { includeFrontmatter = true, imageStyle = 'inline' } = options
|
|
2057
|
+
let markdown = ''
|
|
2058
|
+
|
|
2059
|
+
if (includeFrontmatter && options.frontmatter) {
|
|
2060
|
+
markdown += '---\n'
|
|
2061
|
+
markdown += Object.entries(options.frontmatter)
|
|
2062
|
+
.map(([key, value]) => `${key}: ${JSON.stringify(value)}`)
|
|
2063
|
+
.join('\n')
|
|
2064
|
+
markdown += '\n---\n\n'
|
|
2065
|
+
}
|
|
2066
|
+
|
|
2067
|
+
markdown += serializeNode(doc)
|
|
2068
|
+
return markdown
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
export function importFromMarkdown(markdown, options = {}) {
|
|
2072
|
+
// Parse frontmatter
|
|
2073
|
+
const { content, frontmatter } = parseFrontmatter(markdown)
|
|
2074
|
+
|
|
2075
|
+
// Convert Markdown to ProseMirror document
|
|
2076
|
+
const doc = parseMarkdown(content)
|
|
2077
|
+
|
|
2078
|
+
return { doc, frontmatter }
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
function serializeTable(node, serialize) {
|
|
2082
|
+
const rows = []
|
|
2083
|
+
let headerRow = null
|
|
2084
|
+
|
|
2085
|
+
node.content.forEach((row, index) => {
|
|
2086
|
+
const cells = row.content.map(cell => serialize(cell).trim())
|
|
2087
|
+
if (index === 0) {
|
|
2088
|
+
headerRow = '| ' + cells.join(' | ') + ' |'
|
|
2089
|
+
rows.push(headerRow)
|
|
2090
|
+
rows.push('| ' + cells.map(() => '---').join(' | ') + ' |')
|
|
2091
|
+
} else {
|
|
2092
|
+
rows.push('| ' + cells.join(' | ') + ' |')
|
|
2093
|
+
}
|
|
2094
|
+
})
|
|
2095
|
+
|
|
2096
|
+
return rows.join('\n') + '\n\n'
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
function serializeCallout(node, serialize) {
|
|
2100
|
+
const type = node.attrs.type || 'info'
|
|
2101
|
+
const emoji = node.attrs.emoji || ''
|
|
2102
|
+
const content = serialize(node.content)
|
|
2103
|
+
|
|
2104
|
+
// Convert to blockquote with type indicator
|
|
2105
|
+
return `> [!${type.toUpperCase()}] ${emoji}\n> ${content.replace(/\n/g, '\n> ')}\n\n`
|
|
2106
|
+
}
|
|
2107
|
+
```
|
|
2108
|
+
|
|
2109
|
+
**Commands:**
|
|
2110
|
+
```javascript
|
|
2111
|
+
// Added to editor_controller.js
|
|
2112
|
+
|
|
2113
|
+
exportMarkdown(options = {}) {
|
|
2114
|
+
const markdown = exportToMarkdown(this.editor.state.doc, options)
|
|
2115
|
+
return markdown
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
importMarkdown(markdown, options = {}) {
|
|
2119
|
+
const { doc, frontmatter } = importFromMarkdown(markdown, options)
|
|
2120
|
+
this.editor.commands.setContent(doc)
|
|
2121
|
+
return frontmatter
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
downloadMarkdown(filename = 'document.md') {
|
|
2125
|
+
const markdown = this.exportMarkdown()
|
|
2126
|
+
downloadFile(markdown, filename, 'text/markdown')
|
|
2127
|
+
}
|
|
2128
|
+
```
|
|
2129
|
+
|
|
2130
|
+
---
|
|
2131
|
+
|
|
2132
|
+
### 6.2 HTML Export (v0.7.0-beta)
|
|
2133
|
+
|
|
2134
|
+
Export clean, semantic HTML with optional styling.
|
|
2135
|
+
|
|
2136
|
+
**Features:**
|
|
2137
|
+
- Clean semantic HTML5 output
|
|
2138
|
+
- Optional inline CSS styling
|
|
2139
|
+
- Optional external stylesheet link
|
|
2140
|
+
- Configurable class prefixes
|
|
2141
|
+
- Image embedding (base64) or external URLs
|
|
2142
|
+
- Table accessibility attributes
|
|
2143
|
+
- Print-optimized output
|
|
2144
|
+
- Dark mode CSS variant
|
|
2145
|
+
|
|
2146
|
+
**Implementation:**
|
|
2147
|
+
```javascript
|
|
2148
|
+
// app/assets/javascripts/inkpen/export/html.js
|
|
2149
|
+
|
|
2150
|
+
/**
|
|
2151
|
+
* HTML Exporter
|
|
2152
|
+
*
|
|
2153
|
+
* Generates clean, semantic HTML from editor content.
|
|
2154
|
+
*/
|
|
2155
|
+
|
|
2156
|
+
export function exportToHTML(doc, options = {}) {
|
|
2157
|
+
const {
|
|
2158
|
+
includeStyles = true,
|
|
2159
|
+
inlineStyles = false,
|
|
2160
|
+
classPrefix = 'inkpen-',
|
|
2161
|
+
embedImages = false,
|
|
2162
|
+
includeWrapper = true,
|
|
2163
|
+
title = 'Document'
|
|
2164
|
+
} = options
|
|
2165
|
+
|
|
2166
|
+
let html = ''
|
|
2167
|
+
|
|
2168
|
+
// Document wrapper
|
|
2169
|
+
if (includeWrapper) {
|
|
2170
|
+
html += `<!DOCTYPE html>
|
|
2171
|
+
<html lang="en">
|
|
2172
|
+
<head>
|
|
2173
|
+
<meta charset="UTF-8">
|
|
2174
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2175
|
+
<title>${escapeHtml(title)}</title>
|
|
2176
|
+
${includeStyles ? getStyleTag(inlineStyles, classPrefix) : ''}
|
|
2177
|
+
</head>
|
|
2178
|
+
<body>
|
|
2179
|
+
<article class="${classPrefix}document">
|
|
2180
|
+
`
|
|
2181
|
+
}
|
|
2182
|
+
|
|
2183
|
+
// Serialize content
|
|
2184
|
+
html += serializeToHTML(doc, { classPrefix, embedImages })
|
|
2185
|
+
|
|
2186
|
+
if (includeWrapper) {
|
|
2187
|
+
html += ` </article>
|
|
2188
|
+
</body>
|
|
2189
|
+
</html>`
|
|
2190
|
+
}
|
|
2191
|
+
|
|
2192
|
+
return html
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
function getStyleTag(inline, prefix) {
|
|
2196
|
+
if (inline) {
|
|
2197
|
+
return `<style>${getExportStyles(prefix)}</style>`
|
|
2198
|
+
}
|
|
2199
|
+
return `<link rel="stylesheet" href="inkpen-export.css">`
|
|
2200
|
+
}
|
|
2201
|
+
|
|
2202
|
+
function getExportStyles(prefix) {
|
|
2203
|
+
return `
|
|
2204
|
+
.${prefix}document {
|
|
2205
|
+
max-width: 680px;
|
|
2206
|
+
margin: 0 auto;
|
|
2207
|
+
padding: 2rem;
|
|
2208
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
2209
|
+
line-height: 1.6;
|
|
2210
|
+
color: #1a1a1a;
|
|
2211
|
+
}
|
|
2212
|
+
.${prefix}document h1 { font-size: 2rem; margin: 2rem 0 1rem; }
|
|
2213
|
+
.${prefix}document h2 { font-size: 1.5rem; margin: 1.5rem 0 0.75rem; }
|
|
2214
|
+
.${prefix}document h3 { font-size: 1.25rem; margin: 1.25rem 0 0.5rem; }
|
|
2215
|
+
.${prefix}document p { margin: 0 0 1rem; }
|
|
2216
|
+
.${prefix}document blockquote {
|
|
2217
|
+
margin: 1rem 0;
|
|
2218
|
+
padding-left: 1rem;
|
|
2219
|
+
border-left: 3px solid #e0e0e0;
|
|
2220
|
+
color: #666;
|
|
2221
|
+
}
|
|
2222
|
+
.${prefix}document pre {
|
|
2223
|
+
background: #f5f5f5;
|
|
2224
|
+
padding: 1rem;
|
|
2225
|
+
border-radius: 4px;
|
|
2226
|
+
overflow-x: auto;
|
|
2227
|
+
}
|
|
2228
|
+
.${prefix}document code {
|
|
2229
|
+
background: #f0f0f0;
|
|
2230
|
+
padding: 0.125rem 0.25rem;
|
|
2231
|
+
border-radius: 2px;
|
|
2232
|
+
font-size: 0.875em;
|
|
2233
|
+
}
|
|
2234
|
+
.${prefix}document table {
|
|
2235
|
+
width: 100%;
|
|
2236
|
+
border-collapse: collapse;
|
|
2237
|
+
margin: 1rem 0;
|
|
2238
|
+
}
|
|
2239
|
+
.${prefix}document th, .${prefix}document td {
|
|
2240
|
+
border: 1px solid #e0e0e0;
|
|
2241
|
+
padding: 0.5rem;
|
|
2242
|
+
text-align: left;
|
|
2243
|
+
}
|
|
2244
|
+
.${prefix}document img {
|
|
2245
|
+
max-width: 100%;
|
|
2246
|
+
height: auto;
|
|
2247
|
+
}
|
|
2248
|
+
.${prefix}callout {
|
|
2249
|
+
padding: 1rem;
|
|
2250
|
+
margin: 1rem 0;
|
|
2251
|
+
border-radius: 4px;
|
|
2252
|
+
border-left: 4px solid;
|
|
2253
|
+
}
|
|
2254
|
+
.${prefix}callout--info { background: #e3f2fd; border-color: #2196f3; }
|
|
2255
|
+
.${prefix}callout--warning { background: #fff3e0; border-color: #ff9800; }
|
|
2256
|
+
.${prefix}callout--tip { background: #e8f5e9; border-color: #4caf50; }
|
|
2257
|
+
@media print {
|
|
2258
|
+
.${prefix}document { max-width: none; padding: 0; }
|
|
2259
|
+
}
|
|
2260
|
+
`
|
|
2261
|
+
}
|
|
2262
|
+
```
|
|
2263
|
+
|
|
2264
|
+
**Commands:**
|
|
2265
|
+
```javascript
|
|
2266
|
+
exportHTML(options = {}) {
|
|
2267
|
+
return exportToHTML(this.editor.state.doc, options)
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
downloadHTML(filename = 'document.html', options = {}) {
|
|
2271
|
+
const html = this.exportHTML(options)
|
|
2272
|
+
downloadFile(html, filename, 'text/html')
|
|
2273
|
+
}
|
|
2274
|
+
|
|
2275
|
+
copyHTML() {
|
|
2276
|
+
const html = this.exportHTML({ includeWrapper: false, includeStyles: false })
|
|
2277
|
+
navigator.clipboard.writeText(html)
|
|
2278
|
+
}
|
|
2279
|
+
```
|
|
2280
|
+
|
|
2281
|
+
---
|
|
2282
|
+
|
|
2283
|
+
### 6.3 PDF Export (v0.7.0-rc)
|
|
2284
|
+
|
|
2285
|
+
Generate PDF documents from editor content.
|
|
2286
|
+
|
|
2287
|
+
**Features:**
|
|
2288
|
+
- Client-side PDF generation (no server required)
|
|
2289
|
+
- Page size options (A4, Letter, Legal)
|
|
2290
|
+
- Margins and orientation
|
|
2291
|
+
- Header/footer with page numbers
|
|
2292
|
+
- Table of contents generation
|
|
2293
|
+
- Cover page option
|
|
2294
|
+
- Custom fonts
|
|
2295
|
+
- Image quality settings
|
|
2296
|
+
- Watermark support
|
|
2297
|
+
|
|
2298
|
+
**Implementation:**
|
|
2299
|
+
```javascript
|
|
2300
|
+
// app/assets/javascripts/inkpen/export/pdf.js
|
|
2301
|
+
|
|
2302
|
+
/**
|
|
2303
|
+
* PDF Exporter
|
|
2304
|
+
*
|
|
2305
|
+
* Generates PDF using html2pdf.js or jsPDF.
|
|
2306
|
+
* Falls back to print dialog if libraries unavailable.
|
|
2307
|
+
*/
|
|
2308
|
+
|
|
2309
|
+
import { exportToHTML } from './html'
|
|
2310
|
+
|
|
2311
|
+
export async function exportToPDF(doc, options = {}) {
|
|
2312
|
+
const {
|
|
2313
|
+
filename = 'document.pdf',
|
|
2314
|
+
pageSize = 'a4',
|
|
2315
|
+
orientation = 'portrait',
|
|
2316
|
+
margins = { top: 20, right: 20, bottom: 20, left: 20 },
|
|
2317
|
+
includeHeader = false,
|
|
2318
|
+
includeFooter = true,
|
|
2319
|
+
includeTOC = false,
|
|
2320
|
+
coverPage = null,
|
|
2321
|
+
watermark = null,
|
|
2322
|
+
quality = 2
|
|
2323
|
+
} = options
|
|
2324
|
+
|
|
2325
|
+
// Generate HTML first
|
|
2326
|
+
const html = exportToHTML(doc, {
|
|
2327
|
+
includeStyles: true,
|
|
2328
|
+
inlineStyles: true,
|
|
2329
|
+
includeWrapper: false
|
|
2330
|
+
})
|
|
2331
|
+
|
|
2332
|
+
// Check for html2pdf library
|
|
2333
|
+
if (typeof html2pdf !== 'undefined') {
|
|
2334
|
+
return generateWithHtml2Pdf(html, {
|
|
2335
|
+
filename,
|
|
2336
|
+
pageSize,
|
|
2337
|
+
orientation,
|
|
2338
|
+
margins,
|
|
2339
|
+
quality
|
|
2340
|
+
})
|
|
2341
|
+
}
|
|
2342
|
+
|
|
2343
|
+
// Fallback to print dialog
|
|
2344
|
+
return printToPDF(html, options)
|
|
2345
|
+
}
|
|
2346
|
+
|
|
2347
|
+
async function generateWithHtml2Pdf(html, options) {
|
|
2348
|
+
const { filename, pageSize, orientation, margins, quality } = options
|
|
2349
|
+
|
|
2350
|
+
const element = document.createElement('div')
|
|
2351
|
+
element.innerHTML = html
|
|
2352
|
+
element.style.width = '210mm' // A4 width
|
|
2353
|
+
document.body.appendChild(element)
|
|
2354
|
+
|
|
2355
|
+
const opt = {
|
|
2356
|
+
margin: [margins.top, margins.right, margins.bottom, margins.left],
|
|
2357
|
+
filename,
|
|
2358
|
+
image: { type: 'jpeg', quality: 0.98 },
|
|
2359
|
+
html2canvas: { scale: quality, useCORS: true },
|
|
2360
|
+
jsPDF: { unit: 'mm', format: pageSize, orientation }
|
|
2361
|
+
}
|
|
2362
|
+
|
|
2363
|
+
try {
|
|
2364
|
+
await html2pdf().set(opt).from(element).save()
|
|
2365
|
+
} finally {
|
|
2366
|
+
document.body.removeChild(element)
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
function printToPDF(html, options) {
|
|
2371
|
+
// Open print dialog as fallback
|
|
2372
|
+
const printWindow = window.open('', '_blank')
|
|
2373
|
+
printWindow.document.write(`
|
|
2374
|
+
<!DOCTYPE html>
|
|
2375
|
+
<html>
|
|
2376
|
+
<head>
|
|
2377
|
+
<title>${options.filename || 'Document'}</title>
|
|
2378
|
+
<style>
|
|
2379
|
+
@page {
|
|
2380
|
+
size: ${options.pageSize || 'A4'} ${options.orientation || 'portrait'};
|
|
2381
|
+
margin: ${options.margins?.top || 20}mm ${options.margins?.right || 20}mm
|
|
2382
|
+
${options.margins?.bottom || 20}mm ${options.margins?.left || 20}mm;
|
|
2383
|
+
}
|
|
2384
|
+
body {
|
|
2385
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
2386
|
+
line-height: 1.6;
|
|
2387
|
+
}
|
|
2388
|
+
@media print {
|
|
2389
|
+
.no-print { display: none; }
|
|
2390
|
+
}
|
|
2391
|
+
</style>
|
|
2392
|
+
</head>
|
|
2393
|
+
<body>
|
|
2394
|
+
${html}
|
|
2395
|
+
<script>window.onload = function() { window.print(); window.close(); }</script>
|
|
2396
|
+
</body>
|
|
2397
|
+
</html>
|
|
2398
|
+
`)
|
|
2399
|
+
printWindow.document.close()
|
|
2400
|
+
}
|
|
2401
|
+
```
|
|
2402
|
+
|
|
2403
|
+
**Commands:**
|
|
2404
|
+
```javascript
|
|
2405
|
+
async exportPDF(options = {}) {
|
|
2406
|
+
await exportToPDF(this.editor.state.doc, options)
|
|
2407
|
+
}
|
|
2408
|
+
|
|
2409
|
+
async downloadPDF(filename = 'document.pdf', options = {}) {
|
|
2410
|
+
await this.exportPDF({ ...options, filename })
|
|
2411
|
+
}
|
|
2412
|
+
```
|
|
2413
|
+
|
|
2414
|
+
---
|
|
2415
|
+
|
|
2416
|
+
### 6.4 Export Toolbar/Menu
|
|
2417
|
+
|
|
2418
|
+
Add export options to the UI.
|
|
2419
|
+
|
|
2420
|
+
**Features:**
|
|
2421
|
+
- Export dropdown menu in toolbar
|
|
2422
|
+
- Keyboard shortcuts for quick export
|
|
2423
|
+
- Recent exports history
|
|
2424
|
+
- Export presets (save favorite settings)
|
|
2425
|
+
|
|
2426
|
+
**Implementation:**
|
|
2427
|
+
```javascript
|
|
2428
|
+
// Added to sticky_toolbar_controller.js or separate export_controller.js
|
|
2429
|
+
|
|
2430
|
+
const EXPORT_MENU = [
|
|
2431
|
+
{ id: 'markdown', label: 'Markdown (.md)', icon: 'M↓', shortcut: 'Cmd+Shift+M' },
|
|
2432
|
+
{ id: 'html', label: 'HTML (.html)', icon: '<>', shortcut: 'Cmd+Shift+H' },
|
|
2433
|
+
{ id: 'pdf', label: 'PDF (.pdf)', icon: '📄', shortcut: 'Cmd+Shift+P' },
|
|
2434
|
+
{ divider: true },
|
|
2435
|
+
{ id: 'copy-html', label: 'Copy as HTML', icon: '📋' },
|
|
2436
|
+
{ id: 'copy-markdown', label: 'Copy as Markdown', icon: '📋' }
|
|
2437
|
+
]
|
|
2438
|
+
```
|
|
2439
|
+
|
|
2440
|
+
**CSS:**
|
|
2441
|
+
```css
|
|
2442
|
+
/* app/assets/stylesheets/inkpen/export.css */
|
|
2443
|
+
|
|
2444
|
+
.inkpen-export-menu {
|
|
2445
|
+
position: absolute;
|
|
2446
|
+
top: 100%;
|
|
2447
|
+
right: 0;
|
|
2448
|
+
min-width: 200px;
|
|
2449
|
+
padding: 0.5rem;
|
|
2450
|
+
background: var(--inkpen-toolbar-bg);
|
|
2451
|
+
border: 1px solid var(--inkpen-color-border);
|
|
2452
|
+
border-radius: var(--inkpen-radius);
|
|
2453
|
+
box-shadow: var(--inkpen-shadow-lg);
|
|
2454
|
+
z-index: 100;
|
|
2455
|
+
}
|
|
2456
|
+
|
|
2457
|
+
.inkpen-export-menu__item {
|
|
2458
|
+
display: flex;
|
|
2459
|
+
align-items: center;
|
|
2460
|
+
gap: 0.75rem;
|
|
2461
|
+
width: 100%;
|
|
2462
|
+
padding: 0.5rem 0.75rem;
|
|
2463
|
+
border: none;
|
|
2464
|
+
border-radius: var(--inkpen-radius-sm);
|
|
2465
|
+
background: transparent;
|
|
2466
|
+
color: var(--inkpen-color-text);
|
|
2467
|
+
font-size: 0.875rem;
|
|
2468
|
+
text-align: left;
|
|
2469
|
+
cursor: pointer;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
.inkpen-export-menu__item:hover {
|
|
2473
|
+
background: var(--inkpen-color-bg-subtle);
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
.inkpen-export-menu__shortcut {
|
|
2477
|
+
margin-left: auto;
|
|
2478
|
+
font-size: 0.75rem;
|
|
2479
|
+
color: var(--inkpen-color-text-muted);
|
|
2480
|
+
}
|
|
2481
|
+
|
|
2482
|
+
.inkpen-export-menu__divider {
|
|
2483
|
+
height: 1px;
|
|
2484
|
+
margin: 0.5rem 0;
|
|
2485
|
+
background: var(--inkpen-color-border);
|
|
2486
|
+
}
|
|
2487
|
+
```
|
|
2488
|
+
|
|
2489
|
+
---
|
|
2490
|
+
|
|
2491
|
+
### Implementation Priority
|
|
2492
|
+
|
|
2493
|
+
| Feature | Priority | Complexity | Files |
|
|
2494
|
+
|---------|----------|------------|-------|
|
|
2495
|
+
| Markdown Export | High | Medium | markdown.js |
|
|
2496
|
+
| Markdown Import | High | High | markdown.js |
|
|
2497
|
+
| HTML Export | High | Low | html.js |
|
|
2498
|
+
| PDF Export | Medium | Medium | pdf.js |
|
|
2499
|
+
| Export Menu | Medium | Low | export_menu.js, export.css |
|
|
2500
|
+
|
|
2501
|
+
---
|
|
2502
|
+
|
|
2503
|
+
### Files to Create (v0.7.0)
|
|
2504
|
+
|
|
2505
|
+
```
|
|
2506
|
+
app/assets/javascripts/inkpen/export/
|
|
2507
|
+
├── markdown.js ← v0.7.0-alpha
|
|
2508
|
+
├── html.js ← v0.7.0-beta
|
|
2509
|
+
├── pdf.js ← v0.7.0-rc
|
|
2510
|
+
└── index.js ← exports all
|
|
2511
|
+
|
|
2512
|
+
app/assets/stylesheets/inkpen/
|
|
2513
|
+
└── export.css ← v0.7.0
|
|
2514
|
+
|
|
2515
|
+
lib/inkpen/
|
|
2516
|
+
└── export.rb ← Ruby helpers for server-side export (optional)
|
|
2517
|
+
```
|
|
2518
|
+
|
|
2519
|
+
---
|
|
2520
|
+
|
|
2521
|
+
## Technical References
|
|
2522
|
+
|
|
2523
|
+
### TipTap/ProseMirror
|
|
2524
|
+
- [TipTap Documentation](https://tiptap.dev/docs)
|
|
2525
|
+
- [TipTap Notion Template](https://tiptap.dev/docs/ui-components/templates/notion-like-editor)
|
|
2526
|
+
- [TipTap Suggestion Plugin](https://tiptap.dev/docs/editor/api/utilities/suggestion)
|
|
2527
|
+
- [ProseMirror Guide](https://prosemirror.net/docs/guide/)
|
|
2528
|
+
- [ProseMirror Decorations](https://prosemirror.net/docs/ref/#view.Decorations)
|
|
2529
|
+
|
|
2530
|
+
### Block Editors
|
|
2531
|
+
- [Plate Editor](https://platejs.org/) - React block editor on ProseMirror
|
|
2532
|
+
- [BlockNote](https://www.blocknotejs.org/) - Notion-style TipTap wrapper
|
|
2533
|
+
- [Editor.js](https://editorjs.io/) - Block editor with JSON output
|
|
2534
|
+
- [Notitap](https://github.com/sereneinserenade/notitap) - Notion clone on TipTap
|
|
2535
|
+
|
|
2536
|
+
### Drag & Drop
|
|
2537
|
+
- [Plate DnD](https://platejs.org/docs/dnd)
|
|
2538
|
+
- [dnd-kit](https://dndkit.com/) - Modern React DnD
|
|
2539
|
+
- [Editor.js Drag Plugin](https://github.com/kommitters/editorjs-drag-drop)
|
|
2540
|
+
|
|
2541
|
+
### Design Patterns
|
|
2542
|
+
- [Notion Block Model](https://www.notion.so/help/what-is-a-block)
|
|
2543
|
+
- [Craft.do Editor](https://www.craft.do/)
|
|
2544
|
+
- [Coda Editor](https://coda.io/)
|
|
2545
|
+
|
|
2546
|
+
---
|
|
2547
|
+
|
|
2548
|
+
## Implementation Priority
|
|
2549
|
+
|
|
2550
|
+
| Phase | Feature | Priority | Complexity | Impact |
|
|
2551
|
+
|-------|---------|----------|------------|--------|
|
|
2552
|
+
| 1 | Slash Commands | High | Medium | High |
|
|
2553
|
+
| 2 | Block Gutter | High | Medium | High |
|
|
2554
|
+
| 3 | Drag & Drop | Medium | High | Medium |
|
|
2555
|
+
| 4.1 | Toggle Blocks | Medium | Low | Medium |
|
|
2556
|
+
| 4.2 | Columns | Low | Medium | Low |
|
|
2557
|
+
| 4.3 | Enhanced Callouts | Medium | Low | Medium |
|
|
2558
|
+
| 5 | Polish & Animations | Low | Medium | High |
|
|
2559
|
+
|
|
2560
|
+
---
|
|
2561
|
+
|
|
2562
|
+
## File Structure After Implementation
|
|
2563
|
+
|
|
2564
|
+
```
|
|
2565
|
+
app/assets/javascripts/inkpen/
|
|
2566
|
+
├── controllers/
|
|
2567
|
+
│ ├── editor_controller.js
|
|
2568
|
+
│ ├── toolbar_controller.js
|
|
2569
|
+
│ ├── sticky_toolbar_controller.js
|
|
2570
|
+
│ └── block_menu_controller.js ← v0.3.1
|
|
2571
|
+
├── extensions/
|
|
2572
|
+
│ ├── section.js ✅ DONE
|
|
2573
|
+
│ ├── preformatted.js ✅ DONE
|
|
2574
|
+
│ ├── slash_commands.js ✅ DONE
|
|
2575
|
+
│ ├── block_gutter.js ✅ DONE
|
|
2576
|
+
│ ├── drag_handle.js ✅ DONE
|
|
2577
|
+
│ ├── toggle_block.js ✅ DONE
|
|
2578
|
+
│ ├── columns.js ✅ DONE
|
|
2579
|
+
│ ├── callout.js ✅ DONE
|
|
2580
|
+
│ └── block_commands.js ✅ DONE
|
|
2581
|
+
├── helpers/
|
|
2582
|
+
│ └── block_helpers.js ← future
|
|
2583
|
+
└── index.js
|
|
2584
|
+
|
|
2585
|
+
app/assets/stylesheets/inkpen/
|
|
2586
|
+
├── editor.css
|
|
2587
|
+
├── toolbar.css
|
|
2588
|
+
├── sticky_toolbar.css
|
|
2589
|
+
├── section.css ✅ DONE
|
|
2590
|
+
├── preformatted.css ✅ DONE
|
|
2591
|
+
├── slash_menu.css ✅ DONE
|
|
2592
|
+
├── block_gutter.css ✅ DONE
|
|
2593
|
+
├── drag_drop.css ✅ DONE
|
|
2594
|
+
├── toggle.css ✅ DONE
|
|
2595
|
+
├── columns.css ✅ DONE
|
|
2596
|
+
├── callout.css ✅ DONE
|
|
2597
|
+
└── animations.css ✅ DONE
|
|
2598
|
+
|
|
2599
|
+
lib/inkpen/extensions/
|
|
2600
|
+
├── base.rb
|
|
2601
|
+
├── section.rb ✅ DONE
|
|
2602
|
+
├── preformatted.rb ✅ DONE
|
|
2603
|
+
├── slash_commands.rb ✅ DONE
|
|
2604
|
+
├── mention.rb
|
|
2605
|
+
├── table.rb
|
|
2606
|
+
├── task_list.rb
|
|
2607
|
+
├── code_block_syntax.rb
|
|
2608
|
+
└── forced_document.rb
|
|
2609
|
+
```
|
|
2610
|
+
|
|
2611
|
+
---
|
|
2612
|
+
|
|
2613
|
+
## Phase 7: Document Sections (v0.8.0) — PLANNED
|
|
2614
|
+
|
|
2615
|
+
### Goal
|
|
2616
|
+
Add true "content grouping" sections that group blocks under a heading, enabling Notion-style document structure, collapsible sections, and outline navigation.
|
|
2617
|
+
|
|
2618
|
+
**Status:** Planned
|
|
2619
|
+
|
|
2620
|
+
> **Note**: This is different from our existing `Section` extension, which controls layout (width/spacing). Document sections are **container nodes** that group related content.
|
|
2621
|
+
|
|
2622
|
+
---
|
|
2623
|
+
|
|
2624
|
+
### 7.1 Understanding the Difference
|
|
2625
|
+
|
|
2626
|
+
| Feature | Layout Section (v0.2.2) | Document Section (v0.8.0) |
|
|
2627
|
+
|---------|------------------------|---------------------------|
|
|
2628
|
+
| Purpose | Visual presentation | Content organization |
|
|
2629
|
+
| Contains | Any blocks | Title + blocks |
|
|
2630
|
+
| Schema | `content: 'block+'` | `content: 'sectionTitle block*'` |
|
|
2631
|
+
| Use case | Page builder widths | Document outline |
|
|
2632
|
+
| Collapsible | No | Yes |
|
|
2633
|
+
| Drag behavior | Single block | Group of blocks |
|
|
2634
|
+
|
|
2635
|
+
---
|
|
2636
|
+
|
|
2637
|
+
### 7.2 Document Section Extension
|
|
2638
|
+
|
|
2639
|
+
A container node with explicit title + content structure.
|
|
2640
|
+
|
|
2641
|
+
**Features:**
|
|
2642
|
+
- Section title (sectionTitle node, renders as H2)
|
|
2643
|
+
- Collapsible content (toggle visibility)
|
|
2644
|
+
- Drag entire section as a group
|
|
2645
|
+
- Outline navigation integration
|
|
2646
|
+
- Nesting support (sections within sections)
|
|
2647
|
+
- Keyboard shortcuts for navigation
|
|
2648
|
+
|
|
2649
|
+
**Schema:**
|
|
2650
|
+
```javascript
|
|
2651
|
+
// Document Section container
|
|
2652
|
+
const DocumentSection = Node.create({
|
|
2653
|
+
name: 'documentSection',
|
|
2654
|
+
group: 'block',
|
|
2655
|
+
content: 'sectionTitle block*', // Title + any blocks
|
|
2656
|
+
defining: true,
|
|
2657
|
+
isolating: true,
|
|
2658
|
+
draggable: true,
|
|
2659
|
+
|
|
2660
|
+
addAttributes() {
|
|
2661
|
+
return {
|
|
2662
|
+
collapsed: { default: false },
|
|
2663
|
+
id: { default: null } // For deep linking
|
|
2664
|
+
}
|
|
2665
|
+
}
|
|
2666
|
+
})
|
|
2667
|
+
|
|
2668
|
+
// Section Title (always first child)
|
|
2669
|
+
const SectionTitle = Node.create({
|
|
2670
|
+
name: 'sectionTitle',
|
|
2671
|
+
content: 'inline*',
|
|
2672
|
+
defining: true,
|
|
2673
|
+
|
|
2674
|
+
parseHTML() {
|
|
2675
|
+
return [{ tag: 'h2[data-section-title]' }]
|
|
2676
|
+
},
|
|
2677
|
+
|
|
2678
|
+
renderHTML({ HTMLAttributes }) {
|
|
2679
|
+
return ['h2', { ...HTMLAttributes, 'data-section-title': 'true' }, 0]
|
|
2680
|
+
}
|
|
2681
|
+
})
|
|
2682
|
+
```
|
|
2683
|
+
|
|
2684
|
+
**NodeView:**
|
|
2685
|
+
```javascript
|
|
2686
|
+
// Section wrapper with collapse UI
|
|
2687
|
+
const DocumentSectionView = ({ node, editor, getPos }) => {
|
|
2688
|
+
const dom = document.createElement('div')
|
|
2689
|
+
dom.className = 'inkpen-doc-section'
|
|
2690
|
+
|
|
2691
|
+
// Collapse toggle
|
|
2692
|
+
const toggle = document.createElement('button')
|
|
2693
|
+
toggle.className = 'inkpen-doc-section__toggle'
|
|
2694
|
+
toggle.innerHTML = node.attrs.collapsed ? '▶' : '▼'
|
|
2695
|
+
toggle.onclick = () => toggleCollapsed(editor, getPos())
|
|
2696
|
+
|
|
2697
|
+
// Section chrome
|
|
2698
|
+
const header = document.createElement('div')
|
|
2699
|
+
header.className = 'inkpen-doc-section__header'
|
|
2700
|
+
header.appendChild(toggle)
|
|
2701
|
+
|
|
2702
|
+
// Content area (ProseMirror renders children here)
|
|
2703
|
+
const contentDOM = document.createElement('div')
|
|
2704
|
+
contentDOM.className = 'inkpen-doc-section__content'
|
|
2705
|
+
if (node.attrs.collapsed) contentDOM.style.display = 'none'
|
|
2706
|
+
|
|
2707
|
+
dom.appendChild(header)
|
|
2708
|
+
dom.appendChild(contentDOM)
|
|
2709
|
+
|
|
2710
|
+
return { dom, contentDOM }
|
|
2711
|
+
}
|
|
2712
|
+
```
|
|
2713
|
+
|
|
2714
|
+
**Commands:**
|
|
2715
|
+
- `insertDocumentSection()` - Insert new section with title
|
|
2716
|
+
- `toggleSectionCollapsed()` - Expand/collapse section
|
|
2717
|
+
- `wrapInDocumentSection()` - Wrap selected blocks in section
|
|
2718
|
+
- `unwrapDocumentSection()` - Remove section wrapper, keep content
|
|
2719
|
+
- `moveSectionUp()` / `moveSectionDown()` - Reorder sections
|
|
2720
|
+
- `goToNextSection()` / `goToPreviousSection()` - Navigation
|
|
2721
|
+
|
|
2722
|
+
**Keyboard Shortcuts:**
|
|
2723
|
+
| Shortcut | Action |
|
|
2724
|
+
|----------|--------|
|
|
2725
|
+
| `Cmd+Shift+Enter` | Insert document section |
|
|
2726
|
+
| `Cmd+.` | Toggle section collapsed |
|
|
2727
|
+
| `Cmd+Alt+↑` | Go to previous section |
|
|
2728
|
+
| `Cmd+Alt+↓` | Go to next section |
|
|
2729
|
+
|
|
2730
|
+
---
|
|
2731
|
+
|
|
2732
|
+
### 7.3 Section Outline Panel
|
|
2733
|
+
|
|
2734
|
+
A sidebar/panel showing document structure.
|
|
2735
|
+
|
|
2736
|
+
**Features:**
|
|
2737
|
+
- Tree view of all document sections
|
|
2738
|
+
- Click to navigate to section
|
|
2739
|
+
- Drag to reorder sections
|
|
2740
|
+
- Collapse/expand from outline
|
|
2741
|
+
- Search/filter sections
|
|
2742
|
+
- Section word count
|
|
2743
|
+
|
|
2744
|
+
**Implementation:**
|
|
2745
|
+
```javascript
|
|
2746
|
+
// Get all sections as tree
|
|
2747
|
+
function getSectionOutline(editor) {
|
|
2748
|
+
const sections = []
|
|
2749
|
+
let index = 0
|
|
2750
|
+
|
|
2751
|
+
editor.state.doc.descendants((node, pos) => {
|
|
2752
|
+
if (node.type.name === 'documentSection') {
|
|
2753
|
+
const title = node.firstChild?.textContent || 'Untitled'
|
|
2754
|
+
sections.push({
|
|
2755
|
+
id: node.attrs.id || `section-${index++}`,
|
|
2756
|
+
title,
|
|
2757
|
+
pos,
|
|
2758
|
+
collapsed: node.attrs.collapsed,
|
|
2759
|
+
depth: getDepth(node, editor.state.doc)
|
|
2760
|
+
})
|
|
2761
|
+
}
|
|
2762
|
+
})
|
|
2763
|
+
|
|
2764
|
+
return sections
|
|
2765
|
+
}
|
|
2766
|
+
```
|
|
2767
|
+
|
|
2768
|
+
---
|
|
2769
|
+
|
|
2770
|
+
### 7.4 Ruby Layer
|
|
2771
|
+
|
|
2772
|
+
**Block Registry (optional, for validation):**
|
|
2773
|
+
```ruby
|
|
2774
|
+
# lib/inkpen/block_registry.rb
|
|
2775
|
+
module Inkpen
|
|
2776
|
+
class BlockRegistry
|
|
2777
|
+
ALLOWED_NODES = %w[
|
|
2778
|
+
doc paragraph text heading bulletList orderedList listItem
|
|
2779
|
+
blockquote codeBlock horizontalRule hardBreak
|
|
2780
|
+
documentSection sectionTitle
|
|
2781
|
+
callout toggleBlock columns section
|
|
2782
|
+
enhancedImage fileAttachment embed
|
|
2783
|
+
database tableOfContents
|
|
2784
|
+
].freeze
|
|
2785
|
+
|
|
2786
|
+
def self.validate!(doc_json)
|
|
2787
|
+
walk_nodes(doc_json) do |node|
|
|
2788
|
+
type = node['type']
|
|
2789
|
+
raise InvalidNodeError, "Unknown node: #{type}" unless valid_type?(type)
|
|
2790
|
+
end
|
|
2791
|
+
end
|
|
2792
|
+
|
|
2793
|
+
def self.valid_type?(type)
|
|
2794
|
+
ALLOWED_NODES.include?(type) || core_type?(type)
|
|
2795
|
+
end
|
|
2796
|
+
end
|
|
2797
|
+
end
|
|
2798
|
+
```
|
|
2799
|
+
|
|
2800
|
+
**Document Section Extension PORO:**
|
|
2801
|
+
```ruby
|
|
2802
|
+
# lib/inkpen/extensions/document_section.rb
|
|
2803
|
+
module Inkpen
|
|
2804
|
+
module Extensions
|
|
2805
|
+
class DocumentSection < Base
|
|
2806
|
+
def name
|
|
2807
|
+
:document_section
|
|
2808
|
+
end
|
|
2809
|
+
|
|
2810
|
+
def default_collapsed
|
|
2811
|
+
options.fetch(:default_collapsed, false)
|
|
2812
|
+
end
|
|
2813
|
+
|
|
2814
|
+
def nesting_enabled?
|
|
2815
|
+
options.fetch(:nesting, true)
|
|
2816
|
+
end
|
|
2817
|
+
|
|
2818
|
+
def max_depth
|
|
2819
|
+
options.fetch(:max_depth, 3)
|
|
2820
|
+
end
|
|
2821
|
+
|
|
2822
|
+
def to_config
|
|
2823
|
+
{
|
|
2824
|
+
defaultCollapsed: default_collapsed,
|
|
2825
|
+
nestingEnabled: nesting_enabled?,
|
|
2826
|
+
maxDepth: max_depth
|
|
2827
|
+
}
|
|
2828
|
+
end
|
|
2829
|
+
end
|
|
2830
|
+
end
|
|
2831
|
+
end
|
|
2832
|
+
```
|
|
2833
|
+
|
|
2834
|
+
---
|
|
2835
|
+
|
|
2836
|
+
### 7.5 CSS
|
|
2837
|
+
|
|
2838
|
+
```css
|
|
2839
|
+
/* app/assets/stylesheets/inkpen/document_section.css */
|
|
2840
|
+
|
|
2841
|
+
.inkpen-doc-section {
|
|
2842
|
+
position: relative;
|
|
2843
|
+
margin: 1.5rem 0;
|
|
2844
|
+
padding-left: 1.5rem;
|
|
2845
|
+
border-left: 2px solid var(--inkpen-color-border);
|
|
2846
|
+
}
|
|
2847
|
+
|
|
2848
|
+
.inkpen-doc-section__header {
|
|
2849
|
+
position: absolute;
|
|
2850
|
+
left: -0.75rem;
|
|
2851
|
+
top: 0;
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
.inkpen-doc-section__toggle {
|
|
2855
|
+
width: 1.5rem;
|
|
2856
|
+
height: 1.5rem;
|
|
2857
|
+
display: flex;
|
|
2858
|
+
align-items: center;
|
|
2859
|
+
justify-content: center;
|
|
2860
|
+
border: none;
|
|
2861
|
+
background: var(--inkpen-toolbar-bg);
|
|
2862
|
+
border-radius: var(--inkpen-radius-sm);
|
|
2863
|
+
cursor: pointer;
|
|
2864
|
+
font-size: 0.75rem;
|
|
2865
|
+
color: var(--inkpen-color-text-muted);
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
.inkpen-doc-section__toggle:hover {
|
|
2869
|
+
background: var(--inkpen-color-bg-subtle);
|
|
2870
|
+
color: var(--inkpen-color-text);
|
|
2871
|
+
}
|
|
2872
|
+
|
|
2873
|
+
.inkpen-doc-section__content {
|
|
2874
|
+
/* Content area */
|
|
2875
|
+
}
|
|
2876
|
+
|
|
2877
|
+
.inkpen-doc-section.is-collapsed .inkpen-doc-section__content {
|
|
2878
|
+
display: none;
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
/* Nested sections */
|
|
2882
|
+
.inkpen-doc-section .inkpen-doc-section {
|
|
2883
|
+
margin-left: 1rem;
|
|
2884
|
+
border-left-color: var(--inkpen-color-border-light);
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
/* Drag state */
|
|
2888
|
+
.inkpen-doc-section.is-dragging {
|
|
2889
|
+
opacity: 0.5;
|
|
2890
|
+
}
|
|
2891
|
+
|
|
2892
|
+
/* Selected section */
|
|
2893
|
+
.inkpen-doc-section.is-selected {
|
|
2894
|
+
border-left-color: var(--inkpen-color-primary);
|
|
2895
|
+
background: rgba(var(--inkpen-color-primary-rgb), 0.05);
|
|
2896
|
+
}
|
|
2897
|
+
```
|
|
2898
|
+
|
|
2899
|
+
---
|
|
2900
|
+
|
|
2901
|
+
### 7.6 Integration Points
|
|
2902
|
+
|
|
2903
|
+
**Table of Contents:**
|
|
2904
|
+
- TOC extension should recognize documentSection nodes
|
|
2905
|
+
- Show section titles in outline
|
|
2906
|
+
- Navigate to section on click
|
|
2907
|
+
|
|
2908
|
+
**Export:**
|
|
2909
|
+
- Markdown: Use heading + content pattern
|
|
2910
|
+
- HTML: Use `<section>` + `<h2>` structure
|
|
2911
|
+
- PDF: Respect collapsed state (expand for export)
|
|
2912
|
+
|
|
2913
|
+
**Slash Commands:**
|
|
2914
|
+
```javascript
|
|
2915
|
+
{
|
|
2916
|
+
id: 'documentSection',
|
|
2917
|
+
title: 'Section',
|
|
2918
|
+
description: 'Create a collapsible section with title',
|
|
2919
|
+
icon: '📑',
|
|
2920
|
+
group: 'Structure',
|
|
2921
|
+
keywords: ['section', 'group', 'collapse', 'outline']
|
|
2922
|
+
}
|
|
2923
|
+
```
|
|
2924
|
+
|
|
2925
|
+
---
|
|
2926
|
+
|
|
2927
|
+
### Implementation Priority
|
|
2928
|
+
|
|
2929
|
+
| Feature | Priority | Complexity | Notes |
|
|
2930
|
+
|---------|----------|------------|-------|
|
|
2931
|
+
| DocumentSection node | High | Medium | Core functionality |
|
|
2932
|
+
| SectionTitle node | High | Low | Simple inline node |
|
|
2933
|
+
| Collapse/expand | High | Low | Toggle attribute |
|
|
2934
|
+
| NodeView with chrome | Medium | Medium | UI wrapper |
|
|
2935
|
+
| Outline panel | Medium | High | Separate component |
|
|
2936
|
+
| Nesting support | Low | Medium | Recursive schema |
|
|
2937
|
+
| Block registry | Low | Low | Optional validation |
|
|
2938
|
+
|
|
2939
|
+
---
|
|
2940
|
+
|
|
2941
|
+
### Files to Create (v0.8.0)
|
|
2942
|
+
|
|
2943
|
+
```
|
|
2944
|
+
lib/inkpen/extensions/
|
|
2945
|
+
└── document_section.rb
|
|
2946
|
+
|
|
2947
|
+
app/assets/javascripts/inkpen/extensions/
|
|
2948
|
+
├── document_section.js
|
|
2949
|
+
└── section_title.js
|
|
2950
|
+
|
|
2951
|
+
app/assets/stylesheets/inkpen/
|
|
2952
|
+
└── document_section.css
|
|
2953
|
+
```
|
|
2954
|
+
|
|
2955
|
+
---
|
|
2956
|
+
|
|
2957
|
+
## Phase 8: Collaboration (v0.9.0) — FUTURE
|
|
2958
|
+
|
|
2959
|
+
### Goal
|
|
2960
|
+
Real-time collaborative editing with presence awareness.
|
|
2961
|
+
|
|
2962
|
+
**Potential Features:**
|
|
2963
|
+
- Y.js or Hocuspocus integration
|
|
2964
|
+
- Cursor presence (see where others are typing)
|
|
2965
|
+
- User avatars and names
|
|
2966
|
+
- Conflict resolution
|
|
2967
|
+
- Offline support with sync
|
|
2968
|
+
|
|
2969
|
+
---
|
|
2970
|
+
|
|
2971
|
+
## Phase 9: AI Integration (v1.0.0) — FUTURE
|
|
2972
|
+
|
|
2973
|
+
### Goal
|
|
2974
|
+
AI-assisted writing and editing capabilities.
|
|
2975
|
+
|
|
2976
|
+
**Potential Features:**
|
|
2977
|
+
- AI writing suggestions
|
|
2978
|
+
- Grammar and style checking
|
|
2979
|
+
- Content summarization
|
|
2980
|
+
- Translation
|
|
2981
|
+
- Image generation prompts
|
|
2982
|
+
|
|
2983
|
+
---
|
|
2984
|
+
|
|
2985
|
+
## Architecture Notes
|
|
2986
|
+
|
|
2987
|
+
### Block Types Taxonomy
|
|
2988
|
+
|
|
2989
|
+
```
|
|
2990
|
+
Inkpen Node Types
|
|
2991
|
+
├── Core (from TipTap)
|
|
2992
|
+
│ ├── doc
|
|
2993
|
+
│ ├── paragraph
|
|
2994
|
+
│ ├── text
|
|
2995
|
+
│ ├── heading
|
|
2996
|
+
│ ├── bulletList / orderedList / listItem
|
|
2997
|
+
│ ├── blockquote
|
|
2998
|
+
│ ├── codeBlock
|
|
2999
|
+
│ ├── horizontalRule
|
|
3000
|
+
│ └── hardBreak
|
|
3001
|
+
├── Layout Blocks
|
|
3002
|
+
│ ├── section (width/spacing)
|
|
3003
|
+
│ ├── columns / column
|
|
3004
|
+
│ └── documentSection (content grouping) ← v0.8.0
|
|
3005
|
+
├── Content Blocks
|
|
3006
|
+
│ ├── callout
|
|
3007
|
+
│ ├── toggleBlock
|
|
3008
|
+
│ ├── preformatted
|
|
3009
|
+
│ └── database
|
|
3010
|
+
├── Media Blocks
|
|
3011
|
+
│ ├── enhancedImage
|
|
3012
|
+
│ ├── fileAttachment
|
|
3013
|
+
│ ├── embed
|
|
3014
|
+
│ └── youtube
|
|
3015
|
+
└── Navigation Blocks
|
|
3016
|
+
└── tableOfContents
|
|
3017
|
+
```
|
|
3018
|
+
|
|
3019
|
+
### When to Use What
|
|
3020
|
+
|
|
3021
|
+
| Need | Use |
|
|
3022
|
+
|------|-----|
|
|
3023
|
+
| Different content widths | `section` (layout) |
|
|
3024
|
+
| Collapsible single block | `toggleBlock` |
|
|
3025
|
+
| Collapsible group of blocks | `documentSection` |
|
|
3026
|
+
| Side-by-side content | `columns` |
|
|
3027
|
+
| Highlighted message | `callout` |
|
|
3028
|
+
| Structured data | `database` |
|
|
3029
|
+
| Document navigation | `tableOfContents` |
|