Files
blogflare/src/lib/components/admin/ContentEditor.svelte
T

165 lines
4.4 KiB
Svelte

<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>