mirror of
https://github.com/purocean/yn.git
synced 2026-09-24 15:46:02 +08:00
feat: add outline highlight
This commit is contained in:
@@ -1,12 +1,12 @@
|
||||
<template>
|
||||
<div class="outline-toc">
|
||||
<div v-if="(headings || heads).length < 1" class="empty">Empty</div>
|
||||
<div ref="container" class="outline-toc">
|
||||
<div v-if="heads.length < 1" class="empty">Empty</div>
|
||||
<div
|
||||
class="heading"
|
||||
v-for="(head, index) in (headings || heads)"
|
||||
v-for="(head, index) in heads"
|
||||
:key="index"
|
||||
:class="head.class"
|
||||
:style="{paddingLeft: `${head.level + 1}em`}"
|
||||
:data-activated="head.activated"
|
||||
@click="handleClickItem(head)">
|
||||
{{ head.text }}
|
||||
<span class="tag-name">{{head.tag}}</span>
|
||||
@@ -15,45 +15,66 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { throttle } from 'lodash-es'
|
||||
import { defineComponent, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useStore } from 'vuex'
|
||||
import { registerHook, removeHook } from '@fe/core/hook'
|
||||
import { revealLineInCenter } from '@fe/services/editor'
|
||||
import { getHeadings, Heading } from '@fe/services/view'
|
||||
import { defineComponent, onBeforeUnmount, ref } from 'vue'
|
||||
import { DOM_ATTR_NAME } from '@fe/support/constant'
|
||||
import { AppState } from '@fe/support/store'
|
||||
import { getHeadings, getViewDom, Heading } from '@fe/services/view'
|
||||
|
||||
export default defineComponent({
|
||||
name: 'outline',
|
||||
props: {
|
||||
headings: Array as () => Heading[],
|
||||
autoScroll: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
}
|
||||
},
|
||||
setup (props, { emit }) {
|
||||
setup () {
|
||||
const container = ref<HTMLElement>()
|
||||
const heads = ref<Heading[]>([])
|
||||
const store = useStore<AppState>()
|
||||
|
||||
function handleClickItem (heading: Heading) {
|
||||
emit('click-item', heading)
|
||||
if (props.autoScroll) {
|
||||
revealLineInCenter(heading.sourceLine)
|
||||
const line = heading.sourceLine
|
||||
if (store.state.showEditor && !store.state.presentation) {
|
||||
revealLineInCenter(line)
|
||||
} else {
|
||||
getViewDom()
|
||||
?.querySelector<HTMLElement>(`.markdown-body [${DOM_ATTR_NAME.SOURCE_LINE_START}="${line}"]`)
|
||||
?.scrollIntoView()
|
||||
}
|
||||
}
|
||||
|
||||
const heads = ref<Heading[]>([])
|
||||
|
||||
function refresh () {
|
||||
if (props.headings) {
|
||||
heads.value = []
|
||||
} else {
|
||||
heads.value = getHeadings()
|
||||
heads.value = getHeadings(true)
|
||||
|
||||
if (!container.value || container.value.clientWidth < 50) {
|
||||
return
|
||||
}
|
||||
|
||||
nextTick(() => {
|
||||
// skip on hover
|
||||
if (container.value?.parentElement?.querySelector('.outline-toc:hover') !== container.value) {
|
||||
const idx = heads.value.findIndex(head => head.activated)
|
||||
const item: any = container.value?.children.item(idx)
|
||||
if (item) {
|
||||
item.scrollIntoViewIfNeeded(false)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
registerHook('VIEW_RENDERED', refresh)
|
||||
const throttleRefresh = throttle(refresh, 150)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
removeHook('VIEW_RENDERED', refresh)
|
||||
onMounted(() => {
|
||||
refresh()
|
||||
registerHook('VIEW_RENDERED', throttleRefresh)
|
||||
registerHook('VIEW_SCROLL', throttleRefresh)
|
||||
})
|
||||
|
||||
return { heads, handleClickItem }
|
||||
onBeforeUnmount(() => {
|
||||
removeHook('VIEW_RENDERED', throttleRefresh)
|
||||
removeHook('VIEW_SCROLL', throttleRefresh)
|
||||
})
|
||||
|
||||
return { container, heads, handleClickItem }
|
||||
},
|
||||
})
|
||||
</script>
|
||||
@@ -82,6 +103,11 @@ export default defineComponent({
|
||||
display: flex;
|
||||
border-radius: var(--g-border-radius);
|
||||
cursor: pointer;
|
||||
overflow-wrap: break-word;
|
||||
|
||||
&[data-activated="true"] {
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
@@ -100,8 +126,14 @@ export default defineComponent({
|
||||
}
|
||||
|
||||
@include dark-theme {
|
||||
.outline-toc > .heading:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
.outline-toc > .heading {
|
||||
&[data-activated="true"] {
|
||||
background: rgba(255, 255, 255, 0.07);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="catalog" :style="{maxHeight: `min(${(height - 120) + 'px'}, 70vh)`}">
|
||||
<Outline :headings="heads" :auto-scroll="false" @click-item="(head: any) => syncScroll(head.sourceLine)" />
|
||||
<Outline />
|
||||
</div>
|
||||
</div>
|
||||
<div :class="{'scroll-to-top': true, 'hide': scrollTop < 30}" :style="scrollToTopStyle" @click="scrollToTop">TOP</div>
|
||||
@@ -53,6 +53,7 @@ import { DOM_ATTR_NAME } from '@fe/support/constant'
|
||||
import { useI18n } from '@fe/services/i18n'
|
||||
import { getLogger } from '@fe/utils'
|
||||
import type { RenderEnv } from '@fe/types'
|
||||
import type { AppState } from '@fe/support/store'
|
||||
|
||||
import Render from './Render.vue'
|
||||
import SvgIcon from './SvgIcon.vue'
|
||||
@@ -69,7 +70,7 @@ export default defineComponent({
|
||||
setup () {
|
||||
useI18n()
|
||||
|
||||
const store = useStore()
|
||||
const store = useStore<AppState>()
|
||||
|
||||
const pinOutline = ref(false)
|
||||
const { currentContent, currentFile, autoPreview, presentation, inComposition } = toRefs(store.state)
|
||||
@@ -181,23 +182,13 @@ export default defineComponent({
|
||||
triggerHook('VIEW_SCROLL', { e })
|
||||
}
|
||||
|
||||
function syncScroll (line: number) {
|
||||
if (store.state.showEditor) {
|
||||
revealLineInCenter(line)
|
||||
} else {
|
||||
refViewWrapper.value
|
||||
?.querySelector<HTMLElement>(`.markdown-body [${DOM_ATTR_NAME.SOURCE_LINE_START}="${line}"]`)
|
||||
?.scrollIntoView()
|
||||
}
|
||||
}
|
||||
|
||||
function scrollTopTo (top: number) {
|
||||
refViewWrapper.value!.scrollTo(0, top)
|
||||
}
|
||||
|
||||
function scrollToTop () {
|
||||
scrollTopTo(0)
|
||||
syncScroll(1)
|
||||
revealLineInCenter(1)
|
||||
}
|
||||
|
||||
function handleDbClick (e: MouseEvent) {
|
||||
@@ -347,7 +338,6 @@ export default defineComponent({
|
||||
handleClick,
|
||||
handleDbClick,
|
||||
handleContextMenu,
|
||||
syncScroll,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -14,10 +14,11 @@ export type MenuItem = Components.ContextMenu.Item
|
||||
export type BuildContextMenu = (items: MenuItem[], e: MouseEvent) => void
|
||||
export type Heading = {
|
||||
tag: string;
|
||||
class: string[];
|
||||
class: string;
|
||||
text: string;
|
||||
level: number;
|
||||
sourceLine: number;
|
||||
activated?: boolean;
|
||||
}
|
||||
|
||||
let tmpEnableSyncScroll = true
|
||||
@@ -178,22 +179,62 @@ export function getViewDom () {
|
||||
/**
|
||||
* Get Headings
|
||||
*/
|
||||
export function getHeadings (): Heading[] {
|
||||
const tags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']
|
||||
const dom = getViewDom()
|
||||
export function getHeadings (withActivated = false): Heading[] {
|
||||
const dom = getViewDom()?.parentElement
|
||||
if (!dom) {
|
||||
return []
|
||||
}
|
||||
|
||||
const tags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']
|
||||
|
||||
let viewRect: DOMRect
|
||||
let breakCheck = false
|
||||
const isActivated = (nodes: NodeListOf<HTMLHeadElement>, i: number) => {
|
||||
if (!withActivated) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
if (breakCheck) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!viewRect) {
|
||||
viewRect = dom.getBoundingClientRect()
|
||||
}
|
||||
|
||||
const node = nodes[i]
|
||||
const nodeRect = node.getBoundingClientRect()
|
||||
|
||||
const bottom = viewRect.bottom / 3 * 2
|
||||
// in view
|
||||
if (nodeRect.top >= viewRect.top && nodeRect.top < bottom) {
|
||||
breakCheck = true
|
||||
return true
|
||||
} else if (nodeRect.top < viewRect.top) { // before view
|
||||
const nextNode = i < nodes.length - 1 ? nodes[i + 1] : undefined
|
||||
const res = !nextNode || nextNode.getBoundingClientRect().top > bottom
|
||||
if (res) {
|
||||
breakCheck = true
|
||||
}
|
||||
|
||||
return res
|
||||
} else { // after view
|
||||
breakCheck = true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
const nodes = dom.querySelectorAll<HTMLHeadElement>(tags.join(','))
|
||||
return Array.from(nodes).map(node => {
|
||||
return Array.from(nodes).map((node, i) => {
|
||||
const tag = node.tagName.toLowerCase()
|
||||
return {
|
||||
tag,
|
||||
class: [...node.classList, 'tag-' + tag],
|
||||
text: node.innerText,
|
||||
class: `heading ${node.className} tag-${tag}`,
|
||||
text: node.textContent || '',
|
||||
level: tags.indexOf(tag),
|
||||
sourceLine: parseInt(node.dataset.sourceLine || '0')
|
||||
sourceLine: parseInt(node.dataset.sourceLine || '0'),
|
||||
activated: isActivated(nodes, i),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</template>
|
||||
<template v-slot:left>
|
||||
<ActionBar />
|
||||
<Outline v-show="showOutline" />
|
||||
<Outline v-if="showOutline" />
|
||||
<Tree v-show="!showOutline" />
|
||||
</template>
|
||||
<template v-slot:terminal>
|
||||
|
||||
Reference in New Issue
Block a user