inkpen 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/.DS_Store +0 -0
- data/.rubocop.yml +8 -0
- data/.yardopts +11 -0
- data/CLAUDE.md +141 -0
- data/README.md +409 -0
- data/Rakefile +19 -0
- data/app/assets/javascripts/inkpen/controllers/editor_controller.js +2050 -0
- data/app/assets/javascripts/inkpen/controllers/sticky_toolbar_controller.js +667 -0
- data/app/assets/javascripts/inkpen/controllers/toolbar_controller.js +693 -0
- data/app/assets/javascripts/inkpen/export/html.js +637 -0
- data/app/assets/javascripts/inkpen/export/index.js +30 -0
- data/app/assets/javascripts/inkpen/export/markdown.js +697 -0
- data/app/assets/javascripts/inkpen/export/pdf.js +372 -0
- data/app/assets/javascripts/inkpen/extensions/advanced_table.js +640 -0
- data/app/assets/javascripts/inkpen/extensions/block_commands.js +300 -0
- data/app/assets/javascripts/inkpen/extensions/block_gutter.js +338 -0
- data/app/assets/javascripts/inkpen/extensions/callout.js +303 -0
- data/app/assets/javascripts/inkpen/extensions/columns.js +403 -0
- data/app/assets/javascripts/inkpen/extensions/database.js +990 -0
- data/app/assets/javascripts/inkpen/extensions/document_section.js +352 -0
- data/app/assets/javascripts/inkpen/extensions/drag_handle.js +407 -0
- data/app/assets/javascripts/inkpen/extensions/embed.js +629 -0
- data/app/assets/javascripts/inkpen/extensions/enhanced_image.js +566 -0
- data/app/assets/javascripts/inkpen/extensions/export_commands.js +271 -0
- data/app/assets/javascripts/inkpen/extensions/file_attachment.js +593 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/index.js +58 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table.js +638 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_cell.js +100 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_header.js +100 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_constants.js +152 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_helpers.js +254 -0
- data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_menu.js +282 -0
- data/app/assets/javascripts/inkpen/extensions/preformatted.js +239 -0
- data/app/assets/javascripts/inkpen/extensions/section.js +281 -0
- data/app/assets/javascripts/inkpen/extensions/section_title.js +126 -0
- data/app/assets/javascripts/inkpen/extensions/slash_commands.js +439 -0
- data/app/assets/javascripts/inkpen/extensions/table_of_contents.js +474 -0
- data/app/assets/javascripts/inkpen/extensions/toggle_block.js +332 -0
- data/app/assets/javascripts/inkpen/index.js +87 -0
- data/app/assets/stylesheets/inkpen/advanced_table.css +514 -0
- data/app/assets/stylesheets/inkpen/animations.css +626 -0
- data/app/assets/stylesheets/inkpen/block_gutter.css +265 -0
- data/app/assets/stylesheets/inkpen/callout.css +359 -0
- data/app/assets/stylesheets/inkpen/columns.css +314 -0
- data/app/assets/stylesheets/inkpen/database.css +658 -0
- data/app/assets/stylesheets/inkpen/document_section.css +305 -0
- data/app/assets/stylesheets/inkpen/drag_drop.css +220 -0
- data/app/assets/stylesheets/inkpen/editor.css +652 -0
- data/app/assets/stylesheets/inkpen/embed.css +468 -0
- data/app/assets/stylesheets/inkpen/enhanced_image.css +453 -0
- data/app/assets/stylesheets/inkpen/export.css +499 -0
- data/app/assets/stylesheets/inkpen/file_attachment.css +347 -0
- data/app/assets/stylesheets/inkpen/footnotes.css +136 -0
- data/app/assets/stylesheets/inkpen/inkpen_table.css +608 -0
- data/app/assets/stylesheets/inkpen/preformatted.css +215 -0
- data/app/assets/stylesheets/inkpen/search_replace.css +58 -0
- data/app/assets/stylesheets/inkpen/section.css +236 -0
- data/app/assets/stylesheets/inkpen/slash_menu.css +252 -0
- data/app/assets/stylesheets/inkpen/sticky_toolbar.css +314 -0
- data/app/assets/stylesheets/inkpen/toc.css +386 -0
- data/app/assets/stylesheets/inkpen/toggle.css +260 -0
- data/app/helpers/inkpen/editor_helper.rb +114 -0
- data/app/views/inkpen/_editor.html.erb +139 -0
- data/config/importmap.rb +170 -0
- data/docs/.DS_Store +0 -0
- data/docs/CHANGELOG.md +571 -0
- data/docs/FEATURES.md +436 -0
- data/docs/ROADMAP.md +3029 -0
- data/docs/VISION.md +235 -0
- data/docs/extensions/INKPEN_TABLE.md +482 -0
- data/docs/thinking/CORRECTED_NO_VUE.md +756 -0
- data/docs/thinking/EXECUTIVE_SUMMARY.md +403 -0
- data/docs/thinking/INKPEN_CODE_SAMPLES.md +1479 -0
- data/docs/thinking/INKPEN_MASTER_GUIDE.md +891 -0
- data/docs/thinking/README_START_HERE.md +341 -0
- data/lib/inkpen/configuration.rb +175 -0
- data/lib/inkpen/editor.rb +204 -0
- data/lib/inkpen/engine.rb +32 -0
- data/lib/inkpen/extensions/base.rb +109 -0
- data/lib/inkpen/extensions/code_block_syntax.rb +177 -0
- data/lib/inkpen/extensions/document_section.rb +111 -0
- data/lib/inkpen/extensions/forced_document.rb +183 -0
- data/lib/inkpen/extensions/mention.rb +155 -0
- data/lib/inkpen/extensions/preformatted.rb +111 -0
- data/lib/inkpen/extensions/section.rb +139 -0
- data/lib/inkpen/extensions/slash_commands.rb +100 -0
- data/lib/inkpen/extensions/table.rb +182 -0
- data/lib/inkpen/extensions/task_list.rb +145 -0
- data/lib/inkpen/sticky_toolbar.rb +157 -0
- data/lib/inkpen/toolbar.rb +145 -0
- data/lib/inkpen/version.rb +5 -0
- data/lib/inkpen.rb +101 -0
- data/sig/inkpen.rbs +4 -0
- metadata +165 -0
|
@@ -0,0 +1,2050 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// ============================================
|
|
4
|
+
// LAZY LOADING
|
|
5
|
+
// ============================================
|
|
6
|
+
// All TipTap/ProseMirror modules are lazy-loaded when the editor connects.
|
|
7
|
+
// This prevents 50+ CDN requests on pages that don't use the editor.
|
|
8
|
+
// The modules are cached after first load for subsequent editor instances.
|
|
9
|
+
|
|
10
|
+
let cachedModules = null
|
|
11
|
+
|
|
12
|
+
async function loadEditorModules() {
|
|
13
|
+
if (cachedModules) return cachedModules
|
|
14
|
+
|
|
15
|
+
// Load all core modules in parallel
|
|
16
|
+
const [
|
|
17
|
+
tiptapCore,
|
|
18
|
+
tiptapPmModel,
|
|
19
|
+
documentExt,
|
|
20
|
+
starterKit,
|
|
21
|
+
linkExt,
|
|
22
|
+
placeholderExt,
|
|
23
|
+
imageExt,
|
|
24
|
+
tableExt,
|
|
25
|
+
tableRowExt,
|
|
26
|
+
tableCellExt,
|
|
27
|
+
tableHeaderExt,
|
|
28
|
+
taskListExt,
|
|
29
|
+
taskItemExt,
|
|
30
|
+
mentionExt,
|
|
31
|
+
codeBlockLowlightExt,
|
|
32
|
+
lowlightMod,
|
|
33
|
+
typographyExt,
|
|
34
|
+
highlightExt,
|
|
35
|
+
underlineExt,
|
|
36
|
+
subscriptExt,
|
|
37
|
+
superscriptExt,
|
|
38
|
+
youtubeExt,
|
|
39
|
+
characterCountExt,
|
|
40
|
+
bubbleMenuExt,
|
|
41
|
+
// Inkpen custom extensions
|
|
42
|
+
sectionMod,
|
|
43
|
+
preformattedMod,
|
|
44
|
+
slashCommandsMod,
|
|
45
|
+
blockGutterMod,
|
|
46
|
+
dragHandleMod,
|
|
47
|
+
toggleBlockMod,
|
|
48
|
+
columnsMod,
|
|
49
|
+
calloutMod,
|
|
50
|
+
blockCommandsMod,
|
|
51
|
+
enhancedImageMod,
|
|
52
|
+
fileAttachmentMod,
|
|
53
|
+
embedMod,
|
|
54
|
+
advancedTableMod,
|
|
55
|
+
tableOfContentsMod,
|
|
56
|
+
databaseMod,
|
|
57
|
+
documentSectionMod,
|
|
58
|
+
contentEmbedMod
|
|
59
|
+
] = await Promise.all([
|
|
60
|
+
import("@tiptap/core"),
|
|
61
|
+
import("@tiptap/pm/model"),
|
|
62
|
+
import("@tiptap/extension-document"),
|
|
63
|
+
import("@tiptap/starter-kit"),
|
|
64
|
+
import("@tiptap/extension-link"),
|
|
65
|
+
import("@tiptap/extension-placeholder"),
|
|
66
|
+
import("@tiptap/extension-image"),
|
|
67
|
+
import("@tiptap/extension-table"),
|
|
68
|
+
import("@tiptap/extension-table-row"),
|
|
69
|
+
import("@tiptap/extension-table-cell"),
|
|
70
|
+
import("@tiptap/extension-table-header"),
|
|
71
|
+
import("@tiptap/extension-task-list"),
|
|
72
|
+
import("@tiptap/extension-task-item"),
|
|
73
|
+
import("@tiptap/extension-mention"),
|
|
74
|
+
import("@tiptap/extension-code-block-lowlight"),
|
|
75
|
+
import("lowlight"),
|
|
76
|
+
import("@tiptap/extension-typography"),
|
|
77
|
+
import("@tiptap/extension-highlight"),
|
|
78
|
+
import("@tiptap/extension-underline"),
|
|
79
|
+
import("@tiptap/extension-subscript"),
|
|
80
|
+
import("@tiptap/extension-superscript"),
|
|
81
|
+
import("@tiptap/extension-youtube"),
|
|
82
|
+
import("@tiptap/extension-character-count"),
|
|
83
|
+
import("@tiptap/extension-bubble-menu"),
|
|
84
|
+
// Inkpen custom extensions
|
|
85
|
+
import("inkpen/extensions/section"),
|
|
86
|
+
import("inkpen/extensions/preformatted"),
|
|
87
|
+
import("inkpen/extensions/slash_commands"),
|
|
88
|
+
import("inkpen/extensions/block_gutter"),
|
|
89
|
+
import("inkpen/extensions/drag_handle"),
|
|
90
|
+
import("inkpen/extensions/toggle_block"),
|
|
91
|
+
import("inkpen/extensions/columns"),
|
|
92
|
+
import("inkpen/extensions/callout"),
|
|
93
|
+
import("inkpen/extensions/block_commands"),
|
|
94
|
+
import("inkpen/extensions/enhanced_image"),
|
|
95
|
+
import("inkpen/extensions/file_attachment"),
|
|
96
|
+
import("inkpen/extensions/embed"),
|
|
97
|
+
import("inkpen/extensions/advanced_table"),
|
|
98
|
+
import("inkpen/extensions/table_of_contents"),
|
|
99
|
+
import("inkpen/extensions/database"),
|
|
100
|
+
import("inkpen/extensions/document_section"),
|
|
101
|
+
import("inkpen/extensions/content_embed")
|
|
102
|
+
])
|
|
103
|
+
|
|
104
|
+
cachedModules = {
|
|
105
|
+
Editor: tiptapCore.Editor,
|
|
106
|
+
DOMSerializer: tiptapPmModel.DOMSerializer,
|
|
107
|
+
Document: documentExt.default,
|
|
108
|
+
StarterKit: starterKit.default,
|
|
109
|
+
Link: linkExt.default,
|
|
110
|
+
Placeholder: placeholderExt.default,
|
|
111
|
+
Image: imageExt.default,
|
|
112
|
+
Table: tableExt.default,
|
|
113
|
+
TableRow: tableRowExt.default,
|
|
114
|
+
TableCell: tableCellExt.default,
|
|
115
|
+
TableHeader: tableHeaderExt.default,
|
|
116
|
+
TaskList: taskListExt.default,
|
|
117
|
+
TaskItem: taskItemExt.default,
|
|
118
|
+
Mention: mentionExt.default,
|
|
119
|
+
CodeBlockLowlight: codeBlockLowlightExt.default,
|
|
120
|
+
common: lowlightMod.common,
|
|
121
|
+
createLowlight: lowlightMod.createLowlight,
|
|
122
|
+
Typography: typographyExt.default,
|
|
123
|
+
Highlight: highlightExt.default,
|
|
124
|
+
Underline: underlineExt.default,
|
|
125
|
+
Subscript: subscriptExt.default,
|
|
126
|
+
Superscript: superscriptExt.default,
|
|
127
|
+
Youtube: youtubeExt.default,
|
|
128
|
+
CharacterCount: characterCountExt.default,
|
|
129
|
+
BubbleMenu: bubbleMenuExt.default,
|
|
130
|
+
// Inkpen custom extensions
|
|
131
|
+
Section: sectionMod.Section,
|
|
132
|
+
Preformatted: preformattedMod.Preformatted,
|
|
133
|
+
SlashCommands: slashCommandsMod.SlashCommands,
|
|
134
|
+
BlockGutter: blockGutterMod.BlockGutter,
|
|
135
|
+
DragHandle: dragHandleMod.DragHandle,
|
|
136
|
+
ToggleBlock: toggleBlockMod.ToggleBlock,
|
|
137
|
+
ToggleSummary: toggleBlockMod.ToggleSummary,
|
|
138
|
+
Columns: columnsMod.Columns,
|
|
139
|
+
Column: columnsMod.Column,
|
|
140
|
+
Callout: calloutMod.Callout,
|
|
141
|
+
BlockCommands: blockCommandsMod.BlockCommands,
|
|
142
|
+
EnhancedImage: enhancedImageMod.EnhancedImage,
|
|
143
|
+
FileAttachment: fileAttachmentMod.FileAttachment,
|
|
144
|
+
Embed: embedMod.Embed,
|
|
145
|
+
AdvancedTable: advancedTableMod.AdvancedTable,
|
|
146
|
+
AdvancedTableRow: advancedTableMod.AdvancedTableRow,
|
|
147
|
+
AdvancedTableCell: advancedTableMod.AdvancedTableCell,
|
|
148
|
+
AdvancedTableHeader: advancedTableMod.AdvancedTableHeader,
|
|
149
|
+
TableOfContents: tableOfContentsMod.TableOfContents,
|
|
150
|
+
Database: databaseMod.Database,
|
|
151
|
+
DocumentSection: documentSectionMod.DocumentSection,
|
|
152
|
+
ContentEmbed: contentEmbedMod.ContentEmbed
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return cachedModules
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Extensions loaded lazily to prevent import failures from breaking the editor
|
|
159
|
+
let EmojiReplacer = null
|
|
160
|
+
let SearchNReplace = null
|
|
161
|
+
let FootnotesModule = null
|
|
162
|
+
let InkpenTableModule = null
|
|
163
|
+
|
|
164
|
+
async function loadEmojiReplacer() {
|
|
165
|
+
if (EmojiReplacer) return EmojiReplacer
|
|
166
|
+
try {
|
|
167
|
+
const module = await import("@tiptap-extend/emoji-replacer")
|
|
168
|
+
EmojiReplacer = module.EmojiReplacer
|
|
169
|
+
return EmojiReplacer
|
|
170
|
+
} catch (e) {
|
|
171
|
+
console.warn("Inkpen: EmojiReplacer extension not available:", e.message)
|
|
172
|
+
return null
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
async function loadSearchNReplace() {
|
|
177
|
+
if (SearchNReplace) return SearchNReplace
|
|
178
|
+
try {
|
|
179
|
+
const module = await import("@sereneinserenade/tiptap-search-and-replace")
|
|
180
|
+
SearchNReplace = module.default
|
|
181
|
+
return SearchNReplace
|
|
182
|
+
} catch (e) {
|
|
183
|
+
console.warn("Inkpen: SearchNReplace extension not available:", e.message)
|
|
184
|
+
return null
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function loadFootnotes() {
|
|
189
|
+
if (FootnotesModule) return FootnotesModule
|
|
190
|
+
try {
|
|
191
|
+
FootnotesModule = await import("tiptap-footnotes")
|
|
192
|
+
return FootnotesModule
|
|
193
|
+
} catch (e) {
|
|
194
|
+
console.warn("Inkpen: Footnotes extension not available:", e.message)
|
|
195
|
+
return null
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
async function loadInkpenTable() {
|
|
200
|
+
if (InkpenTableModule) return InkpenTableModule
|
|
201
|
+
try {
|
|
202
|
+
InkpenTableModule = await import("inkpen/extensions/inkpen_table")
|
|
203
|
+
return InkpenTableModule
|
|
204
|
+
} catch (e) {
|
|
205
|
+
console.warn("Inkpen: InkpenTable extension not available:", e.message)
|
|
206
|
+
return null
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Export modules are loaded lazily when export_commands extension is enabled
|
|
211
|
+
// This prevents 404 errors for apps that don't use export functionality
|
|
212
|
+
let ExportCommands = null
|
|
213
|
+
let exportModules = null
|
|
214
|
+
|
|
215
|
+
async function loadExportModules() {
|
|
216
|
+
if (exportModules) return exportModules
|
|
217
|
+
try {
|
|
218
|
+
exportModules = await import("inkpen/export")
|
|
219
|
+
return exportModules
|
|
220
|
+
} catch (e) {
|
|
221
|
+
console.warn("Inkpen export modules not available:", e.message)
|
|
222
|
+
return null
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function loadExportCommands() {
|
|
227
|
+
if (ExportCommands) return ExportCommands
|
|
228
|
+
try {
|
|
229
|
+
const module = await import("inkpen/extensions/export_commands")
|
|
230
|
+
ExportCommands = module.ExportCommands
|
|
231
|
+
return ExportCommands
|
|
232
|
+
} catch (e) {
|
|
233
|
+
console.warn("Inkpen ExportCommands extension not available:", e.message)
|
|
234
|
+
return null
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Inkpen Editor Controller
|
|
240
|
+
*
|
|
241
|
+
* Main Stimulus controller for the TipTap editor.
|
|
242
|
+
* Handles initialization, content sync, and editor lifecycle.
|
|
243
|
+
*
|
|
244
|
+
* Supports extensions:
|
|
245
|
+
* - StarterKit (bold, italic, strike, heading, lists, blockquote, code, etc.)
|
|
246
|
+
* - Link, Image
|
|
247
|
+
* - Table (with rows, cells, headers)
|
|
248
|
+
* - TaskList (checkboxes)
|
|
249
|
+
* - Mention (@mentions)
|
|
250
|
+
* - CodeBlockLowlight (syntax highlighting)
|
|
251
|
+
* - Typography (smart quotes, markdown shortcuts)
|
|
252
|
+
* - Highlight, Underline, Subscript, Superscript
|
|
253
|
+
* - YouTube (video embeds)
|
|
254
|
+
* - CharacterCount
|
|
255
|
+
*/
|
|
256
|
+
export default class extends Controller {
|
|
257
|
+
static targets = ["input", "content", "toolbar", "markdownEditor", "modeToggle", "hintsPanel"]
|
|
258
|
+
|
|
259
|
+
static values = {
|
|
260
|
+
extensions: { type: Array, default: [] },
|
|
261
|
+
extensionConfig: { type: Object, default: {} },
|
|
262
|
+
toolbar: { type: String, default: "floating" },
|
|
263
|
+
placeholder: { type: String, default: "Start writing..." },
|
|
264
|
+
autosave: { type: Boolean, default: false },
|
|
265
|
+
autosaveInterval: { type: Number, default: 5000 },
|
|
266
|
+
// Markdown mode values
|
|
267
|
+
markdownEnabled: { type: Boolean, default: false },
|
|
268
|
+
markdownMode: { type: String, default: "wysiwyg" },
|
|
269
|
+
markdownShowToggle: { type: Boolean, default: true },
|
|
270
|
+
markdownToolbarButton: { type: Boolean, default: false },
|
|
271
|
+
markdownSyncDelay: { type: Number, default: 300 },
|
|
272
|
+
markdownShortcuts: { type: Boolean, default: true }
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
connect() {
|
|
276
|
+
this.initializeEditor()
|
|
277
|
+
.then(() => {
|
|
278
|
+
this.setupFormSubmitSync()
|
|
279
|
+
// Initialize markdown mode AFTER editor is ready
|
|
280
|
+
if (this.markdownEnabledValue) {
|
|
281
|
+
this.initializeMarkdownMode()
|
|
282
|
+
}
|
|
283
|
+
})
|
|
284
|
+
.catch(error => {
|
|
285
|
+
console.error("Inkpen: Failed to initialize editor:", error)
|
|
286
|
+
this.dispatchEvent("error", { error })
|
|
287
|
+
// Show fallback UI
|
|
288
|
+
this.showFallbackEditor()
|
|
289
|
+
})
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
disconnect() {
|
|
293
|
+
this.destroyEditor()
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Show a basic textarea fallback if the rich editor fails to load.
|
|
298
|
+
* This ensures users can still edit content even if TipTap fails.
|
|
299
|
+
*/
|
|
300
|
+
showFallbackEditor() {
|
|
301
|
+
if (!this.hasContentTarget || !this.hasInputTarget) return
|
|
302
|
+
|
|
303
|
+
// Create a textarea with the same content
|
|
304
|
+
const textarea = document.createElement("textarea")
|
|
305
|
+
textarea.className = "inkpen-editor__fallback"
|
|
306
|
+
textarea.value = this.inputTarget.value || ""
|
|
307
|
+
textarea.placeholder = this.placeholderValue
|
|
308
|
+
textarea.style.cssText = `
|
|
309
|
+
width: 100%;
|
|
310
|
+
min-height: 200px;
|
|
311
|
+
padding: 1rem;
|
|
312
|
+
border: 1px solid #e5e7eb;
|
|
313
|
+
border-radius: 0.375rem;
|
|
314
|
+
font-family: inherit;
|
|
315
|
+
font-size: 1rem;
|
|
316
|
+
line-height: 1.6;
|
|
317
|
+
resize: vertical;
|
|
318
|
+
`
|
|
319
|
+
|
|
320
|
+
// Sync textarea changes to hidden input
|
|
321
|
+
textarea.addEventListener("input", () => {
|
|
322
|
+
this.inputTarget.value = textarea.value
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
// Replace the content target with the textarea
|
|
326
|
+
this.contentTarget.innerHTML = ""
|
|
327
|
+
this.contentTarget.appendChild(textarea)
|
|
328
|
+
|
|
329
|
+
// Focus the textarea
|
|
330
|
+
textarea.focus()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async initializeEditor() {
|
|
334
|
+
// Lazy-load all TipTap modules
|
|
335
|
+
this.modules = await loadEditorModules()
|
|
336
|
+
const { Editor } = this.modules
|
|
337
|
+
|
|
338
|
+
const extensions = await this.buildExtensions()
|
|
339
|
+
|
|
340
|
+
this.editor = new Editor({
|
|
341
|
+
element: this.contentTarget,
|
|
342
|
+
extensions,
|
|
343
|
+
content: this.inputTarget.value || "",
|
|
344
|
+
editorProps: {
|
|
345
|
+
attributes: {
|
|
346
|
+
class: "inkpen-editor__content"
|
|
347
|
+
}
|
|
348
|
+
},
|
|
349
|
+
onUpdate: ({ editor }) => {
|
|
350
|
+
this.syncContent(editor)
|
|
351
|
+
this.dispatchChangeEvent()
|
|
352
|
+
},
|
|
353
|
+
onSelectionUpdate: ({ editor }) => {
|
|
354
|
+
this.handleSelectionChange(editor)
|
|
355
|
+
},
|
|
356
|
+
onFocus: () => {
|
|
357
|
+
this.element.classList.add("is-focused")
|
|
358
|
+
this.dispatchEvent("focus")
|
|
359
|
+
},
|
|
360
|
+
onBlur: () => {
|
|
361
|
+
this.element.classList.remove("is-focused")
|
|
362
|
+
this.dispatchEvent("blur")
|
|
363
|
+
}
|
|
364
|
+
})
|
|
365
|
+
|
|
366
|
+
// Set up autosave if enabled
|
|
367
|
+
if (this.autosaveValue) {
|
|
368
|
+
this.setupAutosave()
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
// Dispatch ready event
|
|
372
|
+
this.dispatchEvent("ready", { editor: this.editor })
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
destroyEditor() {
|
|
376
|
+
if (this.autosaveTimer) {
|
|
377
|
+
clearInterval(this.autosaveTimer)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
this.clearMarkdownModeSync()
|
|
381
|
+
this.clearSplitSync()
|
|
382
|
+
this.teardownFormSubmitSync()
|
|
383
|
+
|
|
384
|
+
if (this.editor) {
|
|
385
|
+
this.editor.destroy()
|
|
386
|
+
this.editor = null
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async buildExtensions() {
|
|
391
|
+
const config = this.extensionConfigValue
|
|
392
|
+
const enabledExtensions = this.extensionsValue
|
|
393
|
+
|
|
394
|
+
// Destructure all modules from lazy-loaded cache
|
|
395
|
+
const {
|
|
396
|
+
StarterKit, Document, Placeholder, BubbleMenu, Link, Image,
|
|
397
|
+
Table, TableRow, TableCell, TableHeader,
|
|
398
|
+
TaskList, TaskItem, Mention,
|
|
399
|
+
CodeBlockLowlight, common, createLowlight,
|
|
400
|
+
Typography, Highlight, Underline, Subscript, Superscript,
|
|
401
|
+
Youtube, CharacterCount,
|
|
402
|
+
Section, Preformatted, SlashCommands, BlockGutter, DragHandle,
|
|
403
|
+
ToggleBlock, ToggleSummary, Columns, Column, Callout, BlockCommands,
|
|
404
|
+
EnhancedImage, FileAttachment, Embed,
|
|
405
|
+
AdvancedTable, AdvancedTableRow, AdvancedTableCell, AdvancedTableHeader,
|
|
406
|
+
TableOfContents, Database, DocumentSection, ContentEmbed
|
|
407
|
+
} = this.modules
|
|
408
|
+
|
|
409
|
+
// Base StarterKit - disable codeBlock if we're using CodeBlockLowlight
|
|
410
|
+
const starterKitConfig = {
|
|
411
|
+
heading: { levels: [1, 2, 3, 4] }
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (enabledExtensions.includes("code_block_syntax")) {
|
|
415
|
+
starterKitConfig.codeBlock = false
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// If forced_document is enabled, we need to use a custom Document
|
|
419
|
+
// and disable the default document in StarterKit
|
|
420
|
+
if (enabledExtensions.includes("forced_document")) {
|
|
421
|
+
starterKitConfig.document = false
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
const extensions = [
|
|
425
|
+
StarterKit.configure(starterKitConfig)
|
|
426
|
+
]
|
|
427
|
+
|
|
428
|
+
// BubbleMenu (floating toolbar on text selection) - TipTap's native extension
|
|
429
|
+
// Only use BubbleMenu for "floating" mode, not for "fixed" mode
|
|
430
|
+
if (this.hasToolbarTarget && this.toolbarValue === "floating") {
|
|
431
|
+
const toolbarEl = this.toolbarTarget
|
|
432
|
+
extensions.push(
|
|
433
|
+
BubbleMenu.configure({
|
|
434
|
+
element: toolbarEl,
|
|
435
|
+
tippyOptions: {
|
|
436
|
+
duration: 100,
|
|
437
|
+
placement: "top",
|
|
438
|
+
zIndex: 9999,
|
|
439
|
+
},
|
|
440
|
+
shouldShow: ({ editor, state }) => {
|
|
441
|
+
// Only show when there's a text selection (not just cursor)
|
|
442
|
+
return !state.selection.empty && editor.isEditable
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// Fixed toolbar - always visible, no BubbleMenu needed
|
|
449
|
+
// The toolbar is rendered in the DOM and controlled by toolbar_controller.js
|
|
450
|
+
|
|
451
|
+
// Forced Document Structure (title + optional subtitle)
|
|
452
|
+
if (enabledExtensions.includes("forced_document")) {
|
|
453
|
+
const docConfig = config.forced_document || {}
|
|
454
|
+
const contentExpression = docConfig.contentExpression || "heading block*"
|
|
455
|
+
|
|
456
|
+
// Create custom Document with forced structure
|
|
457
|
+
const CustomDocument = Document.extend({
|
|
458
|
+
content: contentExpression
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
extensions.unshift(CustomDocument)
|
|
462
|
+
|
|
463
|
+
// Store config for placeholder use
|
|
464
|
+
this.forcedDocConfig = docConfig
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// Placeholder extension with support for forced document structure
|
|
468
|
+
extensions.push(
|
|
469
|
+
Placeholder.configure({
|
|
470
|
+
includeChildren: true,
|
|
471
|
+
placeholder: ({ node, pos, editor }) => {
|
|
472
|
+
// If forced_document is enabled, show specific placeholders
|
|
473
|
+
if (this.forcedDocConfig && node.type.name === "heading") {
|
|
474
|
+
const docConfig = this.forcedDocConfig
|
|
475
|
+
const titleLevel = docConfig.titleLevel || 1
|
|
476
|
+
const subtitleLevel = docConfig.subtitleLevel || 2
|
|
477
|
+
|
|
478
|
+
// First heading (title) - position 0 and matching title level
|
|
479
|
+
if (pos === 0 && node.attrs.level === titleLevel) {
|
|
480
|
+
return docConfig.titlePlaceholder || "Untitled"
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Second heading (subtitle) - after title, matching subtitle level
|
|
484
|
+
if (docConfig.subtitle && node.attrs.level === subtitleLevel) {
|
|
485
|
+
// Check if this is the second node in the document
|
|
486
|
+
const doc = editor.state.doc
|
|
487
|
+
let isSecondHeading = false
|
|
488
|
+
let nodeIndex = 0
|
|
489
|
+
doc.forEach((n, offset) => {
|
|
490
|
+
if (offset === pos) {
|
|
491
|
+
isSecondHeading = nodeIndex === 1
|
|
492
|
+
}
|
|
493
|
+
nodeIndex++
|
|
494
|
+
})
|
|
495
|
+
if (isSecondHeading) {
|
|
496
|
+
return docConfig.subtitlePlaceholder || "Add a subtitle..."
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// Default placeholder for paragraphs
|
|
502
|
+
if (node.type.name === "paragraph") {
|
|
503
|
+
return this.placeholderValue || "Start writing..."
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
return ""
|
|
507
|
+
}
|
|
508
|
+
})
|
|
509
|
+
)
|
|
510
|
+
|
|
511
|
+
// Link extension
|
|
512
|
+
if (enabledExtensions.includes("link")) {
|
|
513
|
+
extensions.push(
|
|
514
|
+
Link.configure({
|
|
515
|
+
openOnClick: false,
|
|
516
|
+
HTMLAttributes: {
|
|
517
|
+
rel: "noopener noreferrer",
|
|
518
|
+
target: "_blank"
|
|
519
|
+
}
|
|
520
|
+
})
|
|
521
|
+
)
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Image extension
|
|
525
|
+
if (enabledExtensions.includes("image")) {
|
|
526
|
+
extensions.push(
|
|
527
|
+
Image.configure({
|
|
528
|
+
inline: false,
|
|
529
|
+
allowBase64: true
|
|
530
|
+
})
|
|
531
|
+
)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Table extension (use InkpenTable for enhanced Notion-style tables)
|
|
535
|
+
// Loaded lazily to prevent import failures from breaking the editor
|
|
536
|
+
if (enabledExtensions.includes("inkpen_table")) {
|
|
537
|
+
const inkpenTableMod = await loadInkpenTable()
|
|
538
|
+
if (inkpenTableMod) {
|
|
539
|
+
const { InkpenTable, InkpenTableRow, InkpenTableCell, InkpenTableHeader } = inkpenTableMod
|
|
540
|
+
const tableConfig = config.inkpen_table || config.table || {}
|
|
541
|
+
extensions.push(
|
|
542
|
+
InkpenTable.configure({
|
|
543
|
+
resizable: tableConfig.resizable !== false,
|
|
544
|
+
showHandles: tableConfig.showHandles !== false,
|
|
545
|
+
showAddButtons: tableConfig.showAddButtons !== false,
|
|
546
|
+
showCaption: tableConfig.showCaption !== false,
|
|
547
|
+
stickyHeader: tableConfig.stickyHeader || false,
|
|
548
|
+
defaultVariant: tableConfig.defaultVariant || "default",
|
|
549
|
+
HTMLAttributes: {
|
|
550
|
+
class: "inkpen-table"
|
|
551
|
+
}
|
|
552
|
+
}),
|
|
553
|
+
InkpenTableRow,
|
|
554
|
+
InkpenTableHeader,
|
|
555
|
+
InkpenTableCell
|
|
556
|
+
)
|
|
557
|
+
}
|
|
558
|
+
} else if (enabledExtensions.includes("advanced_table")) {
|
|
559
|
+
// Legacy: Use AdvancedTable (deprecated, use inkpen_table instead)
|
|
560
|
+
const tableConfig = config.advanced_table || config.table || {}
|
|
561
|
+
extensions.push(
|
|
562
|
+
AdvancedTable.configure({
|
|
563
|
+
resizable: tableConfig.resizable !== false,
|
|
564
|
+
showControls: tableConfig.showControls !== false,
|
|
565
|
+
defaultVariant: tableConfig.defaultVariant || "default",
|
|
566
|
+
HTMLAttributes: {
|
|
567
|
+
class: "inkpen-table"
|
|
568
|
+
}
|
|
569
|
+
}),
|
|
570
|
+
AdvancedTableRow,
|
|
571
|
+
AdvancedTableHeader,
|
|
572
|
+
AdvancedTableCell
|
|
573
|
+
)
|
|
574
|
+
} else if (enabledExtensions.includes("table")) {
|
|
575
|
+
const tableConfig = config.table || {}
|
|
576
|
+
extensions.push(
|
|
577
|
+
Table.configure({
|
|
578
|
+
resizable: tableConfig.resizable !== false,
|
|
579
|
+
HTMLAttributes: {
|
|
580
|
+
class: "inkpen-table"
|
|
581
|
+
}
|
|
582
|
+
}),
|
|
583
|
+
TableRow,
|
|
584
|
+
TableHeader,
|
|
585
|
+
TableCell
|
|
586
|
+
)
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// TaskList extension (checkboxes)
|
|
590
|
+
if (enabledExtensions.includes("task_list")) {
|
|
591
|
+
const taskConfig = config.task_list || {}
|
|
592
|
+
extensions.push(
|
|
593
|
+
TaskList.configure({
|
|
594
|
+
HTMLAttributes: {
|
|
595
|
+
class: taskConfig.listClass || "inkpen-task-list"
|
|
596
|
+
}
|
|
597
|
+
}),
|
|
598
|
+
TaskItem.configure({
|
|
599
|
+
nested: taskConfig.nested !== false,
|
|
600
|
+
HTMLAttributes: {
|
|
601
|
+
class: taskConfig.itemClass || "inkpen-task-item"
|
|
602
|
+
}
|
|
603
|
+
})
|
|
604
|
+
)
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Mention extension
|
|
608
|
+
if (enabledExtensions.includes("mention")) {
|
|
609
|
+
const mentionConfig = config.mention || {}
|
|
610
|
+
extensions.push(
|
|
611
|
+
Mention.configure({
|
|
612
|
+
HTMLAttributes: {
|
|
613
|
+
class: mentionConfig.HTMLAttributes?.class || "inkpen-mention"
|
|
614
|
+
},
|
|
615
|
+
renderLabel({ node }) {
|
|
616
|
+
// Use label as-is if it starts with @, otherwise prepend @
|
|
617
|
+
const label = node.attrs.label || node.attrs.id || ""
|
|
618
|
+
return label.startsWith("@") ? label : `@${label}`
|
|
619
|
+
},
|
|
620
|
+
suggestion: this.buildMentionSuggestion(mentionConfig)
|
|
621
|
+
})
|
|
622
|
+
)
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
// CodeBlockLowlight (syntax highlighting)
|
|
626
|
+
if (enabledExtensions.includes("code_block_syntax")) {
|
|
627
|
+
const codeConfig = config.code_block_syntax || {}
|
|
628
|
+
const lowlight = createLowlight(common)
|
|
629
|
+
|
|
630
|
+
extensions.push(
|
|
631
|
+
CodeBlockLowlight.configure({
|
|
632
|
+
lowlight,
|
|
633
|
+
defaultLanguage: codeConfig.defaultLanguage || null,
|
|
634
|
+
HTMLAttributes: {
|
|
635
|
+
class: "inkpen-code-block"
|
|
636
|
+
}
|
|
637
|
+
})
|
|
638
|
+
)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// Typography (smart quotes, markdown shortcuts like ## for headings)
|
|
642
|
+
if (enabledExtensions.includes("typography")) {
|
|
643
|
+
extensions.push(Typography)
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// Highlight mark (text highlighting)
|
|
647
|
+
if (enabledExtensions.includes("highlight")) {
|
|
648
|
+
extensions.push(
|
|
649
|
+
Highlight.configure({
|
|
650
|
+
multicolor: true,
|
|
651
|
+
HTMLAttributes: {
|
|
652
|
+
class: "inkpen-highlight"
|
|
653
|
+
}
|
|
654
|
+
})
|
|
655
|
+
)
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Underline mark
|
|
659
|
+
if (enabledExtensions.includes("underline")) {
|
|
660
|
+
extensions.push(Underline)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
// Subscript/Superscript marks
|
|
664
|
+
if (enabledExtensions.includes("subscript")) {
|
|
665
|
+
extensions.push(Subscript)
|
|
666
|
+
}
|
|
667
|
+
if (enabledExtensions.includes("superscript")) {
|
|
668
|
+
extensions.push(Superscript)
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// YouTube video embeds
|
|
672
|
+
if (enabledExtensions.includes("youtube")) {
|
|
673
|
+
const youtubeConfig = config.youtube || {}
|
|
674
|
+
extensions.push(
|
|
675
|
+
Youtube.configure({
|
|
676
|
+
inline: false,
|
|
677
|
+
width: youtubeConfig.width || 640,
|
|
678
|
+
height: youtubeConfig.height || 360,
|
|
679
|
+
HTMLAttributes: {
|
|
680
|
+
class: "inkpen-youtube"
|
|
681
|
+
}
|
|
682
|
+
})
|
|
683
|
+
)
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Character count
|
|
687
|
+
if (enabledExtensions.includes("character_count")) {
|
|
688
|
+
const charConfig = config.character_count || {}
|
|
689
|
+
extensions.push(
|
|
690
|
+
CharacterCount.configure({
|
|
691
|
+
limit: charConfig.limit || null
|
|
692
|
+
})
|
|
693
|
+
)
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// Emoji replacer (auto-converts :emoji: shortcodes to emojis)
|
|
697
|
+
// Loaded lazily to prevent CDN failures from breaking editor
|
|
698
|
+
if (enabledExtensions.includes("emoji")) {
|
|
699
|
+
const EmojiExt = await loadEmojiReplacer()
|
|
700
|
+
if (EmojiExt) {
|
|
701
|
+
const emojiConfig = config.emoji || {}
|
|
702
|
+
extensions.push(
|
|
703
|
+
EmojiExt.configure({
|
|
704
|
+
ruleConfigs: emojiConfig.customEmojis || [],
|
|
705
|
+
shouldUseExtraLookupSpace: emojiConfig.requireSpace !== false,
|
|
706
|
+
shouldUseExtraReplacementSpace: emojiConfig.addSpaceAfter !== false
|
|
707
|
+
})
|
|
708
|
+
)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Search and Replace extension (find & replace text)
|
|
713
|
+
// Loaded lazily to prevent CDN failures from breaking editor
|
|
714
|
+
if (enabledExtensions.includes("search_replace")) {
|
|
715
|
+
const SearchExt = await loadSearchNReplace()
|
|
716
|
+
if (SearchExt) {
|
|
717
|
+
const searchConfig = config.search_replace || {}
|
|
718
|
+
extensions.push(
|
|
719
|
+
SearchExt.configure({
|
|
720
|
+
searchResultClass: searchConfig.resultClass || "inkpen-search-result",
|
|
721
|
+
caseSensitive: searchConfig.caseSensitive || false,
|
|
722
|
+
disableRegex: searchConfig.disableRegex || false
|
|
723
|
+
})
|
|
724
|
+
)
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// Footnotes extension (academic footnotes)
|
|
729
|
+
// Loaded lazily to prevent CDN failures from breaking editor
|
|
730
|
+
if (enabledExtensions.includes("footnotes")) {
|
|
731
|
+
const FootnotesExt = await loadFootnotes()
|
|
732
|
+
if (FootnotesExt) {
|
|
733
|
+
extensions.push(FootnotesExt.Footnotes, FootnotesExt.Footnote, FootnotesExt.FootnoteReference)
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// Section extension (page-builder style width/spacing control)
|
|
738
|
+
if (enabledExtensions.includes("section")) {
|
|
739
|
+
const sectionConfig = config.section || {}
|
|
740
|
+
extensions.push(
|
|
741
|
+
Section.configure({
|
|
742
|
+
defaultWidth: sectionConfig.defaultWidth || "default",
|
|
743
|
+
defaultSpacing: sectionConfig.defaultSpacing || "normal",
|
|
744
|
+
showControls: sectionConfig.showControls !== false,
|
|
745
|
+
widthPresets: sectionConfig.widthPresets || undefined,
|
|
746
|
+
spacingPresets: sectionConfig.spacingPresets || undefined
|
|
747
|
+
})
|
|
748
|
+
)
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
// Document Section extension (collapsible content-grouping with semantic title)
|
|
752
|
+
if (enabledExtensions.includes("document_section")) {
|
|
753
|
+
const docSectionConfig = config.document_section || {}
|
|
754
|
+
extensions.push(
|
|
755
|
+
DocumentSection.configure({
|
|
756
|
+
maxDepth: docSectionConfig.maxDepth || 3,
|
|
757
|
+
showControls: docSectionConfig.showControls !== false,
|
|
758
|
+
defaultCollapsed: docSectionConfig.defaultCollapsed || false,
|
|
759
|
+
allowNesting: docSectionConfig.allowNesting !== false
|
|
760
|
+
})
|
|
761
|
+
)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
// Preformatted extension (ASCII art, tables, diagrams)
|
|
765
|
+
if (enabledExtensions.includes("preformatted")) {
|
|
766
|
+
const preConfig = config.preformatted || {}
|
|
767
|
+
extensions.push(
|
|
768
|
+
Preformatted.configure({
|
|
769
|
+
showLineNumbers: preConfig.showLineNumbers || false,
|
|
770
|
+
wrapLines: preConfig.wrapLines || false,
|
|
771
|
+
tabSize: preConfig.tabSize || 4,
|
|
772
|
+
showLabel: preConfig.showLabel !== false,
|
|
773
|
+
labelText: preConfig.labelText || "Plain Text"
|
|
774
|
+
})
|
|
775
|
+
)
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
// Slash Commands extension (Notion-style "/" menu)
|
|
779
|
+
if (enabledExtensions.includes("slash_commands")) {
|
|
780
|
+
const slashConfig = config.slash_commands || {}
|
|
781
|
+
extensions.push(
|
|
782
|
+
SlashCommands.configure({
|
|
783
|
+
commands: slashConfig.commands || undefined,
|
|
784
|
+
groups: slashConfig.groups || undefined,
|
|
785
|
+
maxSuggestions: slashConfig.maxSuggestions || 10,
|
|
786
|
+
char: slashConfig.trigger || "/",
|
|
787
|
+
startOfLine: slashConfig.startOfLine !== undefined ? slashConfig.startOfLine : false,
|
|
788
|
+
allowSpaces: slashConfig.allowSpaces || false,
|
|
789
|
+
suggestionClass: slashConfig.suggestionClass || "inkpen-slash-menu"
|
|
790
|
+
})
|
|
791
|
+
)
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
// Block Gutter extension (drag handles and plus buttons)
|
|
795
|
+
if (enabledExtensions.includes("block_gutter")) {
|
|
796
|
+
const gutterConfig = config.block_gutter || {}
|
|
797
|
+
extensions.push(
|
|
798
|
+
BlockGutter.configure({
|
|
799
|
+
showDragHandle: gutterConfig.showDragHandle !== false,
|
|
800
|
+
showPlusButton: gutterConfig.showPlusButton !== false,
|
|
801
|
+
skipTypes: gutterConfig.skipTypes || undefined,
|
|
802
|
+
skipParentTypes: gutterConfig.skipParentTypes || undefined
|
|
803
|
+
})
|
|
804
|
+
)
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// Drag Handle extension (block reordering via drag & drop)
|
|
808
|
+
if (enabledExtensions.includes("drag_handle")) {
|
|
809
|
+
const dragConfig = config.drag_handle || {}
|
|
810
|
+
extensions.push(
|
|
811
|
+
DragHandle.configure({
|
|
812
|
+
scrollSpeed: dragConfig.scrollSpeed || 10,
|
|
813
|
+
scrollThreshold: dragConfig.scrollThreshold || 80
|
|
814
|
+
})
|
|
815
|
+
)
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
// Toggle Block extension (collapsible content)
|
|
819
|
+
if (enabledExtensions.includes("toggle_block")) {
|
|
820
|
+
const toggleConfig = config.toggle_block || {}
|
|
821
|
+
extensions.push(
|
|
822
|
+
ToggleSummary,
|
|
823
|
+
ToggleBlock.configure({
|
|
824
|
+
defaultOpen: toggleConfig.defaultOpen !== false
|
|
825
|
+
})
|
|
826
|
+
)
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Columns extension (multi-column layouts)
|
|
830
|
+
if (enabledExtensions.includes("columns")) {
|
|
831
|
+
const columnsConfig = config.columns || {}
|
|
832
|
+
extensions.push(
|
|
833
|
+
Column,
|
|
834
|
+
Columns.configure({
|
|
835
|
+
defaultCount: columnsConfig.defaultCount || 2,
|
|
836
|
+
defaultLayout: columnsConfig.defaultLayout || "equal-2",
|
|
837
|
+
showControls: columnsConfig.showControls !== false
|
|
838
|
+
})
|
|
839
|
+
)
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Callout extension (highlighted blocks)
|
|
843
|
+
if (enabledExtensions.includes("callout")) {
|
|
844
|
+
const calloutConfig = config.callout || {}
|
|
845
|
+
extensions.push(
|
|
846
|
+
Callout.configure({
|
|
847
|
+
defaultType: calloutConfig.defaultType || "info",
|
|
848
|
+
showControls: calloutConfig.showControls !== false
|
|
849
|
+
})
|
|
850
|
+
)
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// Block Commands extension (duplicate, delete, select blocks)
|
|
854
|
+
if (enabledExtensions.includes("block_commands")) {
|
|
855
|
+
const blockConfig = config.block_commands || {}
|
|
856
|
+
extensions.push(
|
|
857
|
+
BlockCommands.configure({
|
|
858
|
+
selectedClass: blockConfig.selectedClass || "is-selected",
|
|
859
|
+
enableGutterSelection: blockConfig.enableGutterSelection !== false
|
|
860
|
+
})
|
|
861
|
+
)
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
// Enhanced Image extension (resizable, alignable images with captions)
|
|
865
|
+
if (enabledExtensions.includes("enhanced_image")) {
|
|
866
|
+
const imageConfig = config.enhanced_image || {}
|
|
867
|
+
extensions.push(
|
|
868
|
+
EnhancedImage.configure({
|
|
869
|
+
resizable: imageConfig.resizable !== false,
|
|
870
|
+
lightbox: imageConfig.lightbox !== false,
|
|
871
|
+
defaultAlignment: imageConfig.defaultAlignment || "center",
|
|
872
|
+
minWidth: imageConfig.minWidth || 100,
|
|
873
|
+
maxWidth: imageConfig.maxWidth || null,
|
|
874
|
+
lockAspectRatio: imageConfig.lockAspectRatio !== false,
|
|
875
|
+
showAlignmentControls: imageConfig.showAlignmentControls !== false,
|
|
876
|
+
allowCaption: imageConfig.allowCaption !== false
|
|
877
|
+
})
|
|
878
|
+
)
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
// File Attachment extension (file uploads with icons and download)
|
|
882
|
+
if (enabledExtensions.includes("file_attachment")) {
|
|
883
|
+
const fileConfig = config.file_attachment || {}
|
|
884
|
+
extensions.push(
|
|
885
|
+
FileAttachment.configure({
|
|
886
|
+
uploadUrl: fileConfig.uploadUrl || null,
|
|
887
|
+
uploadHeaders: fileConfig.uploadHeaders || {},
|
|
888
|
+
uploadFieldName: fileConfig.uploadFieldName || "file",
|
|
889
|
+
allowedTypes: fileConfig.allowedTypes || null,
|
|
890
|
+
maxSize: fileConfig.maxSize || 10 * 1024 * 1024,
|
|
891
|
+
uploadHandler: fileConfig.uploadHandler || null
|
|
892
|
+
})
|
|
893
|
+
)
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
// Embed extension (social media and website embeds)
|
|
897
|
+
if (enabledExtensions.includes("embed")) {
|
|
898
|
+
const embedConfig = config.embed || {}
|
|
899
|
+
extensions.push(
|
|
900
|
+
Embed.configure({
|
|
901
|
+
allowedProviders: embedConfig.allowedProviders || null,
|
|
902
|
+
privacyMode: embedConfig.privacyMode !== false,
|
|
903
|
+
enableLinkCards: embedConfig.enableLinkCards !== false,
|
|
904
|
+
linkCardFetcher: embedConfig.linkCardFetcher || null
|
|
905
|
+
})
|
|
906
|
+
)
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
// Table of Contents extension (auto-generated navigation)
|
|
910
|
+
if (enabledExtensions.includes("table_of_contents")) {
|
|
911
|
+
const tocConfig = config.table_of_contents || {}
|
|
912
|
+
extensions.push(
|
|
913
|
+
TableOfContents.configure({
|
|
914
|
+
defaultMaxDepth: tocConfig.maxDepth || 3,
|
|
915
|
+
defaultStyle: tocConfig.style || "numbered",
|
|
916
|
+
defaultTitle: tocConfig.title || "Table of Contents",
|
|
917
|
+
scrollOffset: tocConfig.scrollOffset || 100
|
|
918
|
+
})
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// Database extension (Notion-style inline databases)
|
|
923
|
+
if (enabledExtensions.includes("database")) {
|
|
924
|
+
const dbConfig = config.database || {}
|
|
925
|
+
extensions.push(
|
|
926
|
+
Database.configure({
|
|
927
|
+
defaultTitle: dbConfig.title || "Untitled Database",
|
|
928
|
+
defaultView: dbConfig.view || "table"
|
|
929
|
+
})
|
|
930
|
+
)
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
// Export Commands extension (keyboard shortcuts for export)
|
|
934
|
+
// Loaded lazily to avoid 404s for apps that don't use export
|
|
935
|
+
if (enabledExtensions.includes("export_commands")) {
|
|
936
|
+
const ExportCommandsExt = await loadExportCommands()
|
|
937
|
+
if (ExportCommandsExt) {
|
|
938
|
+
const exportConfig = config.export_commands || {}
|
|
939
|
+
extensions.push(
|
|
940
|
+
ExportCommandsExt.configure({
|
|
941
|
+
defaultFilename: exportConfig.defaultFilename || "document",
|
|
942
|
+
markdownOptions: exportConfig.markdownOptions || {},
|
|
943
|
+
htmlOptions: exportConfig.htmlOptions || {},
|
|
944
|
+
pdfOptions: exportConfig.pdfOptions || {},
|
|
945
|
+
onExportSuccess: exportConfig.onExportSuccess || null,
|
|
946
|
+
onExportError: exportConfig.onExportError || null
|
|
947
|
+
})
|
|
948
|
+
)
|
|
949
|
+
// Pre-load export modules for methods
|
|
950
|
+
await loadExportModules()
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// Content Embed extension (rich embed cards for app content)
|
|
955
|
+
if (enabledExtensions.includes("content_embed")) {
|
|
956
|
+
extensions.push(ContentEmbed)
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
return extensions
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* Build mention suggestion configuration
|
|
964
|
+
* Handles both static items and async search
|
|
965
|
+
*/
|
|
966
|
+
buildMentionSuggestion(config) {
|
|
967
|
+
const items = config.items || []
|
|
968
|
+
const searchUrl = config.searchUrl
|
|
969
|
+
const minChars = config.minChars || 1
|
|
970
|
+
let debounceTimer = null
|
|
971
|
+
let abortController = null
|
|
972
|
+
const cache = new Map() // query → results cache
|
|
973
|
+
|
|
974
|
+
return {
|
|
975
|
+
char: config.trigger || "@",
|
|
976
|
+
items: ({ query }) => {
|
|
977
|
+
if (query.length < minChars) return []
|
|
978
|
+
|
|
979
|
+
// If search URL provided, fetch with cache + short debounce
|
|
980
|
+
if (searchUrl) {
|
|
981
|
+
// Return cached results instantly if available
|
|
982
|
+
const cached = cache.get(query)
|
|
983
|
+
if (cached) return cached
|
|
984
|
+
|
|
985
|
+
return new Promise((resolve) => {
|
|
986
|
+
clearTimeout(debounceTimer)
|
|
987
|
+
if (abortController) abortController.abort()
|
|
988
|
+
|
|
989
|
+
debounceTimer = setTimeout(async () => {
|
|
990
|
+
abortController = new AbortController()
|
|
991
|
+
try {
|
|
992
|
+
const response = await fetch(
|
|
993
|
+
`${searchUrl}?query=${encodeURIComponent(query)}`,
|
|
994
|
+
{ signal: abortController.signal }
|
|
995
|
+
)
|
|
996
|
+
if (response.ok) {
|
|
997
|
+
const results = await response.json()
|
|
998
|
+
cache.set(query, results)
|
|
999
|
+
// Keep cache small (last 20 queries)
|
|
1000
|
+
if (cache.size > 20) cache.delete(cache.keys().next().value)
|
|
1001
|
+
resolve(results)
|
|
1002
|
+
} else {
|
|
1003
|
+
resolve([])
|
|
1004
|
+
}
|
|
1005
|
+
} catch (error) {
|
|
1006
|
+
if (error.name !== "AbortError") {
|
|
1007
|
+
console.error("Mention search failed:", error)
|
|
1008
|
+
}
|
|
1009
|
+
resolve([])
|
|
1010
|
+
}
|
|
1011
|
+
}, 120)
|
|
1012
|
+
})
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
// Otherwise filter static items
|
|
1016
|
+
return items
|
|
1017
|
+
.filter(item =>
|
|
1018
|
+
item.label.toLowerCase().includes(query.toLowerCase())
|
|
1019
|
+
)
|
|
1020
|
+
.slice(0, 5)
|
|
1021
|
+
},
|
|
1022
|
+
render: () => {
|
|
1023
|
+
let component
|
|
1024
|
+
let popup
|
|
1025
|
+
|
|
1026
|
+
return {
|
|
1027
|
+
onStart: props => {
|
|
1028
|
+
component = this.createMentionPopup(props, config)
|
|
1029
|
+
popup = component.element
|
|
1030
|
+
document.body.appendChild(popup)
|
|
1031
|
+
this.updateMentionPopupPosition(popup, props.clientRect)
|
|
1032
|
+
},
|
|
1033
|
+
onUpdate: props => {
|
|
1034
|
+
this.updateMentionItems(component, props.items, props.command)
|
|
1035
|
+
this.updateMentionPopupPosition(popup, props.clientRect)
|
|
1036
|
+
},
|
|
1037
|
+
onKeyDown: props => {
|
|
1038
|
+
if (props.event.key === "Escape") {
|
|
1039
|
+
popup?.remove()
|
|
1040
|
+
return true
|
|
1041
|
+
}
|
|
1042
|
+
return component?.onKeyDown?.(props.event) || false
|
|
1043
|
+
},
|
|
1044
|
+
onExit: () => {
|
|
1045
|
+
popup?.remove()
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
createMentionPopup(props, config) {
|
|
1053
|
+
const popup = document.createElement("div")
|
|
1054
|
+
popup.className = config.suggestionClass || "inkpen-mention-suggestions"
|
|
1055
|
+
popup.setAttribute("role", "listbox")
|
|
1056
|
+
|
|
1057
|
+
const component = {
|
|
1058
|
+
element: popup,
|
|
1059
|
+
selectedIndex: 0,
|
|
1060
|
+
items: props.items,
|
|
1061
|
+
command: props.command,
|
|
1062
|
+
onKeyDown: (event) => {
|
|
1063
|
+
if (event.key === "ArrowUp") {
|
|
1064
|
+
component.selectedIndex = Math.max(0, component.selectedIndex - 1)
|
|
1065
|
+
this.updateMentionSelection(popup, component.selectedIndex)
|
|
1066
|
+
return true
|
|
1067
|
+
}
|
|
1068
|
+
if (event.key === "ArrowDown") {
|
|
1069
|
+
component.selectedIndex = Math.min(component.items.length - 1, component.selectedIndex + 1)
|
|
1070
|
+
this.updateMentionSelection(popup, component.selectedIndex)
|
|
1071
|
+
return true
|
|
1072
|
+
}
|
|
1073
|
+
if (event.key === "Enter") {
|
|
1074
|
+
const item = component.items[component.selectedIndex]
|
|
1075
|
+
if (item) {
|
|
1076
|
+
component.command(item)
|
|
1077
|
+
}
|
|
1078
|
+
return true
|
|
1079
|
+
}
|
|
1080
|
+
return false
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
this.updateMentionItems(component, props.items, props.command)
|
|
1085
|
+
return component
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
updateMentionItems(component, items, command) {
|
|
1089
|
+
component.items = items
|
|
1090
|
+
component.command = command
|
|
1091
|
+
component.selectedIndex = 0
|
|
1092
|
+
|
|
1093
|
+
const popup = component.element
|
|
1094
|
+
popup.innerHTML = ""
|
|
1095
|
+
|
|
1096
|
+
if (items.length === 0) {
|
|
1097
|
+
popup.innerHTML = '<div class="inkpen-mention-empty">No results</div>'
|
|
1098
|
+
return
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
items.forEach((item, index) => {
|
|
1102
|
+
const button = document.createElement("button")
|
|
1103
|
+
button.className = `inkpen-mention-item ${index === 0 ? "is-selected" : ""}`
|
|
1104
|
+
button.setAttribute("role", "option")
|
|
1105
|
+
|
|
1106
|
+
// Rich rendering: avatar + handle + name (backwards compatible)
|
|
1107
|
+
if (item.avatar_url || item.handle || item.name) {
|
|
1108
|
+
// Avatar or placeholder
|
|
1109
|
+
if (item.avatar_url) {
|
|
1110
|
+
const avatar = document.createElement("img")
|
|
1111
|
+
avatar.className = "inkpen-mention-item__avatar"
|
|
1112
|
+
avatar.src = item.avatar_url
|
|
1113
|
+
avatar.alt = item.label || item.handle || ""
|
|
1114
|
+
avatar.loading = "lazy"
|
|
1115
|
+
button.appendChild(avatar)
|
|
1116
|
+
} else {
|
|
1117
|
+
const placeholder = document.createElement("span")
|
|
1118
|
+
placeholder.className = "inkpen-mention-item__avatar-placeholder"
|
|
1119
|
+
placeholder.textContent = (item.handle || item.label || "?").charAt(0)
|
|
1120
|
+
button.appendChild(placeholder)
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// Info column: handle + name
|
|
1124
|
+
const info = document.createElement("span")
|
|
1125
|
+
info.className = "inkpen-mention-item__info"
|
|
1126
|
+
|
|
1127
|
+
if (item.handle) {
|
|
1128
|
+
const handle = document.createElement("span")
|
|
1129
|
+
handle.className = "inkpen-mention-item__handle"
|
|
1130
|
+
handle.textContent = `@${item.handle}`
|
|
1131
|
+
info.appendChild(handle)
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
if (item.name) {
|
|
1135
|
+
const name = document.createElement("span")
|
|
1136
|
+
name.className = "inkpen-mention-item__name"
|
|
1137
|
+
name.textContent = item.name
|
|
1138
|
+
info.appendChild(name)
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// Fallback: if only handle, show label as name
|
|
1142
|
+
if (item.handle && !item.name && item.label && item.label !== item.handle) {
|
|
1143
|
+
const name = document.createElement("span")
|
|
1144
|
+
name.className = "inkpen-mention-item__name"
|
|
1145
|
+
name.textContent = item.label
|
|
1146
|
+
info.appendChild(name)
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
button.appendChild(info)
|
|
1150
|
+
} else {
|
|
1151
|
+
// Plain text fallback for backwards compatibility
|
|
1152
|
+
button.textContent = item.label
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
button.addEventListener("click", () => command(item))
|
|
1156
|
+
popup.appendChild(button)
|
|
1157
|
+
})
|
|
1158
|
+
}
|
|
1159
|
+
|
|
1160
|
+
updateMentionSelection(popup, selectedIndex) {
|
|
1161
|
+
const items = popup.querySelectorAll(".inkpen-mention-item")
|
|
1162
|
+
items.forEach((item, index) => {
|
|
1163
|
+
item.classList.toggle("is-selected", index === selectedIndex)
|
|
1164
|
+
})
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
updateMentionPopupPosition(popup, clientRect) {
|
|
1168
|
+
if (!clientRect) return
|
|
1169
|
+
|
|
1170
|
+
const rect = clientRect()
|
|
1171
|
+
if (!rect) return
|
|
1172
|
+
|
|
1173
|
+
popup.style.position = "fixed"
|
|
1174
|
+
popup.style.left = `${rect.left}px`
|
|
1175
|
+
popup.style.top = `${rect.bottom + 8}px`
|
|
1176
|
+
popup.style.zIndex = "9999"
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
syncContent(editor) {
|
|
1180
|
+
// If forced_document is enabled, only store body content (not title/subtitle)
|
|
1181
|
+
// Title and subtitle are stored in separate hidden fields
|
|
1182
|
+
if (this.forcedDocConfig) {
|
|
1183
|
+
const body = this.getBody()
|
|
1184
|
+
this.inputTarget.value = body
|
|
1185
|
+
} else {
|
|
1186
|
+
const html = editor.getHTML()
|
|
1187
|
+
this.inputTarget.value = html
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
handleSelectionChange(editor) {
|
|
1192
|
+
// Dispatch event for toolbar to handle
|
|
1193
|
+
this.dispatchEvent("selection-change", {
|
|
1194
|
+
editor,
|
|
1195
|
+
isEmpty: editor.state.selection.empty,
|
|
1196
|
+
from: editor.state.selection.from,
|
|
1197
|
+
to: editor.state.selection.to
|
|
1198
|
+
})
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
toggleHints() {
|
|
1202
|
+
if (!this.hasHintsPanelTarget) return
|
|
1203
|
+
const panel = this.hintsPanelTarget
|
|
1204
|
+
panel.hidden = !panel.hidden
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
setupAutosave() {
|
|
1208
|
+
this.autosaveTimer = setInterval(() => {
|
|
1209
|
+
this.dispatchEvent("autosave", {
|
|
1210
|
+
content: this.inputTarget.value
|
|
1211
|
+
})
|
|
1212
|
+
}, this.autosaveIntervalValue)
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// Public API methods
|
|
1216
|
+
getContent() {
|
|
1217
|
+
return this.editor?.getHTML() || ""
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
getJSON() {
|
|
1221
|
+
return this.editor?.getJSON() || null
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
setContent(content) {
|
|
1225
|
+
this.editor?.commands.setContent(content)
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
focus() {
|
|
1229
|
+
this.editor?.commands.focus()
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
blur() {
|
|
1233
|
+
this.editor?.commands.blur()
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// Formatting commands
|
|
1237
|
+
toggleBold() {
|
|
1238
|
+
this.editor?.chain().focus().toggleBold().run()
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
toggleItalic() {
|
|
1242
|
+
this.editor?.chain().focus().toggleItalic().run()
|
|
1243
|
+
}
|
|
1244
|
+
|
|
1245
|
+
toggleStrike() {
|
|
1246
|
+
this.editor?.chain().focus().toggleStrike().run()
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
toggleHeading(level) {
|
|
1250
|
+
this.editor?.chain().focus().toggleHeading({ level }).run()
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
toggleBulletList() {
|
|
1254
|
+
this.editor?.chain().focus().toggleBulletList().run()
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
toggleOrderedList() {
|
|
1258
|
+
this.editor?.chain().focus().toggleOrderedList().run()
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
toggleBlockquote() {
|
|
1262
|
+
this.editor?.chain().focus().toggleBlockquote().run()
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
toggleCodeBlock() {
|
|
1266
|
+
this.editor?.chain().focus().toggleCodeBlock().run()
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
toggleCode() {
|
|
1270
|
+
this.editor?.chain().focus().toggleCode().run()
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
setLink(url) {
|
|
1274
|
+
if (url) {
|
|
1275
|
+
this.editor?.chain().focus().setLink({ href: url }).run()
|
|
1276
|
+
} else {
|
|
1277
|
+
this.editor?.chain().focus().unsetLink().run()
|
|
1278
|
+
}
|
|
1279
|
+
}
|
|
1280
|
+
|
|
1281
|
+
insertHorizontalRule() {
|
|
1282
|
+
this.editor?.chain().focus().setHorizontalRule().run()
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// Table commands
|
|
1286
|
+
insertTable(rows = 3, cols = 3, withHeaderRow = true) {
|
|
1287
|
+
this.editor?.chain().focus().insertTable({ rows, cols, withHeaderRow }).run()
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
addTableRowBefore() {
|
|
1291
|
+
this.editor?.chain().focus().addRowBefore().run()
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
addTableRowAfter() {
|
|
1295
|
+
this.editor?.chain().focus().addRowAfter().run()
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
deleteTableRow() {
|
|
1299
|
+
this.editor?.chain().focus().deleteRow().run()
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
addTableColumnBefore() {
|
|
1303
|
+
this.editor?.chain().focus().addColumnBefore().run()
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
addTableColumnAfter() {
|
|
1307
|
+
this.editor?.chain().focus().addColumnAfter().run()
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
deleteTableColumn() {
|
|
1311
|
+
this.editor?.chain().focus().deleteColumn().run()
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
deleteTable() {
|
|
1315
|
+
this.editor?.chain().focus().deleteTable().run()
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
mergeCells() {
|
|
1319
|
+
this.editor?.chain().focus().mergeCells().run()
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
splitCell() {
|
|
1323
|
+
this.editor?.chain().focus().splitCell().run()
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// TaskList commands
|
|
1327
|
+
toggleTaskList() {
|
|
1328
|
+
this.editor?.chain().focus().toggleTaskList().run()
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
// Highlight commands
|
|
1332
|
+
toggleHighlight(color = null) {
|
|
1333
|
+
if (color) {
|
|
1334
|
+
this.editor?.chain().focus().toggleHighlight({ color }).run()
|
|
1335
|
+
} else {
|
|
1336
|
+
this.editor?.chain().focus().toggleHighlight().run()
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
// Underline command
|
|
1341
|
+
toggleUnderline() {
|
|
1342
|
+
this.editor?.chain().focus().toggleUnderline().run()
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
// Subscript/Superscript commands
|
|
1346
|
+
toggleSubscript() {
|
|
1347
|
+
this.editor?.chain().focus().toggleSubscript().run()
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1350
|
+
toggleSuperscript() {
|
|
1351
|
+
this.editor?.chain().focus().toggleSuperscript().run()
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
// YouTube embed command
|
|
1355
|
+
insertYoutubeVideo(url) {
|
|
1356
|
+
if (url) {
|
|
1357
|
+
this.editor?.chain().focus().setYoutubeVideo({ src: url }).run()
|
|
1358
|
+
}
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Section commands
|
|
1362
|
+
insertSection(width = "default", spacing = "normal") {
|
|
1363
|
+
this.editor?.chain().focus().insertSection({ width, spacing }).run()
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
setSectionWidth(width) {
|
|
1367
|
+
this.editor?.chain().focus().setSectionWidth(width).run()
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
setSectionSpacing(spacing) {
|
|
1371
|
+
this.editor?.chain().focus().setSectionSpacing(spacing).run()
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
wrapInSection(width = "default", spacing = "normal") {
|
|
1375
|
+
this.editor?.chain().focus().wrapInSection({ width, spacing }).run()
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
// Preformatted commands
|
|
1379
|
+
insertPreformatted(content = "") {
|
|
1380
|
+
this.editor?.chain().focus().insertPreformatted(content).run()
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
togglePreformatted() {
|
|
1384
|
+
this.editor?.chain().focus().togglePreformatted().run()
|
|
1385
|
+
}
|
|
1386
|
+
|
|
1387
|
+
// Callout commands
|
|
1388
|
+
insertCallout(type = "info") {
|
|
1389
|
+
this.editor?.chain().focus().insertCallout({ type }).run()
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
setCalloutType(type) {
|
|
1393
|
+
this.editor?.chain().focus().setCalloutType(type).run()
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
// Toggle block commands
|
|
1397
|
+
insertToggle() {
|
|
1398
|
+
this.editor?.chain().focus().insertToggle().run()
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
// Columns commands
|
|
1402
|
+
insertColumns(count = 2) {
|
|
1403
|
+
this.editor?.chain().focus().setColumns(count).run()
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
// Table of Contents commands
|
|
1407
|
+
insertTableOfContents() {
|
|
1408
|
+
this.editor?.chain().focus().insertTableOfContents().run()
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// Character count methods
|
|
1412
|
+
getCharacterCount() {
|
|
1413
|
+
return this.editor?.storage.characterCount?.characters() || 0
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
getWordCount() {
|
|
1417
|
+
return this.editor?.storage.characterCount?.words() || 0
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// Check if format is active
|
|
1421
|
+
isActive(name, attributes = {}) {
|
|
1422
|
+
return this.editor?.isActive(name, attributes) || false
|
|
1423
|
+
}
|
|
1424
|
+
|
|
1425
|
+
// Export Methods (require export_commands extension to be enabled)
|
|
1426
|
+
|
|
1427
|
+
/**
|
|
1428
|
+
* Export document to Markdown
|
|
1429
|
+
* @param {Object} options - Export options
|
|
1430
|
+
* @returns {Promise<string>} Markdown content
|
|
1431
|
+
*/
|
|
1432
|
+
async exportMarkdown(options = {}) {
|
|
1433
|
+
if (!this.editor) return ""
|
|
1434
|
+
const modules = await loadExportModules()
|
|
1435
|
+
if (!modules) {
|
|
1436
|
+
console.warn("Export modules not available. Enable export_commands extension.")
|
|
1437
|
+
return ""
|
|
1438
|
+
}
|
|
1439
|
+
return modules.exportToMarkdown(this.editor.getJSON(), options)
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
/**
|
|
1443
|
+
* Download document as Markdown file
|
|
1444
|
+
* @param {string} filename - Filename for download
|
|
1445
|
+
* @param {Object} options - Export options
|
|
1446
|
+
*/
|
|
1447
|
+
async downloadAsMarkdown(filename = "document.md", options = {}) {
|
|
1448
|
+
const modules = await loadExportModules()
|
|
1449
|
+
if (!modules) return
|
|
1450
|
+
const markdown = await this.exportMarkdown(options)
|
|
1451
|
+
modules.downloadMarkdown(markdown, filename)
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
/**
|
|
1455
|
+
* Copy document as Markdown to clipboard
|
|
1456
|
+
* @param {Object} options - Export options
|
|
1457
|
+
* @returns {Promise<boolean>} Success status
|
|
1458
|
+
*/
|
|
1459
|
+
async copyAsMarkdown(options = {}) {
|
|
1460
|
+
const modules = await loadExportModules()
|
|
1461
|
+
if (!modules) return false
|
|
1462
|
+
const markdown = await this.exportMarkdown(options)
|
|
1463
|
+
return modules.copyMarkdownToClipboard(markdown)
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
/**
|
|
1467
|
+
* Import Markdown content into editor
|
|
1468
|
+
* @param {string} markdown - Markdown content
|
|
1469
|
+
* @param {Object} options - Import options
|
|
1470
|
+
* @returns {Promise<Object>} { frontmatter } - Parsed frontmatter if present
|
|
1471
|
+
*/
|
|
1472
|
+
async importMarkdown(markdown, options = {}) {
|
|
1473
|
+
if (!this.editor) return {}
|
|
1474
|
+
const modules = await loadExportModules()
|
|
1475
|
+
if (!modules) return {}
|
|
1476
|
+
const result = modules.importFromMarkdown(markdown, this.editor.schema, options)
|
|
1477
|
+
if (result.html) {
|
|
1478
|
+
this.editor.commands.setContent(result.html)
|
|
1479
|
+
}
|
|
1480
|
+
return { frontmatter: result.frontmatter }
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
/**
|
|
1484
|
+
* Export document to HTML
|
|
1485
|
+
* @param {Object} options - Export options
|
|
1486
|
+
* @returns {Promise<string>} HTML content
|
|
1487
|
+
*/
|
|
1488
|
+
async exportHTML(options = {}) {
|
|
1489
|
+
if (!this.editor) return ""
|
|
1490
|
+
const modules = await loadExportModules()
|
|
1491
|
+
if (!modules) {
|
|
1492
|
+
console.warn("Export modules not available. Enable export_commands extension.")
|
|
1493
|
+
return ""
|
|
1494
|
+
}
|
|
1495
|
+
return modules.exportToHTML(this.editor, options)
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Download document as HTML file
|
|
1500
|
+
* @param {string} filename - Filename for download
|
|
1501
|
+
* @param {Object} options - Export options
|
|
1502
|
+
*/
|
|
1503
|
+
async downloadAsHTML(filename = "document.html", options = {}) {
|
|
1504
|
+
const modules = await loadExportModules()
|
|
1505
|
+
if (!modules) return
|
|
1506
|
+
const html = await this.exportHTML(options)
|
|
1507
|
+
modules.downloadHTML(html, filename)
|
|
1508
|
+
}
|
|
1509
|
+
|
|
1510
|
+
/**
|
|
1511
|
+
* Copy document as HTML to clipboard
|
|
1512
|
+
* @param {Object} options - Export options
|
|
1513
|
+
* @returns {Promise<boolean>} Success status
|
|
1514
|
+
*/
|
|
1515
|
+
async copyAsHTML(options = {}) {
|
|
1516
|
+
const modules = await loadExportModules()
|
|
1517
|
+
if (!modules) return false
|
|
1518
|
+
const html = await this.exportHTML({ ...options, includeWrapper: false, includeStyles: false })
|
|
1519
|
+
return modules.copyHTMLToClipboard(html)
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/**
|
|
1523
|
+
* Export document to PDF
|
|
1524
|
+
* @param {Object} options - Export options
|
|
1525
|
+
* @returns {Promise<void>}
|
|
1526
|
+
*/
|
|
1527
|
+
async exportPDF(options = {}) {
|
|
1528
|
+
if (!this.editor) return
|
|
1529
|
+
const modules = await loadExportModules()
|
|
1530
|
+
if (!modules) return
|
|
1531
|
+
await modules.exportToPDF(this.editor, options)
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/**
|
|
1535
|
+
* Download document as PDF file
|
|
1536
|
+
* @param {string} filename - Filename for download
|
|
1537
|
+
* @param {Object} options - Export options
|
|
1538
|
+
* @returns {Promise<void>}
|
|
1539
|
+
*/
|
|
1540
|
+
async downloadAsPDF(filename = "document.pdf", options = {}) {
|
|
1541
|
+
await this.exportPDF({ ...options, filename })
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
/**
|
|
1545
|
+
* Load html2pdf.js library for better PDF support
|
|
1546
|
+
* @returns {Promise<boolean>} Whether library was loaded
|
|
1547
|
+
*/
|
|
1548
|
+
async loadPDFLibrary() {
|
|
1549
|
+
const modules = await loadExportModules()
|
|
1550
|
+
if (!modules) return false
|
|
1551
|
+
return modules.loadHtml2Pdf()
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1554
|
+
/**
|
|
1555
|
+
* Check if PDF export with full features is available
|
|
1556
|
+
* @returns {Promise<boolean>}
|
|
1557
|
+
*/
|
|
1558
|
+
async isPDFExportAvailable() {
|
|
1559
|
+
const modules = await loadExportModules()
|
|
1560
|
+
if (!modules) return false
|
|
1561
|
+
return modules.isPDFExportAvailable()
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
// Forced Document helpers
|
|
1565
|
+
|
|
1566
|
+
/**
|
|
1567
|
+
* Get the title text from the first heading.
|
|
1568
|
+
* Only works when forced_document extension is enabled.
|
|
1569
|
+
* @returns {string} The title text
|
|
1570
|
+
*/
|
|
1571
|
+
getTitle() {
|
|
1572
|
+
if (!this.editor || !this.forcedDocConfig) return ""
|
|
1573
|
+
|
|
1574
|
+
const doc = this.editor.state.doc
|
|
1575
|
+
const firstChild = doc.firstChild
|
|
1576
|
+
|
|
1577
|
+
if (firstChild && firstChild.type.name === "heading") {
|
|
1578
|
+
return firstChild.textContent || ""
|
|
1579
|
+
}
|
|
1580
|
+
return ""
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Get the subtitle text from the second heading.
|
|
1585
|
+
* Only works when forced_document with subtitle is enabled.
|
|
1586
|
+
* @returns {string} The subtitle text
|
|
1587
|
+
*/
|
|
1588
|
+
getSubtitle() {
|
|
1589
|
+
if (!this.editor || !this.forcedDocConfig || !this.forcedDocConfig.subtitle) return ""
|
|
1590
|
+
|
|
1591
|
+
const doc = this.editor.state.doc
|
|
1592
|
+
const subtitleLevel = this.forcedDocConfig.subtitleLevel || 2
|
|
1593
|
+
|
|
1594
|
+
// Look for second heading with matching level
|
|
1595
|
+
let found = false
|
|
1596
|
+
let subtitle = ""
|
|
1597
|
+
|
|
1598
|
+
doc.forEach((node, offset, index) => {
|
|
1599
|
+
if (found) return
|
|
1600
|
+
if (index === 1 && node.type.name === "heading" && node.attrs.level === subtitleLevel) {
|
|
1601
|
+
subtitle = node.textContent || ""
|
|
1602
|
+
found = true
|
|
1603
|
+
}
|
|
1604
|
+
})
|
|
1605
|
+
|
|
1606
|
+
return subtitle
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
/**
|
|
1610
|
+
* Set the title text in the first heading.
|
|
1611
|
+
* @param {string} title The title text to set
|
|
1612
|
+
*/
|
|
1613
|
+
setTitle(title) {
|
|
1614
|
+
if (!this.editor) return
|
|
1615
|
+
|
|
1616
|
+
const { state, view } = this.editor
|
|
1617
|
+
const firstChild = state.doc.firstChild
|
|
1618
|
+
|
|
1619
|
+
if (firstChild && firstChild.type.name === "heading") {
|
|
1620
|
+
const tr = state.tr.replaceWith(
|
|
1621
|
+
1, // Start of first heading content
|
|
1622
|
+
1 + firstChild.content.size, // End of first heading content
|
|
1623
|
+
state.schema.text(title)
|
|
1624
|
+
)
|
|
1625
|
+
view.dispatch(tr)
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
/**
|
|
1630
|
+
* Set the subtitle text in the second heading.
|
|
1631
|
+
* @param {string} subtitle The subtitle text to set
|
|
1632
|
+
*/
|
|
1633
|
+
setSubtitle(subtitle) {
|
|
1634
|
+
if (!this.editor || !this.forcedDocConfig || !this.forcedDocConfig.subtitle) return
|
|
1635
|
+
|
|
1636
|
+
const { state, view } = this.editor
|
|
1637
|
+
const doc = state.doc
|
|
1638
|
+
const subtitleLevel = this.forcedDocConfig.subtitleLevel || 2
|
|
1639
|
+
|
|
1640
|
+
// Find the second heading position
|
|
1641
|
+
let subtitlePos = null
|
|
1642
|
+
let subtitleNode = null
|
|
1643
|
+
|
|
1644
|
+
doc.forEach((node, pos, index) => {
|
|
1645
|
+
if (subtitlePos !== null) return
|
|
1646
|
+
if (index === 1 && node.type.name === "heading" && node.attrs.level === subtitleLevel) {
|
|
1647
|
+
subtitlePos = pos
|
|
1648
|
+
subtitleNode = node
|
|
1649
|
+
}
|
|
1650
|
+
})
|
|
1651
|
+
|
|
1652
|
+
if (subtitlePos !== null && subtitleNode) {
|
|
1653
|
+
const tr = state.tr.replaceWith(
|
|
1654
|
+
subtitlePos + 1, // Start of subtitle content
|
|
1655
|
+
subtitlePos + 1 + subtitleNode.content.size, // End of subtitle content
|
|
1656
|
+
state.schema.text(subtitle)
|
|
1657
|
+
)
|
|
1658
|
+
view.dispatch(tr)
|
|
1659
|
+
}
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1662
|
+
/**
|
|
1663
|
+
* Get body content (everything after title/subtitle).
|
|
1664
|
+
* @returns {string} HTML content of the body
|
|
1665
|
+
*/
|
|
1666
|
+
getBody() {
|
|
1667
|
+
if (!this.editor) return ""
|
|
1668
|
+
|
|
1669
|
+
const doc = this.editor.state.doc
|
|
1670
|
+
const hasSubtitle = this.forcedDocConfig?.subtitle
|
|
1671
|
+
|
|
1672
|
+
// Skip first node (title) and optionally second (subtitle)
|
|
1673
|
+
const startIndex = hasSubtitle ? 2 : 1
|
|
1674
|
+
const bodyNodes = []
|
|
1675
|
+
|
|
1676
|
+
doc.forEach((node, offset, index) => {
|
|
1677
|
+
if (index >= startIndex) {
|
|
1678
|
+
bodyNodes.push(node)
|
|
1679
|
+
}
|
|
1680
|
+
})
|
|
1681
|
+
|
|
1682
|
+
if (bodyNodes.length === 0) return ""
|
|
1683
|
+
|
|
1684
|
+
// Create a temporary document fragment and serialize
|
|
1685
|
+
const fragment = this.editor.state.schema.nodes.doc.create(null, bodyNodes)
|
|
1686
|
+
const serializer = this.modules.DOMSerializer.fromSchema(this.editor.state.schema)
|
|
1687
|
+
const dom = serializer.serializeFragment(fragment.content)
|
|
1688
|
+
const div = document.createElement("div")
|
|
1689
|
+
div.appendChild(dom)
|
|
1690
|
+
|
|
1691
|
+
return div.innerHTML
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
// Helper to dispatch custom events
|
|
1695
|
+
dispatchEvent(name, detail = {}) {
|
|
1696
|
+
this.element.dispatchEvent(
|
|
1697
|
+
new CustomEvent(`inkpen:${name}`, {
|
|
1698
|
+
bubbles: true,
|
|
1699
|
+
detail: { ...detail, controller: this }
|
|
1700
|
+
})
|
|
1701
|
+
)
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
dispatchChangeEvent() {
|
|
1705
|
+
this.dispatchEvent("change", {
|
|
1706
|
+
content: this.inputTarget.value,
|
|
1707
|
+
title: this.getTitle(),
|
|
1708
|
+
subtitle: this.getSubtitle(),
|
|
1709
|
+
body: this.getBody(),
|
|
1710
|
+
wordCount: this.getWordCount(),
|
|
1711
|
+
characterCount: this.getCharacterCount()
|
|
1712
|
+
})
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// ============================================
|
|
1716
|
+
// MARKDOWN MODE
|
|
1717
|
+
// ============================================
|
|
1718
|
+
|
|
1719
|
+
/**
|
|
1720
|
+
* Initialize markdown mode if enabled.
|
|
1721
|
+
* Sets up the initial mode and keyboard shortcuts.
|
|
1722
|
+
* Called AFTER editor is fully initialized.
|
|
1723
|
+
*/
|
|
1724
|
+
initializeMarkdownMode() {
|
|
1725
|
+
// Set up keyboard shortcuts first
|
|
1726
|
+
if (this.markdownShortcutsValue) {
|
|
1727
|
+
this.setupMarkdownShortcuts()
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// Set initial mode if not wysiwyg
|
|
1731
|
+
if (this.markdownModeValue !== "wysiwyg") {
|
|
1732
|
+
this.setMode(this.markdownModeValue)
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// Update toggle button states
|
|
1736
|
+
this.updateModeToggle()
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
/**
|
|
1740
|
+
* Set up keyboard shortcuts for markdown mode.
|
|
1741
|
+
*/
|
|
1742
|
+
setupMarkdownShortcuts() {
|
|
1743
|
+
this.element.addEventListener("keydown", (e) => {
|
|
1744
|
+
// Cmd/Ctrl + Shift + M = Toggle markdown mode
|
|
1745
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "m") {
|
|
1746
|
+
e.preventDefault()
|
|
1747
|
+
this.toggleMarkdownMode()
|
|
1748
|
+
}
|
|
1749
|
+
// Cmd/Ctrl + Shift + V = Toggle split view
|
|
1750
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "v") {
|
|
1751
|
+
e.preventDefault()
|
|
1752
|
+
if (this.markdownModeValue === "split") {
|
|
1753
|
+
this.setMode("wysiwyg")
|
|
1754
|
+
} else {
|
|
1755
|
+
this.setMode("split")
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
// Escape in markdown mode = return to WYSIWYG
|
|
1759
|
+
if (e.key === "Escape" && this.markdownModeValue === "markdown") {
|
|
1760
|
+
e.preventDefault()
|
|
1761
|
+
this.setMode("wysiwyg")
|
|
1762
|
+
}
|
|
1763
|
+
})
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
/**
|
|
1767
|
+
* Get current editing mode.
|
|
1768
|
+
* @returns {string} "wysiwyg" | "markdown" | "split"
|
|
1769
|
+
*/
|
|
1770
|
+
getMode() {
|
|
1771
|
+
return this.markdownModeValue
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
/**
|
|
1775
|
+
* Set editing mode.
|
|
1776
|
+
* Can be called programmatically with a string, or from a Stimulus action with an Event.
|
|
1777
|
+
* @param {string|Event} modeOrEvent - "wysiwyg" | "markdown" | "split" OR click event
|
|
1778
|
+
*/
|
|
1779
|
+
setMode(modeOrEvent) {
|
|
1780
|
+
// Handle Stimulus action call (receives Event)
|
|
1781
|
+
let mode
|
|
1782
|
+
if (modeOrEvent instanceof Event) {
|
|
1783
|
+
mode = modeOrEvent.currentTarget.dataset.mode
|
|
1784
|
+
if (!mode) return
|
|
1785
|
+
} else {
|
|
1786
|
+
mode = modeOrEvent
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
const validModes = ["wysiwyg", "markdown", "split"]
|
|
1790
|
+
if (!validModes.includes(mode)) return
|
|
1791
|
+
|
|
1792
|
+
const previousMode = this.markdownModeValue
|
|
1793
|
+
if (mode === previousMode) return // No change needed
|
|
1794
|
+
|
|
1795
|
+
this.markdownModeValue = mode
|
|
1796
|
+
|
|
1797
|
+
switch (mode) {
|
|
1798
|
+
case "wysiwyg":
|
|
1799
|
+
this.switchToWysiwyg(previousMode)
|
|
1800
|
+
break
|
|
1801
|
+
case "markdown":
|
|
1802
|
+
this.switchToMarkdown()
|
|
1803
|
+
break
|
|
1804
|
+
case "split":
|
|
1805
|
+
this.switchToSplit()
|
|
1806
|
+
break
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
this.updateModeToggle()
|
|
1810
|
+
this.dispatchEvent("mode-change", { mode, previousMode })
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
/**
|
|
1814
|
+
* Toggle between WYSIWYG and markdown mode.
|
|
1815
|
+
*/
|
|
1816
|
+
toggleMarkdownMode() {
|
|
1817
|
+
if (this.markdownModeValue === "wysiwyg") {
|
|
1818
|
+
this.setMode("markdown")
|
|
1819
|
+
} else {
|
|
1820
|
+
this.setMode("wysiwyg")
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
/**
|
|
1825
|
+
* Switch to WYSIWYG mode.
|
|
1826
|
+
* @param {string} previousMode - The mode we're switching from
|
|
1827
|
+
*/
|
|
1828
|
+
switchToWysiwyg(previousMode) {
|
|
1829
|
+
// Import markdown content if coming from markdown mode
|
|
1830
|
+
if (previousMode === "markdown" && this.hasMarkdownEditorTarget) {
|
|
1831
|
+
const markdown = this.markdownEditorTarget.value
|
|
1832
|
+
this.importMarkdown(markdown)
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
// Hide markdown editor
|
|
1836
|
+
if (this.hasMarkdownEditorTarget) {
|
|
1837
|
+
this.markdownEditorTarget.classList.add("hidden")
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// Show WYSIWYG editor
|
|
1841
|
+
this.contentTarget.classList.remove("hidden")
|
|
1842
|
+
|
|
1843
|
+
// Remove split class
|
|
1844
|
+
this.element.classList.remove("inkpen-editor--split")
|
|
1845
|
+
|
|
1846
|
+
// Clear markdown-only sync if active
|
|
1847
|
+
this.clearMarkdownModeSync()
|
|
1848
|
+
|
|
1849
|
+
// Clear split sync if active
|
|
1850
|
+
this.clearSplitSync()
|
|
1851
|
+
|
|
1852
|
+
// Focus WYSIWYG editor
|
|
1853
|
+
this.editor?.commands.focus()
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/**
|
|
1857
|
+
* Switch to markdown mode.
|
|
1858
|
+
*/
|
|
1859
|
+
async switchToMarkdown() {
|
|
1860
|
+
// Export current content to markdown
|
|
1861
|
+
const markdown = await this.exportMarkdown()
|
|
1862
|
+
|
|
1863
|
+
// Show markdown editor
|
|
1864
|
+
if (this.hasMarkdownEditorTarget) {
|
|
1865
|
+
this.markdownEditorTarget.value = markdown
|
|
1866
|
+
this.markdownEditorTarget.classList.remove("hidden")
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
// Hide WYSIWYG editor
|
|
1870
|
+
this.contentTarget.classList.add("hidden")
|
|
1871
|
+
|
|
1872
|
+
// Remove split class
|
|
1873
|
+
this.element.classList.remove("inkpen-editor--split")
|
|
1874
|
+
|
|
1875
|
+
// Keep hidden input synced while typing in markdown-only mode
|
|
1876
|
+
this.setupMarkdownModeSync()
|
|
1877
|
+
|
|
1878
|
+
// Clear split sync if active
|
|
1879
|
+
this.clearSplitSync()
|
|
1880
|
+
|
|
1881
|
+
// Focus markdown editor
|
|
1882
|
+
if (this.hasMarkdownEditorTarget) {
|
|
1883
|
+
this.markdownEditorTarget.focus()
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
/**
|
|
1888
|
+
* Switch to split view mode.
|
|
1889
|
+
*/
|
|
1890
|
+
async switchToSplit() {
|
|
1891
|
+
// Export current content to markdown
|
|
1892
|
+
const markdown = await this.exportMarkdown()
|
|
1893
|
+
|
|
1894
|
+
// Show both editors
|
|
1895
|
+
if (this.hasMarkdownEditorTarget) {
|
|
1896
|
+
this.markdownEditorTarget.value = markdown
|
|
1897
|
+
this.markdownEditorTarget.classList.remove("hidden")
|
|
1898
|
+
}
|
|
1899
|
+
this.contentTarget.classList.remove("hidden")
|
|
1900
|
+
|
|
1901
|
+
// Add split class for layout
|
|
1902
|
+
this.element.classList.add("inkpen-editor--split")
|
|
1903
|
+
|
|
1904
|
+
// Split mode has its own bidirectional sync handlers
|
|
1905
|
+
this.clearMarkdownModeSync()
|
|
1906
|
+
|
|
1907
|
+
// Set up sync between editors
|
|
1908
|
+
this.setupSplitSync()
|
|
1909
|
+
}
|
|
1910
|
+
|
|
1911
|
+
setupMarkdownModeSync() {
|
|
1912
|
+
this.clearMarkdownModeSync()
|
|
1913
|
+
if (!this.hasMarkdownEditorTarget) return
|
|
1914
|
+
|
|
1915
|
+
const debounce = (fn, delay) => {
|
|
1916
|
+
let timeoutId
|
|
1917
|
+
return (...args) => {
|
|
1918
|
+
clearTimeout(timeoutId)
|
|
1919
|
+
timeoutId = setTimeout(() => fn.apply(this, args), delay)
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
this._markdownModeInputHandler = debounce(() => {
|
|
1924
|
+
if (this.markdownModeValue !== "markdown") return
|
|
1925
|
+
this.importMarkdown(this.markdownEditorTarget.value)
|
|
1926
|
+
}, this.markdownSyncDelayValue)
|
|
1927
|
+
|
|
1928
|
+
this.markdownEditorTarget.addEventListener("input", this._markdownModeInputHandler)
|
|
1929
|
+
}
|
|
1930
|
+
|
|
1931
|
+
clearMarkdownModeSync() {
|
|
1932
|
+
if (this._markdownModeInputHandler && this.hasMarkdownEditorTarget) {
|
|
1933
|
+
this.markdownEditorTarget.removeEventListener("input", this._markdownModeInputHandler)
|
|
1934
|
+
this._markdownModeInputHandler = null
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
/**
|
|
1939
|
+
* Set up sync between markdown and WYSIWYG in split mode.
|
|
1940
|
+
*/
|
|
1941
|
+
setupSplitSync() {
|
|
1942
|
+
// Clear any existing sync
|
|
1943
|
+
this.clearSplitSync()
|
|
1944
|
+
|
|
1945
|
+
if (!this.hasMarkdownEditorTarget) return
|
|
1946
|
+
|
|
1947
|
+
// Debounce function
|
|
1948
|
+
const debounce = (fn, delay) => {
|
|
1949
|
+
let timeoutId
|
|
1950
|
+
return (...args) => {
|
|
1951
|
+
clearTimeout(timeoutId)
|
|
1952
|
+
timeoutId = setTimeout(() => fn.apply(this, args), delay)
|
|
1953
|
+
}
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
// Markdown → WYSIWYG (debounced)
|
|
1957
|
+
this._markdownInputHandler = debounce(() => {
|
|
1958
|
+
if (this.markdownModeValue !== "split") return
|
|
1959
|
+
this._syncingFromMarkdown = true
|
|
1960
|
+
this.importMarkdown(this.markdownEditorTarget.value)
|
|
1961
|
+
this._syncingFromMarkdown = false
|
|
1962
|
+
}, this.markdownSyncDelayValue)
|
|
1963
|
+
|
|
1964
|
+
this.markdownEditorTarget.addEventListener("input", this._markdownInputHandler)
|
|
1965
|
+
|
|
1966
|
+
// WYSIWYG → Markdown (on update, but not when syncing from markdown)
|
|
1967
|
+
this._wysiwygUpdateHandler = async () => {
|
|
1968
|
+
if (this.markdownModeValue !== "split") return
|
|
1969
|
+
if (this._syncingFromMarkdown) return
|
|
1970
|
+
const markdown = await this.exportMarkdown()
|
|
1971
|
+
this.markdownEditorTarget.value = markdown
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
this.editor?.on("update", this._wysiwygUpdateHandler)
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
/**
|
|
1978
|
+
* Clear split sync handlers.
|
|
1979
|
+
*/
|
|
1980
|
+
clearSplitSync() {
|
|
1981
|
+
if (this._markdownInputHandler && this.hasMarkdownEditorTarget) {
|
|
1982
|
+
this.markdownEditorTarget.removeEventListener("input", this._markdownInputHandler)
|
|
1983
|
+
this._markdownInputHandler = null
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
if (this._wysiwygUpdateHandler && this.editor) {
|
|
1987
|
+
this.editor.off("update", this._wysiwygUpdateHandler)
|
|
1988
|
+
this._wysiwygUpdateHandler = null
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
setupFormSubmitSync() {
|
|
1993
|
+
this.formElement = this.element.closest("form")
|
|
1994
|
+
if (!this.formElement) return
|
|
1995
|
+
|
|
1996
|
+
this._formSubmitHandler = () => {
|
|
1997
|
+
if (!this.markdownEnabledValue || !this.hasMarkdownEditorTarget) return
|
|
1998
|
+
|
|
1999
|
+
if (this.markdownModeValue === "markdown" || this.markdownModeValue === "split") {
|
|
2000
|
+
this.importMarkdown(this.markdownEditorTarget.value)
|
|
2001
|
+
}
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
this.formElement.addEventListener("submit", this._formSubmitHandler)
|
|
2005
|
+
}
|
|
2006
|
+
|
|
2007
|
+
teardownFormSubmitSync() {
|
|
2008
|
+
if (!this.formElement || !this._formSubmitHandler) return
|
|
2009
|
+
|
|
2010
|
+
this.formElement.removeEventListener("submit", this._formSubmitHandler)
|
|
2011
|
+
this._formSubmitHandler = null
|
|
2012
|
+
this.formElement = null
|
|
2013
|
+
}
|
|
2014
|
+
|
|
2015
|
+
/**
|
|
2016
|
+
* Update mode toggle button states.
|
|
2017
|
+
*/
|
|
2018
|
+
updateModeToggle() {
|
|
2019
|
+
if (!this.hasModeToggleTarget) return
|
|
2020
|
+
|
|
2021
|
+
const buttons = this.modeToggleTarget.querySelectorAll("[data-mode]")
|
|
2022
|
+
buttons.forEach((button) => {
|
|
2023
|
+
const mode = button.dataset.mode
|
|
2024
|
+
button.classList.toggle("active", mode === this.markdownModeValue)
|
|
2025
|
+
})
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/**
|
|
2029
|
+
* Get markdown content.
|
|
2030
|
+
* Always available regardless of current mode.
|
|
2031
|
+
* @returns {Promise<string>} Markdown content
|
|
2032
|
+
*/
|
|
2033
|
+
async getMarkdown() {
|
|
2034
|
+
if (this.markdownModeValue === "markdown" && this.hasMarkdownEditorTarget) {
|
|
2035
|
+
return this.markdownEditorTarget.value
|
|
2036
|
+
}
|
|
2037
|
+
return this.exportMarkdown()
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
/**
|
|
2041
|
+
* Set content from markdown.
|
|
2042
|
+
* @param {string} markdown - Markdown content
|
|
2043
|
+
*/
|
|
2044
|
+
async setMarkdown(markdown) {
|
|
2045
|
+
if (this.hasMarkdownEditorTarget) {
|
|
2046
|
+
this.markdownEditorTarget.value = markdown
|
|
2047
|
+
}
|
|
2048
|
+
await this.importMarkdown(markdown)
|
|
2049
|
+
}
|
|
2050
|
+
}
|