165 lines
4.4 KiB
Svelte
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>
|