4d17befb0d
Adds footnotes: a superscript marker in the text linked to an editable definition in a Footnotes section at the end of the page, with auto-numbering and a read-only hover popover. Chose the reference+definitions model (3 plain nodes) over an inline atom with a sub-editor specifically for collaboration safety. editor-ext (packages/editor-ext/src/lib/footnote/): - footnoteReference (inline atom, id), footnotesList (block, last child), footnoteDefinition (paragraph+, id). renderHTML emits sup[data-footnote-ref] / section[data-footnotes] / div[data-footnote-def]; parse-rule priority makes the empty reference win over the Superscript mark (else it is dropped on the server save). - numbering: a decoration-only plugin (pure function of doc order) -> every client computes identical numbers, no document mutation, Yjs-safe. - sync plugin: single-pass, always SYNC_META-tagged and skipping remote txns (terminates, no loop), idempotent; canonicalizes to one trailing footnotesList (merging duplicates), creates missing definitions, drops orphans, and coexists with TrailingNode. Disabled in read-only. - commands setFootnote (one tx: reference + definition at the matching index + focus) / removeFootnote (cascade, one undo) / scrollTo*. slash /footnote. client: superscript NodeView + floating-ui read-only popover; bottom-list and definition NodeViews; registered in mainExtensions. server: the three nodes registered in tiptapExtensions so collab/save/export keep them. Round-trip regression spec guards the Superscript parse-priority. markdown: turndown/marked round-trip to pandoc/GFM [^id] (+ a code-fence guard so footnote-like lines inside code blocks are not extracted). MCP mirror: schema + markdown-converter + commentsToFootnotes rewritten to real footnote nodes + diff marker counting; NUL sentinels written as \u0000 escapes. v2 follow-ups (per plan): definition reordering on reference move, id-collision regeneration on paste, multiple references to one footnote. Implements docs/footnotes-plan.md (variant B). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
117 lines
3.4 KiB
TypeScript
117 lines
3.4 KiB
TypeScript
import "@/features/editor/styles/index.css";
|
|
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
|
import { EditorProvider } from "@tiptap/react";
|
|
import { mainExtensions } from "@/features/editor/extensions/extensions";
|
|
import { Document } from "@tiptap/extension-document";
|
|
import { Heading, UniqueID } from "@docmost/editor-ext";
|
|
import { Text } from "@tiptap/extension-text";
|
|
import { Placeholder } from "@tiptap/extension-placeholder";
|
|
import { useAtom } from "jotai";
|
|
import { readOnlyEditorAtom } from "@/features/editor/atoms/editor-atoms.ts";
|
|
import { useEditorScroll } from "./hooks/use-editor-scroll";
|
|
import { TransclusionLookupProvider } from "@/features/editor/components/transclusion/transclusion-lookup-context";
|
|
|
|
interface PageEditorProps {
|
|
title: string;
|
|
content: any;
|
|
pageId?: string;
|
|
/**
|
|
* When rendering inside a public share, pass the share's id (or key). Lookups
|
|
* for transclusion content then resolve against the share graph instead of
|
|
* the viewer's personal permissions, so a share never leaks source content
|
|
* that isn't itself shared.
|
|
*/
|
|
shareId?: string;
|
|
}
|
|
|
|
export default function ReadonlyPageEditor({
|
|
title,
|
|
content,
|
|
pageId,
|
|
shareId,
|
|
}: PageEditorProps) {
|
|
const [, setReadOnlyEditor] = useAtom(readOnlyEditorAtom);
|
|
const isComponentMounted = useRef(false);
|
|
const editorCreated = useRef(false);
|
|
|
|
const canScroll = useCallback(
|
|
() => isComponentMounted.current && editorCreated.current,
|
|
[isComponentMounted, editorCreated],
|
|
);
|
|
const initialScrollTo = window.location.hash
|
|
? window.location.hash.slice(1)
|
|
: "";
|
|
const { handleScrollTo } = useEditorScroll({ canScroll, initialScrollTo });
|
|
|
|
useEffect(() => {
|
|
isComponentMounted.current = true;
|
|
}, []);
|
|
|
|
const extensions = useMemo(() => {
|
|
const filteredExtensions = mainExtensions
|
|
.filter((ext) => ext.name !== "uniqueID")
|
|
// Read-only must only DECORATE footnotes (numbering), never mutate the
|
|
// doc. Disable the footnote sync/integrity plugin so a programmatic
|
|
// setContent on a doc the viewer can't edit is never rewritten.
|
|
.map((ext) =>
|
|
ext.name === "footnoteReference"
|
|
? ext.configure({ enableSync: false })
|
|
: ext,
|
|
);
|
|
|
|
return [
|
|
...filteredExtensions,
|
|
UniqueID.configure({
|
|
types: ["heading", "paragraph"],
|
|
updateDocument: false,
|
|
}),
|
|
];
|
|
}, []);
|
|
|
|
const titleExtensions = [
|
|
Document.extend({
|
|
content: "heading",
|
|
}),
|
|
Heading,
|
|
Text,
|
|
Placeholder.configure({
|
|
placeholder: "Untitled",
|
|
showOnlyWhenEditable: false,
|
|
}),
|
|
];
|
|
|
|
return (
|
|
<TransclusionLookupProvider shareId={shareId}>
|
|
<div className="page-title">
|
|
<EditorProvider
|
|
editable={false}
|
|
immediatelyRender={true}
|
|
extensions={titleExtensions}
|
|
content={title}
|
|
></EditorProvider>
|
|
</div>
|
|
|
|
<EditorProvider
|
|
editable={false}
|
|
immediatelyRender={true}
|
|
extensions={extensions}
|
|
content={content}
|
|
onCreate={({ editor }) => {
|
|
if (editor) {
|
|
if (pageId) {
|
|
// @ts-ignore
|
|
editor.storage.pageId = pageId;
|
|
}
|
|
// @ts-ignore
|
|
setReadOnlyEditor(editor);
|
|
|
|
handleScrollTo(editor);
|
|
editorCreated.current = true;
|
|
}
|
|
}}
|
|
></EditorProvider>
|
|
<div style={{ paddingBottom: "20vh" }}></div>
|
|
</TransclusionLookupProvider>
|
|
);
|
|
}
|