feat(editor): add scroll navigation and update theme Markdown behavior
This commit is contained in:
@@ -3,12 +3,14 @@ import { defineAsyncComponent, ref, watch } from 'vue'
|
||||
import { useEditorStore } from '@/stores/editor'
|
||||
import { useSettingsStore } from '@/stores/settings'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import EditorScrollButtons from './EditorScrollButtons.vue'
|
||||
const VisualMarkdownEditor = defineAsyncComponent(() => import('./VisualMarkdownEditor.vue'))
|
||||
|
||||
const editorStore = useEditorStore()
|
||||
const settingsStore = useSettingsStore()
|
||||
const themeStore = useThemeStore()
|
||||
const sourceEditor = ref<HTMLTextAreaElement | null>(null)
|
||||
const container = ref<HTMLElement | null>(null)
|
||||
watch(() => editorStore.headingRequest, request => {
|
||||
const input = sourceEditor.value
|
||||
if (!request || !input || request.path !== editorStore.currentFilePath) return
|
||||
@@ -24,13 +26,17 @@ function updateContent(event: Event) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="container" class="editor-scroll-pane">
|
||||
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${editorStore.contentRevision}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
|
||||
:initial-content="editorStore.content" />
|
||||
<textarea v-else ref="sourceEditor" class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
|
||||
:lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
|
||||
<EditorScrollButtons :container="container" :content="editorStore.content" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.editor-scroll-pane { position: relative; display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; overflow: hidden; }
|
||||
.editor-pane {
|
||||
box-sizing: border-box;
|
||||
flex: 1;
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import EditorScrollButtons from './EditorScrollButtons.vue'
|
||||
|
||||
afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); document.body.innerHTML = '' })
|
||||
|
||||
it('shows only useful directions, scrolls the editor, and hides empty or short documents', async () => {
|
||||
vi.useFakeTimers()
|
||||
vi.stubGlobal('matchMedia', () => ({ matches: false }))
|
||||
const container = document.createElement('div')
|
||||
const viewport = document.createElement('textarea')
|
||||
viewport.className = 'source'
|
||||
container.append(viewport)
|
||||
document.body.append(container)
|
||||
Object.defineProperty(viewport, 'scrollHeight', { configurable: true, value: 1000 })
|
||||
Object.defineProperty(viewport, 'clientHeight', { configurable: true, value: 200 })
|
||||
const scroll = vi.fn()
|
||||
viewport.scrollTo = scroll
|
||||
const wrapper = mount(EditorScrollButtons, { props: { container, content: 'A long note' } })
|
||||
const update = async (top: number) => {
|
||||
viewport.scrollTop = top
|
||||
viewport.dispatchEvent(new Event('scroll'))
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
}
|
||||
try {
|
||||
await update(0)
|
||||
expect(wrapper.findAll('button')).toHaveLength(1)
|
||||
expect(wrapper.find('button').attributes('aria-label')).toBe('滑动到底部')
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(scroll).toHaveBeenLastCalledWith({ top: 1000, behavior: 'smooth' })
|
||||
await update(400)
|
||||
expect(wrapper.findAll('button')).toHaveLength(2)
|
||||
await update(800)
|
||||
expect(wrapper.findAll('button')).toHaveLength(1)
|
||||
expect(wrapper.find('button').attributes('aria-label')).toBe('滑动到顶部')
|
||||
await wrapper.find('button').trigger('click')
|
||||
expect(scroll).toHaveBeenLastCalledWith({ top: 0, behavior: 'smooth' })
|
||||
await wrapper.setProps({ content: ' \n' })
|
||||
await update(400)
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
Object.defineProperty(viewport, 'scrollHeight', { value: 200 })
|
||||
await wrapper.setProps({ content: 'Short note' })
|
||||
await update(0)
|
||||
expect(wrapper.findAll('button')).toHaveLength(0)
|
||||
} finally { wrapper.unmount() }
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { Top, Bottom } from '@element-plus/icons-vue'
|
||||
import AppIcon from '@/components/common/AppIcon.vue'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const props = defineProps<{ container: HTMLElement | null; content: string }>()
|
||||
const hasContent = computed(() => !!props.content.trim())
|
||||
const canUp = ref(false), canDown = ref(false)
|
||||
let viewport: HTMLElement | null = null
|
||||
let frame = 0
|
||||
let mutation: MutationObserver | undefined
|
||||
let resize: ResizeObserver | undefined
|
||||
function update() {
|
||||
frame = 0
|
||||
const next = props.container?.querySelector<HTMLElement>('.milkdown-host, textarea.source') ?? null
|
||||
if (next !== viewport) {
|
||||
viewport?.removeEventListener('scroll', schedule)
|
||||
resize?.disconnect()
|
||||
viewport = next
|
||||
viewport?.addEventListener('scroll', schedule, { passive: true })
|
||||
}
|
||||
if (viewport) {
|
||||
resize?.observe(viewport)
|
||||
for (const child of viewport.children) resize?.observe(child)
|
||||
const document = viewport.querySelector('.ProseMirror')
|
||||
if (document) resize?.observe(document)
|
||||
}
|
||||
const max = viewport ? viewport.scrollHeight - viewport.clientHeight : 0
|
||||
const visible = hasContent.value && max > 2
|
||||
canUp.value = visible && viewport!.scrollTop > 2
|
||||
canDown.value = visible && viewport!.scrollTop < max - 2
|
||||
}
|
||||
function schedule() { if (!frame) frame = requestAnimationFrame(update) }
|
||||
function scroll(to: 'top' | 'bottom') {
|
||||
viewport?.scrollTo({ top: to === 'top' ? 0 : viewport.scrollHeight,
|
||||
behavior: window.matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' })
|
||||
}
|
||||
watch(() => props.container, container => {
|
||||
mutation?.disconnect()
|
||||
resize?.disconnect()
|
||||
mutation = new MutationObserver(schedule)
|
||||
resize = new ResizeObserver(schedule)
|
||||
if (container) mutation.observe(container, { childList: true, subtree: true, characterData: true, attributes: true })
|
||||
schedule()
|
||||
}, { immediate: true, flush: 'post' })
|
||||
watch(() => props.content, schedule)
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(frame)
|
||||
mutation?.disconnect()
|
||||
resize?.disconnect()
|
||||
viewport?.removeEventListener('scroll', schedule)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="canUp || canDown" class="editor-scroll-buttons" :aria-label="t('文档滚动', 'Document scrolling')">
|
||||
<button v-if="canUp" type="button" class="button-secondary" :title="t('滑动到顶部', 'Scroll to top')" :aria-label="t('滑动到顶部', 'Scroll to top')" @click="scroll('top')"><AppIcon :icon="Top" :size="20" /></button>
|
||||
<button v-if="canDown" type="button" class="button-secondary" :title="t('滑动到底部', 'Scroll to bottom')" :aria-label="t('滑动到底部', 'Scroll to bottom')" @click="scroll('bottom')"><AppIcon :icon="Bottom" :size="20" /></button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user