import { DEFAULT_FONTS, extractOutline } from '@dorfteich/shared'; import type { PageClassification, PageListItemView, PageStateView, PondView, } from '@dorfteich/shared'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Collaboration } from '@tiptap/extension-collaboration'; import { EditorContent, useEditor } from '@tiptap/react'; import { RefreshCw, Wifi, WifiOff } from 'lucide-react'; import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { Link, useNavigate, useParams } from 'react-router-dom'; import * as Y from 'yjs'; import { useAuth } from '../auth/auth-context'; import { CommentsSection } from '../comments/CommentsSection'; import { PrintFrame } from '../components/ClassificationBanner'; import { FormError } from '../components/forms'; import { useToast } from '../components/Toast'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; import { AttachmentsPanel } from '../files/AttachmentsPanel'; import { HistoryPanel } from '../editor/HistoryPanel'; import { LabelPicker } from '../labels/LabelPicker'; import { LocalGraphPanel } from '../graph/LocalGraphPanel'; import { BacklinksPanel } from '../links/BacklinksPanel'; import { collaborationCaretFor } from '../editor/collaboration-caret'; import { documentExtensions } from '../editor/document-extensions'; import { PondFontScope } from '../fonts/PondFontScope'; import { PondThemeScope } from '../theme/PondThemeScope'; import { ImageUpload } from '../editor/image-upload'; import { PresenceStrip } from '../editor/PresenceStrip'; import { Toolbar } from '../editor/Toolbar'; import { useCollabProvider } from '../editor/use-collab-provider'; import { MentionAutocomplete } from '../editor/MentionAutocomplete'; import { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete'; import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context'; import { usePageActionsSlot } from '../layout/page-actions'; import { useForceSidebarHidden } from '../layout/sidebar-chrome'; import { ApiError, apiGet, apiGetText, apiPatch, apiPost } from '../lib/api'; import { hasPrimaryModifier, isTypingTarget } from '../lib/keyboard'; import { countWords } from '../lib/word-count'; import { PageActions } from './PageActions'; import { PageStatusBar } from './PageStatusBar'; import { recallPage, rememberPage } from '../offline/page-cache'; import { PluginBlockContext } from '../editor/plugin-block-context'; import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel'; import { SectionStyleSheets } from '../plugins/SectionStyleSheets'; import { useDocumentTitle } from '../lib/use-document-title'; import { singleKeyShortcutsDisabled } from '../lib/single-key-shortcuts'; import { pluginBlockOptions, sectionStyleOptions, usePondPlugins, } from '../plugins/use-pond-plugins'; // A pond loaded offline (no settings) still renders the vision defaults. const DEFAULT_POND_FONTS = { heading: DEFAULT_FONTS.heading, body: DEFAULT_FONTS.body, mono: DEFAULT_FONTS.mono, }; type Mode = 'view' | 'edit'; /** Footer status glyph per collab connection state (M10 follow-up). */ /** * The way out of a phantom-wikilink dead end (issue #115): create the missing * page in place. Title = the slug, so the generated slug matches the URL and * every link pointing here resolves (the PhantomPagesView mechanic, #47). * On success the invalidated page query refetches and mounts the editor — * the URL already names the new page. */ function CreateMissingPage({ pondId, pageSlug, }: { pondId: string; pageSlug: string; }): React.JSX.Element { const { t } = useTranslation('editor'); const queryClient = useQueryClient(); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); async function create(): Promise { setError(null); setBusy(true); try { await apiPost(`/ponds/${pondId}/pages`, { title: pageSlug }); await Promise.all([ queryClient.invalidateQueries({ queryKey: ['page', pondId, pageSlug] }), queryClient.invalidateQueries({ queryKey: ['pages', pondId] }), queryClient.invalidateQueries({ queryKey: ['phantom-links', pondId] }), queryClient.invalidateQueries({ queryKey: ['pond-links', pondId] }), ]); } catch (err) { setError(err); setBusy(false); } } return (

{t('notFound.hint')}

); } function ConnectionIcon({ status }: { status: string }): React.JSX.Element { if (status === 'connected') return ; if (status === 'offline') return ; return ; } /** The minimal page identity the editor needs — available from the API online * or from the offline page cache after a reload without a connection (#38). */ interface ResolvedPage { id: string; pondId: string; slug: string; title: string; /** VS-NfD level (issue #213); undefined for the offline-cache fallback. */ classification?: PageClassification; } function PageEditor({ page, mode, pondSlug, showAttachments, showPageTools, onCloseAttachments, onWriteAccess, }: { page: ResolvedPage; mode: Mode; pondSlug: string; showAttachments: boolean; showPageTools: boolean; onCloseAttachments: () => void; /** Reports write access up so the outer page can render the inline * comments composer (issue #133); the collab mode lives with the provider. */ onWriteAccess: (canWrite: boolean) => void; }): React.JSX.Element { const { t } = useTranslation('editor'); const { user } = useAuth(); const navigate = useNavigate(); const { presenceElement, statusElement } = usePageActionsSlot(); const showToast = useToast(); // Created and destroyed within the same effect (not `useMemo` + a separate // cleanup effect): React StrictMode's dev-only mount→cleanup→remount would // otherwise destroy the memoized `Y.Doc` on the simulated unmount without // ever creating a fresh one, silently breaking Yjs-internal machinery // (e.g. the undo manager) while `Y.encodeStateAsUpdate`/`applyUpdate` // still happen to keep working — a bug that only shows up in dev. // // The document starts empty: the collab provider (below) loads the page // state from the server (#35/#36), so there is no REST seed to merge — that // would create a second doc lineage and duplicate the content. const [ydoc, setYdoc] = useState(null); useEffect(() => { const doc = new Y.Doc(); setYdoc(doc); return () => doc.destroy(); }, [page.id]); const collab = useCollabProvider(ydoc, page.id); const readOnly = collab.mode === 'ro'; // The collab token's rw grant is the authoritative write signal; report it up // so the outer page can decide whether to show the inline comment composer // (issue #133). `readers`-policy ponds let anyone comment regardless. useEffect(() => { onWriteAccess(collab.mode === 'rw'); }, [collab.mode, onWriteAccess]); // A revoked page can no longer be edited (issue #39); the content stays // visible for export via the dialog below. const canEdit = mode === 'edit' && !readOnly && !collab.accessRevoked; async function discardLocalAndLeave(): Promise { await collab.discardLocal(); navigate(`/p/${pondSlug}`); } const editor = useEditor( { // `documentExtensions` alone is a valid (uncollaborated) schema, so the // editor never gets built without its 'doc'/'paragraph'/'text' nodes // while `ydoc` is still being created (see the effect above). The // collaboration-caret extension is added once the provider exists, so // remote carets and presence share the same awareness (#37). extensions: ydoc ? [ ...documentExtensions, ImageUpload.configure({ pondId: page.pondId }), Collaboration.configure({ document: ydoc, field: 'default' }), ...(collab.provider && user ? [collaborationCaretFor(collab.provider, user, readOnly)] : []), ] : documentExtensions, editable: canEdit, immediatelyRender: false, }, [ydoc, collab.provider, readOnly, user?.id], ); useLayoutEffect(() => { editor?.setEditable(canEdit); // TipTap rendert die Fläche als role="textbox" — ohne Namen, und im // Lesemodus ist "Textfeld" schlicht falsch (#164, WCAG 4.1.2). Im // Lesemodus wird sie zum benannten Dokument-Bereich. editor?.setOptions({ editorProps: { attributes: { role: canEdit ? 'textbox' : 'document', 'aria-label': t('contentLabel'), ...(canEdit ? { 'aria-multiline': 'true' } : {}), }, }, }); }, [editor, canEdit, t]); // Entering edit mode drops the caret straight into the content, so writing // starts without an extra — and on empty pages pixel-precise — click. Skip // when a text control (e.g. the title input) already holds focus: switching // modes must not yank the caret out of it. useEffect(() => { if (!editor || !canEdit) return; const active = document.activeElement; if ( active instanceof HTMLElement && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable) ) { return; } editor.commands.focus(); }, [editor, canEdit]); // Active plugins feed the section-style stylesheets (read + edit mode) and // the toolbar's style picker (#75). Offline the query fails silently and // sections render with neutral styling — content stays intact. const pondPlugins = usePondPlugins(page.pondId); const sectionStyles = useMemo(() => sectionStyleOptions(pondPlugins.data), [pondPlugins.data]); // Pond pages power wikilink title resolution + the `[[` autocomplete (#46). const pondPages = useQuery({ queryKey: ['pages', page.pondId], queryFn: () => apiGet(`/ponds/${page.pondId}/pages`), }); const wikilinks = useMemo(() => { const targets = (pondPages.data ?? []).map((p) => ({ slug: p.slug, title: p.title })); return { targets, resolve: makeWikilinkResolver(targets), pondSlug, editable: canEdit }; }, [pondPages.data, pondSlug, canEdit]); // The surface plugin blocks run against (#76): ids for the viewer-scoped // read capabilities, and `ui.openPage` resolved through the pond's page // list (a plugin only knows page ids; navigation needs the slug). const pondPagesData = pondPages.data; const pluginBlockScope = useMemo( () => ({ pageId: page.id, pondId: page.pondId, openPage: (pageId: string) => { const target = (pondPagesData ?? []).find((p) => p.id === pageId); if (target) navigate(`/p/${pondSlug}/${target.slug}`); }, // Plugins share the app's toast stack (`ui.toast`, #130). toast: showToast, // Outline ids are derived from the doc (extractOutline), never stamped // into the DOM — so resolve the id to its heading *position* and scroll // the matching rendered heading (#77). scrollToHeading: (headingId: string) => { if (!editor) return; const index = extractOutline(editor.state.doc).findIndex((entry) => entry.id === headingId); if (index < 0) return; const headings = editor.view.dom.querySelectorAll('h1, h2, h3, h4'); headings[index]?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, }), [page.id, page.pondId, pondPagesData, pondSlug, navigate, editor, showToast], ); const blockInserts = useMemo(() => pluginBlockOptions(pondPlugins.data), [pondPlugins.data]); if (!editor || !ydoc) return <>; return (
{canEdit && ( )} {showPageTools && ( )} {showAttachments && ( )} {/* The connection status renders as an icon in the content footer (left half); the localized text stays for screen readers and as the hover tooltip. */} {statusElement && createPortal(
{t(`connection.${collab.status}`)}
, statusElement, )} {/* Live presence renders in the TopBar next to the page actions (#102). The slot only exists for signed-in users, and the public read view never mounts this editor (or any awareness connection) at all. */} {presenceElement && createPortal(, presenceElement)} {collab.localOnly && (
{t('offline.localOnly')}
)} {mode === 'edit' && readOnly && (
{t('readOnly.notice')}
)} {collab.tooLarge && (
{t('tooLarge.notice')}
)} {collab.accessRevoked && ( )} {canEdit && } {canEdit && }
); } export function PageEditorPage(): React.JSX.Element { const { t } = useTranslation('editor'); const { user } = useAuth(); const { pondSlug = '', pageSlug = '' } = useParams<{ pondSlug: string; pageSlug: string }>(); const [mode, setMode] = useState('view'); const [title, setTitle] = useState(''); const [showHistory, setShowHistory] = useState(false); const [showLabels, setShowLabels] = useState(false); const [showAttachments, setShowAttachments] = useState(false); const [showPageTools, setShowPageTools] = useState(false); // Write access reported up by the editor's collab provider, so the outer page // can decide whether to show the inline comment composer (issue #133). const [canWrite, setCanWrite] = useState(false); const actionsSlot = usePageActionsSlot(); const queryClient = useQueryClient(); const showToast = useToast(); useForceSidebarHidden(mode === 'edit'); const pond = useQuery({ queryKey: ['pond', pondSlug], queryFn: () => apiGet(`/ponds/${pondSlug}`), }); const page = useQuery({ queryKey: ['page', pond.data?.id, pageSlug], queryFn: () => apiGet(`/ponds/${pond.data!.id}/pages/${pageSlug}`), enabled: Boolean(pond.data), }); useDocumentTitle(page.data?.title, pond.data?.name); // Word count for the read-mode status line (#134). Derived from the same // Markdown export the history panel uses (shared query key), fetched only in // reading mode so the editor session pays nothing. const pageMarkdown = useQuery({ queryKey: ['page-markdown', page.data?.id], queryFn: () => apiGetText(`/pages/${page.data!.id}/export/markdown`), enabled: mode === 'view' && Boolean(page.data?.id), }); const wordCount = useMemo(() => countWords(pageMarkdown.data ?? ''), [pageMarkdown.data]); // Remember this page's metadata while online so it can be opened offline (#38). useEffect(() => { if (pond.data && page.data) { rememberPage({ pondSlug, pondId: pond.data.id, pageSlug, pageId: page.data.id, title: page.data.title, }); } }, [pondSlug, pageSlug, pond.data?.id, page.data?.id, page.data?.title]); // Prefer the live API result; when offline and it is unavailable, fall back to // the locally cached metadata so the editor still mounts and shows the // IndexedDB copy of a previously-visited page (#38). Errors while online // (e.g. a trashed page) still surface below. const offlineCached = !navigator.onLine && !page.data ? recallPage(pondSlug, pageSlug) : null; const resolved: ResolvedPage | null = page.data ? { id: page.data.id, pondId: page.data.pondId, slug: page.data.slug, title: page.data.title, classification: page.data.classification, } : offlineCached ? { id: offlineCached.pageId, pondId: offlineCached.pondId, slug: offlineCached.pageSlug, title: offlineCached.title, } : null; useEffect(() => { if (resolved) setTitle(resolved.title); }, [resolved?.id, resolved?.title]); // Keyboard shortcuts (#125): plain "e" in reading mode enters edit mode; // in edit mode the platform chord (Cmd on macOS, Ctrl elsewhere) + S // snapshots an unnamed version in place, and +Shift+S asks for a name and // returns to reading mode. Chords are seen even while typing in the // editor (ProseMirror doesn't bind them); the plain "e" is guarded. const pageId = resolved?.id; useEffect(() => { if (!pageId || !user) return undefined; async function snapshot(label: string | null): Promise { try { await apiPost(`/pages/${pageId}/versions`, label ? { label } : {}); await queryClient.invalidateQueries({ queryKey: ['versions', pageId] }); // The snapshot used to happen silently (#130) — confirm it. showToast(label ? t('history.savedToastNamed', { label }) : t('history.savedToast')); return true; } catch { showToast(t('history.saveFailed'), 'error'); return false; } } const onKeyDown = (event: KeyboardEvent): void => { if (mode === 'view') { if ( event.key === 'e' && !event.ctrlKey && !event.metaKey && !event.altKey && !isTypingTarget(event.target) && !singleKeyShortcutsDisabled() ) { event.preventDefault(); setMode('edit'); } return; } if (event.key.toLowerCase() !== 's' || !hasPrimaryModifier(event) || event.altKey) return; // Always swallow the chord in edit mode — the browser's save dialog // must never appear over the editor. event.preventDefault(); if (event.shiftKey) { const label = window.prompt(t('history.savePrompt'))?.trim(); if (!label) return; void snapshot(label.slice(0, 100)).then((saved) => { if (saved) setMode('view'); }); } else { void snapshot(null); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [pageId, user, mode, queryClient, t, showToast]); // TopBar action data (issue #101): the plugin page-tools visibility lives // next to the icons, not inside the editor. const pagePlugins = usePondPlugins(resolved?.pondId); // Comments are shown inline in the read view (issue #133). `readers`-policy // ponds let any reader comment; `editors` require the collab rw grant the // editor reports up via onWriteAccess. const mayComment = (pond.data?.settings.commentPolicy ?? 'readers') === 'readers' || canWrite; // Deep link from a comment notification (issue #94): ?comments=1 scrolls to // the inline discussion once it has mounted in read mode. useEffect(() => { if (mode !== 'view') return; if (new URLSearchParams(window.location.search).get('comments') !== '1') return; const timer = window.setTimeout(() => { document.getElementById('comments')?.scrollIntoView({ behavior: 'smooth', block: 'start' }); }, 200); return () => window.clearTimeout(timer); }, [mode, resolved?.id]); async function saveTitle(): Promise { if (!resolved || title === resolved.title) return; await apiPatch(`/pages/${resolved.id}`, { title }); } if ((pond.error || page.error) && !resolved) { // Editors get a distinguishable hint (and a way out) instead of a dead // end when the page they followed a link to is in the trash (#31). const trashed = page.error instanceof ApiError && page.error.body.code === 'page_trashed'; // A plain not-found (typically a phantom wikilink, #115) offers creating // the page right here. Deliberately ungated like the sidebar's "new // page" button — non-editors get the 403 banner from the POST. const missing = !trashed && Boolean(pond.data) && page.error instanceof ApiError && page.error.body.code === 'not_found'; return ( <> {trashed && {t('trash.restoreLink')}} {missing && } ); } if (!resolved) { return <>; } return ( {/* The page's actions render as icons in the TopBar (issue #101). */} {actionsSlot.element && createPortal( setMode(mode === 'edit' ? 'view' : 'edit')} showAttachments={showAttachments} onToggleAttachments={() => setShowAttachments((open) => !open)} hasTools={hasPageTools(pagePlugins.data)} showPageTools={showPageTools} onTogglePageTools={() => setShowPageTools((open) => !open)} showLabels={showLabels} onToggleLabels={() => setShowLabels((open) => !open)} showHistory={showHistory} onToggleHistory={() => setShowHistory((open) => !open)} />, actionsSlot.element, )}
{/* VS-NfD marking above and below the content (issue #206, ADR 0022) — in reading AND edit mode; unclassified pages show none. The PrintFrame repeats the pair on every printed sheet (#207). */}
{/* The visible title is an input; give assistive tech the page heading it expects on an article view (#166). */}

{title || t('title.placeholder')}

setTitle(event.target.value)} onBlur={() => void saveTitle()} />
{/* Status line between the header and the article (#134): last update, word count, reading time — reading mode only. */} {mode === 'view' && page.data && ( )}
setShowAttachments(false)} onWriteAccess={setCanWrite} /> {/* Side panels stack vertically in one column (M10 follow-up). */} {(showLabels || showHistory) && (
{showLabels && ( setShowLabels(false)} /> )} {showHistory && ( setShowHistory(false)} /> )}
)}
{/* "Linked from" appears below the content in read mode (issue #48); the inline discussion (issue #133) and the local neighborhood graph (issue #113) follow it, in that order. */} {mode === 'view' && } {mode === 'view' && } {mode === 'view' && ( )}
); }