@puredesktop/platform-editor 1.0.0-beta.1 → 1.0.0-beta.2

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 (45) hide show
  1. package/package.json +1 -1
  2. package/src/CollapsePanel.tsx +35 -35
  3. package/src/DocumentDiffView.tsx +130 -130
  4. package/src/DocumentEditor.test.ts +118 -118
  5. package/src/DocumentEditor.tsx +2651 -2631
  6. package/src/alignedLineDiff.test.ts +52 -52
  7. package/src/alignedLineDiff.ts +67 -67
  8. package/src/collectionAssetSrc.ts +2 -2
  9. package/src/commentUtils.test.ts +350 -350
  10. package/src/commentUtils.ts +546 -546
  11. package/src/constants/toolKeys.ts +14 -14
  12. package/src/contentFormat.test.ts +27 -27
  13. package/src/contentFormat.ts +18 -18
  14. package/src/editorExtensions.ts +155 -155
  15. package/src/extensions/appliedChangeMark.ts +115 -115
  16. package/src/extensions/autoReviewPrompts.test.ts +529 -529
  17. package/src/extensions/autoReviewPrompts.ts +1442 -1442
  18. package/src/extensions/collectionImage.ts +26 -26
  19. package/src/extensions/collectionImagePaste.ts +215 -215
  20. package/src/extensions/commentMark.ts +223 -223
  21. package/src/extensions/documentAssetIdentity.ts +54 -54
  22. package/src/extensions/figure.ts +92 -92
  23. package/src/extensions/footnote.ts +186 -186
  24. package/src/extensions/indexMarker.ts +88 -88
  25. package/src/extensions/mathEditing.ts +239 -239
  26. package/src/extensions/mermaidBlock.tsx +297 -297
  27. package/src/extensions/slashCommands.test.ts +170 -170
  28. package/src/extensions/slashCommands.ts +746 -746
  29. package/src/extensions/smartTypography.test.ts +74 -74
  30. package/src/extensions/smartTypography.ts +120 -120
  31. package/src/index.ts +138 -137
  32. package/src/insertCollectionImage.test.ts +60 -60
  33. package/src/insertCollectionImage.ts +63 -63
  34. package/src/insertInlineAssetKind.test.ts +67 -67
  35. package/src/insertInlineAssetKind.ts +49 -49
  36. package/src/mermaidPreview.test.ts +54 -54
  37. package/src/mermaidPreview.ts +115 -115
  38. package/src/styled.ts +697 -676
  39. package/src/toolbar/ToolBtn.tsx +77 -63
  40. package/src/toolbar/Toolbar.test.tsx +325 -325
  41. package/src/toolbar/Toolbar.tsx +662 -624
  42. package/src/toolbar/groups/HeadingGroup.tsx +175 -153
  43. package/src/toolbar/overlayTypes.ts +28 -28
  44. package/src/useEditorExtensions.ts +22 -22
  45. package/src/utils/markdownUtils.ts +331 -331
@@ -1,746 +1,746 @@
1
- import { Extension, type Editor } from '@tiptap/core'
2
- import { Plugin, PluginKey, type EditorState } from '@tiptap/pm/state'
3
- import type { EditorView } from '@tiptap/pm/view'
4
- import {
5
- insertEditableBlockMath,
6
- insertEditableInlineMath,
7
- } from './mathEditing.js'
8
-
9
- export interface SlashCommandRange {
10
- from: number
11
- to: number
12
- }
13
-
14
- export interface SlashCommandRunOptions {
15
- editor: Editor
16
- range: SlashCommandRange
17
- query: string
18
- source: 'typed' | 'selection'
19
- }
20
-
21
- export interface SlashCommandItem {
22
- id: string
23
- title: string
24
- description?: string
25
- keywords?: string[]
26
- section?: string
27
- run: (options: SlashCommandRunOptions) => void
28
- }
29
-
30
- interface SlashCommandMatch {
31
- range: SlashCommandRange
32
- query: string
33
- }
34
-
35
- interface ActiveSlashCommandState extends SlashCommandMatch {
36
- items: SlashCommandItem[]
37
- source: 'typed' | 'selection'
38
- }
39
-
40
- interface SlashCommandsOptions {
41
- items: SlashCommandItem[]
42
- }
43
-
44
- const SLASH_COMMAND_LIMIT = 32
45
- const SLASH_MENU_MARGIN_PX = 12
46
- const SLASH_MENU_GAP_PX = 8
47
- const SLASH_MENU_MAX_HEIGHT_PX = 360
48
- const SLASH_MENU_MIN_HEIGHT_PX = 120
49
-
50
- export const SlashCommands = Extension.create<SlashCommandsOptions>({
51
- name: 'slashCommands',
52
-
53
- addOptions() {
54
- return {
55
- items: [],
56
- }
57
- },
58
-
59
- addProseMirrorPlugins() {
60
- const extension = this
61
- let active: ActiveSlashCommandState | null = null
62
- let selectedIndex = 0
63
- let menu: HTMLDivElement | null = null
64
- let detachViewportListeners: (() => void) | null = null
65
-
66
- const emitOpenState = (open: boolean): void => {
67
- extension.editor?.view?.dom?.dispatchEvent(
68
- new CustomEvent('ps-slash-menu-toggle', { detail: { open } }),
69
- )
70
- }
71
-
72
- const closeMenu = (): void => {
73
- const wasOpen = menu !== null
74
- active = null
75
- selectedIndex = 0
76
- detachViewportListeners?.()
77
- detachViewportListeners = null
78
- menu?.remove()
79
- menu = null
80
- if (wasOpen) emitOpenState(false)
81
- }
82
-
83
- const positionMenu = (view: EditorView): void => {
84
- if (!active || !menu) return
85
-
86
- const coords = view.coordsAtPos(active.range.from)
87
- const viewportWidth = window.innerWidth
88
- const viewportHeight = window.innerHeight
89
- const margin = SLASH_MENU_MARGIN_PX
90
- const gap = SLASH_MENU_GAP_PX
91
- const maxPanelWidth = Math.max(0, viewportWidth - margin * 2)
92
-
93
- menu.style.maxHeight = `${SLASH_MENU_MAX_HEIGHT_PX}px`
94
- menu.style.overflowY = 'auto'
95
-
96
- const measured = menu.getBoundingClientRect()
97
- const width = Math.min(
98
- Math.max(measured.width || 280, 280),
99
- maxPanelWidth,
100
- )
101
- const naturalHeight = Math.min(
102
- menu.scrollHeight,
103
- SLASH_MENU_MAX_HEIGHT_PX,
104
- )
105
- const spaceBelow = Math.max(
106
- 0,
107
- viewportHeight - coords.bottom - gap - margin,
108
- )
109
- const spaceAbove = Math.max(0, coords.top - gap - margin)
110
- const openAbove = naturalHeight > spaceBelow && spaceAbove > spaceBelow
111
- const availableSpace = openAbove ? spaceAbove : spaceBelow
112
- const height = Math.max(
113
- Math.min(naturalHeight, availableSpace),
114
- Math.min(SLASH_MENU_MIN_HEIGHT_PX, naturalHeight, availableSpace),
115
- )
116
- const left = Math.min(
117
- Math.max(margin, coords.left),
118
- Math.max(margin, viewportWidth - width - margin),
119
- )
120
- const top = openAbove
121
- ? Math.max(margin, coords.top - gap - height)
122
- : Math.min(coords.bottom + gap, viewportHeight - margin - height)
123
-
124
- menu.style.left = `${left}px`
125
- menu.style.top = `${top}px`
126
- menu.style.width = `${width}px`
127
- menu.style.maxHeight = `${height}px`
128
- }
129
-
130
- const attachViewportListeners = (view: EditorView): void => {
131
- if (detachViewportListeners) return
132
-
133
- let animationFrame = 0
134
- const reposition = (): void => {
135
- cancelAnimationFrame(animationFrame)
136
- animationFrame = requestAnimationFrame(() => {
137
- positionMenu(view)
138
- })
139
- }
140
-
141
- window.addEventListener('resize', reposition)
142
- window.addEventListener('scroll', reposition, true)
143
- detachViewportListeners = () => {
144
- cancelAnimationFrame(animationFrame)
145
- window.removeEventListener('resize', reposition)
146
- window.removeEventListener('scroll', reposition, true)
147
- }
148
- }
149
-
150
- const renderMenu = (view: EditorView): void => {
151
- if (!active || !active.items.length) {
152
- closeMenu()
153
- return
154
- }
155
-
156
- const justOpened = !menu
157
- if (!menu) {
158
- menu = document.createElement('div')
159
- menu.className = 'ps-slash-command-menu'
160
- document.body.appendChild(menu)
161
- }
162
- attachViewportListeners(view)
163
- if (justOpened) emitOpenState(true)
164
-
165
- menu.replaceChildren()
166
-
167
- let previousSection: string | null = null
168
-
169
- active.items.forEach((item, index) => {
170
- const section = item.section ?? 'Writing'
171
- if (section !== previousSection) {
172
- if (previousSection !== null) {
173
- const divider = document.createElement('div')
174
- divider.className = 'ps-slash-command-divider'
175
- menu?.appendChild(divider)
176
- }
177
-
178
- const heading = document.createElement('div')
179
- heading.className = 'ps-slash-command-section'
180
- heading.textContent = section
181
- menu?.appendChild(heading)
182
- previousSection = section
183
- }
184
-
185
- const button = document.createElement('button')
186
- button.type = 'button'
187
- button.className = 'ps-slash-command-item'
188
- button.dataset.active = String(index === selectedIndex)
189
- button.addEventListener('mousedown', event => {
190
- event.preventDefault()
191
- runItem(view, item)
192
- })
193
-
194
- const title = document.createElement('span')
195
- title.className = 'ps-slash-command-title'
196
- title.textContent = item.title
197
- button.appendChild(title)
198
-
199
- if (item.description) {
200
- const description = document.createElement('span')
201
- description.className = 'ps-slash-command-description'
202
- description.textContent = item.description
203
- button.appendChild(description)
204
- }
205
-
206
- menu?.appendChild(button)
207
- })
208
-
209
- positionMenu(view)
210
- menu
211
- .querySelector<HTMLButtonElement>(
212
- '.ps-slash-command-item[data-active="true"]',
213
- )
214
- ?.scrollIntoView({ block: 'nearest' })
215
- }
216
-
217
- const runItem = (view: EditorView, item: SlashCommandItem): void => {
218
- if (!active) return
219
- const current = active
220
- closeMenu()
221
- item.run({
222
- editor: extension.editor,
223
- range: current.range,
224
- query: current.query,
225
- source: current.source,
226
- })
227
- view.focus()
228
- }
229
-
230
- const updateActiveState = (state: EditorState): void => {
231
- // A selection-opened menu has no typed "/" to match, so the typed-match
232
- // logic below would close it on the next view update. Keep it open until
233
- // the selection collapses (or it's dismissed via Escape/click/run).
234
- if (active?.source === 'selection') {
235
- if (state.selection.empty) closeMenu()
236
- return
237
- }
238
-
239
- const match = findSlashCommandMatch(state)
240
- if (!match) {
241
- closeMenu()
242
- return
243
- }
244
-
245
- const items = filterSlashCommandItems(
246
- extension.options.items,
247
- match.query,
248
- )
249
- if (!items.length) {
250
- closeMenu()
251
- return
252
- }
253
-
254
- active = { ...match, items, source: 'typed' }
255
- selectedIndex = Math.min(selectedIndex, items.length - 1)
256
- }
257
-
258
- return [
259
- new Plugin({
260
- key: new PluginKey('slashCommands'),
261
- props: {
262
- handleKeyDown(view, event) {
263
- if (!active && event.key === '/') {
264
- const { from, to, empty } = view.state.selection
265
- if (!empty) {
266
- event.preventDefault()
267
- const items = filterSlashCommandItems(extension.options.items, '')
268
- if (!items.length) return true
269
- active = {
270
- range: { from, to },
271
- query: '',
272
- items,
273
- source: 'selection',
274
- }
275
- selectedIndex = 0
276
- renderMenu(view)
277
- return true
278
- }
279
- }
280
-
281
- if (!active) return false
282
-
283
- if (active.source === 'selection') {
284
- if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
285
- event.preventDefault()
286
- active = {
287
- ...active,
288
- query: `${active.query}${event.key}`.trim(),
289
- items: filterSlashCommandItems(
290
- extension.options.items,
291
- `${active.query}${event.key}`.trim(),
292
- ),
293
- }
294
- selectedIndex = 0
295
- renderMenu(view)
296
- return true
297
- }
298
-
299
- if (event.key === 'Backspace') {
300
- event.preventDefault()
301
- const query = active.query.slice(0, -1)
302
- active = {
303
- ...active,
304
- query,
305
- items: filterSlashCommandItems(extension.options.items, query),
306
- }
307
- selectedIndex = 0
308
- renderMenu(view)
309
- return true
310
- }
311
- }
312
-
313
- if (event.key === 'ArrowDown') {
314
- event.preventDefault()
315
- selectedIndex = (selectedIndex + 1) % active.items.length
316
- renderMenu(view)
317
- return true
318
- }
319
-
320
- if (event.key === 'ArrowUp') {
321
- event.preventDefault()
322
- selectedIndex =
323
- (selectedIndex - 1 + active.items.length) % active.items.length
324
- renderMenu(view)
325
- return true
326
- }
327
-
328
- if (event.key === 'Enter') {
329
- event.preventDefault()
330
- runItem(view, active.items[selectedIndex])
331
- return true
332
- }
333
-
334
- if (event.key === 'Escape') {
335
- event.preventDefault()
336
- closeMenu()
337
- return true
338
- }
339
-
340
- return false
341
- },
342
- },
343
- view(view) {
344
- injectSlashCommandStyles()
345
- updateActiveState(view.state)
346
- renderMenu(view)
347
-
348
- return {
349
- update(nextView) {
350
- updateActiveState(nextView.state)
351
- renderMenu(nextView)
352
- },
353
- destroy() {
354
- closeMenu()
355
- },
356
- }
357
- },
358
- }),
359
- ]
360
- },
361
- })
362
-
363
- interface DefaultSlashCommandOptions {
364
- includePrompts?: boolean
365
- }
366
-
367
- type ChainWithOptionalCommands = ReturnType<Editor['chain']> & {
368
- toggleUnderline?: () => ChainWithOptionalCommands
369
- toggleCode?: () => ChainWithOptionalCommands
370
- toggleTaskList?: () => ChainWithOptionalCommands
371
- sinkListItem?: (type: string) => ChainWithOptionalCommands
372
- liftListItem?: (type: string) => ChainWithOptionalCommands
373
- insertTable?: (options: {
374
- rows: number
375
- cols: number
376
- withHeaderRow: boolean
377
- }) => ChainWithOptionalCommands
378
- insertFootnote?: () => ChainWithOptionalCommands
379
- toggleIndexMarker?: () => ChainWithOptionalCommands
380
- insertInlineMath?: (options: { latex: string }) => ChainWithOptionalCommands
381
- insertBlockMath?: (options: { latex: string }) => ChainWithOptionalCommands
382
- insertMermaidBlock?: () => ChainWithOptionalCommands
383
- setLink?: (attributes: { href: string }) => ChainWithOptionalCommands
384
- }
385
-
386
- function commandChain(
387
- editor: Editor,
388
- range: SlashCommandRange,
389
- source: SlashCommandRunOptions['source'],
390
- ): ChainWithOptionalCommands {
391
- const chain = editor.chain().focus() as ChainWithOptionalCommands
392
- if (source === 'typed') return chain.deleteRange(range)
393
- // Opened from a selection: re-assert the original range so toggles like
394
- // bold/italic actually transform the selected text (a bare focus() can land
395
- // on a collapsed cursor and no-op).
396
- if (source === 'selection') return chain.setTextSelection(range)
397
- return chain
398
- }
399
-
400
- export function createDefaultSlashCommands(
401
- options: DefaultSlashCommandOptions = {},
402
- ): SlashCommandItem[] {
403
- const promptEnabled = options.includePrompts ?? true
404
- return [
405
- {
406
- id: 'paragraph',
407
- title: 'Text',
408
- description: 'Start a normal paragraph',
409
- keywords: ['paragraph', 'normal'],
410
- section: 'Writing',
411
- run: ({ editor, range, source }) => {
412
- commandChain(editor, range, source).setParagraph().run()
413
- },
414
- },
415
- {
416
- id: 'heading-1',
417
- title: 'Heading 1',
418
- description: 'Large section heading',
419
- keywords: ['title', 'h1'],
420
- section: 'Writing',
421
- run: ({ editor, range, source }) => {
422
- commandChain(editor, range, source).setNode('heading', { level: 1 }).run()
423
- },
424
- },
425
- {
426
- id: 'heading-2',
427
- title: 'Heading 2',
428
- description: 'Medium section heading',
429
- keywords: ['subtitle', 'h2'],
430
- section: 'Writing',
431
- run: ({ editor, range, source }) => {
432
- commandChain(editor, range, source).setNode('heading', { level: 2 }).run()
433
- },
434
- },
435
- {
436
- id: 'heading-3',
437
- title: 'Heading 3',
438
- description: 'Small section heading',
439
- keywords: ['subtitle', 'h3'],
440
- section: 'Writing',
441
- run: ({ editor, range, source }) => {
442
- commandChain(editor, range, source).setNode('heading', { level: 3 }).run()
443
- },
444
- },
445
- {
446
- id: 'bold',
447
- title: 'Bold',
448
- description: 'Emphasize the selected text',
449
- keywords: ['strong'],
450
- section: 'Formatting',
451
- run: ({ editor, range, source }) => {
452
- commandChain(editor, range, source).toggleBold().run()
453
- },
454
- },
455
- {
456
- id: 'italic',
457
- title: 'Italic',
458
- description: 'Italicize the selected text',
459
- keywords: ['emphasis'],
460
- section: 'Formatting',
461
- run: ({ editor, range, source }) => {
462
- commandChain(editor, range, source).toggleItalic().run()
463
- },
464
- },
465
- {
466
- id: 'underline',
467
- title: 'Underline',
468
- description: 'Underline the selected text',
469
- section: 'Formatting',
470
- run: ({ editor, range, source }) => {
471
- commandChain(editor, range, source).toggleUnderline?.()?.run()
472
- },
473
- },
474
- {
475
- id: 'inline-code',
476
- title: 'Inline code',
477
- description: 'Mark text as code',
478
- keywords: ['code'],
479
- section: 'Formatting',
480
- run: ({ editor, range, source }) => {
481
- commandChain(editor, range, source).toggleCode?.()?.run()
482
- },
483
- },
484
- {
485
- id: 'link',
486
- title: 'Link',
487
- description: 'Add a link to the selected text',
488
- keywords: ['url', 'href'],
489
- section: 'Formatting',
490
- run: ({ editor, range, source }) => {
491
- if (!promptEnabled) return
492
- const href = window.prompt('Link URL')?.trim()
493
- if (!href) return
494
- commandChain(editor, range, source).setLink?.({ href })?.run()
495
- },
496
- },
497
- {
498
- id: 'bullet-list',
499
- title: 'Bullet list',
500
- description: 'Create a simple list',
501
- keywords: ['unordered', 'list'],
502
- section: 'Lists',
503
- run: ({ editor, range, source }) => {
504
- commandChain(editor, range, source).toggleBulletList().run()
505
- },
506
- },
507
- {
508
- id: 'numbered-list',
509
- title: 'Numbered list',
510
- description: 'Create an ordered list',
511
- keywords: ['ordered', 'list'],
512
- section: 'Lists',
513
- run: ({ editor, range, source }) => {
514
- commandChain(editor, range, source).toggleOrderedList().run()
515
- },
516
- },
517
- {
518
- id: 'task-list',
519
- title: 'Task list',
520
- description: 'Create a checklist',
521
- keywords: ['todo', 'checkbox'],
522
- section: 'Lists',
523
- run: ({ editor, range, source }) => {
524
- commandChain(editor, range, source).toggleTaskList?.()?.run()
525
- },
526
- },
527
- {
528
- id: 'indent-list',
529
- title: 'Indent list item',
530
- description: 'Nest the current list item',
531
- keywords: ['sink', 'nest'],
532
- section: 'Lists',
533
- run: ({ editor, range, source }) => {
534
- commandChain(editor, range, source).sinkListItem?.('listItem')?.run()
535
- },
536
- },
537
- {
538
- id: 'outdent-list',
539
- title: 'Outdent list item',
540
- description: 'Lift the current list item',
541
- keywords: ['lift', 'unnest'],
542
- section: 'Lists',
543
- run: ({ editor, range, source }) => {
544
- commandChain(editor, range, source).liftListItem?.('listItem')?.run()
545
- },
546
- },
547
- {
548
- id: 'quote',
549
- title: 'Quote',
550
- description: 'Call out a quoted passage',
551
- keywords: ['blockquote', 'citation'],
552
- section: 'Blocks',
553
- run: ({ editor, range, source }) => {
554
- commandChain(editor, range, source).toggleBlockquote().run()
555
- },
556
- },
557
- {
558
- id: 'code-block',
559
- title: 'Code block',
560
- description: 'Insert a code section',
561
- keywords: ['code', 'pre'],
562
- section: 'Blocks',
563
- run: ({ editor, range, source }) => {
564
- commandChain(editor, range, source).toggleCodeBlock().run()
565
- },
566
- },
567
- {
568
- id: 'table',
569
- title: 'Table',
570
- description: 'Insert a simple table',
571
- keywords: ['grid'],
572
- section: 'Blocks',
573
- run: ({ editor, range, source }) => {
574
- commandChain(editor, range, source)
575
- .insertTable?.({ rows: 3, cols: 3, withHeaderRow: true })
576
- ?.run()
577
- },
578
- },
579
- {
580
- id: 'footnote',
581
- title: 'Footnote',
582
- description: 'Insert a footnote marker',
583
- keywords: ['note', 'reference'],
584
- section: 'Blocks',
585
- run: ({ editor, range, source }) => {
586
- commandChain(editor, range, source).insertFootnote?.()?.run()
587
- },
588
- },
589
- {
590
- id: 'index-marker',
591
- title: 'Index marker',
592
- description: 'Mark selected text for the index',
593
- keywords: ['index'],
594
- section: 'Blocks',
595
- run: ({ editor, range, source }) => {
596
- commandChain(editor, range, source).toggleIndexMarker?.()?.run()
597
- },
598
- },
599
- {
600
- id: 'inline-equation',
601
- title: 'Inline equation',
602
- description: 'Insert an editable inline equation',
603
- keywords: ['math', 'latex'],
604
- section: 'Blocks',
605
- run: ({ editor, range, source }) => {
606
- // Remove the typed "/…" text, then insert an empty equation and open
607
- // its editor immediately (live preview; renders in place on blur).
608
- commandChain(editor, range, source).run()
609
- insertEditableInlineMath(editor)
610
- },
611
- },
612
- {
613
- id: 'display-equation',
614
- title: 'Display equation',
615
- description: 'Insert an editable centered equation',
616
- keywords: ['math', 'latex', 'block'],
617
- section: 'Blocks',
618
- run: ({ editor, range, source }) => {
619
- commandChain(editor, range, source).run()
620
- insertEditableBlockMath(editor)
621
- },
622
- },
623
- {
624
- id: 'mermaid',
625
- title: 'Mermaid diagram',
626
- description: 'Insert a diagram block',
627
- keywords: ['diagram', 'flowchart'],
628
- section: 'Blocks',
629
- run: ({ editor, range, source }) => {
630
- commandChain(editor, range, source).insertMermaidBlock?.()?.run()
631
- },
632
- },
633
- ]
634
- }
635
-
636
- function findSlashCommandMatch(state: EditorState): SlashCommandMatch | null {
637
- const { selection } = state
638
- if (!selection.empty) return null
639
-
640
- const { $from } = selection
641
- const textBefore = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0')
642
- const match = /(?:^|\s)\/([\w\s-]{0,48})$/.exec(textBefore)
643
- if (!match) return null
644
-
645
- const fullMatch = match[0]
646
- const slashOffset = fullMatch.lastIndexOf('/')
647
- const from = $from.pos - (fullMatch.length - slashOffset)
648
-
649
- return {
650
- range: { from, to: $from.pos },
651
- query: match[1]?.trim() ?? '',
652
- }
653
- }
654
-
655
- function filterSlashCommandItems(
656
- items: SlashCommandItem[],
657
- query: string,
658
- ): SlashCommandItem[] {
659
- const normalizedQuery = query.toLowerCase()
660
- if (!normalizedQuery) return items
661
-
662
- return items
663
- .filter(item => {
664
- const haystack = [
665
- item.title,
666
- item.description ?? '',
667
- ...(item.keywords ?? []),
668
- ]
669
- .join(' ')
670
- .toLowerCase()
671
- return haystack.includes(normalizedQuery)
672
- })
673
- .slice(0, SLASH_COMMAND_LIMIT)
674
- }
675
-
676
- function injectSlashCommandStyles(): void {
677
- if (document.getElementById('ps-slash-command-styles')) return
678
-
679
- const style = document.createElement('style')
680
- style.id = 'ps-slash-command-styles'
681
- style.textContent = `
682
- .ps-slash-command-menu {
683
- position: fixed;
684
- z-index: var(--platform-z-index-zi-app-menus, 1000);
685
- box-sizing: border-box;
686
- min-width: 280px;
687
- max-width: 340px;
688
- padding: 6px;
689
- border: 1px solid var(--platform-colors-border, #d7dbe0);
690
- border-radius: 10px;
691
- background: var(--platform-colors-surface, #fff);
692
- box-shadow: 0 14px 34px rgb(23 29 36 / 0.14);
693
- color: var(--platform-colors-text, #2e3338);
694
- font-family: var(--platform-typography-font-family, system-ui, sans-serif);
695
- overflow-x: hidden;
696
- overscroll-behavior: contain;
697
- }
698
-
699
- .ps-slash-command-item {
700
- display: grid;
701
- width: 100%;
702
- gap: 2px;
703
- padding: 8px 10px;
704
- border: 0;
705
- border-radius: 7px;
706
- background: transparent;
707
- color: inherit;
708
- font: inherit;
709
- text-align: left;
710
- cursor: pointer;
711
- }
712
-
713
- .ps-slash-command-section {
714
- color: var(--platform-colors-text-tertiary, #8a9097);
715
- font-size: 10px;
716
- font-weight: 700;
717
- letter-spacing: 0.08em;
718
- line-height: 1.2;
719
- padding: 9px 10px 5px;
720
- text-transform: uppercase;
721
- }
722
-
723
- .ps-slash-command-divider {
724
- border-top: 1px solid var(--platform-colors-border, #d7dbe0);
725
- margin: 6px 4px 2px;
726
- }
727
-
728
- .ps-slash-command-item[data-active="true"],
729
- .ps-slash-command-item:hover {
730
- background: var(--platform-colors-surface-active, #edeff2);
731
- }
732
-
733
- .ps-slash-command-title {
734
- font-size: 13px;
735
- font-weight: 650;
736
- line-height: 1.25;
737
- }
738
-
739
- .ps-slash-command-description {
740
- color: var(--platform-colors-text-secondary, #6e737a);
741
- font-size: 12px;
742
- line-height: 1.25;
743
- }
744
- `
745
- document.head.appendChild(style)
746
- }
1
+ import { Extension, type Editor } from '@tiptap/core'
2
+ import { Plugin, PluginKey, type EditorState } from '@tiptap/pm/state'
3
+ import type { EditorView } from '@tiptap/pm/view'
4
+ import {
5
+ insertEditableBlockMath,
6
+ insertEditableInlineMath,
7
+ } from './mathEditing.js'
8
+
9
+ export interface SlashCommandRange {
10
+ from: number
11
+ to: number
12
+ }
13
+
14
+ export interface SlashCommandRunOptions {
15
+ editor: Editor
16
+ range: SlashCommandRange
17
+ query: string
18
+ source: 'typed' | 'selection'
19
+ }
20
+
21
+ export interface SlashCommandItem {
22
+ id: string
23
+ title: string
24
+ description?: string
25
+ keywords?: string[]
26
+ section?: string
27
+ run: (options: SlashCommandRunOptions) => void
28
+ }
29
+
30
+ interface SlashCommandMatch {
31
+ range: SlashCommandRange
32
+ query: string
33
+ }
34
+
35
+ interface ActiveSlashCommandState extends SlashCommandMatch {
36
+ items: SlashCommandItem[]
37
+ source: 'typed' | 'selection'
38
+ }
39
+
40
+ interface SlashCommandsOptions {
41
+ items: SlashCommandItem[]
42
+ }
43
+
44
+ const SLASH_COMMAND_LIMIT = 32
45
+ const SLASH_MENU_MARGIN_PX = 12
46
+ const SLASH_MENU_GAP_PX = 8
47
+ const SLASH_MENU_MAX_HEIGHT_PX = 360
48
+ const SLASH_MENU_MIN_HEIGHT_PX = 120
49
+
50
+ export const SlashCommands = Extension.create<SlashCommandsOptions>({
51
+ name: 'slashCommands',
52
+
53
+ addOptions() {
54
+ return {
55
+ items: [],
56
+ }
57
+ },
58
+
59
+ addProseMirrorPlugins() {
60
+ const extension = this
61
+ let active: ActiveSlashCommandState | null = null
62
+ let selectedIndex = 0
63
+ let menu: HTMLDivElement | null = null
64
+ let detachViewportListeners: (() => void) | null = null
65
+
66
+ const emitOpenState = (open: boolean): void => {
67
+ extension.editor?.view?.dom?.dispatchEvent(
68
+ new CustomEvent('ps-slash-menu-toggle', { detail: { open } }),
69
+ )
70
+ }
71
+
72
+ const closeMenu = (): void => {
73
+ const wasOpen = menu !== null
74
+ active = null
75
+ selectedIndex = 0
76
+ detachViewportListeners?.()
77
+ detachViewportListeners = null
78
+ menu?.remove()
79
+ menu = null
80
+ if (wasOpen) emitOpenState(false)
81
+ }
82
+
83
+ const positionMenu = (view: EditorView): void => {
84
+ if (!active || !menu) return
85
+
86
+ const coords = view.coordsAtPos(active.range.from)
87
+ const viewportWidth = window.innerWidth
88
+ const viewportHeight = window.innerHeight
89
+ const margin = SLASH_MENU_MARGIN_PX
90
+ const gap = SLASH_MENU_GAP_PX
91
+ const maxPanelWidth = Math.max(0, viewportWidth - margin * 2)
92
+
93
+ menu.style.maxHeight = `${SLASH_MENU_MAX_HEIGHT_PX}px`
94
+ menu.style.overflowY = 'auto'
95
+
96
+ const measured = menu.getBoundingClientRect()
97
+ const width = Math.min(
98
+ Math.max(measured.width || 280, 280),
99
+ maxPanelWidth,
100
+ )
101
+ const naturalHeight = Math.min(
102
+ menu.scrollHeight,
103
+ SLASH_MENU_MAX_HEIGHT_PX,
104
+ )
105
+ const spaceBelow = Math.max(
106
+ 0,
107
+ viewportHeight - coords.bottom - gap - margin,
108
+ )
109
+ const spaceAbove = Math.max(0, coords.top - gap - margin)
110
+ const openAbove = naturalHeight > spaceBelow && spaceAbove > spaceBelow
111
+ const availableSpace = openAbove ? spaceAbove : spaceBelow
112
+ const height = Math.max(
113
+ Math.min(naturalHeight, availableSpace),
114
+ Math.min(SLASH_MENU_MIN_HEIGHT_PX, naturalHeight, availableSpace),
115
+ )
116
+ const left = Math.min(
117
+ Math.max(margin, coords.left),
118
+ Math.max(margin, viewportWidth - width - margin),
119
+ )
120
+ const top = openAbove
121
+ ? Math.max(margin, coords.top - gap - height)
122
+ : Math.min(coords.bottom + gap, viewportHeight - margin - height)
123
+
124
+ menu.style.left = `${left}px`
125
+ menu.style.top = `${top}px`
126
+ menu.style.width = `${width}px`
127
+ menu.style.maxHeight = `${height}px`
128
+ }
129
+
130
+ const attachViewportListeners = (view: EditorView): void => {
131
+ if (detachViewportListeners) return
132
+
133
+ let animationFrame = 0
134
+ const reposition = (): void => {
135
+ cancelAnimationFrame(animationFrame)
136
+ animationFrame = requestAnimationFrame(() => {
137
+ positionMenu(view)
138
+ })
139
+ }
140
+
141
+ window.addEventListener('resize', reposition)
142
+ window.addEventListener('scroll', reposition, true)
143
+ detachViewportListeners = () => {
144
+ cancelAnimationFrame(animationFrame)
145
+ window.removeEventListener('resize', reposition)
146
+ window.removeEventListener('scroll', reposition, true)
147
+ }
148
+ }
149
+
150
+ const renderMenu = (view: EditorView): void => {
151
+ if (!active || !active.items.length) {
152
+ closeMenu()
153
+ return
154
+ }
155
+
156
+ const justOpened = !menu
157
+ if (!menu) {
158
+ menu = document.createElement('div')
159
+ menu.className = 'ps-slash-command-menu'
160
+ document.body.appendChild(menu)
161
+ }
162
+ attachViewportListeners(view)
163
+ if (justOpened) emitOpenState(true)
164
+
165
+ menu.replaceChildren()
166
+
167
+ let previousSection: string | null = null
168
+
169
+ active.items.forEach((item, index) => {
170
+ const section = item.section ?? 'Writing'
171
+ if (section !== previousSection) {
172
+ if (previousSection !== null) {
173
+ const divider = document.createElement('div')
174
+ divider.className = 'ps-slash-command-divider'
175
+ menu?.appendChild(divider)
176
+ }
177
+
178
+ const heading = document.createElement('div')
179
+ heading.className = 'ps-slash-command-section'
180
+ heading.textContent = section
181
+ menu?.appendChild(heading)
182
+ previousSection = section
183
+ }
184
+
185
+ const button = document.createElement('button')
186
+ button.type = 'button'
187
+ button.className = 'ps-slash-command-item'
188
+ button.dataset.active = String(index === selectedIndex)
189
+ button.addEventListener('mousedown', event => {
190
+ event.preventDefault()
191
+ runItem(view, item)
192
+ })
193
+
194
+ const title = document.createElement('span')
195
+ title.className = 'ps-slash-command-title'
196
+ title.textContent = item.title
197
+ button.appendChild(title)
198
+
199
+ if (item.description) {
200
+ const description = document.createElement('span')
201
+ description.className = 'ps-slash-command-description'
202
+ description.textContent = item.description
203
+ button.appendChild(description)
204
+ }
205
+
206
+ menu?.appendChild(button)
207
+ })
208
+
209
+ positionMenu(view)
210
+ menu
211
+ .querySelector<HTMLButtonElement>(
212
+ '.ps-slash-command-item[data-active="true"]',
213
+ )
214
+ ?.scrollIntoView({ block: 'nearest' })
215
+ }
216
+
217
+ const runItem = (view: EditorView, item: SlashCommandItem): void => {
218
+ if (!active) return
219
+ const current = active
220
+ closeMenu()
221
+ item.run({
222
+ editor: extension.editor,
223
+ range: current.range,
224
+ query: current.query,
225
+ source: current.source,
226
+ })
227
+ view.focus()
228
+ }
229
+
230
+ const updateActiveState = (state: EditorState): void => {
231
+ // A selection-opened menu has no typed "/" to match, so the typed-match
232
+ // logic below would close it on the next view update. Keep it open until
233
+ // the selection collapses (or it's dismissed via Escape/click/run).
234
+ if (active?.source === 'selection') {
235
+ if (state.selection.empty) closeMenu()
236
+ return
237
+ }
238
+
239
+ const match = findSlashCommandMatch(state)
240
+ if (!match) {
241
+ closeMenu()
242
+ return
243
+ }
244
+
245
+ const items = filterSlashCommandItems(
246
+ extension.options.items,
247
+ match.query,
248
+ )
249
+ if (!items.length) {
250
+ closeMenu()
251
+ return
252
+ }
253
+
254
+ active = { ...match, items, source: 'typed' }
255
+ selectedIndex = Math.min(selectedIndex, items.length - 1)
256
+ }
257
+
258
+ return [
259
+ new Plugin({
260
+ key: new PluginKey('slashCommands'),
261
+ props: {
262
+ handleKeyDown(view, event) {
263
+ if (!active && event.key === '/') {
264
+ const { from, to, empty } = view.state.selection
265
+ if (!empty) {
266
+ event.preventDefault()
267
+ const items = filterSlashCommandItems(extension.options.items, '')
268
+ if (!items.length) return true
269
+ active = {
270
+ range: { from, to },
271
+ query: '',
272
+ items,
273
+ source: 'selection',
274
+ }
275
+ selectedIndex = 0
276
+ renderMenu(view)
277
+ return true
278
+ }
279
+ }
280
+
281
+ if (!active) return false
282
+
283
+ if (active.source === 'selection') {
284
+ if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
285
+ event.preventDefault()
286
+ active = {
287
+ ...active,
288
+ query: `${active.query}${event.key}`.trim(),
289
+ items: filterSlashCommandItems(
290
+ extension.options.items,
291
+ `${active.query}${event.key}`.trim(),
292
+ ),
293
+ }
294
+ selectedIndex = 0
295
+ renderMenu(view)
296
+ return true
297
+ }
298
+
299
+ if (event.key === 'Backspace') {
300
+ event.preventDefault()
301
+ const query = active.query.slice(0, -1)
302
+ active = {
303
+ ...active,
304
+ query,
305
+ items: filterSlashCommandItems(extension.options.items, query),
306
+ }
307
+ selectedIndex = 0
308
+ renderMenu(view)
309
+ return true
310
+ }
311
+ }
312
+
313
+ if (event.key === 'ArrowDown') {
314
+ event.preventDefault()
315
+ selectedIndex = (selectedIndex + 1) % active.items.length
316
+ renderMenu(view)
317
+ return true
318
+ }
319
+
320
+ if (event.key === 'ArrowUp') {
321
+ event.preventDefault()
322
+ selectedIndex =
323
+ (selectedIndex - 1 + active.items.length) % active.items.length
324
+ renderMenu(view)
325
+ return true
326
+ }
327
+
328
+ if (event.key === 'Enter') {
329
+ event.preventDefault()
330
+ runItem(view, active.items[selectedIndex])
331
+ return true
332
+ }
333
+
334
+ if (event.key === 'Escape') {
335
+ event.preventDefault()
336
+ closeMenu()
337
+ return true
338
+ }
339
+
340
+ return false
341
+ },
342
+ },
343
+ view(view) {
344
+ injectSlashCommandStyles()
345
+ updateActiveState(view.state)
346
+ renderMenu(view)
347
+
348
+ return {
349
+ update(nextView) {
350
+ updateActiveState(nextView.state)
351
+ renderMenu(nextView)
352
+ },
353
+ destroy() {
354
+ closeMenu()
355
+ },
356
+ }
357
+ },
358
+ }),
359
+ ]
360
+ },
361
+ })
362
+
363
+ interface DefaultSlashCommandOptions {
364
+ includePrompts?: boolean
365
+ }
366
+
367
+ type ChainWithOptionalCommands = ReturnType<Editor['chain']> & {
368
+ toggleUnderline?: () => ChainWithOptionalCommands
369
+ toggleCode?: () => ChainWithOptionalCommands
370
+ toggleTaskList?: () => ChainWithOptionalCommands
371
+ sinkListItem?: (type: string) => ChainWithOptionalCommands
372
+ liftListItem?: (type: string) => ChainWithOptionalCommands
373
+ insertTable?: (options: {
374
+ rows: number
375
+ cols: number
376
+ withHeaderRow: boolean
377
+ }) => ChainWithOptionalCommands
378
+ insertFootnote?: () => ChainWithOptionalCommands
379
+ toggleIndexMarker?: () => ChainWithOptionalCommands
380
+ insertInlineMath?: (options: { latex: string }) => ChainWithOptionalCommands
381
+ insertBlockMath?: (options: { latex: string }) => ChainWithOptionalCommands
382
+ insertMermaidBlock?: () => ChainWithOptionalCommands
383
+ setLink?: (attributes: { href: string }) => ChainWithOptionalCommands
384
+ }
385
+
386
+ function commandChain(
387
+ editor: Editor,
388
+ range: SlashCommandRange,
389
+ source: SlashCommandRunOptions['source'],
390
+ ): ChainWithOptionalCommands {
391
+ const chain = editor.chain().focus() as ChainWithOptionalCommands
392
+ if (source === 'typed') return chain.deleteRange(range)
393
+ // Opened from a selection: re-assert the original range so toggles like
394
+ // bold/italic actually transform the selected text (a bare focus() can land
395
+ // on a collapsed cursor and no-op).
396
+ if (source === 'selection') return chain.setTextSelection(range)
397
+ return chain
398
+ }
399
+
400
+ export function createDefaultSlashCommands(
401
+ options: DefaultSlashCommandOptions = {},
402
+ ): SlashCommandItem[] {
403
+ const promptEnabled = options.includePrompts ?? true
404
+ return [
405
+ {
406
+ id: 'paragraph',
407
+ title: 'Text',
408
+ description: 'Start a normal paragraph',
409
+ keywords: ['paragraph', 'normal'],
410
+ section: 'Writing',
411
+ run: ({ editor, range, source }) => {
412
+ commandChain(editor, range, source).setParagraph().run()
413
+ },
414
+ },
415
+ {
416
+ id: 'heading-1',
417
+ title: 'Heading 1',
418
+ description: 'Large section heading',
419
+ keywords: ['title', 'h1'],
420
+ section: 'Writing',
421
+ run: ({ editor, range, source }) => {
422
+ commandChain(editor, range, source).setNode('heading', { level: 1 }).run()
423
+ },
424
+ },
425
+ {
426
+ id: 'heading-2',
427
+ title: 'Heading 2',
428
+ description: 'Medium section heading',
429
+ keywords: ['subtitle', 'h2'],
430
+ section: 'Writing',
431
+ run: ({ editor, range, source }) => {
432
+ commandChain(editor, range, source).setNode('heading', { level: 2 }).run()
433
+ },
434
+ },
435
+ {
436
+ id: 'heading-3',
437
+ title: 'Heading 3',
438
+ description: 'Small section heading',
439
+ keywords: ['subtitle', 'h3'],
440
+ section: 'Writing',
441
+ run: ({ editor, range, source }) => {
442
+ commandChain(editor, range, source).setNode('heading', { level: 3 }).run()
443
+ },
444
+ },
445
+ {
446
+ id: 'bold',
447
+ title: 'Bold',
448
+ description: 'Emphasize the selected text',
449
+ keywords: ['strong'],
450
+ section: 'Formatting',
451
+ run: ({ editor, range, source }) => {
452
+ commandChain(editor, range, source).toggleBold().run()
453
+ },
454
+ },
455
+ {
456
+ id: 'italic',
457
+ title: 'Italic',
458
+ description: 'Italicize the selected text',
459
+ keywords: ['emphasis'],
460
+ section: 'Formatting',
461
+ run: ({ editor, range, source }) => {
462
+ commandChain(editor, range, source).toggleItalic().run()
463
+ },
464
+ },
465
+ {
466
+ id: 'underline',
467
+ title: 'Underline',
468
+ description: 'Underline the selected text',
469
+ section: 'Formatting',
470
+ run: ({ editor, range, source }) => {
471
+ commandChain(editor, range, source).toggleUnderline?.()?.run()
472
+ },
473
+ },
474
+ {
475
+ id: 'inline-code',
476
+ title: 'Inline code',
477
+ description: 'Mark text as code',
478
+ keywords: ['code'],
479
+ section: 'Formatting',
480
+ run: ({ editor, range, source }) => {
481
+ commandChain(editor, range, source).toggleCode?.()?.run()
482
+ },
483
+ },
484
+ {
485
+ id: 'link',
486
+ title: 'Link',
487
+ description: 'Add a link to the selected text',
488
+ keywords: ['url', 'href'],
489
+ section: 'Formatting',
490
+ run: ({ editor, range, source }) => {
491
+ if (!promptEnabled) return
492
+ const href = window.prompt('Link URL')?.trim()
493
+ if (!href) return
494
+ commandChain(editor, range, source).setLink?.({ href })?.run()
495
+ },
496
+ },
497
+ {
498
+ id: 'bullet-list',
499
+ title: 'Bullet list',
500
+ description: 'Create a simple list',
501
+ keywords: ['unordered', 'list'],
502
+ section: 'Lists',
503
+ run: ({ editor, range, source }) => {
504
+ commandChain(editor, range, source).toggleBulletList().run()
505
+ },
506
+ },
507
+ {
508
+ id: 'numbered-list',
509
+ title: 'Numbered list',
510
+ description: 'Create an ordered list',
511
+ keywords: ['ordered', 'list'],
512
+ section: 'Lists',
513
+ run: ({ editor, range, source }) => {
514
+ commandChain(editor, range, source).toggleOrderedList().run()
515
+ },
516
+ },
517
+ {
518
+ id: 'task-list',
519
+ title: 'Task list',
520
+ description: 'Create a checklist',
521
+ keywords: ['todo', 'checkbox'],
522
+ section: 'Lists',
523
+ run: ({ editor, range, source }) => {
524
+ commandChain(editor, range, source).toggleTaskList?.()?.run()
525
+ },
526
+ },
527
+ {
528
+ id: 'indent-list',
529
+ title: 'Indent list item',
530
+ description: 'Nest the current list item',
531
+ keywords: ['sink', 'nest'],
532
+ section: 'Lists',
533
+ run: ({ editor, range, source }) => {
534
+ commandChain(editor, range, source).sinkListItem?.('listItem')?.run()
535
+ },
536
+ },
537
+ {
538
+ id: 'outdent-list',
539
+ title: 'Outdent list item',
540
+ description: 'Lift the current list item',
541
+ keywords: ['lift', 'unnest'],
542
+ section: 'Lists',
543
+ run: ({ editor, range, source }) => {
544
+ commandChain(editor, range, source).liftListItem?.('listItem')?.run()
545
+ },
546
+ },
547
+ {
548
+ id: 'quote',
549
+ title: 'Quote',
550
+ description: 'Call out a quoted passage',
551
+ keywords: ['blockquote', 'citation'],
552
+ section: 'Blocks',
553
+ run: ({ editor, range, source }) => {
554
+ commandChain(editor, range, source).toggleBlockquote().run()
555
+ },
556
+ },
557
+ {
558
+ id: 'code-block',
559
+ title: 'Code block',
560
+ description: 'Insert a code section',
561
+ keywords: ['code', 'pre'],
562
+ section: 'Blocks',
563
+ run: ({ editor, range, source }) => {
564
+ commandChain(editor, range, source).toggleCodeBlock().run()
565
+ },
566
+ },
567
+ {
568
+ id: 'table',
569
+ title: 'Table',
570
+ description: 'Insert a simple table',
571
+ keywords: ['grid'],
572
+ section: 'Blocks',
573
+ run: ({ editor, range, source }) => {
574
+ commandChain(editor, range, source)
575
+ .insertTable?.({ rows: 3, cols: 3, withHeaderRow: true })
576
+ ?.run()
577
+ },
578
+ },
579
+ {
580
+ id: 'footnote',
581
+ title: 'Footnote',
582
+ description: 'Insert a footnote marker',
583
+ keywords: ['note', 'reference'],
584
+ section: 'Blocks',
585
+ run: ({ editor, range, source }) => {
586
+ commandChain(editor, range, source).insertFootnote?.()?.run()
587
+ },
588
+ },
589
+ {
590
+ id: 'index-marker',
591
+ title: 'Index marker',
592
+ description: 'Mark selected text for the index',
593
+ keywords: ['index'],
594
+ section: 'Blocks',
595
+ run: ({ editor, range, source }) => {
596
+ commandChain(editor, range, source).toggleIndexMarker?.()?.run()
597
+ },
598
+ },
599
+ {
600
+ id: 'inline-equation',
601
+ title: 'Inline equation',
602
+ description: 'Insert an editable inline equation',
603
+ keywords: ['math', 'latex'],
604
+ section: 'Blocks',
605
+ run: ({ editor, range, source }) => {
606
+ // Remove the typed "/…" text, then insert an empty equation and open
607
+ // its editor immediately (live preview; renders in place on blur).
608
+ commandChain(editor, range, source).run()
609
+ insertEditableInlineMath(editor)
610
+ },
611
+ },
612
+ {
613
+ id: 'display-equation',
614
+ title: 'Display equation',
615
+ description: 'Insert an editable centered equation',
616
+ keywords: ['math', 'latex', 'block'],
617
+ section: 'Blocks',
618
+ run: ({ editor, range, source }) => {
619
+ commandChain(editor, range, source).run()
620
+ insertEditableBlockMath(editor)
621
+ },
622
+ },
623
+ {
624
+ id: 'mermaid',
625
+ title: 'Mermaid diagram',
626
+ description: 'Insert a diagram block',
627
+ keywords: ['diagram', 'flowchart'],
628
+ section: 'Blocks',
629
+ run: ({ editor, range, source }) => {
630
+ commandChain(editor, range, source).insertMermaidBlock?.()?.run()
631
+ },
632
+ },
633
+ ]
634
+ }
635
+
636
+ function findSlashCommandMatch(state: EditorState): SlashCommandMatch | null {
637
+ const { selection } = state
638
+ if (!selection.empty) return null
639
+
640
+ const { $from } = selection
641
+ const textBefore = $from.parent.textBetween(0, $from.parentOffset, '\0', '\0')
642
+ const match = /(?:^|\s)\/([\w\s-]{0,48})$/.exec(textBefore)
643
+ if (!match) return null
644
+
645
+ const fullMatch = match[0]
646
+ const slashOffset = fullMatch.lastIndexOf('/')
647
+ const from = $from.pos - (fullMatch.length - slashOffset)
648
+
649
+ return {
650
+ range: { from, to: $from.pos },
651
+ query: match[1]?.trim() ?? '',
652
+ }
653
+ }
654
+
655
+ function filterSlashCommandItems(
656
+ items: SlashCommandItem[],
657
+ query: string,
658
+ ): SlashCommandItem[] {
659
+ const normalizedQuery = query.toLowerCase()
660
+ if (!normalizedQuery) return items
661
+
662
+ return items
663
+ .filter(item => {
664
+ const haystack = [
665
+ item.title,
666
+ item.description ?? '',
667
+ ...(item.keywords ?? []),
668
+ ]
669
+ .join(' ')
670
+ .toLowerCase()
671
+ return haystack.includes(normalizedQuery)
672
+ })
673
+ .slice(0, SLASH_COMMAND_LIMIT)
674
+ }
675
+
676
+ function injectSlashCommandStyles(): void {
677
+ if (document.getElementById('ps-slash-command-styles')) return
678
+
679
+ const style = document.createElement('style')
680
+ style.id = 'ps-slash-command-styles'
681
+ style.textContent = `
682
+ .ps-slash-command-menu {
683
+ position: fixed;
684
+ z-index: var(--platform-z-index-zi-app-menus, 1000);
685
+ box-sizing: border-box;
686
+ min-width: 280px;
687
+ max-width: 340px;
688
+ padding: 6px;
689
+ border: 1px solid var(--platform-colors-border, #d7dbe0);
690
+ border-radius: 10px;
691
+ background: var(--platform-colors-surface, #fff);
692
+ box-shadow: 0 14px 34px rgb(23 29 36 / 0.14);
693
+ color: var(--platform-colors-text, #2e3338);
694
+ font-family: var(--platform-typography-font-family, system-ui, sans-serif);
695
+ overflow-x: hidden;
696
+ overscroll-behavior: contain;
697
+ }
698
+
699
+ .ps-slash-command-item {
700
+ display: grid;
701
+ width: 100%;
702
+ gap: 2px;
703
+ padding: 8px 10px;
704
+ border: 0;
705
+ border-radius: 7px;
706
+ background: transparent;
707
+ color: inherit;
708
+ font: inherit;
709
+ text-align: left;
710
+ cursor: pointer;
711
+ }
712
+
713
+ .ps-slash-command-section {
714
+ color: var(--platform-colors-text-tertiary, #8a9097);
715
+ font-size: 10px;
716
+ font-weight: 700;
717
+ letter-spacing: 0.08em;
718
+ line-height: 1.2;
719
+ padding: 9px 10px 5px;
720
+ text-transform: uppercase;
721
+ }
722
+
723
+ .ps-slash-command-divider {
724
+ border-top: 1px solid var(--platform-colors-border, #d7dbe0);
725
+ margin: 6px 4px 2px;
726
+ }
727
+
728
+ .ps-slash-command-item[data-active="true"],
729
+ .ps-slash-command-item:hover {
730
+ background: var(--platform-colors-surface-active, #edeff2);
731
+ }
732
+
733
+ .ps-slash-command-title {
734
+ font-size: 13px;
735
+ font-weight: 650;
736
+ line-height: 1.25;
737
+ }
738
+
739
+ .ps-slash-command-description {
740
+ color: var(--platform-colors-text-secondary, #6e737a);
741
+ font-size: 12px;
742
+ line-height: 1.25;
743
+ }
744
+ `
745
+ document.head.appendChild(style)
746
+ }