Files
edentext/src/lib/editor/Editor.svelte
T

166 lines
6.6 KiB
Svelte

<script lang="ts">
import { onMount, onDestroy, untrack } from 'svelte';
import { Editor } from '@tiptap/core';
import { Slice, Fragment } from 'prosemirror-model';
import type { Node as PmNode, MarkType } from 'prosemirror-model';
import { extensions } from './extensions';
import { saveDocument, loadDocument } from '../storage/autosave';
import '../../styles/editor.css';
const DEFAULT_EDITOR_FONT = 'Georgia'; // must match ToolbarExpanded.svelte
let { editor = $bindable(), tick = $bindable(0), currentPage = $bindable(1), numPages = $bindable(1), zoom = 100, showFormattingMarks = false }: {
editor: Editor | null; tick: number; currentPage: number; numPages: number; zoom: number; showFormattingMarks?: boolean;
} = $props();
let element: HTMLDivElement;
let editorContainer: HTMLDivElement;
const CYCLE = 1143; // PAGE_HEIGHT + PAGE_GAP, must match pageBreaks.ts
// Throttle the value actually written to the DOM to one update per animation frame,
// so rapid slider events don't trigger 50+ layout/paint cycles per second.
let appliedZoom = $state(untrack(() => zoom));
let zoomRaf: number | null = null;
$effect(() => {
zoom; // track
if (zoomRaf !== null) return;
zoomRaf = requestAnimationFrame(() => {
zoomRaf = null;
appliedZoom = zoom; // pick up the latest value, not a captured snapshot
});
});
// Preserve the top-of-viewport anchor across zoom changes.
let prevZoom = -1;
let pendingAnchorDocY: number | null = null;
$effect.pre(() => {
const z = appliedZoom;
if (prevZoom < 0 || !editorContainer || !element || z === prevZoom) {
prevZoom = z;
return;
}
const editorRect = editorContainer.getBoundingClientRect();
const paperRect = element.getBoundingClientRect();
pendingAnchorDocY = (editorRect.top - paperRect.top) / (prevZoom / 100);
prevZoom = z;
});
$effect(() => {
appliedZoom; // track to fire after the pre-effect / DOM update
if (pendingAnchorDocY === null || !editorContainer || !element) return;
const docY = pendingAnchorDocY;
pendingAnchorDocY = null;
const editorRect = editorContainer.getBoundingClientRect();
const paperRect = element.getBoundingClientRect();
const targetScreenY = paperRect.top + docY * (appliedZoom / 100);
editorContainer.scrollTop += targetScreenY - editorRect.top;
});
function updateCurrentPage() {
const tiptap = element?.querySelector('.tiptap') as HTMLElement | null;
if (!tiptap || !editorContainer) return;
const editorRect = editorContainer.getBoundingClientRect();
const tiptapRect = tiptap.getBoundingClientRect();
// getBoundingClientRect and coordsAtPos return zoomed viewport pixels;
// divide by zoom factor to convert to document coordinates before comparing with CYCLE.
const zoomFactor = appliedZoom / 100;
if (editor) {
try {
const coords = editor.view.coordsAtPos(editor.state.selection.head);
const cursorMidY = (coords.top + coords.bottom) / 2;
if (cursorMidY >= editorRect.top && cursorMidY <= editorRect.bottom) {
const cursorInDoc = (cursorMidY - tiptapRect.top) / zoomFactor;
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / CYCLE) + 1));
return;
}
} catch {
// coordsAtPos can fail during editor teardown — fall through
}
}
const visibleTopInDoc = (editorRect.top - tiptapRect.top) / zoomFactor;
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, visibleTopInDoc) / CYCLE) + 1));
}
function onPageCount(e: Event) {
numPages = (e as CustomEvent<{ numPages: number }>).detail.numPages;
updateCurrentPage();
}
function applyFontToFragment(frag: Fragment, textStyleType: MarkType, font: string): Fragment {
const nodes: PmNode[] = [];
frag.forEach((node: PmNode) => {
if (node.isText) {
const existingTS = node.marks.find(m => m.type === textStyleType);
if (existingTS?.attrs.fontFamily) {
nodes.push(node);
} else {
const newAttrs = { ...(existingTS?.attrs ?? {}), fontFamily: font };
const otherMarks = node.marks.filter(m => m.type !== textStyleType);
nodes.push(node.mark([...otherMarks, textStyleType.create(newAttrs)]));
}
} else {
nodes.push(node.copy(applyFontToFragment(node.content, textStyleType, font)));
}
});
return Fragment.fromArray(nodes);
}
onMount(() => {
const saved = loadDocument();
editor = new Editor({
element,
extensions,
content: saved || undefined,
editorProps: {
transformPasted(slice, view) {
const textStyleType = view.state.schema.marks.textStyle;
if (!textStyleType) return slice;
const cursorMarks = view.state.storedMarks ?? view.state.selection.$head.marks();
const cursorFont = cursorMarks.find(m => m.type === textStyleType)?.attrs.fontFamily as string | undefined;
const font = cursorFont ?? DEFAULT_EDITOR_FONT;
return new Slice(applyFontToFragment(slice.content, textStyleType, font), slice.openStart, slice.openEnd);
},
},
onTransaction: () => {
tick++;
},
onSelectionUpdate: ({ editor: e }) => {
// Use the editor instance passed by TipTap directly — avoids any Svelte
// prop-reactivity timing issues. TipTap always auto-scrolls the cursor
// into view before firing this, so no visibility check is needed here.
const tiptap = element?.querySelector('.tiptap') as HTMLElement | null;
if (!tiptap) return;
try {
const coords = e.view.coordsAtPos(e.state.selection.head);
const cursorInDoc = ((coords.top + coords.bottom) / 2 - tiptap.getBoundingClientRect().top) / (appliedZoom / 100);
currentPage = Math.max(1, Math.min(numPages, Math.floor(Math.max(0, cursorInDoc) / CYCLE) + 1));
} catch { /* ignore */ }
},
onUpdate: ({ editor: e }) => {
saveDocument(e.getJSON());
},
});
element.addEventListener('pm-pagecount', onPageCount);
editorContainer.addEventListener('scroll', updateCurrentPage);
});
onDestroy(() => {
if (zoomRaf !== null) cancelAnimationFrame(zoomRaf);
editor?.destroy();
element?.removeEventListener('pm-pagecount', onPageCount);
editorContainer?.removeEventListener('scroll', updateCurrentPage);
});
</script>
<div class="editor" bind:this={editorContainer}>
<div bind:this={element} class="paper" class:show-formatting-marks={showFormattingMarks} style="zoom: {appliedZoom / 100}"></div>
</div>