inkpen 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. checksums.yaml +7 -0
  2. data/.DS_Store +0 -0
  3. data/.rubocop.yml +8 -0
  4. data/.yardopts +11 -0
  5. data/CLAUDE.md +141 -0
  6. data/README.md +409 -0
  7. data/Rakefile +19 -0
  8. data/app/assets/javascripts/inkpen/controllers/editor_controller.js +2050 -0
  9. data/app/assets/javascripts/inkpen/controllers/sticky_toolbar_controller.js +667 -0
  10. data/app/assets/javascripts/inkpen/controllers/toolbar_controller.js +693 -0
  11. data/app/assets/javascripts/inkpen/export/html.js +637 -0
  12. data/app/assets/javascripts/inkpen/export/index.js +30 -0
  13. data/app/assets/javascripts/inkpen/export/markdown.js +697 -0
  14. data/app/assets/javascripts/inkpen/export/pdf.js +372 -0
  15. data/app/assets/javascripts/inkpen/extensions/advanced_table.js +640 -0
  16. data/app/assets/javascripts/inkpen/extensions/block_commands.js +300 -0
  17. data/app/assets/javascripts/inkpen/extensions/block_gutter.js +338 -0
  18. data/app/assets/javascripts/inkpen/extensions/callout.js +303 -0
  19. data/app/assets/javascripts/inkpen/extensions/columns.js +403 -0
  20. data/app/assets/javascripts/inkpen/extensions/database.js +990 -0
  21. data/app/assets/javascripts/inkpen/extensions/document_section.js +352 -0
  22. data/app/assets/javascripts/inkpen/extensions/drag_handle.js +407 -0
  23. data/app/assets/javascripts/inkpen/extensions/embed.js +629 -0
  24. data/app/assets/javascripts/inkpen/extensions/enhanced_image.js +566 -0
  25. data/app/assets/javascripts/inkpen/extensions/export_commands.js +271 -0
  26. data/app/assets/javascripts/inkpen/extensions/file_attachment.js +593 -0
  27. data/app/assets/javascripts/inkpen/extensions/inkpen_table/index.js +58 -0
  28. data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table.js +638 -0
  29. data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_cell.js +100 -0
  30. data/app/assets/javascripts/inkpen/extensions/inkpen_table/inkpen_table_header.js +100 -0
  31. data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_constants.js +152 -0
  32. data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_helpers.js +254 -0
  33. data/app/assets/javascripts/inkpen/extensions/inkpen_table/table_menu.js +282 -0
  34. data/app/assets/javascripts/inkpen/extensions/preformatted.js +239 -0
  35. data/app/assets/javascripts/inkpen/extensions/section.js +281 -0
  36. data/app/assets/javascripts/inkpen/extensions/section_title.js +126 -0
  37. data/app/assets/javascripts/inkpen/extensions/slash_commands.js +439 -0
  38. data/app/assets/javascripts/inkpen/extensions/table_of_contents.js +474 -0
  39. data/app/assets/javascripts/inkpen/extensions/toggle_block.js +332 -0
  40. data/app/assets/javascripts/inkpen/index.js +87 -0
  41. data/app/assets/stylesheets/inkpen/advanced_table.css +514 -0
  42. data/app/assets/stylesheets/inkpen/animations.css +626 -0
  43. data/app/assets/stylesheets/inkpen/block_gutter.css +265 -0
  44. data/app/assets/stylesheets/inkpen/callout.css +359 -0
  45. data/app/assets/stylesheets/inkpen/columns.css +314 -0
  46. data/app/assets/stylesheets/inkpen/database.css +658 -0
  47. data/app/assets/stylesheets/inkpen/document_section.css +305 -0
  48. data/app/assets/stylesheets/inkpen/drag_drop.css +220 -0
  49. data/app/assets/stylesheets/inkpen/editor.css +652 -0
  50. data/app/assets/stylesheets/inkpen/embed.css +468 -0
  51. data/app/assets/stylesheets/inkpen/enhanced_image.css +453 -0
  52. data/app/assets/stylesheets/inkpen/export.css +499 -0
  53. data/app/assets/stylesheets/inkpen/file_attachment.css +347 -0
  54. data/app/assets/stylesheets/inkpen/footnotes.css +136 -0
  55. data/app/assets/stylesheets/inkpen/inkpen_table.css +608 -0
  56. data/app/assets/stylesheets/inkpen/preformatted.css +215 -0
  57. data/app/assets/stylesheets/inkpen/search_replace.css +58 -0
  58. data/app/assets/stylesheets/inkpen/section.css +236 -0
  59. data/app/assets/stylesheets/inkpen/slash_menu.css +252 -0
  60. data/app/assets/stylesheets/inkpen/sticky_toolbar.css +314 -0
  61. data/app/assets/stylesheets/inkpen/toc.css +386 -0
  62. data/app/assets/stylesheets/inkpen/toggle.css +260 -0
  63. data/app/helpers/inkpen/editor_helper.rb +114 -0
  64. data/app/views/inkpen/_editor.html.erb +139 -0
  65. data/config/importmap.rb +170 -0
  66. data/docs/.DS_Store +0 -0
  67. data/docs/CHANGELOG.md +571 -0
  68. data/docs/FEATURES.md +436 -0
  69. data/docs/ROADMAP.md +3029 -0
  70. data/docs/VISION.md +235 -0
  71. data/docs/extensions/INKPEN_TABLE.md +482 -0
  72. data/docs/thinking/CORRECTED_NO_VUE.md +756 -0
  73. data/docs/thinking/EXECUTIVE_SUMMARY.md +403 -0
  74. data/docs/thinking/INKPEN_CODE_SAMPLES.md +1479 -0
  75. data/docs/thinking/INKPEN_MASTER_GUIDE.md +891 -0
  76. data/docs/thinking/README_START_HERE.md +341 -0
  77. data/lib/inkpen/configuration.rb +175 -0
  78. data/lib/inkpen/editor.rb +204 -0
  79. data/lib/inkpen/engine.rb +32 -0
  80. data/lib/inkpen/extensions/base.rb +109 -0
  81. data/lib/inkpen/extensions/code_block_syntax.rb +177 -0
  82. data/lib/inkpen/extensions/document_section.rb +111 -0
  83. data/lib/inkpen/extensions/forced_document.rb +183 -0
  84. data/lib/inkpen/extensions/mention.rb +155 -0
  85. data/lib/inkpen/extensions/preformatted.rb +111 -0
  86. data/lib/inkpen/extensions/section.rb +139 -0
  87. data/lib/inkpen/extensions/slash_commands.rb +100 -0
  88. data/lib/inkpen/extensions/table.rb +182 -0
  89. data/lib/inkpen/extensions/task_list.rb +145 -0
  90. data/lib/inkpen/sticky_toolbar.rb +157 -0
  91. data/lib/inkpen/toolbar.rb +145 -0
  92. data/lib/inkpen/version.rb +5 -0
  93. data/lib/inkpen.rb +101 -0
  94. data/sig/inkpen.rbs +4 -0
  95. metadata +165 -0
@@ -0,0 +1,439 @@
1
+ import { Extension } from "@tiptap/core"
2
+ import { Plugin, PluginKey } from "@tiptap/pm/state"
3
+ import Suggestion from "@tiptap/suggestion"
4
+
5
+ /**
6
+ * Slash Commands Extension for TipTap
7
+ *
8
+ * Notion-style "/" command palette for rapid block insertion.
9
+ * Type "/" to open the command menu, then type to filter.
10
+ *
11
+ * Features:
12
+ * - Fuzzy search/filter as you type
13
+ * - Keyboard navigation (arrows, enter, escape)
14
+ * - Grouped commands (Basic, Lists, Blocks, Media, Advanced)
15
+ * - Customizable command list
16
+ * - Icons and descriptions
17
+ *
18
+ * @example
19
+ * editor.commands.insertContent("/") // Opens menu
20
+ *
21
+ * @since 0.3.0
22
+ */
23
+
24
+ // Default command definitions with icons and metadata
25
+ const DEFAULT_COMMANDS = [
26
+ // Basic
27
+ { id: "paragraph", title: "Text", description: "Plain text block", icon: "¶", keywords: ["text", "p"], group: "Basic" },
28
+ { id: "heading1", title: "Heading 1", description: "Large heading", icon: "H1", keywords: ["h1", "title", "large"], group: "Basic" },
29
+ { id: "heading2", title: "Heading 2", description: "Medium heading", icon: "H2", keywords: ["h2", "subtitle"], group: "Basic" },
30
+ { id: "heading3", title: "Heading 3", description: "Small heading", icon: "H3", keywords: ["h3"], group: "Basic" },
31
+
32
+ // Lists
33
+ { id: "bulletList", title: "Bullet List", description: "Unordered list", icon: "•", keywords: ["ul", "unordered", "bullets"], group: "Lists" },
34
+ { id: "orderedList", title: "Numbered List", description: "Ordered list", icon: "1.", keywords: ["ol", "numbered", "numbers"], group: "Lists" },
35
+ { id: "taskList", title: "Task List", description: "Checklist with checkboxes", icon: "☐", keywords: ["todo", "checkbox", "checklist"], group: "Lists" },
36
+
37
+ // Blocks
38
+ { id: "blockquote", title: "Quote", description: "Quote block", icon: "❝", keywords: ["quote", "citation", "pullquote"], group: "Blocks" },
39
+ { id: "codeBlock", title: "Code Block", description: "Code with syntax highlighting", icon: "</>", keywords: ["code", "pre", "syntax"], group: "Blocks" },
40
+ { id: "preformatted", title: "Plain Text", description: "Preformatted text for ASCII art", icon: "⎔", keywords: ["ascii", "pre", "monospace", "plain"], group: "Blocks" },
41
+ { id: "divider", title: "Divider", description: "Horizontal line", icon: "—", keywords: ["hr", "line", "separator"], group: "Blocks" },
42
+
43
+ // Media
44
+ { id: "image", title: "Image", description: "Upload or embed an image", icon: "🖼", keywords: ["img", "picture", "photo"], group: "Media" },
45
+ { id: "file", title: "File", description: "Upload a file attachment", icon: "📎", keywords: ["upload", "attachment", "document", "pdf"], group: "Media" },
46
+ { id: "youtube", title: "YouTube", description: "Embed a YouTube video", icon: "▶", keywords: ["video", "embed", "youtube"], group: "Media" },
47
+ { id: "table", title: "Table", description: "Insert a table", icon: "⊞", keywords: ["grid", "data", "rows", "columns"], group: "Media" },
48
+
49
+ // Advanced
50
+ { id: "section", title: "Section", description: "Page section with width control", icon: "▢", keywords: ["layout", "container", "wrapper"], group: "Advanced" },
51
+ { id: "documentSection", title: "Document Section", description: "Collapsible section with title", icon: "📑", keywords: ["section", "group", "collapse", "outline", "heading"], group: "Advanced" },
52
+ { id: "toggle", title: "Toggle", description: "Collapsible content block", icon: "▸", keywords: ["collapse", "expand", "accordion", "details"], group: "Advanced" },
53
+ { id: "columns2", title: "2 Columns", description: "Side-by-side columns", icon: "▥", keywords: ["layout", "grid", "split", "two"], group: "Advanced" },
54
+ { id: "columns3", title: "3 Columns", description: "Three column layout", icon: "▦", keywords: ["layout", "grid", "three"], group: "Advanced" },
55
+ { id: "calloutInfo", title: "Info Callout", description: "Informational note", icon: "ℹ️", keywords: ["callout", "note", "info", "alert"], group: "Advanced" },
56
+ { id: "calloutWarning", title: "Warning Callout", description: "Warning or caution", icon: "⚠️", keywords: ["callout", "warning", "caution", "alert"], group: "Advanced" },
57
+ { id: "calloutTip", title: "Tip Callout", description: "Helpful tip", icon: "💡", keywords: ["callout", "tip", "hint", "idea"], group: "Advanced" },
58
+
59
+ // Data (v0.6.0)
60
+ { id: "footnote", title: "Footnote", description: "Add a footnote reference", icon: "¹", keywords: ["footnote", "reference", "citation", "note", "academic"], group: "Data" },
61
+ { id: "toc", title: "Table of Contents", description: "Auto-generated navigation", icon: "📑", keywords: ["toc", "contents", "navigation", "index", "outline"], group: "Data" },
62
+ { id: "database", title: "Database", description: "Inline database with views", icon: "🗃️", keywords: ["database", "notion", "table", "kanban", "board"], group: "Data" },
63
+ { id: "databaseBoard", title: "Kanban Board", description: "Database with board view", icon: "▣", keywords: ["kanban", "board", "trello", "tasks"], group: "Data" },
64
+ { id: "databaseGallery", title: "Gallery", description: "Database with gallery view", icon: "⊟", keywords: ["gallery", "cards", "grid"], group: "Data" },
65
+
66
+ // Embeds
67
+ { id: "embed", title: "Embed", description: "Embed from URL", icon: "🔗", keywords: ["embed", "url", "link", "website"], group: "Media" },
68
+ { id: "embedTwitter", title: "Twitter/X", description: "Embed a tweet", icon: "𝕏", keywords: ["twitter", "x", "tweet", "social"], group: "Media" },
69
+ { id: "embedInstagram", title: "Instagram", description: "Embed Instagram post", icon: "📷", keywords: ["instagram", "ig", "social", "photo"], group: "Media" },
70
+ { id: "embedFigma", title: "Figma", description: "Embed Figma design", icon: "◈", keywords: ["figma", "design", "prototype"], group: "Media" },
71
+ { id: "embedLoom", title: "Loom", description: "Embed Loom video", icon: "🎥", keywords: ["loom", "video", "recording"], group: "Media" },
72
+ { id: "embedCodePen", title: "CodePen", description: "Embed CodePen", icon: "⌨", keywords: ["codepen", "code", "demo"], group: "Media" },
73
+ { id: "embedSpotify", title: "Spotify", description: "Embed Spotify track", icon: "🎵", keywords: ["spotify", "music", "audio"], group: "Media" },
74
+
75
+ // Export (v0.7.0) - requires export_commands extension
76
+ { id: "exportMarkdown", title: "Export Markdown", description: "Download as .md file", icon: "↓", keywords: ["export", "download", "markdown", "md"], group: "Export", requiresCommand: "downloadMarkdown" },
77
+ { id: "exportHTML", title: "Export HTML", description: "Download as .html file", icon: "↓", keywords: ["export", "download", "html"], group: "Export", requiresCommand: "downloadHTML" },
78
+ { id: "exportPDF", title: "Export PDF", description: "Download as .pdf file", icon: "↓", keywords: ["export", "download", "pdf", "print"], group: "Export", requiresCommand: "downloadPDF" },
79
+ { id: "copyMarkdown", title: "Copy as Markdown", description: "Copy content to clipboard", icon: "📋", keywords: ["copy", "clipboard", "markdown"], group: "Export", requiresCommand: "copyMarkdown" },
80
+ { id: "copyHTML", title: "Copy as HTML", description: "Copy HTML to clipboard", icon: "📋", keywords: ["copy", "clipboard", "html"], group: "Export", requiresCommand: "copyHTML" }
81
+ ]
82
+
83
+ export const SlashCommands = Extension.create({
84
+ name: "slashCommands",
85
+
86
+ addOptions() {
87
+ return {
88
+ commands: DEFAULT_COMMANDS,
89
+ groups: ["Basic", "Lists", "Blocks", "Media", "Data", "Advanced", "Export"],
90
+ maxSuggestions: 10,
91
+ char: "/",
92
+ startOfLine: false,
93
+ allowSpaces: false,
94
+ decorationTag: "span",
95
+ decorationClass: "inkpen-slash-decoration",
96
+ suggestionClass: "inkpen-slash-menu",
97
+ itemClass: "inkpen-slash-menu__item",
98
+ activeClass: "is-selected",
99
+ groupClass: "inkpen-slash-menu__group",
100
+ // Callbacks
101
+ onOpen: null,
102
+ onClose: null,
103
+ onSelect: null
104
+ }
105
+ },
106
+
107
+ addProseMirrorPlugins() {
108
+ const options = this.options
109
+ const editor = this.editor
110
+
111
+ // Normalize commands: support both {id, title} and {name, label} formats
112
+ const normalizeCommand = (cmd) => ({
113
+ ...cmd,
114
+ id: cmd.id || cmd.name,
115
+ title: cmd.title || cmd.label
116
+ })
117
+
118
+ // Filter commands based on query and availability
119
+ const filterCommands = (query) => {
120
+ const commands = options.commands.map(normalizeCommand)
121
+ const maxSuggestions = options.maxSuggestions
122
+
123
+ const availableCommands = commands.filter(cmd => {
124
+ if (cmd.requiresCommand) {
125
+ return editor.commands[cmd.requiresCommand] !== undefined
126
+ }
127
+ return true
128
+ })
129
+
130
+ if (!query) {
131
+ return availableCommands.slice(0, maxSuggestions)
132
+ }
133
+
134
+ const q = query.toLowerCase()
135
+
136
+ return availableCommands
137
+ .filter(cmd => {
138
+ const titleMatch = cmd.title?.toLowerCase().includes(q)
139
+ const keywordMatch = cmd.keywords?.some(k => k.toLowerCase().includes(q))
140
+ const descMatch = cmd.description?.toLowerCase().includes(q)
141
+ return titleMatch || keywordMatch || descMatch
142
+ })
143
+ .slice(0, maxSuggestions)
144
+ }
145
+
146
+ // Group items by their group property
147
+ const groupItems = (items) => {
148
+ const groups = options.groups
149
+ const grouped = {}
150
+
151
+ groups.forEach(group => { grouped[group] = [] })
152
+
153
+ items.forEach(item => {
154
+ const group = item.group || "Other"
155
+ if (!grouped[group]) grouped[group] = []
156
+ grouped[group].push(item)
157
+ })
158
+
159
+ Object.keys(grouped).forEach(key => {
160
+ if (grouped[key].length === 0) delete grouped[key]
161
+ })
162
+
163
+ return grouped
164
+ }
165
+
166
+ // Execute a command by ID
167
+ const executeCommand = (commandId) => {
168
+ const chain = editor.chain().focus()
169
+
170
+ switch (commandId) {
171
+ case "paragraph": chain.setParagraph().run(); break
172
+ case "heading1": chain.toggleHeading({ level: 1 }).run(); break
173
+ case "heading2": chain.toggleHeading({ level: 2 }).run(); break
174
+ case "heading3": chain.toggleHeading({ level: 3 }).run(); break
175
+ case "bulletList": chain.toggleBulletList().run(); break
176
+ case "orderedList": chain.toggleOrderedList().run(); break
177
+ case "taskList": chain.toggleTaskList().run(); break
178
+ case "blockquote": chain.toggleBlockquote().run(); break
179
+ case "codeBlock": chain.toggleCodeBlock().run(); break
180
+ case "preformatted":
181
+ if (editor.commands.togglePreformatted) chain.togglePreformatted().run()
182
+ break
183
+ case "divider": case "horizontalRule":
184
+ chain.setHorizontalRule().run(); break
185
+ case "image": {
186
+ const url = prompt("Enter image URL:")
187
+ if (url) {
188
+ if (editor.commands.setEnhancedImage) chain.setEnhancedImage({ src: url }).run()
189
+ else chain.setImage({ src: url }).run()
190
+ }
191
+ break
192
+ }
193
+ case "file":
194
+ if (editor.commands.uploadFile) {
195
+ const input = document.createElement("input")
196
+ input.type = "file"; input.accept = "*/*"
197
+ input.onchange = (e) => { if (e.target.files?.[0]) editor.commands.uploadFile(e.target.files[0]) }
198
+ input.click()
199
+ }
200
+ break
201
+ case "youtube": {
202
+ const url = prompt("Enter YouTube URL:")
203
+ if (url) chain.setYoutubeVideo({ src: url }).run()
204
+ break
205
+ }
206
+ case "table": chain.insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(); break
207
+ case "section":
208
+ if (editor.commands.insertSection) chain.insertSection().run()
209
+ break
210
+ case "documentSection":
211
+ if (editor.commands.insertDocumentSection) chain.insertDocumentSection().run()
212
+ break
213
+ case "toggle":
214
+ if (editor.commands.insertToggle) chain.insertToggle().run()
215
+ break
216
+ case "columns2":
217
+ if (editor.commands.insertColumns) chain.insertColumns({ count: 2 }).run()
218
+ break
219
+ case "columns3":
220
+ if (editor.commands.insertColumns) chain.insertColumns({ count: 3 }).run()
221
+ break
222
+ case "calloutInfo":
223
+ if (editor.commands.insertCallout) chain.insertCallout({ type: "info" }).run()
224
+ break
225
+ case "calloutWarning":
226
+ if (editor.commands.insertCallout) chain.insertCallout({ type: "warning" }).run()
227
+ break
228
+ case "calloutTip":
229
+ if (editor.commands.insertCallout) chain.insertCallout({ type: "tip" }).run()
230
+ break
231
+ case "embed": case "embedTwitter": case "embedInstagram":
232
+ case "embedFigma": case "embedLoom": case "embedCodePen": case "embedSpotify": {
233
+ if (editor.commands.insertEmbed) {
234
+ const url = prompt("Enter URL to embed:")
235
+ if (url) editor.commands.insertEmbed(url)
236
+ }
237
+ break
238
+ }
239
+ case "footnote":
240
+ if (editor.commands.insertFootnoteReference) chain.insertFootnoteReference().run()
241
+ break
242
+ case "toc":
243
+ if (editor.commands.insertTableOfContents) chain.insertTableOfContents().run()
244
+ break
245
+ case "database":
246
+ if (editor.commands.insertDatabase) chain.insertDatabase({ view: "table" }).run()
247
+ break
248
+ case "databaseBoard":
249
+ if (editor.commands.insertDatabase) chain.insertDatabase({ view: "board" }).run()
250
+ break
251
+ case "databaseGallery":
252
+ if (editor.commands.insertDatabase) chain.insertDatabase({ view: "gallery" }).run()
253
+ break
254
+ case "exportMarkdown": editor.commands.downloadMarkdown?.(); break
255
+ case "exportHTML": editor.commands.downloadHTML?.(); break
256
+ case "exportPDF": editor.commands.downloadPDF?.(); break
257
+ case "copyMarkdown": editor.commands.copyMarkdown?.(); break
258
+ case "copyHTML": editor.commands.copyHTML?.(); break
259
+ default: {
260
+ // Dispatch custom event for app-level handling (e.g., embed commands)
261
+ const event = new CustomEvent("inkpen:slash-command", {
262
+ detail: { commandId },
263
+ bubbles: true,
264
+ cancelable: true
265
+ })
266
+ editor.view.dom.dispatchEvent(event)
267
+ break
268
+ }
269
+ }
270
+ }
271
+
272
+ return [
273
+ Suggestion({
274
+ editor: this.editor,
275
+ char: options.char,
276
+ startOfLine: options.startOfLine,
277
+ allowSpaces: options.allowSpaces,
278
+ decorationTag: options.decorationTag,
279
+ decorationClass: options.decorationClass,
280
+
281
+ command: ({ editor, range, props }) => {
282
+ editor.chain().focus().deleteRange(range).run()
283
+ executeCommand(props.id)
284
+ options.onSelect?.(props)
285
+ },
286
+
287
+ items: ({ query }) => {
288
+ return filterCommands(query)
289
+ },
290
+
291
+ render: () => {
292
+ let popup = null
293
+ let selectedIndex = 0
294
+ let items = []
295
+ let command = null
296
+
297
+ const createPopup = () => {
298
+ const el = document.createElement("div")
299
+ el.className = options.suggestionClass
300
+ el.setAttribute("role", "listbox")
301
+ el.setAttribute("aria-label", "Slash commands")
302
+ document.body.appendChild(el)
303
+ return el
304
+ }
305
+
306
+ const updatePopup = (filteredItems, newCommand) => {
307
+ command = newCommand
308
+ items = filteredItems
309
+ selectedIndex = 0
310
+
311
+ if (!popup) return
312
+ if (items.length === 0) {
313
+ popup.innerHTML = `<div class="${options.suggestionClass}__empty">No results</div>`
314
+ return
315
+ }
316
+
317
+ const grouped = groupItems(items)
318
+
319
+ popup.innerHTML = Object.entries(grouped).map(([group, groupItems]) => `
320
+ <div class="${options.groupClass}">
321
+ <div class="${options.groupClass}-title">${group}</div>
322
+ ${groupItems.map((item, i) => {
323
+ const globalIndex = items.indexOf(item)
324
+ return `
325
+ <button type="button"
326
+ class="${options.itemClass} ${globalIndex === selectedIndex ? options.activeClass : ""}"
327
+ role="option"
328
+ aria-selected="${globalIndex === selectedIndex}"
329
+ data-index="${globalIndex}">
330
+ <span class="${options.itemClass}-icon">${item.icon || ""}</span>
331
+ <span class="${options.itemClass}-content">
332
+ <span class="${options.itemClass}-title">${item.title || ""}</span>
333
+ <span class="${options.itemClass}-description">${item.description || ""}</span>
334
+ </span>
335
+ </button>
336
+ `
337
+ }).join("")}
338
+ </div>
339
+ `).join("")
340
+
341
+ popup.querySelectorAll(`.${options.itemClass}`).forEach(button => {
342
+ button.addEventListener("click", () => {
343
+ const index = parseInt(button.dataset.index)
344
+ command(items[index])
345
+ })
346
+ })
347
+ }
348
+
349
+ const positionPopup = (clientRect) => {
350
+ if (!popup || !clientRect) return
351
+
352
+ const rect = clientRect()
353
+ if (!rect) return
354
+
355
+ popup.style.position = "fixed"
356
+ popup.style.left = `${rect.left}px`
357
+ popup.style.top = `${rect.bottom + 8}px`
358
+ popup.style.zIndex = "10000"
359
+
360
+ const popupRect = popup.getBoundingClientRect()
361
+ const viewportHeight = window.innerHeight
362
+ const viewportWidth = window.innerWidth
363
+
364
+ if (popupRect.bottom > viewportHeight) {
365
+ popup.style.top = `${rect.top - popupRect.height - 8}px`
366
+ }
367
+
368
+ if (popupRect.right > viewportWidth) {
369
+ popup.style.left = `${viewportWidth - popupRect.width - 16}px`
370
+ }
371
+ }
372
+
373
+ const updateSelection = (newIndex) => {
374
+ selectedIndex = newIndex
375
+
376
+ popup?.querySelectorAll(`.${options.itemClass}`).forEach((button, i) => {
377
+ const isSelected = i === selectedIndex
378
+ button.classList.toggle(options.activeClass, isSelected)
379
+ button.setAttribute("aria-selected", isSelected.toString())
380
+ })
381
+ }
382
+
383
+ return {
384
+ onStart: (props) => {
385
+ popup = createPopup()
386
+ updatePopup(props.items, props.command)
387
+ positionPopup(props.clientRect)
388
+ options.onOpen?.()
389
+ },
390
+
391
+ onUpdate: (props) => {
392
+ updatePopup(props.items, props.command)
393
+ positionPopup(props.clientRect)
394
+ },
395
+
396
+ onKeyDown: (props) => {
397
+ const { event } = props
398
+
399
+ if (event.key === "ArrowDown") {
400
+ event.preventDefault()
401
+ updateSelection((selectedIndex + 1) % items.length)
402
+ return true
403
+ }
404
+
405
+ if (event.key === "ArrowUp") {
406
+ event.preventDefault()
407
+ updateSelection((selectedIndex - 1 + items.length) % items.length)
408
+ return true
409
+ }
410
+
411
+ if (event.key === "Enter") {
412
+ event.preventDefault()
413
+ if (items[selectedIndex] && command) {
414
+ command(items[selectedIndex])
415
+ }
416
+ return true
417
+ }
418
+
419
+ if (event.key === "Escape") {
420
+ event.preventDefault()
421
+ return true
422
+ }
423
+
424
+ return false
425
+ },
426
+
427
+ onExit: () => {
428
+ popup?.remove()
429
+ popup = null
430
+ options.onClose?.()
431
+ }
432
+ }
433
+ }
434
+ })
435
+ ]
436
+ }
437
+ })
438
+
439
+ export default SlashCommands