@puredesktop/platform-editor 1.0.0-beta.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.
- package/package.json +63 -0
- package/src/CollapsePanel.tsx +35 -0
- package/src/DocumentDiffView.tsx +130 -0
- package/src/DocumentEditor.test.ts +118 -0
- package/src/DocumentEditor.tsx +2631 -0
- package/src/alignedLineDiff.test.ts +52 -0
- package/src/alignedLineDiff.ts +67 -0
- package/src/collectionAssetSrc.ts +2 -0
- package/src/commentUtils.test.ts +350 -0
- package/src/commentUtils.ts +546 -0
- package/src/constants/toolKeys.ts +14 -0
- package/src/contentFormat.test.ts +27 -0
- package/src/contentFormat.ts +18 -0
- package/src/editorExtensions.ts +155 -0
- package/src/extensions/appliedChangeMark.ts +115 -0
- package/src/extensions/autoReviewPrompts.test.ts +529 -0
- package/src/extensions/autoReviewPrompts.ts +1442 -0
- package/src/extensions/collectionImage.ts +26 -0
- package/src/extensions/collectionImagePaste.ts +215 -0
- package/src/extensions/commentMark.ts +223 -0
- package/src/extensions/documentAssetIdentity.ts +54 -0
- package/src/extensions/figure.ts +92 -0
- package/src/extensions/footnote.ts +186 -0
- package/src/extensions/indexMarker.ts +88 -0
- package/src/extensions/mathEditing.ts +239 -0
- package/src/extensions/mermaidBlock.tsx +297 -0
- package/src/extensions/slashCommands.test.ts +170 -0
- package/src/extensions/slashCommands.ts +746 -0
- package/src/extensions/smartTypography.test.ts +74 -0
- package/src/extensions/smartTypography.ts +120 -0
- package/src/index.ts +137 -0
- package/src/insertCollectionImage.test.ts +60 -0
- package/src/insertCollectionImage.ts +63 -0
- package/src/insertInlineAssetKind.test.ts +67 -0
- package/src/insertInlineAssetKind.ts +49 -0
- package/src/mermaidPreview.test.ts +54 -0
- package/src/mermaidPreview.ts +115 -0
- package/src/styled.ts +676 -0
- package/src/toolbar/ToolBtn.tsx +63 -0
- package/src/toolbar/Toolbar.test.tsx +325 -0
- package/src/toolbar/Toolbar.tsx +624 -0
- package/src/toolbar/groups/HeadingGroup.tsx +153 -0
- package/src/toolbar/overlayTypes.ts +28 -0
- package/src/useEditorExtensions.ts +22 -0
- package/src/utils/markdownUtils.ts +331 -0
|
@@ -0,0 +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
|
+
}
|