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">
|
<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>
|
||||||
Reference in new issue
Block a user