feat: add outline highlight

This commit is contained in:
purocean
2022-03-26 00:07:40 +08:00
parent c86d32e7dc
commit 4601aba8ef
4 changed files with 115 additions and 52 deletions
+61 -29
View File
@@ -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>
+4 -14
View File
@@ -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,
}
},
})
+49 -8
View File
@@ -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),
}
})
}
+1 -1
View File
@@ -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>