diff --git a/frontend/src/shared/extensions/BlockOutlineRail.vue b/frontend/src/shared/extensions/BlockOutlineRail.vue index d564eb8f0..9ea1eb720 100644 --- a/frontend/src/shared/extensions/BlockOutlineRail.vue +++ b/frontend/src/shared/extensions/BlockOutlineRail.vue @@ -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; +}