优化:关键架构改进
Slash Command → 使用 @tiptap/suggestion 官方引擎 Link Panel → 使用 @tiptap/vue-3 FloatingMenu 组件 基础扩展 → 使用 StarterKit 简化 Typography → 启用智能标点 键盘处理 → 统一到 extension 层
This commit is contained in:
parent
e0f572fdb7
commit
f2fa1dea2e
File diff suppressed because it is too large
Load Diff
326
frontend/src/shared/extensions/SlashMenu.vue
Normal file
326
frontend/src/shared/extensions/SlashMenu.vue
Normal file
@ -0,0 +1,326 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* SlashMenu.vue — Vue 3 component for TipTap slash command suggestion popup.
|
||||
*
|
||||
* This component is rendered by the suggestion engine (onStart/onUpdate/onExit hooks).
|
||||
* Position is managed by the suggestion engine via getBoundingClientRect.
|
||||
* The 'react-renderer' class is required for TipTap v3 suggestion compatibility.
|
||||
*
|
||||
* Props are passed from VueRenderer and contain suggestion state:
|
||||
* - items: filtered suggestion items
|
||||
* - query: current search query
|
||||
* - clientRect: function returning DOMRect for positioning
|
||||
* - command: function to execute when an item is selected
|
||||
*/
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { SuggestionProps } from '@tiptap/suggestion'
|
||||
import type { SlashCommandItem } from './slash-command'
|
||||
|
||||
const props = defineProps<SuggestionProps>()
|
||||
|
||||
// ── Local state ────────────────────────────────────────────────────────────────
|
||||
const selectedIndex = ref(0)
|
||||
const menuEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// ── Position (from suggestion engine via clientRect) ─────────────────────────
|
||||
const position = ref({
|
||||
top: 0,
|
||||
left: 0,
|
||||
visible: false,
|
||||
})
|
||||
|
||||
// ── Reset selected index when items change ─────────────────────────────────
|
||||
watch(
|
||||
() => props.items,
|
||||
() => {
|
||||
selectedIndex.value = 0
|
||||
},
|
||||
)
|
||||
|
||||
// ── Grouped items for display ─────────────────────────────────────────────────
|
||||
const groupedItems = computed(() => {
|
||||
const groups: Record<string, SlashCommandItem[]> = {}
|
||||
const cats = ['Text', 'Lists', 'Blocks', 'Media']
|
||||
const items = (props.items as SlashCommandItem[]) || []
|
||||
|
||||
for (const item of items) {
|
||||
if (!groups[item.group]) groups[item.group] = []
|
||||
groups[item.group].push(item)
|
||||
}
|
||||
|
||||
return cats
|
||||
.filter((g) => groups[g]?.length)
|
||||
.map((g) => ({ label: g, items: groups[g] }))
|
||||
})
|
||||
|
||||
// ── Update position from clientRect ─────────────────────────────────────────
|
||||
function updatePosition() {
|
||||
if (!props.clientRect) {
|
||||
position.value.visible = false
|
||||
return
|
||||
}
|
||||
const rect = props.clientRect()
|
||||
if (!rect) {
|
||||
position.value.visible = false
|
||||
return
|
||||
}
|
||||
|
||||
const editorDom = props.editor?.view?.dom?.closest?.('.blockeditor-mount')
|
||||
if (editorDom) {
|
||||
const editorRect = editorDom.getBoundingClientRect()
|
||||
position.value.visible = true
|
||||
position.value.top = rect.bottom - editorRect.top + 8
|
||||
position.value.left = rect.left - editorRect.left
|
||||
} else {
|
||||
position.value.visible = true
|
||||
position.value.top = rect.bottom + 8
|
||||
position.value.left = rect.left
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.clientRect,
|
||||
() => {
|
||||
nextTick(updatePosition)
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// ── Selection ─────────────────────────────────────────────────────────────────
|
||||
function selectItem(item: SlashCommandItem) {
|
||||
if (props.command) {
|
||||
props.command(item)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Flat list helpers for keyboard nav ───────────────────────────────────────
|
||||
function getFlatIndex(groupIdx: number, itemIdx: number): number {
|
||||
let flat = 0
|
||||
for (let g = 0; g < groupIdx; g++) {
|
||||
flat += groupedItems.value[g]?.items.length || 0
|
||||
}
|
||||
return flat + itemIdx
|
||||
}
|
||||
|
||||
function isSelected(groupIdx: number, itemIdx: number): boolean {
|
||||
return getFlatIndex(groupIdx, itemIdx) === selectedIndex.value
|
||||
}
|
||||
|
||||
// ── Keyboard event handling ──────────────────────────────────────────────────
|
||||
// The suggestion engine dispatches keydown events to the menu via ProseMirror.
|
||||
// We listen on the menu element and stop propagation for arrow/enter/escape
|
||||
// so they don't bubble to the editor while navigating the menu.
|
||||
function handleKeydown(event: KeyboardEvent) {
|
||||
const items = props.items as SlashCommandItem[]
|
||||
const max = items.length - 1
|
||||
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
selectedIndex.value = Math.min(selectedIndex.value + 1, max)
|
||||
break
|
||||
case 'ArrowUp':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
selectedIndex.value = Math.max(selectedIndex.value - 1, 0)
|
||||
break
|
||||
case 'Enter':
|
||||
case 'Tab':
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (items[selectedIndex.value]) {
|
||||
selectItem(items[selectedIndex.value])
|
||||
}
|
||||
break
|
||||
case 'Escape':
|
||||
event.stopPropagation()
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
menuEl.value?.addEventListener('keydown', handleKeydown)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
menuEl.value?.removeEventListener('keydown', handleKeydown)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!--
|
||||
'react-renderer' class is REQUIRED for TipTap v3 suggestion compatibility.
|
||||
TipTap checks for this class to detect Vue-rendered suggestion popups.
|
||||
-->
|
||||
<div
|
||||
v-if="position.visible"
|
||||
ref="menuEl"
|
||||
class="blockeditor-slash-menu react-renderer"
|
||||
:style="{
|
||||
top: position.top + 'px',
|
||||
left: position.left + 'px',
|
||||
}"
|
||||
>
|
||||
<!-- Search hint -->
|
||||
<div v-if="query" class="blockeditor-slash-search">
|
||||
搜索: <strong>{{ query }}</strong>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!items || items.length === 0" class="blockeditor-slash-empty">
|
||||
未找到匹配项
|
||||
</div>
|
||||
|
||||
<!-- Grouped block types -->
|
||||
<template v-for="(group, gi) in groupedItems" :key="group.label">
|
||||
<div class="blockeditor-slash-header">{{ group.label }}</div>
|
||||
<div
|
||||
v-for="(item, ii) in group.items"
|
||||
:key="item.title"
|
||||
class="blockeditor-slash-item"
|
||||
:class="{ 'blockeditor-slash-item-active': isSelected(gi, ii) }"
|
||||
@mousedown.prevent="selectItem(item)"
|
||||
@mouseenter="selectedIndex = getFlatIndex(gi, ii)"
|
||||
>
|
||||
<span class="blockeditor-slash-icon" v-html="getSlashIcon(item.icon)"></span>
|
||||
<div class="blockeditor-slash-info">
|
||||
<div class="blockeditor-slash-label">{{ item.title }}</div>
|
||||
<div v-if="item.subtitle" class="blockeditor-slash-desc">{{ item.subtitle }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="blockeditor-slash-footer">
|
||||
<kbd>↑↓</kbd> 导航 <kbd>↵</kbd> 选择 <kbd>esc</kbd> 关闭
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
// Icon helper — returns SVG strings for slash menu items
|
||||
function getSlashIcon(iconName?: string): string {
|
||||
const icons: Record<string, string> = {
|
||||
text: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 4v16m0-8h12m0-8v16"/></svg>`,
|
||||
h1: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"><text x="2" y="18" font-family="system-ui,-apple-system,sans-serif" font-size="14" font-weight="700" fill="currentColor" stroke="none">H1</text></svg>`,
|
||||
h2: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"><text x="2" y="18" font-family="system-ui,-apple-system,sans-serif" font-size="14" font-weight="700" fill="currentColor" stroke="none">H2</text></svg>`,
|
||||
h3: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24"><text x="2" y="18" font-family="system-ui,-apple-system,sans-serif" font-size="14" font-weight="700" fill="currentColor" stroke="none">H3</text></svg>`,
|
||||
list: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 6h11M9 12h11M9 18h11M5 6v.01M5 12v.01M5 18v.01"/></svg>`,
|
||||
'ordered-list': `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><text x="6" y="7" font-family="system-ui,-apple-system,sans-serif" font-size="8" font-weight="800" fill="currentColor" stroke="none">1</text><path d="M9 6h12"/><text x="6" y="13" font-family="system-ui,-apple-system,sans-serif" font-size="8" font-weight="800" fill="currentColor" stroke="none">2</text><path d="M9 12h12"/><text x="6" y="19" font-family="system-ui,-apple-system,sans-serif" font-size="8" font-weight="800" fill="currentColor" stroke="none">3</text><path d="M9 18h12"/></svg>`,
|
||||
'task-list': `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 5.5L5 7l2.5-2.5m-4 7L5 13l2.5-2.5m-4 7L5 19l2.5-2.5M11 6h9m-9 6h9m-9 6h9"/></svg>`,
|
||||
quote: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 11H6a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v6q0 4-4 5m13-7h-4a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v6q0 4-4 5"/></svg>`,
|
||||
callout: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4m0-4h.01"/></svg>`,
|
||||
'code-block': `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m7 8l-4 4l4 4m10-8l4 4l-4 4M14 4l-4 16"/></svg>`,
|
||||
divider: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M5 12h14"/></svg>`,
|
||||
toggle: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m9 18 6-6-6-6"/><path d="m15 6 6 6-6 6"/></svg>`,
|
||||
image: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 8h.01M3 6a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3v12a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3z"/><path d="m3 16l5-5c.928-.893 2.072-.893 3 0l5 5"/><path d="m14 14l1-1c.928-.893 2.072-.893 3 0l3 3"/></svg>`,
|
||||
table: `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M3 5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2zm0 5h18M10 3v18"/></svg>`,
|
||||
}
|
||||
return icons[iconName || 'text'] || icons.text
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* TipTap v3 requires 'react-renderer' class on root element for suggestion compatibility */
|
||||
.react-renderer {
|
||||
position: absolute;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.blockeditor-slash-menu {
|
||||
width: 280px;
|
||||
max-height: 380px;
|
||||
overflow-y: auto;
|
||||
background: #fff;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.15);
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.blockeditor-slash-search {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
padding: 4px 8px 2px;
|
||||
}
|
||||
|
||||
.blockeditor-slash-empty {
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
padding: 12px 8px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.blockeditor-slash-header {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
color: #9ca3af;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 6px 8px 3px;
|
||||
}
|
||||
|
||||
.blockeditor-slash-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 5px 8px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
|
||||
.blockeditor-slash-item:hover,
|
||||
.blockeditor-slash-item-active {
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.blockeditor-slash-icon {
|
||||
flex-shrink: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f9fafb;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 5px;
|
||||
color: #6b7280;
|
||||
}
|
||||
|
||||
.blockeditor-slash-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.blockeditor-slash-label {
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: #111827;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.blockeditor-slash-desc {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.blockeditor-slash-footer {
|
||||
font-size: 11px;
|
||||
color: #9ca3af;
|
||||
padding: 6px 8px 2px;
|
||||
border-top: 1px solid #f3f4f6;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.blockeditor-slash-footer kbd {
|
||||
display: inline-block;
|
||||
padding: 1px 5px;
|
||||
background: #f3f4f6;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 4px;
|
||||
font-size: 10px;
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
@ -1,12 +1,15 @@
|
||||
/**
|
||||
* BlockKeyboard — minimal keyboard shortcuts for block editor UX.
|
||||
* BlockKeyboardExtension — Core keyboard shortcuts for block editor UX.
|
||||
*
|
||||
* This extension handles ONLY core block-level keyboard behaviors.
|
||||
* Slash command handling has been moved to @tiptap/suggestion via slash-command.ts
|
||||
*
|
||||
* Behaviors:
|
||||
* - Enter at end of empty block → split out a new paragraph block
|
||||
* - Backspace on empty block (non-first) → delete the block, focus previous
|
||||
* - Tab → indent list items
|
||||
* - Shift+Tab → outdent list items
|
||||
* - Mod+/ → toggle slash menu (alternative trigger)
|
||||
* - Tab → indent list items (via built-in list-keymap)
|
||||
* - Shift+Tab → outdent list items (via built-in list-keymap)
|
||||
* - Mod+Shift+ArrowUp → move block up
|
||||
* - Mod+Shift+ArrowDown → move block down
|
||||
*/
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
@ -16,12 +19,6 @@ const BLOCK_KEYBOARD_KEY = new PluginKey('blockKeyboard')
|
||||
export const BlockKeyboardExtension = Extension.create({
|
||||
name: 'blockKeyboard',
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
onDeleteEmptyBlock: null as ((pos: number) => void) | null,
|
||||
}
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const ext = this
|
||||
|
||||
@ -32,11 +29,17 @@ export const BlockKeyboardExtension = Extension.create({
|
||||
props: {
|
||||
handleKeyDown(view, event) {
|
||||
const { state } = view
|
||||
const { $from, empty } = state.selection
|
||||
const { $from } = state.selection
|
||||
const node = $from.node()
|
||||
|
||||
// ── Backspace on empty block (not first block) → delete block ──
|
||||
if (event.key === 'Backspace' && !event.shiftKey && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
const node = $from.node()
|
||||
// ── Backspace on empty block (non-first) → delete block ──
|
||||
if (
|
||||
event.key === 'Backspace' &&
|
||||
!event.shiftKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey
|
||||
) {
|
||||
const isEmptyParagraph =
|
||||
node.isTextblock &&
|
||||
node.type.name === 'paragraph' &&
|
||||
@ -46,8 +49,12 @@ export const BlockKeyboardExtension = Extension.create({
|
||||
// Check if this is the first block in the document
|
||||
const docFirstPos = 0
|
||||
const firstChild = state.doc.firstChild
|
||||
if (firstChild && firstChild.type.name === 'paragraph' && $from.start() === docFirstPos + 1) {
|
||||
// First block — let ProseMirror handle it (just delete char)
|
||||
if (
|
||||
firstChild &&
|
||||
firstChild.type.name === 'paragraph' &&
|
||||
$from.start() === docFirstPos + 1
|
||||
) {
|
||||
// First block — let ProseMirror handle it
|
||||
return false
|
||||
}
|
||||
|
||||
@ -67,7 +74,6 @@ export const BlockKeyboardExtension = Extension.create({
|
||||
|
||||
// Use editor command to delete and focus previous
|
||||
const chain = ext.editor.chain().focus()
|
||||
// Select from previous position to current block start
|
||||
try {
|
||||
chain
|
||||
.deleteRange({ from: prevPos, to: pos + node.nodeSize })
|
||||
|
||||
@ -1,12 +1,17 @@
|
||||
/**
|
||||
* DragHandle — wraps the official @tiptap/extension-drag-handle.
|
||||
* Positioned with @floating-ui/dom at placement: 'left-start'.
|
||||
* Plus button triggers the slash menu.
|
||||
*
|
||||
* Drop indicator is handled by the official @tiptap/extensions Dropcursor extension.
|
||||
* Features:
|
||||
* - Drag & drop block reordering via @tiptap/extension-drag-handle
|
||||
* - Custom drag preview card (floating card showing block type + text snippet)
|
||||
* - Edge auto-scroll during drag
|
||||
* - Plus button (⊕) as floating menu trigger — BUT slash commands are now handled
|
||||
* by @tiptap/suggestion, so the plus button opens a /-triggered slash menu
|
||||
* via a custom event dispatched to BlockEditor.vue
|
||||
*
|
||||
* Drop indicator: handled by @tiptap/extension-dropcursor (configured in BlockEditor.vue)
|
||||
*/
|
||||
import { Extension } from '@tiptap/core'
|
||||
import { Plugin, PluginKey } from '@tiptap/pm/state'
|
||||
import {
|
||||
DragHandlePlugin,
|
||||
defaultComputePositionConfig,
|
||||
@ -14,11 +19,10 @@ import {
|
||||
type NestedOptions,
|
||||
} from '@tiptap/extension-drag-handle'
|
||||
|
||||
let _slashMenuCallback: ((pos: number) => void) | null = null
|
||||
let _editorRef: any = null
|
||||
let _currentDragPos = 0
|
||||
|
||||
// ─── Custom drag preview card (C) ─────────────────────────────────────────────
|
||||
// ─── Custom drag preview card ───────────────────────────────────────────────────
|
||||
|
||||
let _previewCard: HTMLElement | null = null
|
||||
let _previewCardX = 0
|
||||
@ -39,12 +43,22 @@ function createPreviewCard(blockDom: HTMLElement): HTMLElement {
|
||||
const typeIcon = document.createElement('span')
|
||||
typeIcon.style.cssText = 'font-size:11px;font-weight:600;color:#9ca3af;flex-shrink:0;width:40px;'
|
||||
const typeMap: Record<string, string> = {
|
||||
paragraph: 'P', heading: 'H', blockquote: '❝', codeBlock: '</>',
|
||||
imageResizable: '🖼', bulletList: '•', orderedList: '1.',
|
||||
taskList: '☑', callout: '💡', table: '⊞', horizontalRule: '—',
|
||||
customDiv: '⊞', toggleHeading: '⊞',
|
||||
paragraph: 'P',
|
||||
heading: 'H',
|
||||
blockquote: '❝',
|
||||
codeBlock: '</>',
|
||||
imageResizable: '🖼',
|
||||
bulletList: '•',
|
||||
orderedList: '1.',
|
||||
taskList: '☑',
|
||||
callout: '💡',
|
||||
table: '⊞',
|
||||
horizontalRule: '—',
|
||||
customDiv: '⊞',
|
||||
toggleHeading: '⊞',
|
||||
}
|
||||
const nodeName = blockDom.getAttribute('data-type') ||
|
||||
const nodeName =
|
||||
blockDom.getAttribute('data-type') ||
|
||||
blockDom.tagName.toLowerCase().replace(/^h(\d)$/, 'h$1')
|
||||
typeIcon.textContent = typeMap[nodeName] || '▤'
|
||||
|
||||
@ -77,11 +91,17 @@ function showPreviewCard(blockDom: HTMLElement, mouseX: number, mouseY: number)
|
||||
}
|
||||
|
||||
function hidePreviewCard() {
|
||||
if (_previewCard) { _previewCard.remove(); _previewCard = null }
|
||||
if (_previewCardRafId !== null) { cancelAnimationFrame(_previewCardRafId); _previewCardRafId = null }
|
||||
if (_previewCard) {
|
||||
_previewCard.remove()
|
||||
_previewCard = null
|
||||
}
|
||||
if (_previewCardRafId !== null) {
|
||||
cancelAnimationFrame(_previewCardRafId)
|
||||
_previewCardRafId = null
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Edge auto-scroll (D) ─────────────────────────────────────────────────────
|
||||
// ─── Edge auto-scroll ───────────────────────────────────────────────────────────
|
||||
|
||||
let _scrollInterval: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
@ -92,7 +112,8 @@ function startAutoScroll() {
|
||||
if (!scroller) return
|
||||
const rect = scroller.getBoundingClientRect()
|
||||
const EDGE = 80
|
||||
const FAST = 8, SLOW = 3
|
||||
const FAST = 8
|
||||
const SLOW = 3
|
||||
if (_previewCardY < rect.top + EDGE) {
|
||||
scroller.scrollTop -= _previewCardY < rect.top + 40 ? FAST : SLOW
|
||||
} else if (_previewCardY > rect.bottom - EDGE) {
|
||||
@ -102,7 +123,10 @@ function startAutoScroll() {
|
||||
}
|
||||
|
||||
function stopAutoScroll() {
|
||||
if (_scrollInterval) { clearInterval(_scrollInterval); _scrollInterval = null }
|
||||
if (_scrollInterval) {
|
||||
clearInterval(_scrollInterval)
|
||||
_scrollInterval = null
|
||||
}
|
||||
}
|
||||
|
||||
function onDocumentDragover(e: DragEvent) {
|
||||
@ -119,20 +143,22 @@ function onDocumentDragend() {
|
||||
stopAutoScroll()
|
||||
}
|
||||
|
||||
// ─── Drag handle DOM ───────────────────────────────────────────────────────────
|
||||
// ─── Drag handle DOM ────────────────────────────────────────────────────────────
|
||||
|
||||
function makeSvg(content: string): string {
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" shape-rendering="geometricPrecision">${content}</svg>`
|
||||
}
|
||||
|
||||
const PLUS_ICON = makeSvg('<path d="M12 5v14M5 12h14" stroke-width="2.5" stroke-linecap="round"/>')
|
||||
const PLUS_ICON = makeSvg(
|
||||
'<path d="M12 5v14M5 12h14" stroke-width="2.5" stroke-linecap="round"/>',
|
||||
)
|
||||
const DRAG_ICON = makeSvg(
|
||||
'<circle cx="8" cy="6" r="1.3" fill="currentColor" stroke="none"/>' +
|
||||
'<circle cx="16" cy="6" r="1.3" fill="currentColor" stroke="none"/>' +
|
||||
'<circle cx="8" cy="12" r="1.3" fill="currentColor" stroke="none"/>' +
|
||||
'<circle cx="16" cy="12" r="1.3" fill="currentColor" stroke="none"/>' +
|
||||
'<circle cx="8" cy="18" r="1.3" fill="currentColor" stroke="none"/>' +
|
||||
'<circle cx="16" cy="18" r="1.3" fill="currentColor" stroke="none"/>'
|
||||
'<circle cx="16" cy="6" r="1.3" fill="currentColor" stroke="none"/>' +
|
||||
'<circle cx="8" cy="12" r="1.3" fill="currentColor" stroke="none"/>' +
|
||||
'<circle cx="16" cy="12" r="1.3" fill="currentColor" stroke="none"/>' +
|
||||
'<circle cx="8" cy="18" r="1.3" fill="currentColor" stroke="none"/>' +
|
||||
'<circle cx="16" cy="18" r="1.3" fill="currentColor" stroke="none"/>',
|
||||
)
|
||||
|
||||
function buildHandleElement(): HTMLElement {
|
||||
@ -151,18 +177,32 @@ function buildHandleElement(): HTMLElement {
|
||||
plusBtn.type = 'button'
|
||||
plusBtn.className = 'drag-handle-plus'
|
||||
plusBtn.innerHTML = PLUS_ICON
|
||||
plusBtn.title = 'Insert block'
|
||||
plusBtn.title = 'Insert block (type /)'
|
||||
plusBtn.style.cssText =
|
||||
'display:flex;align-items:center;justify-content:center;' +
|
||||
'width:22px;height:22px;border:none;border-radius:4px;' +
|
||||
'background:transparent;cursor:pointer;color:#9ca3af;padding:0;' +
|
||||
'transition:background 0.1s,color 0.1s;flex-shrink:0;'
|
||||
plusBtn.addEventListener('mouseenter', () => { plusBtn.style.background = '#e5e7eb'; plusBtn.style.color = '#1f2937' })
|
||||
plusBtn.addEventListener('mouseleave', () => { plusBtn.style.background = 'transparent'; plusBtn.style.color = '#9ca3af' })
|
||||
plusBtn.addEventListener('mouseenter', () => {
|
||||
;(plusBtn as HTMLElement).style.background = '#e5e7eb'
|
||||
;(plusBtn as HTMLElement).style.color = '#1f2937'
|
||||
})
|
||||
plusBtn.addEventListener('mouseleave', () => {
|
||||
;(plusBtn as HTMLElement).style.background = 'transparent'
|
||||
;(plusBtn as HTMLElement).style.color = '#9ca3af'
|
||||
})
|
||||
plusBtn.addEventListener('mousedown', (e) => e.stopPropagation())
|
||||
plusBtn.addEventListener('click', (e) => {
|
||||
e.preventDefault(); e.stopPropagation()
|
||||
if (_slashMenuCallback) _slashMenuCallback(_currentDragPos)
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
// Dispatch custom event to trigger slash menu at current position
|
||||
if (_editorRef) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('blockeditor:open-slash-menu', {
|
||||
detail: { editor: _editorRef, pos: _currentDragPos },
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const dragBtn = document.createElement('button')
|
||||
@ -175,8 +215,14 @@ function buildHandleElement(): HTMLElement {
|
||||
'width:22px;height:22px;border:none;border-radius:4px;' +
|
||||
'background:transparent;cursor:grab;color:#9ca3af;padding:0;' +
|
||||
'transition:background 0.1s,color 0.1s;flex-shrink:0;'
|
||||
dragBtn.addEventListener('mouseenter', () => { dragBtn.style.background = '#e5e7eb'; dragBtn.style.color = '#1f2937' })
|
||||
dragBtn.addEventListener('mouseleave', () => { dragBtn.style.background = 'transparent'; dragBtn.style.color = '#9ca3af' })
|
||||
dragBtn.addEventListener('mouseenter', () => {
|
||||
;(dragBtn as HTMLElement).style.background = '#e5e7eb'
|
||||
;(dragBtn as HTMLElement).style.color = '#1f2937'
|
||||
})
|
||||
dragBtn.addEventListener('mouseleave', () => {
|
||||
;(dragBtn as HTMLElement).style.background = 'transparent'
|
||||
;(dragBtn as HTMLElement).style.color = '#9ca3af'
|
||||
})
|
||||
dragBtn.addEventListener('mousedown', (e) => e.stopPropagation())
|
||||
|
||||
container.appendChild(plusBtn)
|
||||
@ -184,19 +230,75 @@ function buildHandleElement(): HTMLElement {
|
||||
return container
|
||||
}
|
||||
|
||||
// ─── Keyboard move extension (E) ──────────────────────────────────────────────
|
||||
// ─── Extension ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const BlockMoveExtension = Extension.create({
|
||||
name: 'blockMove',
|
||||
export const DragHandleExtension = Extension.create({
|
||||
name: 'dragHandle',
|
||||
|
||||
addKeyboardShortcuts() {
|
||||
addOptions() {
|
||||
return {
|
||||
'Mod-Shift-ArrowUp': () => moveBlock(this.editor, -1),
|
||||
'Mod-Shift-ArrowDown': () => moveBlock(this.editor, 1),
|
||||
nested: false as unknown as NestedOptions,
|
||||
}
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
_editorRef = this.editor
|
||||
document.addEventListener('dragover', onDocumentDragover)
|
||||
document.addEventListener('dragend', onDocumentDragend)
|
||||
},
|
||||
|
||||
onDestroy() {
|
||||
_editorRef = null
|
||||
document.removeEventListener('dragover', onDocumentDragover)
|
||||
document.removeEventListener('dragend', onDocumentDragend)
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const element = buildHandleElement()
|
||||
const nestedOptions = normalizeNestedOptions(this.options.nested)
|
||||
|
||||
return [
|
||||
DragHandlePlugin({
|
||||
element,
|
||||
editor: this.editor,
|
||||
computePositionConfig: { ...defaultComputePositionConfig },
|
||||
nestedOptions,
|
||||
|
||||
onNodeChange({ pos }) {
|
||||
_currentDragPos = pos ?? 0
|
||||
},
|
||||
|
||||
onElementDragStart() {
|
||||
element.style.opacity = '0.5'
|
||||
if (_editorRef) {
|
||||
try {
|
||||
const node = _editorRef.view.nodeDOM(_currentDragPos)
|
||||
if (node instanceof HTMLElement) {
|
||||
let el: HTMLElement | null = node
|
||||
const dom = _editorRef.view.dom as HTMLElement
|
||||
while (el && el.parentElement !== dom) el = el.parentElement
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
showPreviewCard(el, rect.left + 40, rect.top + rect.height / 2)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
onElementDragEnd() {
|
||||
element.style.opacity = '1'
|
||||
hidePreviewCard()
|
||||
},
|
||||
}).plugin,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Block move keyboard shortcut extension ───────────────────────────────────────
|
||||
|
||||
function moveBlock(editor: any, dir: 1 | -1): boolean {
|
||||
const { state } = editor.view
|
||||
const { $from } = state.selection
|
||||
@ -247,87 +349,21 @@ function moveBlock(editor: any, dir: 1 | -1): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Slash menu plugin ─────────────────────────────────────────────────────────
|
||||
export const BlockMoveExtension = Extension.create({
|
||||
name: 'blockMove',
|
||||
|
||||
const SLASH_MENU_KEY = new PluginKey('slashMenu')
|
||||
|
||||
// ─── Extension ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export const DragHandleExtension = Extension.create({
|
||||
name: 'dragHandle',
|
||||
|
||||
addOptions() {
|
||||
addKeyboardShortcuts() {
|
||||
return {
|
||||
onShowSlashMenu: null as ((pos: number) => void) | null,
|
||||
nested: false as unknown as NestedOptions,
|
||||
'Mod-Shift-ArrowUp': () => moveBlock(this.editor, -1),
|
||||
'Mod-Shift-ArrowDown': () => moveBlock(this.editor, 1),
|
||||
}
|
||||
},
|
||||
|
||||
onCreate() {
|
||||
_slashMenuCallback = this.options.onShowSlashMenu
|
||||
_editorRef = this.editor
|
||||
document.addEventListener('dragover', onDocumentDragover)
|
||||
document.addEventListener('dragend', onDocumentDragend)
|
||||
},
|
||||
|
||||
onDestroy() {
|
||||
_slashMenuCallback = null
|
||||
_editorRef = null
|
||||
document.removeEventListener('dragover', onDocumentDragover)
|
||||
document.removeEventListener('dragend', onDocumentDragend)
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const element = buildHandleElement()
|
||||
const nestedOptions = normalizeNestedOptions(this.options.nested)
|
||||
|
||||
const slashPlugin = new Plugin({ key: SLASH_MENU_KEY })
|
||||
|
||||
return [
|
||||
DragHandlePlugin({
|
||||
element,
|
||||
editor: this.editor,
|
||||
computePositionConfig: { ...defaultComputePositionConfig },
|
||||
nestedOptions,
|
||||
|
||||
onNodeChange({ pos }) {
|
||||
_currentDragPos = pos ?? 0
|
||||
},
|
||||
|
||||
onElementDragStart() {
|
||||
element.style.opacity = '0.5'
|
||||
if (_editorRef) {
|
||||
try {
|
||||
const node = _editorRef.view.nodeDOM(_currentDragPos)
|
||||
if (node instanceof HTMLElement) {
|
||||
let el: HTMLElement | null = node
|
||||
const dom = _editorRef.view.dom as HTMLElement
|
||||
while (el && el.parentElement !== dom) el = el.parentElement
|
||||
if (el) {
|
||||
const rect = el.getBoundingClientRect()
|
||||
showPreviewCard(el, rect.left + 40, rect.top + rect.height / 2)
|
||||
}
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
},
|
||||
|
||||
onElementDragEnd() {
|
||||
element.style.opacity = '1'
|
||||
hidePreviewCard()
|
||||
},
|
||||
}).plugin,
|
||||
|
||||
slashPlugin,
|
||||
]
|
||||
},
|
||||
})
|
||||
|
||||
// ─── Public cleanup ─────────────────────────────────────────────────────────
|
||||
// ─── Public cleanup ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function cleanupHandleDOM() {
|
||||
_currentDragPos = 0
|
||||
_slashMenuCallback = null
|
||||
_editorRef = null
|
||||
hidePreviewCard()
|
||||
stopAutoScroll()
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
/**
|
||||
* link-panel.ts — DOM-based link insertion panel.
|
||||
* Lives in a separate file to avoid Node type conflict between ProseMirror Node
|
||||
* and the DOM Node interface used by panel.contains().
|
||||
*/
|
||||
export function showLinkPanel(
|
||||
e: MouseEvent,
|
||||
editorInstance: import('@tiptap/core').Editor,
|
||||
t: (key: string) => string,
|
||||
onDone: () => void
|
||||
) {
|
||||
const ed = editorInstance
|
||||
const existing = document.querySelector('.blockeditor-link-panel')
|
||||
if (existing) { existing.remove(); return }
|
||||
|
||||
const panel = document.createElement('div')
|
||||
panel.className = 'blockeditor-link-panel'
|
||||
panel.style.cssText =
|
||||
'position:fixed;z-index:1000;background:#fff;border:1px solid #e5e7eb;' +
|
||||
'border-radius:8px;box-shadow:0 4px 12px rgba(0,0,0,0.1);' +
|
||||
'padding:8px;display:flex;gap:6px;align-items:center;'
|
||||
|
||||
const input = document.createElement('input')
|
||||
input.type = 'text'
|
||||
input.placeholder = t('link_url')
|
||||
input.style.cssText =
|
||||
'width:240px;padding:4px 8px;border:1px solid #d1d5db;' +
|
||||
'border-radius:4px;font-size:13px;outline:none;font-family:inherit;'
|
||||
|
||||
const apply = document.createElement('button')
|
||||
apply.textContent = t('apply')
|
||||
apply.style.cssText =
|
||||
'padding:4px 12px;font-size:13px;font-weight:500;white-space:nowrap;' +
|
||||
'border:none;border-radius:4px;background:#2383e2;color:#fff;cursor:pointer;'
|
||||
|
||||
panel.appendChild(input)
|
||||
panel.appendChild(apply)
|
||||
|
||||
const btnRect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
||||
panel.style.left = `${Math.max(8, btnRect.left)}px`
|
||||
panel.style.top = `${btnRect.bottom + 4}px`
|
||||
|
||||
function closePanel() {
|
||||
panel.remove()
|
||||
document.removeEventListener('mousedown', onOutsideClick)
|
||||
document.removeEventListener('keydown', onKey)
|
||||
}
|
||||
|
||||
function onOutsideClick(ev: MouseEvent) {
|
||||
if (!panel.contains(ev.target as Node)) closePanel()
|
||||
}
|
||||
|
||||
function onKey(ev: KeyboardEvent) {
|
||||
if (ev.key === 'Escape') closePanel()
|
||||
}
|
||||
|
||||
function doApply() {
|
||||
let url = input.value.trim()
|
||||
if (url) {
|
||||
if (!/^[a-z][a-z0-9+.-]*:/i.test(url)) url = 'https://' + url
|
||||
ed.chain().focus().setLink({ href: url }).run()
|
||||
}
|
||||
closePanel()
|
||||
onDone()
|
||||
}
|
||||
|
||||
apply.addEventListener('mousedown', (ev) => { ev.preventDefault(); doApply() })
|
||||
input.addEventListener('keydown', (ev) => {
|
||||
if (ev.key === 'Enter') doApply()
|
||||
if (ev.key === 'Escape') closePanel()
|
||||
})
|
||||
|
||||
document.body.appendChild(panel)
|
||||
input.focus()
|
||||
input.select()
|
||||
|
||||
setTimeout(() => {
|
||||
document.addEventListener('mousedown', onOutsideClick)
|
||||
document.addEventListener('keydown', onKey)
|
||||
}, 0)
|
||||
}
|
||||
264
frontend/src/shared/extensions/slash-command.ts
Normal file
264
frontend/src/shared/extensions/slash-command.ts
Normal file
@ -0,0 +1,264 @@
|
||||
/**
|
||||
* slash-command.ts — TipTap v3 @tiptap/suggestion integration for slash commands.
|
||||
*
|
||||
* Uses the official Suggestion utility engine wrapped in a TipTap Extension:
|
||||
* - Trigger detection (typing '/')
|
||||
* - Position calculation via getBoundingClientRect
|
||||
* - Query filtering
|
||||
* - Keyboard navigation
|
||||
* - Vue component rendering via VueRenderer
|
||||
*
|
||||
* Based on official TipTap v3 suggestion API.
|
||||
*/
|
||||
import type { Component } from 'vue'
|
||||
import { markRaw } from 'vue'
|
||||
import { Extension } from '@tiptap/core'
|
||||
import type { Editor, Range } from '@tiptap/core'
|
||||
import { PluginKey } from '@tiptap/pm/state'
|
||||
import { Suggestion, exitSuggestion } from '@tiptap/suggestion'
|
||||
import type { SuggestionProps, SuggestionKeyDownProps } from '@tiptap/suggestion'
|
||||
import { VueRenderer } from '@tiptap/vue-3'
|
||||
|
||||
export interface SlashCommandItem {
|
||||
title: string
|
||||
subtitle?: string
|
||||
group: string
|
||||
icon?: string
|
||||
command: (props: { editor: Editor; range: Range }) => void
|
||||
}
|
||||
|
||||
export const SLASH_COMMAND_PLUGIN_KEY = new PluginKey('slashCommand')
|
||||
|
||||
// ─── Built-in slash commands ───────────────────────────────────────────────────
|
||||
|
||||
export function getSlashCommands(): SlashCommandItem[] {
|
||||
return [
|
||||
// Text
|
||||
{
|
||||
title: 'Paragraph',
|
||||
subtitle: 'Plain text',
|
||||
group: 'Text',
|
||||
icon: 'text',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).setParagraph().run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Heading 1',
|
||||
subtitle: 'Big heading',
|
||||
group: 'Text',
|
||||
icon: 'h1',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleHeading({ level: 1 }).run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Heading 2',
|
||||
subtitle: 'Medium heading',
|
||||
group: 'Text',
|
||||
icon: 'h2',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleHeading({ level: 2 }).run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Heading 3',
|
||||
subtitle: 'Small heading',
|
||||
group: 'Text',
|
||||
icon: 'h3',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleHeading({ level: 3 }).run()
|
||||
},
|
||||
},
|
||||
|
||||
// Lists
|
||||
{
|
||||
title: 'Bullet List',
|
||||
subtitle: 'Unordered list',
|
||||
group: 'Lists',
|
||||
icon: 'list',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBulletList().run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Numbered List',
|
||||
subtitle: 'Ordered list',
|
||||
group: 'Lists',
|
||||
icon: 'ordered-list',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleOrderedList().run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Task List',
|
||||
subtitle: 'Todo checklist',
|
||||
group: 'Lists',
|
||||
icon: 'task-list',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleTaskList().run()
|
||||
},
|
||||
},
|
||||
|
||||
// Blocks
|
||||
{
|
||||
title: 'Blockquote',
|
||||
subtitle: 'Quote block',
|
||||
group: 'Blocks',
|
||||
icon: 'quote',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleBlockquote().run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Callout',
|
||||
subtitle: 'Highlighted info block',
|
||||
group: 'Blocks',
|
||||
icon: 'callout',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleCallout({ icon: '💡' }).run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Code Block',
|
||||
subtitle: 'Syntax highlighted code',
|
||||
group: 'Blocks',
|
||||
icon: 'code-block',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleCodeBlock().run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Divider',
|
||||
subtitle: 'Horizontal rule',
|
||||
group: 'Blocks',
|
||||
icon: 'divider',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).setHorizontalRule().run()
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Toggle Heading',
|
||||
subtitle: 'Collapsible heading',
|
||||
group: 'Blocks',
|
||||
icon: 'toggle',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).toggleToggleHeading({ level: 2 }).run()
|
||||
},
|
||||
},
|
||||
|
||||
// Media
|
||||
{
|
||||
title: 'Image',
|
||||
subtitle: 'Insert image',
|
||||
group: 'Media',
|
||||
icon: 'image',
|
||||
command: ({ editor, range }) => {
|
||||
editor.chain().focus().deleteRange(range).run()
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('blockeditor:image-picker', { detail: { editor } }),
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: 'Table',
|
||||
subtitle: 'Insert table',
|
||||
group: 'Media',
|
||||
icon: 'table',
|
||||
command: ({ editor, range }) => {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.deleteRange(range)
|
||||
.insertTable({ rows: 3, cols: 3, withHeaderRow: true })
|
||||
.run()
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the slash command extension.
|
||||
* Wraps @tiptap/suggestion in a TipTap Extension so it can be added to Editor.extensions[].
|
||||
*
|
||||
* The VueComponent is marked raw to prevent Vue from reactivity-wrapping it.
|
||||
* The suggestion engine manages all keyboard/position/query logic.
|
||||
*/
|
||||
export function SlashCommandExtension(VueComponent: Component) {
|
||||
return Extension.create({
|
||||
name: 'slashCommand',
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
const ext = this
|
||||
let renderer: VueRenderer | null = null
|
||||
|
||||
return [
|
||||
Suggestion({
|
||||
pluginKey: SLASH_COMMAND_PLUGIN_KEY,
|
||||
editor: ext.editor,
|
||||
startOfLine: false,
|
||||
allowSpaces: false,
|
||||
allowedPrefixes: null,
|
||||
allowToIncludeChar: false,
|
||||
|
||||
items: ({ query }): SlashCommandItem[] => {
|
||||
const q = query.toLowerCase()
|
||||
return getSlashCommands().filter(
|
||||
(item) =>
|
||||
item.title.toLowerCase().includes(q) ||
|
||||
(item.subtitle && item.subtitle.toLowerCase().includes(q)),
|
||||
)
|
||||
},
|
||||
|
||||
command: ({ editor, range, props }) => {
|
||||
;(props as SlashCommandItem).command({ editor, range })
|
||||
},
|
||||
|
||||
render: () => {
|
||||
return {
|
||||
onStart: (props: SuggestionProps) => {
|
||||
if (!renderer) {
|
||||
renderer = new VueRenderer(markRaw(VueComponent) as Component, {
|
||||
editor: props.editor,
|
||||
})
|
||||
}
|
||||
|
||||
// Append renderer element to editor container for proper positioning
|
||||
const container = props.editor.view.dom.closest('.blockeditor-mount')
|
||||
if (container && renderer.element) {
|
||||
container.appendChild(renderer.element)
|
||||
}
|
||||
|
||||
renderer.updateProps(props)
|
||||
},
|
||||
|
||||
onUpdate: (props: SuggestionProps) => {
|
||||
renderer?.updateProps(props)
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
renderer?.destroy()
|
||||
renderer = null
|
||||
},
|
||||
|
||||
onKeyDown: ({ event }: SuggestionKeyDownProps): boolean => {
|
||||
if (event.key === 'Escape') {
|
||||
return true
|
||||
}
|
||||
// Arrow key navigation is handled by the Vue component
|
||||
return false
|
||||
},
|
||||
}
|
||||
},
|
||||
}),
|
||||
]
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Exit the slash command menu programmatically.
|
||||
*/
|
||||
export function closeSlashMenu(editor: Editor) {
|
||||
exitSuggestion(editor.view, SLASH_COMMAND_PLUGIN_KEY)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user