feat: 实现文章编辑器大纲导航、侧边栏标签页及弹窗组件

This commit is contained in:
seahi committed 2026-09-24 08:48:16 +08:00
1 parent 674123e6c9
commit 19d934065c
5 files changed
+546 -3

No files matched your search

@@ -0,0 +1,92 @@
<script lang="ts">
import type { EditorOutline } from '$lib/editor/outline.svelte';
let { outline, onselect }: { outline: EditorOutline; onselect?: () => void } = $props();
let baseLevel = $derived(Math.min(...outline.headings.map((heading) => heading.level), 6));
</script>
<nav aria-label="正文大纲" class="article-outline">
{#if outline.headings.length}
<p class="count">{outline.headings.length} 个标题</p>
<ul>
{#each outline.headings as heading, index (heading.from)}
<li>
<button
type="button"
class:active={outline.active === index}
aria-current={outline.active === index ? 'location' : undefined}
aria-label={`${heading.level} 级标题:${heading.text}`}
title={heading.text}
style:padding-left={`${10 + (heading.level - baseLevel) * 14}px`}
onclick={() => {
onselect?.();
outline.navigate(heading);
}}
>
<span>{heading.text}</span>
</button>
</li>
{/each}
</ul>
{:else}
<p class="empty">暂无标题。使用 #、## 等 Markdown 标题组织正文后,大纲会显示在这里。</p>
{/if}
</nav>
<style>
.article-outline {
min-width: 0;
}
.count,
.empty {
margin: 0 0 12px;
color: var(--text-muted);
font-size: 0.85rem;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
margin: 0 0 4px;
}
button {
display: block;
width: 100%;
border: 0;
border-left: 2px solid transparent;
border-radius: 3px;
padding: 8px 10px;
background: transparent;
color: var(--text-color);
font: inherit;
font-size: 0.85rem;
text-align: left;
cursor: pointer;
}
button:hover {
background: rgba(65, 155, 56, 0.06);
}
button.active {
border-left-color: var(--accent-color, #419b38);
color: var(--accent-color, #419b38);
background: rgba(65, 155, 56, 0.1);
}
button:focus-visible {
outline: 2px solid var(--accent-color, #419b38);
outline-offset: -2px;
}
span {
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
overflow-wrap: anywhere;
}
@media (pointer: coarse) {
button {
min-height: 44px;
}
}
</style>
@@ -0,0 +1,164 @@
<script lang="ts">
import { onMount, tick, untrack } from 'svelte';
import MarkdownEditor from './MarkdownEditor.svelte';
import MarkdownRenderer from '$lib/components/MarkdownRenderer.svelte';
import OutlinePopover from './OutlinePopover.svelte';
import { activeHeadingAt, extractOutline, type OutlineHeading } from '$lib/editor/outline';
import type { EditorOutline } from '$lib/editor/outline.svelte';
let {
value = $bindable(''),
mode,
outline,
onUpload
}: {
value?: string;
mode: 'edit' | 'preview';
outline: EditorOutline;
onUpload?: (file: { url: string; key: string; filename: string }) => void;
} = $props();
let editor: MarkdownEditor | undefined = $state();
let preview: HTMLDivElement | undefined = $state();
let previewHeadings: HTMLElement[] = [];
let navigatingPreview = false;
onMount(() => {
const resume = () => {
navigatingPreview = false;
};
const events = ['wheel', 'touchstart', 'pointerdown', 'keydown'] as const;
for (const event of events) window.addEventListener(event, resume, { passive: true });
return () => {
for (const event of events) window.removeEventListener(event, resume);
};
});
function trackPreview() {
if (!preview || navigatingPreview) return;
const top = preview.getBoundingClientRect().top + 24;
let active = -1;
previewHeadings.forEach((heading, index) => {
if (heading.getBoundingClientRect().top <= top) active = index;
});
outline.active = active;
}
function navigate(heading: OutlineHeading) {
if (mode === 'edit') {
editor?.navigateTo(heading.from);
} else if (preview) {
const target = previewHeadings[heading.from];
if (!target) return;
navigatingPreview = true;
// Headings can live inside a collapsed admonition in the preview.
let parent = target.parentElement;
while (parent && parent !== preview) {
if (parent.classList.contains('details')) {
parent.classList.add('open');
const body = parent.querySelector<HTMLElement>(':scope > .details-content');
if (body) body.style.maxHeight = 'none';
}
parent = parent.parentElement;
}
preview.scrollIntoView({ block: 'nearest' });
preview.scrollTop +=
target.getBoundingClientRect().top - preview.getBoundingClientRect().top - 20;
target.setAttribute('tabindex', '-1');
target.focus({ preventScroll: true });
outline.active = heading.from;
}
}
$effect(() => {
outline.navigate = navigate;
return () => {
outline.navigate = () => {};
};
});
$effect(() => {
const content = value;
navigatingPreview = false;
if (mode === 'edit') {
const headings = extractOutline(content);
outline.headings = headings;
outline.active = activeHeadingAt(
headings,
untrack(() => editor?.currentPosition() ?? 0)
);
return;
}
let cancelled = false;
// Wait for the renderer; use real elements rather than recreating heading IDs.
tick().then(() => {
if (cancelled || !preview) return;
previewHeadings = [...preview.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')];
outline.headings = previewHeadings.map((heading, index) => ({
from: index,
level: Number(heading.tagName.slice(1)),
text: heading.textContent?.trim() || '未命名标题'
}));
trackPreview();
});
return () => {
cancelled = true;
};
});
</script>
{#if mode === 'edit'}
<MarkdownEditor
bind:this={editor}
bind:value
{onUpload}
onPositionChange={(position) => {
outline.active = activeHeadingAt(outline.headings, position);
}}
>
{#snippet toolbarEnd()}<OutlinePopover {outline} />{/snippet}
</MarkdownEditor>
{:else}
<div class="preview-toolbar"><span>正文预览</span><OutlinePopover {outline} /></div>
<div
class="preview-area"
bind:this={preview}
onscroll={trackPreview}
onscrollend={() => {
navigatingPreview = false;
}}
>
<MarkdownRenderer content={value} />
</div>
{/if}
<style>
.preview-area {
padding: 20px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: rgba(0, 0, 0, 0.015);
min-height: 400px;
max-height: 600px;
overflow-y: auto;
scroll-margin-top: 110px;
}
.preview-toolbar {
display: none;
position: sticky;
top: 58px;
z-index: 20;
align-items: center;
padding: 4px;
margin-bottom: 10px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: var(--bg-color, #fff);
}
.preview-toolbar > span {
padding-left: 8px;
color: var(--text-muted);
font-size: 0.85rem;
}
@media (max-width: 900px) {
.preview-toolbar {
display: flex;
}
}
</style>
@@ -0,0 +1,68 @@
<script lang="ts">
import { onMount } from 'svelte';
import Tabs from './Tabs.svelte';
import ArticleOutline from './ArticleOutline.svelte';
import type { EditorOutline } from '$lib/editor/outline.svelte';
let {
outline,
activeTab = $bindable('settings')
}: { outline: EditorOutline; activeTab?: string } = $props();
let narrow = $state(false);
const storageKey = 'blogflare.editor.sidebar';
let preferred = 'settings';
onMount(() => {
try {
const saved = localStorage.getItem(storageKey);
if (saved && ['settings', 'media', 'outline'].includes(saved)) preferred = saved;
} catch {
/* Storage can be unavailable in private browsers. */
}
const media = window.matchMedia('(max-width: 900px)');
const update = () => {
narrow = media.matches;
activeTab = narrow && preferred === 'outline' ? 'settings' : preferred;
};
update();
media.addEventListener('change', update);
return () => media.removeEventListener('change', update);
});
function remember(id: string) {
preferred = id;
try {
localStorage.setItem(storageKey, id);
} catch {
/* Optional preference only. */
}
}
</script>
<div class="sidebar-navigation">
<Tabs
tabs={[
...(!narrow ? [{ id: 'outline', label: '大纲' }] : []),
{ id: 'settings', label: '设置' },
{ id: 'media', label: '媒体附件' }
]}
bind:activeTab
fullWidth
small
onchange={remember}
/>
{#if activeTab === 'outline' && !narrow}
<div class="outline-scroll"><ArticleOutline {outline} /></div>
{/if}
</div>
<style>
.sidebar-navigation {
width: 100%;
min-width: 0;
}
.outline-scroll {
max-height: calc(100dvh - 165px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
</style>
+62 -3
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */ /* eslint-disable @typescript-eslint/no-explicit-any */
import { onMount, onDestroy } from 'svelte'; import { onMount, onDestroy, type Snippet } from 'svelte';
import { foldedRanges, unfoldEffect } from '@codemirror/language';
import { EditorView, basicSetup } from 'codemirror'; import { EditorView, basicSetup } from 'codemirror';
import { EditorState } from '@codemirror/state'; import { EditorState } from '@codemirror/state';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown'; import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
@@ -26,16 +27,58 @@
let { let {
value = $bindable(''), value = $bindable(''),
placeholderText = '支持 Markdown 格式...', placeholderText = '支持 Markdown 格式...',
onUpload onUpload,
toolbarEnd,
onPositionChange
} = $props<{ } = $props<{
value?: string; value?: string;
placeholderText?: string; placeholderText?: string;
onUpload?: (file: { url: string; key: string; filename: string }) => void; onUpload?: (file: { url: string; key: string; filename: string }) => void;
toolbarEnd?: Snippet;
onPositionChange?: (position: number) => void;
}>(); }>();
let editorContainer: HTMLDivElement; let editorContainer: HTMLDivElement;
let view: EditorView; let view: EditorView;
let toolbar: HTMLDivElement;
let isUpdating = false; let isUpdating = false;
let scrollFrame = 0;
// A clamped jump near the document end must not replace the clicked heading's highlight.
let navigationPending = false;
export function currentPosition() {
return view?.state.selection.main.head ?? 0;
}
export function navigateTo(position: number) {
if (!view) return;
navigationPending = true;
const anchor = Math.min(Math.max(position, 0), view.state.doc.length);
const effects = [
EditorView.scrollIntoView(anchor, {
y: 'start',
yMargin: 58 + toolbar.getBoundingClientRect().height + 12
})
];
foldedRanges(view.state).between(0, view.state.doc.length, (from, to) => {
if (from <= anchor && to >= anchor) effects.push(unfoldEffect.of({ from, to }));
});
view.dispatch({ selection: { anchor }, effects });
view.focus();
}
function trackScroll() {
if (scrollFrame) return;
scrollFrame = requestAnimationFrame(() => {
scrollFrame = 0;
if (!view || navigationPending) return;
const rect = view.contentDOM.getBoundingClientRect();
const top = Math.max(toolbar.getBoundingClientRect().bottom + 12, rect.top + 1);
if (top >= window.innerHeight || top >= rect.bottom) return;
const position = view.posAtCoords({ x: rect.left + 8, y: top }, false);
if (position !== null) onPositionChange?.(position);
});
}
let showImageMenu = $state(false); let showImageMenu = $state(false);
let fileInput: HTMLInputElement; let fileInput: HTMLInputElement;
@@ -131,6 +174,9 @@
value = update.state.doc.toString(); value = update.state.doc.toString();
isUpdating = false; isUpdating = false;
} }
if (update.selectionSet || update.docChanged) {
onPositionChange?.(update.state.selection.main.head);
}
}) })
] ]
}); });
@@ -139,6 +185,18 @@
state, state,
parent: editorContainer parent: editorContainer
}); });
window.addEventListener('scroll', trackScroll, { passive: true });
const resumeScrollTracking = () => {
navigationPending = false;
};
const resumeEvents = ['scrollend', 'wheel', 'touchstart', 'pointerdown', 'keydown'] as const;
for (const event of resumeEvents)
window.addEventListener(event, resumeScrollTracking, { passive: true });
return () => {
window.removeEventListener('scroll', trackScroll);
for (const event of resumeEvents) window.removeEventListener(event, resumeScrollTracking);
cancelAnimationFrame(scrollFrame);
};
}); });
onDestroy(() => { onDestroy(() => {
@@ -216,7 +274,7 @@
</script> </script>
<div class="editor-wrapper"> <div class="editor-wrapper">
<div class="md-toolbar"> <div class="md-toolbar" bind:this={toolbar}>
<button <button
type="button" type="button"
class="md-btn" class="md-btn"
@@ -362,6 +420,7 @@
<button type="button" class="md-btn" onclick={insertMore} title="插入摘要分割线"> <button type="button" class="md-btn" onclick={insertMore} title="插入摘要分割线">
<SquareSplitVertical aria-hidden="true" /> <SquareSplitVertical aria-hidden="true" />
</button> </button>
{@render toolbarEnd?.()}
</div> </div>
<div bind:this={editorContainer} class="editor-container"></div> <div bind:this={editorContainer} class="editor-container"></div>
@@ -0,0 +1,160 @@
<script lang="ts">
import { tick } from 'svelte';
import { ListTree, X } from 'lucide-svelte';
import ArticleOutline from './ArticleOutline.svelte';
import type { EditorOutline } from '$lib/editor/outline.svelte';
let { outline }: { outline: EditorOutline } = $props();
let open = $state(false);
let wrapper: HTMLDivElement;
let trigger: HTMLButtonElement;
let panel: HTMLDivElement = $state()!;
let maxHeight = $state(400);
let above = $state(false);
const id = $props.id();
function positionPanel() {
if (!open || !panel) return;
const toolbar = wrapper.parentElement!.getBoundingClientRect();
const belowSpace = window.innerHeight - toolbar.bottom - 21;
const aboveSpace = toolbar.top - 58 - 16;
above = belowSpace < 220 && aboveSpace > belowSpace;
maxHeight = Math.max(0, above ? aboveSpace : belowSpace);
}
async function toggle() {
open = !open;
if (open) {
await tick();
positionPanel();
panel.querySelector<HTMLButtonElement>('button')?.focus();
}
}
function dismiss() {
open = false;
trigger.focus();
}
</script>
<svelte:window
onpointerdown={(event) => {
if (open && !wrapper.contains(event.target as Node)) open = false;
}}
onkeydown={(event) => {
if (open && event.key === 'Escape') {
event.preventDefault();
dismiss();
}
}}
onresize={() => {
if (window.innerWidth > 900) open = false;
else positionPanel();
}}
/>
<div
class="outline-popover"
bind:this={wrapper}
onfocusout={(event) => {
if (!wrapper.contains(event.relatedTarget as Node)) open = false;
}}
>
<button
type="button"
class="trigger"
bind:this={trigger}
aria-expanded={open}
aria-controls={id}
onclick={toggle}
>
<ListTree size={17} aria-hidden="true" /> 大纲
</button>
{#if open}
<div class="panel" class:above {id} bind:this={panel} style:max-height={`${maxHeight}px`}>
<div class="panel-header">
<strong>大纲</strong><button
type="button"
class="close"
aria-label="关闭大纲"
onclick={dismiss}><X size={18} aria-hidden="true" /></button
>
</div>
<ArticleOutline
{outline}
onselect={() => {
open = false;
}}
/>
</div>
{/if}
</div>
<style>
.outline-popover {
display: none;
margin-left: auto;
}
button {
font: inherit;
color: var(--text-color);
background: transparent;
border: 0;
cursor: pointer;
border-radius: 4px;
}
.trigger {
display: flex;
align-items: center;
gap: 5px;
min-height: 30px;
padding: 3px 8px;
font-size: 0.85rem;
white-space: nowrap;
}
button:hover {
background: rgba(65, 155, 56, 0.1);
color: var(--accent-color, #419b38);
}
button:focus-visible {
outline: 2px solid var(--accent-color, #419b38);
}
.panel {
position: absolute;
top: calc(100% + 5px);
right: 0;
width: min(320px, 100%);
overflow-y: auto;
overscroll-behavior: contain;
padding: 12px;
border: 1px solid rgba(0, 0, 0, 0.15);
background: var(--bg-color, #fff);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.12);
z-index: 100;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.panel.above {
top: auto;
bottom: calc(100% + 5px);
}
.close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
}
@media (max-width: 900px) {
.outline-popover {
display: block;
}
}
@media (pointer: coarse) {
.trigger,
.close {
min-height: 44px;
min-width: 44px;
}
}
</style>