优化BlockOutlineRail.vue

This commit is contained in:
jingrow 2026-05-30 19:34:53 +08:00
parent 7eaeda0bfe
commit c8b32273e4

View File

@ -10,6 +10,7 @@
*/
import { ref, computed, onMounted, onBeforeUnmount, nextTick } from 'vue'
import type { Editor } from '@tiptap/core'
import { TextSelection } from '@tiptap/pm/state'
interface BlockInfo {
pos: number
@ -184,15 +185,88 @@ function onPopupLeave() {
}
function scrollToBlock(block: BlockInfo) {
try {
const domNode = props.editor.view.nodeDOM(block.pos)
if (domNode instanceof HTMLElement) {
domNode.scrollIntoView({ behavior: 'smooth', block: 'start' })
props.editor.commands.setTextSelection(block.pos)
activeBlockPos.value = block.pos
const { view } = props.editor
console.group('[BlockOutlineRail] scrollToBlock')
console.log('block:', { pos: block.pos, text: block.text, level: block.level })
// ProseMirror transaction: heading
view.dispatch(
view.state.tr.setSelection(
TextSelection.create(view.state.doc, block.pos + 1),
),
)
//
const domNode = view.nodeDOM(block.pos)
console.log('domNode:', domNode)
if (domNode instanceof HTMLElement) {
const container = findScrollContainer(view.dom as HTMLElement)
const containerRect = container instanceof HTMLElement
? container.getBoundingClientRect()
: new DOMRect(0, 0, window.innerWidth, window.innerHeight)
const headingRect = domNode.getBoundingClientRect()
const offset = headingRect.top - containerRect.top
console.log('container:', container === window ? 'window' : (container as HTMLElement).className || (container as HTMLElement).tagName)
console.log('containerRect.top:', containerRect.top)
console.log('headingRect.top:', headingRect.top)
console.log('offset:', offset)
if (Math.abs(offset) > 2) {
console.log('>>> scrolling by', offset)
if (container === window) {
window.scrollBy({ top: offset })
} else {
const el = container as HTMLElement
console.log('current scrollTop:', el.scrollTop, '->', el.scrollTop + offset)
el.scrollTop += offset
console.log('after scrollTop:', el.scrollTop)
}
} else {
console.log('>>> offset too small, skip scroll')
}
} catch {
props.editor.commands.setTextSelection(block.pos)
} else {
console.warn('domNode is not HTMLElement:', typeof domNode, domNode)
}
//
view.dom.focus({ preventScroll: true } as FocusOptions)
//
if (domNode instanceof HTMLElement) {
domNode.classList.add('block-outline-highlight')
setTimeout(() => domNode.classList.remove('block-outline-highlight'), 2000)
}
console.groupEnd()
}
/** 向上查找真正可滚动的祖先容器 */
function findScrollContainer(el: HTMLElement): HTMLElement | Window {
let parent: HTMLElement | null = el.parentElement
while (parent) {
if (parent === document.body) {
console.log('findScrollContainer: reached body, return window')
return window
}
const style = window.getComputedStyle(parent)
const isScrollable =
style.overflow === 'auto' || style.overflow === 'scroll' ||
style.overflowY === 'auto' || style.overflowY === 'scroll'
const canScroll = isScrollable && parent.scrollHeight > parent.clientHeight
console.log(
'findScrollContainer:', (parent as HTMLElement).className || parent.tagName,
'overflow:', style.overflowY,
'scrollH:', parent.scrollHeight, 'clientH:', parent.clientHeight,
'canScroll:', canScroll,
)
if (canScroll) {
return parent
}
parent = parent.parentElement
}
console.log('findScrollContainer: no container found, return window')
return window
}
function getIcon(level?: number): string {
@ -449,4 +523,18 @@ onBeforeUnmount(() => {
color: #1e40af;
font-weight: 500;
}
/* ── 点击跳转后编辑器内临时高亮 ── */
@keyframes outline-highlight-pulse {
0% { background-color: transparent; }
15% { background-color: rgba(59, 130, 246, 0.18); }
40% { background-color: rgba(59, 130, 246, 0.08); }
80% { background-color: rgba(59, 130, 246, 0.03); }
100% { background-color: transparent; }
}
.block-outline-highlight {
animation: outline-highlight-pulse 2s ease-out forwards;
border-radius: 4px;
}
</style>