feat: 实现文章编辑器大纲导航、侧边栏标签页及弹窗组件
This commit is contained in:
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>
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
/* 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 { EditorState } from '@codemirror/state';
|
||||
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
|
||||
@@ -26,16 +27,58 @@
|
||||
let {
|
||||
value = $bindable(''),
|
||||
placeholderText = '支持 Markdown 格式...',
|
||||
onUpload
|
||||
onUpload,
|
||||
toolbarEnd,
|
||||
onPositionChange
|
||||
} = $props<{
|
||||
value?: string;
|
||||
placeholderText?: string;
|
||||
onUpload?: (file: { url: string; key: string; filename: string }) => void;
|
||||
toolbarEnd?: Snippet;
|
||||
onPositionChange?: (position: number) => void;
|
||||
}>();
|
||||
|
||||
let editorContainer: HTMLDivElement;
|
||||
let view: EditorView;
|
||||
let toolbar: HTMLDivElement;
|
||||
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 fileInput: HTMLInputElement;
|
||||
@@ -131,6 +174,9 @@
|
||||
value = update.state.doc.toString();
|
||||
isUpdating = false;
|
||||
}
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
onPositionChange?.(update.state.selection.main.head);
|
||||
}
|
||||
})
|
||||
]
|
||||
});
|
||||
@@ -139,6 +185,18 @@
|
||||
state,
|
||||
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(() => {
|
||||
@@ -216,7 +274,7 @@
|
||||
</script>
|
||||
|
||||
<div class="editor-wrapper">
|
||||
<div class="md-toolbar">
|
||||
<div class="md-toolbar" bind:this={toolbar}>
|
||||
<button
|
||||
type="button"
|
||||
class="md-btn"
|
||||
@@ -362,6 +420,7 @@
|
||||
<button type="button" class="md-btn" onclick={insertMore} title="插入摘要分割线">
|
||||
<SquareSplitVertical aria-hidden="true" />
|
||||
</button>
|
||||
{@render toolbarEnd?.()}
|
||||
</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>
|
||||
Reference in new issue
Block a user