feat(editor): add scroll navigation and update theme Markdown behavior
This commit is contained in:
@@ -1,5 +1,28 @@
|
|||||||
# 主题组件覆盖检查(2026-09-05)
|
# 主题组件覆盖检查(2026-09-05)
|
||||||
|
|
||||||
|
## 2026-09-06:Markdown 行为与文档滚动适配
|
||||||
|
|
||||||
|
| 主题 | 当前版本 |
|
||||||
|
| --- | --- |
|
||||||
|
| Light / Dark / Sepia | 1.4.0 |
|
||||||
|
| 纸间时光 | 1.9.0 |
|
||||||
|
| Ocean Blue | 1.6.0 |
|
||||||
|
| Midnight Purple | 2.4.0 |
|
||||||
|
|
||||||
|
新增共享 `frontend/src/styles/markdown-behavior.css`,由应用入口及隔离主题预览同时加载。六个主题通过语义变量提供链接悬停与键盘焦点、任务复选框、引用与分隔线、文字选区、表格选区、标题折叠状态、警告框折叠状态和文档滚动按钮配色。标题箭头沿用悬停或键盘聚焦时显示的规则;表格选区采用透明叠色,避免遮住单元格文字。纸间时光保留长文轻量背景实现。
|
||||||
|
|
||||||
|
只读 Markdown(包括 AI 对话)现在同步代码行号、自动换行和缩进宽度设置。语法是否启用仍由 Markdown 预设与解析器决定;ATX / Setext 等源码写法解析成同级标题后使用相同主题样式,不用 CSS 模拟语法。主题预览新增 H4–H6、嵌套列表、任务列表、链接、行内代码、折叠警告框、带行号长代码及滚动按钮样例。
|
||||||
|
|
||||||
|
工作区滚动按钮固定在编辑区域右下角,适用于写作和源码模式:顶部只显示到底部、底部只显示到顶部、中间同时显示;空文档或没有可滚动内容时隐藏。滚动状态通过被动监听与动画帧合并更新,尺寸、内容和折叠变化会重新计算,减少动画偏好开启时直接跳转。
|
||||||
|
|
||||||
|
验证方法:
|
||||||
|
|
||||||
|
1. 运行 `cd frontend` 后执行 `npm test` 和 `npm run build`,检查主题变量覆盖、六主题预览矩阵、主题升级、Markdown 预设和滚动按钮回归。
|
||||||
|
2. 本地启动前端,访问 `tests/visual/index.html?theme=light`,依次切换表中六个主题,检查新增样例的链接焦点、复选框、折叠态、代码换行及行号。社区主题已安装旧版本时,应在主题页更新后再检查。
|
||||||
|
3. 工作区分别打开空笔记、短笔记、长笔记,切换写作与源码模式,在顶部、中间、底部验证按钮数量和跳转方向;调整窗口尺寸、折叠标题后再次检查。
|
||||||
|
|
||||||
|
本次自动检查覆盖仓库内六主题的结构和样式变量,不包含用户自行导入的任意第三方 CSS,也不等同于所有浏览器的逐页视觉验收。
|
||||||
|
|
||||||
本次检查仓库内 3 个内置主题和 3 个社区预设,共扫描 105 个前端源文件的组件与语义样式变量。用户自行导入的第三方 CSS 不在仓库中,不据此声称已验收。
|
本次检查仓库内 3 个内置主题和 3 个社区预设,共扫描 105 个前端源文件的组件与语义样式变量。用户自行导入的第三方 CSS 不在仓库中,不据此声称已验收。
|
||||||
|
|
||||||
## 范围与结果
|
## 范围与结果
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
theme_id: paper-moments
|
theme_id: paper-moments
|
||||||
name: 纸间时光 · Paper Moments
|
name: 纸间时光 · Paper Moments
|
||||||
version: 1.8.1
|
version: 1.9.0
|
||||||
author: NotesAgent
|
author: NotesAgent
|
||||||
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
|
description: 奶油纸张、手帐虚线与粉蓝胶带,把每天的灵感好好收藏。
|
||||||
min_app_version: 0.2.0
|
min_app_version: 0.2.0
|
||||||
@@ -9,6 +9,9 @@ css_entry: theme.css
|
|||||||
license: MIT
|
license: MIT
|
||||||
---
|
---
|
||||||
[data-theme="paper-moments"] {
|
[data-theme="paper-moments"] {
|
||||||
|
--color-markdown-selection: #ecd3c7;
|
||||||
|
--color-editor-scroll-background: #fff9e9;
|
||||||
|
--color-editor-scroll-text: #875343;
|
||||||
--color-callout-info: #406b7b;
|
--color-callout-info: #406b7b;
|
||||||
--color-callout-success: #536f43;
|
--color-callout-success: #536f43;
|
||||||
--color-callout-warning: #875f25;
|
--color-callout-warning: #875f25;
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ watch([() => props.source, diagramTheme, () => themeStore.currentThemeId, () =>
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<DiagramInteractions :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables"><div class="markdown-content" v-html="html" /></DiagramInteractions>
|
<DiagramInteractions :data-heading-style="headingAppearance.preferences.custom ? 'custom' : undefined" :style="headingAppearance.cssVariables"><div class="markdown-content" :data-code-wrap="markdownPreferences.normalized.wrapCode" :data-line-numbers="markdownPreferences.normalized.lineNumbers" :style="{ '--markdown-code-indent': markdownPreferences.normalized.indent }" v-html="html" /></DiagramInteractions>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
|||||||
@@ -3,12 +3,14 @@ import { defineAsyncComponent, ref, watch } from 'vue'
|
|||||||
import { useEditorStore } from '@/stores/editor'
|
import { useEditorStore } from '@/stores/editor'
|
||||||
import { useSettingsStore } from '@/stores/settings'
|
import { useSettingsStore } from '@/stores/settings'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import EditorScrollButtons from './EditorScrollButtons.vue'
|
||||||
const VisualMarkdownEditor = defineAsyncComponent(() => import('./VisualMarkdownEditor.vue'))
|
const VisualMarkdownEditor = defineAsyncComponent(() => import('./VisualMarkdownEditor.vue'))
|
||||||
|
|
||||||
const editorStore = useEditorStore()
|
const editorStore = useEditorStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const themeStore = useThemeStore()
|
const themeStore = useThemeStore()
|
||||||
const sourceEditor = ref<HTMLTextAreaElement | null>(null)
|
const sourceEditor = ref<HTMLTextAreaElement | null>(null)
|
||||||
|
const container = ref<HTMLElement | null>(null)
|
||||||
watch(() => editorStore.headingRequest, request => {
|
watch(() => editorStore.headingRequest, request => {
|
||||||
const input = sourceEditor.value
|
const input = sourceEditor.value
|
||||||
if (!request || !input || request.path !== editorStore.currentFilePath) return
|
if (!request || !input || request.path !== editorStore.currentFilePath) return
|
||||||
@@ -24,13 +26,17 @@ function updateContent(event: Event) {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div ref="container" class="editor-scroll-pane">
|
||||||
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${editorStore.contentRevision}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
|
<VisualMarkdownEditor v-if="editorStore.mode === 'wysiwyg'" :key="`${editorStore.currentFilePath ?? 'empty'}:${editorStore.contentRevision}:${themeStore.resolvedCodeBlockTheme}:${settingsStore.language}`"
|
||||||
:initial-content="editorStore.content" />
|
:initial-content="editorStore.content" />
|
||||||
<textarea v-else ref="sourceEditor" class="editor-pane source" :value="editorStore.content" :spellcheck="settingsStore.spellCheck"
|
<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" />
|
:lang="settingsStore.language" :aria-label="settingsStore.language === 'en' ? 'Markdown source editor' : 'Markdown 源码编辑器'" @input="updateContent" />
|
||||||
|
<EditorScrollButtons :container="container" :content="editorStore.content" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.editor-scroll-pane { position: relative; display: flex; flex-direction: column; flex: 1; min-height: 0; min-width: 0; overflow: hidden; }
|
||||||
.editor-pane {
|
.editor-pane {
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
flex: 1;
|
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>
|
||||||
|
|
||||||
@@ -6,6 +6,7 @@ vi.mock('@/styles/features.css?raw', async () => ({ default: (await import('node
|
|||||||
vi.mock('@/styles/tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/tokens.css', 'utf8') }))
|
vi.mock('@/styles/tokens.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/tokens.css', 'utf8') }))
|
||||||
vi.mock('@/styles/callouts.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/callouts.css', 'utf8') }))
|
vi.mock('@/styles/callouts.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/callouts.css', 'utf8') }))
|
||||||
import CommunityThemePreview from './CommunityThemePreview.vue'
|
import CommunityThemePreview from './CommunityThemePreview.vue'
|
||||||
|
vi.mock('@/styles/markdown-behavior.css?raw', async () => ({ default: (await import('node:fs')).readFileSync(process.cwd() + '/src/styles/markdown-behavior.css', 'utf8') }))
|
||||||
import { mockCommunityThemes } from '@/services/themePackageService'
|
import { mockCommunityThemes } from '@/services/themePackageService'
|
||||||
const themes = [...['light','dark','sepia'].map(theme_id => ({theme_id,name:theme_id,builtin:true})), ...mockCommunityThemes.map(t => ({...t,builtin:false}))]
|
const themes = [...['light','dark','sepia'].map(theme_id => ({theme_id,name:theme_id,builtin:true})), ...mockCommunityThemes.map(t => ({...t,builtin:false}))]
|
||||||
it.each(themes)('previews shared component states safely for $theme_id', theme => {
|
it.each(themes)('previews shared component states safely for $theme_id', theme => {
|
||||||
@@ -16,6 +17,9 @@ it.each(themes)('previews shared component states safely for $theme_id', theme =
|
|||||||
const doc = new DOMParser().parseFromString(iframe.attributes('srcdoc')!, 'text/html')
|
const doc = new DOMParser().parseFromString(iframe.attributes('srcdoc')!, 'text/html')
|
||||||
expect(doc.documentElement.dataset.theme).toBe(theme.theme_id)
|
expect(doc.documentElement.dataset.theme).toBe(theme.theme_id)
|
||||||
expect(doc.querySelector('script')).toBeNull()
|
expect(doc.querySelector('script')).toBeNull()
|
||||||
|
for (const selector of ['.editor-scroll-buttons button', '.markdown-content h6', '.task-list-item input:checked', '.markdown-content[data-code-wrap="true"][data-line-numbers="true"] .line', '.heading-fold-toggle']) expect(doc.querySelector(selector), selector).not.toBeNull()
|
||||||
|
expect(doc.querySelector('style')!.textContent).toContain('.editor-scroll-buttons button:focus-visible')
|
||||||
|
expect(doc.querySelector('style')!.textContent).toContain(".markdown-content[data-code-wrap='true'] .shiki code")
|
||||||
expect(doc.querySelectorAll('.specimen-callouts > aside.markdown-callout')).toHaveLength(14)
|
expect(doc.querySelectorAll('.specimen-callouts > aside.markdown-callout')).toHaveLength(14)
|
||||||
expect(doc.querySelector('.specimen-callouts > details[open]')).not.toBeNull()
|
expect(doc.querySelector('.specimen-callouts > details[open]')).not.toBeNull()
|
||||||
expect(doc.querySelector('.specimen-callouts > details:not([open])')).not.toBeNull()
|
expect(doc.querySelector('.specimen-callouts > details:not([open])')).not.toBeNull()
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getCommunityThemePreviewCss, mockCommunityThemes } from '@/services/the
|
|||||||
import tokensCss from '@/styles/tokens.css?raw'
|
import tokensCss from '@/styles/tokens.css?raw'
|
||||||
import featuresCss from '@/styles/features.css?raw'
|
import featuresCss from '@/styles/features.css?raw'
|
||||||
import headingsCss from '@/styles/headings.css?raw'
|
import headingsCss from '@/styles/headings.css?raw'
|
||||||
|
import markdownBehaviorCss from '@/styles/markdown-behavior.css?raw'
|
||||||
import calloutsCss from '@/styles/callouts.css?raw'
|
import calloutsCss from '@/styles/callouts.css?raw'
|
||||||
import { calloutTypes } from '@/utils/callouts'
|
import { calloutTypes } from '@/utils/callouts'
|
||||||
import specimenHtml from './themeSpecimen.html?raw'
|
import specimenHtml from './themeSpecimen.html?raw'
|
||||||
@@ -25,6 +26,7 @@ const previewDocument = computed(() => {
|
|||||||
const style = doc.createElement('style')
|
const style = doc.createElement('style')
|
||||||
style.textContent = `${tokensCss}\n${featuresCss}\n${calloutsCss}\n${headingsCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nhtml { height:100% !important; overflow-y:auto !important; overflow-x:hidden !important; overscroll-behavior:contain; } body { height:auto !important; min-height:100%; overflow:visible !important; margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { min-width:0; } .theme-specimen { display:grid; gap:16px; margin-top:20px; } .surface-nested { padding:12px; border:1px solid var(--color-border-default); border-radius:var(--radius-md); } .specimen-markdown { overflow:auto; } .specimen-markdown code { background:var(--color-code-background, var(--color-background-secondary)); color:var(--color-code-text, var(--color-text-primary)); padding:3px 6px; border-radius:4px; } .specimen-markdown pre { padding:12px; background:var(--color-background-secondary); } .specimen-markdown blockquote { border-left:3px solid var(--color-accent-primary); padding-left:12px; } .specimen-markdown table { width:100%; border-collapse:collapse; } .specimen-markdown td,.specimen-markdown th { padding:8px; border:1px solid var(--color-border-default); } .specimen-markdown th { background:var(--color-markdown-table-header); } .specimen-chart > div { display:flex; align-items:flex-end; gap:12px; height:100px; border-bottom:1px solid var(--color-border-default); } .specimen-chart span { width:36px; } .specimen-long { overflow-wrap:anywhere; } @media(max-width:480px) { body { padding:12px; } .form-grid { grid-template-columns:minmax(0,1fr); } }`
|
style.textContent = `${tokensCss}\n${featuresCss}\n${calloutsCss}\n${headingsCss}\n${props.css ?? getCommunityThemePreviewCss(props.themeId)}\nhtml { height:100% !important; overflow-y:auto !important; overflow-x:hidden !important; overscroll-behavior:contain; } body { height:auto !important; min-height:100%; overflow:visible !important; margin:0; padding:24px; background:var(--color-background-primary); color:var(--color-text-primary); font:16px/1.6 system-ui; } article { min-width:0; } .theme-specimen { display:grid; gap:16px; margin-top:20px; } .surface-nested { padding:12px; border:1px solid var(--color-border-default); border-radius:var(--radius-md); } .specimen-markdown { overflow:auto; } .specimen-markdown code { background:var(--color-code-background, var(--color-background-secondary)); color:var(--color-code-text, var(--color-text-primary)); padding:3px 6px; border-radius:4px; } .specimen-markdown pre { padding:12px; background:var(--color-background-secondary); } .specimen-markdown blockquote { border-left:3px solid var(--color-accent-primary); padding-left:12px; } .specimen-markdown table { width:100%; border-collapse:collapse; } .specimen-markdown td,.specimen-markdown th { padding:8px; border:1px solid var(--color-border-default); } .specimen-markdown th { background:var(--color-markdown-table-header); } .specimen-chart > div { display:flex; align-items:flex-end; gap:12px; height:100px; border-bottom:1px solid var(--color-border-default); } .specimen-chart span { width:36px; } .specimen-long { overflow-wrap:anywhere; } @media(max-width:480px) { body { padding:12px; } .form-grid { grid-template-columns:minmax(0,1fr); } }`
|
||||||
|
|
||||||
|
style.textContent += `\n${markdownBehaviorCss}\n.specimen-scroll { position:relative; min-height:110px; }`
|
||||||
doc.head.append(style)
|
doc.head.append(style)
|
||||||
const article = doc.createElement('article')
|
const article = doc.createElement('article')
|
||||||
article.className = 'panel'
|
article.className = 'panel'
|
||||||
|
|||||||
@@ -105,6 +105,6 @@ it('offers and applies the paper theme update without discarding the active them
|
|||||||
const card = wrapper.findAll('article.theme-card').find(item => item.text().includes('Paper Moments'))!
|
const card = wrapper.findAll('article.theme-card').find(item => item.text().includes('Paper Moments'))!
|
||||||
await card.findAll('button').find(button => button.text() === '更新')!.trigger('click')
|
await card.findAll('button').find(button => button.text() === '更新')!.trigger('click')
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.8.1')
|
expect(store.allThemes.find(theme => theme.theme_id === 'paper-moments')?.version).toBe('1.9.0')
|
||||||
expect(document.getElementById('theme-style-paper-moments')!.textContent).toContain('.surface-nested')
|
expect(document.getElementById('theme-style-paper-moments')!.textContent).toContain('.surface-nested')
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -20,3 +20,20 @@ console.log(note);</code></pre><table><thead><tr><th>名称</th><th>状态</th><
|
|||||||
|
|
||||||
<section class="surface-nested markdown-preferences"><h2>Markdown 语法预设</h2><label>标题样式 <select class="select"><option>ATX (#)</option><option>Setext</option></select></label><label><input type="checkbox" checked> 警告框与提示框</label><button class="button-secondary">保存为预设</button></section>
|
<section class="surface-nested markdown-preferences"><h2>Markdown 语法预设</h2><label>标题样式 <select class="select"><option>ATX (#)</option><option>Setext</option></select></label><label><input type="checkbox" checked> 警告框与提示框</label><button class="button-secondary">保存为预设</button></section>
|
||||||
<section class="milkdown"><div class="ProseMirror"><h2><button class="heading-fold-toggle" aria-expanded="true" aria-label="折叠示例标题"></button>悬停查看折叠箭头</h2><p>折叠按钮跟随主题,键盘聚焦时也可见。</p></div></section>
|
<section class="milkdown"><div class="ProseMirror"><h2><button class="heading-fold-toggle" aria-expanded="true" aria-label="折叠示例标题"></button>悬停查看折叠箭头</h2><p>折叠按钮跟随主题,键盘聚焦时也可见。</p></div></section>
|
||||||
|
<section class="markdown-content specimen-markdown">
|
||||||
|
<h2>Markdown 行为与状态</h2>
|
||||||
|
<h4>四级标题</h4><h5>五级标题</h5><h6>六级标题</h6>
|
||||||
|
<p><a href="#markdown-preview-target">链接:检查悬停与键盘焦点</a>,<code>行内代码</code>与<strong>强调</strong>、<em>斜体</em>、<del>删除线</del>。选中这段文字检查主题选区颜色。</p>
|
||||||
|
<ul><li>无序列表<ul><li>嵌套列表</li></ul></li></ul><ol><li>有序列表</li><li>第二项</li></ol>
|
||||||
|
<ul><li class="task-list-item"><input type="checkbox" disabled> 未完成任务</li><li class="task-list-item"><input type="checkbox" checked disabled> 已完成任务</li></ul>
|
||||||
|
<blockquote><p>普通引用</p><blockquote>嵌套引用</blockquote></blockquote><hr>
|
||||||
|
<details class="markdown-callout" data-callout="tip"><summary class="callout-title">点击展开提示</summary><div class="callout-body"><p>提示内的 <code>代码</code> 与列表也继承主题。</p></div></details>
|
||||||
|
<p id="markdown-preview-target">ATX 和 Setext 解析后使用相同标题样式;关闭扩展语法时保留源码展示。</p>
|
||||||
|
<pre><code>> [!NOTE]
|
||||||
|
关闭警告框扩展时显示原始内容
|
||||||
|
```mermaid
|
||||||
|
graph LR; A-->B
|
||||||
|
```</code></pre>
|
||||||
|
</section>
|
||||||
|
<section class="surface-nested specimen-scroll"><p>工作区滚动按钮:悬停、按下或 Tab 聚焦检查配色。</p><div class="editor-scroll-buttons"><button class="button-secondary" aria-label="滑动到顶部" title="滑动到顶部">↑</button><button class="button-secondary" aria-label="滑动到底部" title="滑动到底部">↓</button></div></section>
|
||||||
|
<section class="markdown-content specimen-markdown" data-code-wrap="true" data-line-numbers="true"><h3>代码换行与行号</h3><pre class="shiki"><code><span class="line">const description = "这是一段用于检查代码自动换行、行号与主题配色的较长说明,较窄视口下也应该保留可读内容。";</span><span class="line">console.log(description);</span></code></pre></section>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import App from './App.vue'
|
|||||||
import router from './router'
|
import router from './router'
|
||||||
import './styles/tokens.css'
|
import './styles/tokens.css'
|
||||||
import './styles/features.css'
|
import './styles/features.css'
|
||||||
|
import './styles/markdown-behavior.css'
|
||||||
import { useThemeStore } from './stores/theme'
|
import { useThemeStore } from './stores/theme'
|
||||||
import { useSettingsStore } from './stores/settings'
|
import { useSettingsStore } from './stores/settings'
|
||||||
import { watch } from 'vue'
|
import { watch } from 'vue'
|
||||||
|
|||||||
@@ -361,7 +361,7 @@ export const mockCommunityThemes: ThemeManifest[] = [
|
|||||||
{
|
{
|
||||||
theme_id: 'ocean-blue',
|
theme_id: 'ocean-blue',
|
||||||
name: 'Ocean Blue',
|
name: 'Ocean Blue',
|
||||||
version: '1.5.0',
|
version: '1.6.0',
|
||||||
author: 'community',
|
author: 'community',
|
||||||
description: '宁静的海洋蓝色主题,适合长时间阅读',
|
description: '宁静的海洋蓝色主题,适合长时间阅读',
|
||||||
min_app_version: '0.2.0',
|
min_app_version: '0.2.0',
|
||||||
@@ -373,7 +373,7 @@ export const mockCommunityThemes: ThemeManifest[] = [
|
|||||||
{
|
{
|
||||||
theme_id: 'midnight-purple',
|
theme_id: 'midnight-purple',
|
||||||
name: 'Midnight Purple',
|
name: 'Midnight Purple',
|
||||||
version: '2.3.0',
|
version: '2.4.0',
|
||||||
author: 'night-owl',
|
author: 'night-owl',
|
||||||
description: '深紫色暗夜主题,适合编码',
|
description: '深紫色暗夜主题,适合编码',
|
||||||
min_app_version: '0.2.0',
|
min_app_version: '0.2.0',
|
||||||
@@ -464,6 +464,9 @@ export function getCommunityThemePreviewCss(themeId: string): string {
|
|||||||
return buildCommunityThemeCss(themeId, t.is_dark) + `
|
return buildCommunityThemeCss(themeId, t.is_dark) + `
|
||||||
[data-theme="${themeId}"] {
|
[data-theme="${themeId}"] {
|
||||||
color-scheme: ${t.is_dark ? 'dark' : 'light'};
|
color-scheme: ${t.is_dark ? 'dark' : 'light'};
|
||||||
|
--color-markdown-selection: ${t.is_dark ? '#443252' : '#d4eaf5'};
|
||||||
|
--color-editor-scroll-background: var(--color-surface-elevated);
|
||||||
|
--color-editor-scroll-text: var(--color-accent-primary);
|
||||||
--color-callout-info: ${t.is_dark ? '#9dbbff' : '#126589'};
|
--color-callout-info: ${t.is_dark ? '#9dbbff' : '#126589'};
|
||||||
--color-callout-success: ${t.is_dark ? '#a7d58c' : '#267049'};
|
--color-callout-success: ${t.is_dark ? '#a7d58c' : '#267049'};
|
||||||
--color-callout-warning: ${t.is_dark ? '#efc886' : '#885c13'};
|
--color-callout-warning: ${t.is_dark ? '#efc886' : '#885c13'};
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import * as themePkg from '@/services/themePackageService'
|
|||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
const builtinThemes = (): ThemeConfig[] => [
|
const builtinThemes = (): ThemeConfig[] => [
|
||||||
{ theme_id: 'light', name: t('浅色', 'Light'), version: '1.3.0', description: t('默认浅色主题', 'Default light theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
|
{ theme_id: 'light', name: t('浅色', 'Light'), version: '1.4.0', description: t('默认浅色主题', 'Default light theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||||
{ theme_id: 'dark', name: t('深色', 'Dark'), version: '1.3.0', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' },
|
{ theme_id: 'dark', name: t('深色', 'Dark'), version: '1.4.0', description: t('默认深色主题', 'Default dark theme'), is_dark: true, builtin: true, code_theme: 'github-dark' },
|
||||||
{ theme_id: 'sepia', name: t('护眼', 'Sepia'), version: '1.3.0', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
|
{ theme_id: 'sepia', name: t('护眼', 'Sepia'), version: '1.4.0', description: t('护眼暖色调', 'Warm, low-glare theme'), is_dark: false, builtin: true, code_theme: 'github-light' },
|
||||||
]
|
]
|
||||||
|
|
||||||
export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
|
export type CodeBlockThemePreference = 'auto' | 'github-light' | 'github-dark'
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
/* Shared interaction layer: colors follow the active theme; syntax preferences
|
||||||
|
continue to be handled by the parser/editor rather than CSS-generated content. */
|
||||||
|
:is(.markdown-content, .milkdown .ProseMirror) a:not(.callout-title) {
|
||||||
|
color: var(--color-text-link); text-decoration: underline; text-underline-offset: .18em;
|
||||||
|
text-decoration-color: color-mix(in srgb, currentColor 45%, transparent);
|
||||||
|
}
|
||||||
|
:is(.markdown-content, .milkdown .ProseMirror) a:hover { text-decoration-color: currentColor; }
|
||||||
|
:is(.markdown-content, .milkdown .ProseMirror) a:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 3px; border-radius: var(--radius-sm); }
|
||||||
|
:is(.markdown-content, .milkdown .ProseMirror) input[type='checkbox'] { accent-color: var(--color-accent-primary); }
|
||||||
|
:is(.markdown-content, .milkdown .ProseMirror) input[type='checkbox']:disabled { opacity: 1; }
|
||||||
|
.markdown-content .task-list-item { list-style: none; }
|
||||||
|
:is(.markdown-content, .milkdown .ProseMirror) blockquote:not(.markdown-callout) { border-inline-start: 3px solid var(--color-markdown-marker); color: var(--color-text-secondary); }
|
||||||
|
:is(.markdown-content, .milkdown .ProseMirror) hr { border: 0; border-top: 1px solid var(--color-markdown-grid); }
|
||||||
|
:is(.markdown-content, .milkdown .ProseMirror) :is(h1,h2,h3,h4,h5,h6) { scroll-margin-top: 16px; }
|
||||||
|
:is(.markdown-content, .milkdown .ProseMirror) :is(p,li,h1,h2,h3,h4,h5,h6,code)::selection,
|
||||||
|
.editor-pane.source::selection { background: var(--color-markdown-selection); color: var(--color-text-primary); }
|
||||||
|
.milkdown .ProseMirror .selectedCell::after { background: color-mix(in srgb, var(--color-accent-primary) 18%, transparent); pointer-events: none; }
|
||||||
|
.milkdown .ProseMirror .heading-fold-toggle[aria-expanded='false'] { color: var(--color-accent-primary); background: var(--color-accent-soft); }
|
||||||
|
.markdown-callout[data-collapsed='true'] { border-style: dashed; border-inline-start-style: solid; }
|
||||||
|
.markdown-content details.markdown-callout:not([open]) { border-style: dashed; border-inline-start-style: solid; }
|
||||||
|
.editor-pane.source { caret-color: var(--color-accent-primary); }
|
||||||
|
.markdown-content[data-code-wrap] .shiki { tab-size: var(--markdown-code-indent, 4); }
|
||||||
|
.markdown-content[data-code-wrap='true'] .shiki code { min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||||
|
.markdown-content[data-line-numbers='true'] .shiki code { counter-reset: code-line; }
|
||||||
|
.markdown-content[data-line-numbers='true'] .shiki .line { position: relative; padding-inline-start: 3.5em; }
|
||||||
|
.markdown-content[data-line-numbers='true'] .shiki .line::before { counter-increment: code-line; content: counter(code-line); position: absolute; inset-inline-start: 0; width: 2.5em; text-align: end; color: var(--color-code-muted); user-select: none; }
|
||||||
|
:is(.milkdown-host, .editor-pane.source) { scrollbar-color: var(--color-border-default) var(--color-background-primary); }
|
||||||
|
.editor-scroll-buttons { position: absolute; right: 24px; bottom: 24px; z-index: 10; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.editor-scroll-buttons button { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; min-height: 38px; padding: 0; border-radius: var(--radius-md); background: var(--color-editor-scroll-background); color: var(--color-editor-scroll-text); border: 1px solid var(--color-border-default); box-shadow: var(--shadow-sm); }
|
||||||
|
.editor-scroll-buttons button:hover { background: var(--color-background-hover); border-color: var(--color-accent-primary); }
|
||||||
|
.editor-scroll-buttons button:active { background: var(--color-accent-soft); }
|
||||||
|
.editor-scroll-buttons button:focus-visible { outline: 2px solid var(--color-border-focus); outline-offset: 2px; }
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
:root {
|
:root {
|
||||||
|
--color-markdown-selection: color-mix(in srgb, var(--color-accent-primary) 24%, var(--color-background-primary));
|
||||||
|
--color-editor-scroll-background: var(--color-surface-elevated);
|
||||||
|
--color-editor-scroll-text: var(--color-accent-primary);
|
||||||
/* Callout heading colors also serve as borders; keep text readable on tint. */
|
/* Callout heading colors also serve as borders; keep text readable on tint. */
|
||||||
--color-callout-info: var(--color-info);
|
--color-callout-info: var(--color-info);
|
||||||
--color-callout-success: var(--color-success);
|
--color-callout-success: var(--color-success);
|
||||||
|
|||||||
Reference in New Issue
Block a user