feat: 分页支持持续分页

This commit is contained in:
zhuzhengjian
2025-11-21 17:17:39 +08:00
parent 8a1f976f7f
commit f093ff4662
5 changed files with 484 additions and 2 deletions

View File

@@ -22,6 +22,7 @@
"@tiptap/extension-table-cell": "^3.11.0",
"@tiptap/extension-table-header": "^3.11.0",
"@tiptap/extension-table-row": "^3.11.0",
"@tiptap/extension-text-align": "^3.11.0",
"@tiptap/pm": "^3.11.0",
"@tiptap/starter-kit": "^3.11.0",
"@tiptap/suggestion": "^3.11.0",
@@ -30,6 +31,7 @@
"lucide-vue-next": "^0.554.0",
"monaco-editor": "^0.55.1",
"shiki": "^3.15.0",
"tiptap-markdown": "^0.9.0",
"vue": "^3.5.24"
},
"devDependencies": {

51
pnpm-lock.yaml generated
View File

@@ -50,6 +50,9 @@ importers:
'@tiptap/extension-table-row':
specifier: ^3.11.0
version: 3.11.0(@tiptap/extension-table@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))(@tiptap/pm@3.11.0))
'@tiptap/extension-text-align':
specifier: ^3.11.0
version: 3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))
'@tiptap/pm':
specifier: ^3.11.0
version: 3.11.0
@@ -74,6 +77,9 @@ importers:
shiki:
specifier: ^3.15.0
version: 3.15.0
tiptap-markdown:
specifier: ^0.9.0
version: 0.9.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))
vue:
specifier: ^3.5.24
version: 3.5.24(typescript@5.9.3)
@@ -443,6 +449,11 @@ packages:
'@tiptap/core': ^3.11.0
'@tiptap/pm': ^3.11.0
'@tiptap/extension-text-align@3.11.0':
resolution: {integrity: sha512-Hmcnc10vP2TecVYEuIKpx9HPWXQ263Vaqq8BoplXIt7XQ+pCZFS/TF6F8zcClb8gMIhICI89GzF4TEvxnHlxFw==}
peerDependencies:
'@tiptap/core': ^3.11.0
'@tiptap/extension-text@3.11.0':
resolution: {integrity: sha512-ELAYm2BuChzZOqDG9B0k3W6zqM4pwNvXkam28KgHGiT2y7Ni68Rb+NXp16uVR+5zR6hkqnQ/BmJSKzAW59MXpA==}
peerDependencies:
@@ -495,15 +506,24 @@ packages:
'@types/hast@3.0.4':
resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==}
'@types/linkify-it@3.0.5':
resolution: {integrity: sha512-yg6E+u0/+Zjva+buc3EIb+29XEg4wltq7cSmd4Uc2EE/1nUVmxyzpX6gUXD0V8jIrG0r7YeOGVIbYRkxeooCtw==}
'@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/markdown-it@13.0.9':
resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
'@types/markdown-it@14.1.2':
resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==}
'@types/mdast@4.0.4':
resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==}
'@types/mdurl@1.0.5':
resolution: {integrity: sha512-6L6VymKTzYSrEf4Nev4Xa1LCHKrlTlYCBMTlQKFuddo1CvQcE52I0mwfOJayueUC7MJuXOeHTcIU683lzd0cUA==}
'@types/mdurl@2.0.0':
resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==}
@@ -754,6 +774,9 @@ packages:
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
markdown-it-task-lists@2.1.1:
resolution: {integrity: sha512-TxFAc76Jnhb2OUu+n3yz9RMu4CwGfaT788br6HhEDlvWfdeJcLUsxk1Hgw2yJio0OXsxv7pyIPmvECY7bMbluA==}
markdown-it@14.1.0:
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
hasBin: true
@@ -957,6 +980,11 @@ packages:
resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==}
engines: {node: '>=12.0.0'}
tiptap-markdown@0.9.0:
resolution: {integrity: sha512-dKLQ9iiuGNgrlGVjrNauF/UBzWu4LYOx5pkD0jNkmQt/GOwfCJsBuzZTsf1jZ204ANHOm572mZ9PYvGh1S7tpQ==}
peerDependencies:
'@tiptap/core': ^3.0.1
trim-lines@3.0.1:
resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==}
@@ -1324,6 +1352,10 @@ snapshots:
'@tiptap/core': 3.11.0(@tiptap/pm@3.11.0)
'@tiptap/pm': 3.11.0
'@tiptap/extension-text-align@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))':
dependencies:
'@tiptap/core': 3.11.0(@tiptap/pm@3.11.0)
'@tiptap/extension-text@3.11.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0))':
dependencies:
'@tiptap/core': 3.11.0(@tiptap/pm@3.11.0)
@@ -1418,8 +1450,15 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/linkify-it@3.0.5': {}
'@types/linkify-it@5.0.0': {}
'@types/markdown-it@13.0.9':
dependencies:
'@types/linkify-it': 3.0.5
'@types/mdurl': 1.0.5
'@types/markdown-it@14.1.2':
dependencies:
'@types/linkify-it': 5.0.0
@@ -1429,6 +1468,8 @@ snapshots:
dependencies:
'@types/unist': 3.0.3
'@types/mdurl@1.0.5': {}
'@types/mdurl@2.0.0': {}
'@types/node@24.10.1':
@@ -1669,6 +1710,8 @@ snapshots:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
markdown-it-task-lists@2.1.1: {}
markdown-it@14.1.0:
dependencies:
argparse: 2.0.1
@@ -1919,6 +1962,14 @@ snapshots:
fdir: 6.5.0(picomatch@4.0.3)
picomatch: 4.0.3
tiptap-markdown@0.9.0(@tiptap/core@3.11.0(@tiptap/pm@3.11.0)):
dependencies:
'@tiptap/core': 3.11.0(@tiptap/pm@3.11.0)
'@types/markdown-it': 13.0.9
markdown-it: 14.1.0
markdown-it-task-lists: 2.1.1
prosemirror-markdown: 1.13.2
trim-lines@3.0.1: {}
tslib@2.8.1:

View File

@@ -1,5 +1,9 @@
<template>
<div class="editor-container" :class="{ dark: theme === 'dark' }" v-if="editor">
<div class="editor-header">
<button @click="exportHTML" class="export-btn">Export HTML</button>
<button @click="exportMarkdown" class="export-btn">Export Markdown</button>
</div>
<drag-handle
class="drag-handle"
:editor="editor"
@@ -45,6 +49,8 @@
</floating-menu>
<editor-content :editor="editor" class="editor-content" />
<table-controls :editor="editor" />
</div>
</template>
@@ -54,11 +60,13 @@ import { BubbleMenu, FloatingMenu } from '@tiptap/vue-3/menus'
import StarterKit from '@tiptap/starter-kit'
import Placeholder from '@tiptap/extension-placeholder'
import CodeBlockLowlight from '@tiptap/extension-code-block-lowlight'
import TextAlign from '@tiptap/extension-text-align'
import { Table } from '@tiptap/extension-table'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'
import { TableRow } from '@tiptap/extension-table-row'
import { DragHandle } from '@tiptap/extension-drag-handle-vue-3'
import { Markdown } from 'tiptap-markdown'
import SlashCommand from '../extensions/SlashCommand'
import { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom'
import {
@@ -78,6 +86,7 @@ import {
} from 'lucide-vue-next'
import SlashCommandList from './SlashCommandList.vue'
import CodeBlockComponent from './CodeBlockComponent.vue'
import TableControls from './TableControls.vue'
import { all, createLowlight } from 'lowlight'
const props = defineProps({
@@ -262,11 +271,64 @@ const editor = useEditor({
render: renderSuggestion,
},
}),
TextAlign.configure({
types: ['heading', 'paragraph', 'tableCell', 'tableHeader'],
}),
Markdown,
],
content: '<p>Hello World!</p>',
})
const exportHTML = () => {
const html = editor.value?.getHTML()
if (html) {
const blob = new Blob([html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'document.html'
a.click()
URL.revokeObjectURL(url)
}
}
const exportMarkdown = () => {
const markdown = (editor.value?.storage as any).markdown.getMarkdown()
if (markdown) {
const blob = new Blob([markdown], { type: 'text/markdown' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = 'document.md'
a.click()
URL.revokeObjectURL(url)
}
}
</script>
<style>
@import '../styles/index.css';
.editor-header {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
padding: 0.5rem;
margin-bottom: 1rem;
}
.export-btn {
padding: 0.5rem 1rem;
border-radius: 0.25rem;
background-color: var(--bg-secondary);
color: var(--text-primary);
border: 1px solid var(--border-color);
cursor: pointer;
font-size: 0.875rem;
transition: background-color 0.2s;
}
.export-btn:hover {
background-color: var(--bg-hover);
}
</style>

View File

@@ -1,5 +1,5 @@
<template>
<div class="slash-command-list">
<div class="slash-command-list" ref="containerRef">
<template v-if="items.length">
<button
v-for="(item, index) in items"
@@ -8,6 +8,7 @@
:class="{ 'is-selected': index === selectedIndex }"
@click="selectItem(index)"
@mouseenter="selectedIndex = index"
ref="itemRefs"
>
<component :is="item.icon" class="icon" />
<div class="label">
@@ -23,7 +24,7 @@
</template>
<script setup lang="ts">
import { ref, watch } from 'vue'
import { ref, watch, nextTick } from 'vue'
import type { Component } from 'vue'
interface CommandItem {
@@ -39,6 +40,8 @@ const props = defineProps<{
}>()
const selectedIndex = ref(0)
const containerRef = ref<HTMLElement | null>(null)
const itemRefs = ref<HTMLElement[]>([])
watch(
() => props.items,
@@ -47,6 +50,28 @@ watch(
},
)
watch(selectedIndex, () => {
scrollToSelected()
})
const scrollToSelected = () => {
nextTick(() => {
const container = containerRef.value
const item = itemRefs.value[selectedIndex.value]
if (container && item) {
const containerRect = container.getBoundingClientRect()
const itemRect = item.getBoundingClientRect()
if (itemRect.bottom > containerRect.bottom) {
container.scrollTop += itemRect.bottom - containerRect.bottom
} else if (itemRect.top < containerRect.top) {
container.scrollTop -= containerRect.top - itemRect.top
}
}
})
}
const onKeyDown = ({ event }: { event: KeyboardEvent }) => {
if (event.key === 'ArrowUp') {
upHandler()

View File

@@ -0,0 +1,342 @@
<template>
<div class="table-controls" v-if="editor">
<!-- Row Handle -->
<div
v-if="activeCell"
class="table-handle row-handle"
:style="rowHandleStyle"
@click.stop="showRowMenu"
>
<div class="handle-icon"></div>
</div>
<!-- Column Handle -->
<div
v-if="activeCell"
class="table-handle col-handle"
:style="colHandleStyle"
@click.stop="showColMenu"
>
<div class="handle-icon">···</div>
</div>
<!-- Row Menu -->
<div v-if="activeRowMenu" class="table-menu" :style="rowMenuStyle">
<button @click="addRowAbove">Add Row Above</button>
<button @click="addRowBelow">Add Row Below</button>
<button @click="deleteRow" class="danger">Delete Row</button>
</div>
<!-- Column Menu -->
<div v-if="activeColMenu" class="table-menu" :style="colMenuStyle">
<button @click="addColumnLeft">Add Column Left</button>
<button @click="addColumnRight">Add Column Right</button>
<div class="alignment-controls">
<button
@click="alignLeft"
:class="{ 'is-active': editor.isActive({ textAlign: 'left' }) }"
title="Align Left"
>
<AlignLeft :size="16" />
</button>
<button
@click="alignCenter"
:class="{ 'is-active': editor.isActive({ textAlign: 'center' }) }"
title="Align Center"
>
<AlignCenter :size="16" />
</button>
<button
@click="alignRight"
:class="{ 'is-active': editor.isActive({ textAlign: 'right' }) }"
title="Align Right"
>
<AlignRight :size="16" />
</button>
</div>
<button @click="deleteColumn" class="danger">Delete Column</button>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { Editor } from '@tiptap/vue-3'
import { AlignLeft, AlignCenter, AlignRight } from 'lucide-vue-next'
const props = defineProps<{
editor: Editor
}>()
const activeCell = ref<HTMLElement | null>(null)
const activeTable = ref<HTMLElement | null>(null)
const activeRowMenu = ref(false)
const activeColMenu = ref(false)
const menuPosition = ref({ x: 0, y: 0 })
const hideTimeout = ref<ReturnType<typeof setTimeout> | null>(null)
const rowHandleStyle = computed(() => {
if (!activeCell.value || !activeTable.value) return {}
const cellRect = activeCell.value.getBoundingClientRect()
const tableRect = activeTable.value.getBoundingClientRect()
return {
top: `${cellRect.top}px`,
left: `${tableRect.left - 24}px`,
height: `${cellRect.height}px`,
}
})
const colHandleStyle = computed(() => {
if (!activeCell.value || !activeTable.value) return {}
const cellRect = activeCell.value.getBoundingClientRect()
const tableRect = activeTable.value.getBoundingClientRect()
return {
top: `${tableRect.top - 24}px`,
left: `${cellRect.left}px`,
width: `${cellRect.width}px`,
}
})
const rowMenuStyle = computed(() => ({
top: `${menuPosition.value.y}px`,
left: `${menuPosition.value.x}px`,
}))
const colMenuStyle = computed(() => ({
top: `${menuPosition.value.y}px`,
left: `${menuPosition.value.x}px`,
}))
const updateActiveCell = (event: Event) => {
if (activeRowMenu.value || activeColMenu.value) return
const target = event.target as HTMLElement
const cell = target.closest('td, th') as HTMLElement
const isControl = target.closest('.table-handle') || target.closest('.table-menu')
if (cell || isControl) {
if (hideTimeout.value) {
clearTimeout(hideTimeout.value)
hideTimeout.value = null
}
if (cell) {
activeCell.value = cell
activeTable.value = cell.closest('table') as HTMLElement
}
} else {
if (!hideTimeout.value) {
hideTimeout.value = setTimeout(() => {
activeCell.value = null
activeTable.value = null
hideTimeout.value = null
}, 300)
}
}
}
const showRowMenu = (event: MouseEvent) => {
activeRowMenu.value = true
activeColMenu.value = false
const rect = (event.target as HTMLElement).getBoundingClientRect()
menuPosition.value = {
x: rect.left,
y: rect.bottom + 5
}
// Select the cell to ensure commands apply to the correct row
selectHoveredCell()
}
const showColMenu = (event: MouseEvent) => {
activeColMenu.value = true
activeRowMenu.value = false
const rect = (event.target as HTMLElement).getBoundingClientRect()
menuPosition.value = {
x: rect.left,
y: rect.bottom + 5
}
// Select the cell to ensure commands apply to the correct column
selectHoveredCell()
}
const selectHoveredCell = () => {
if (!activeCell.value) return
const pos = props.editor.view.posAtDOM(activeCell.value, 0)
props.editor.chain().setTextSelection(pos).run()
}
const closeMenus = (event: MouseEvent) => {
if (!(event.target as HTMLElement).closest('.table-menu') &&
!(event.target as HTMLElement).closest('.table-handle')) {
activeRowMenu.value = false
activeColMenu.value = false
}
}
// Operations
const addRowAbove = () => {
props.editor.chain().focus().addRowBefore().run()
activeRowMenu.value = false
}
const addRowBelow = () => {
props.editor.chain().focus().addRowAfter().run()
activeRowMenu.value = false
}
const deleteRow = () => {
props.editor.chain().focus().deleteRow().run()
activeRowMenu.value = false
activeCell.value = null
}
const addColumnLeft = () => {
props.editor.chain().focus().addColumnBefore().run()
activeColMenu.value = false
}
const addColumnRight = () => {
props.editor.chain().focus().addColumnAfter().run()
activeColMenu.value = false
}
const deleteColumn = () => {
props.editor.chain().focus().deleteColumn().run()
activeColMenu.value = false
activeCell.value = null
}
const alignLeft = () => props.editor.chain().focus().setTextAlign('left').run()
const alignCenter = () => props.editor.chain().focus().setTextAlign('center').run()
const alignRight = () => props.editor.chain().focus().setTextAlign('right').run()
onMounted(() => {
document.addEventListener('mousemove', updateActiveCell)
document.addEventListener('click', closeMenus)
window.addEventListener('scroll', updateActiveCell, { capture: true })
})
onUnmounted(() => {
document.removeEventListener('mousemove', updateActiveCell)
document.removeEventListener('click', closeMenus)
window.removeEventListener('scroll', updateActiveCell, { capture: true })
})
</script>
<style scoped>
.table-controls {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 10;
}
.table-handle {
position: absolute;
background-color: var(--bg-secondary, #f1f5f9);
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 4px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
pointer-events: auto;
transition: background-color 0.2s;
color: var(--text-secondary, #64748b);
}
.table-handle:hover {
background-color: var(--bg-hover, #e2e8f0);
color: var(--text-primary, #1e293b);
}
.row-handle {
width: 20px;
transform: translateX(-50%);
}
.col-handle {
height: 20px;
transform: translateY(-50%);
}
.handle-icon {
font-size: 12px;
line-height: 1;
}
.table-menu {
position: absolute;
background: white;
border: 1px solid var(--border-color, #e2e8f0);
border-radius: 6px;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
padding: 4px;
display: flex;
flex-direction: column;
gap: 2px;
pointer-events: auto;
z-index: 20;
min-width: 140px;
}
.table-menu button {
text-align: left;
padding: 6px 12px;
border: none;
background: transparent;
cursor: pointer;
border-radius: 4px;
font-size: 13px;
color: var(--text-primary, #1e293b);
}
.table-menu button:hover {
background-color: var(--bg-hover, #f1f5f9);
}
.table-menu button.danger {
color: #ef4444;
}
.table-menu button.danger:hover {
background-color: #fee2e2;
}
.alignment-controls {
display: flex;
gap: 2px;
padding: 4px 8px;
border-bottom: 1px solid var(--border-color, #e2e8f0);
margin-bottom: 4px;
}
.alignment-controls button {
padding: 4px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
color: var(--text-secondary, #64748b);
width: 28px;
height: 28px;
}
.alignment-controls button:hover {
background-color: var(--bg-hover, #f1f5f9);
color: var(--text-primary, #1e293b);
}
.alignment-controls button.is-active {
background-color: var(--bg-active, #e2e8f0);
color: var(--text-primary, #1e293b);
}
</style>