优化:关键架构改进

Slash Command → 使用 @tiptap/suggestion 官方引擎
Link Panel → 使用 @tiptap/vue-3 FloatingMenu 组件
基础扩展 → 使用 StarterKit 简化
Typography → 启用智能标点
键盘处理 → 统一到 extension 层
This commit is contained in:
jingrow 2026-05-30 16:20:17 +08:00
parent e0f572fdb7
commit f2fa1dea2e
6 changed files with 1217 additions and 740 deletions

File diff suppressed because it is too large Load Diff

View 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> 导航 &nbsp; <kbd></kbd> 选择 &nbsp; <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>

View File

@ -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 })

View File

@ -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()

View File

@ -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)
}

View 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)
}