dorfteich/apps/web/src/pages/PageEditorPage.tsx
Claude Fable 5 31b59f0fb6 #170: Statusmeldungen, Einzeltasten-Shortcuts, Bewegung
Toast-Standzeit 2,5s auf 6s (WCAG 2.2.1 — für Screenreader-/Zoom-Nutzer
kaum erfassbar). Neue Einstellungs-Sektion Bedienung mit dem Schalter
Einzeltasten-Kürzel deaktivieren (lokale Geräte-Einstellung); die
Handler von e und / prüfen sie beim Tastendruck (WCAG 2.1.4).
prefers-reduced-motion: CSS-Transitions kollabieren auf instant, die
Graph-Simulation rechnet ihr Layout synchron zu Ende statt zu animieren
(WCAG 2.2.2). settings-nav-Spec auf 8 Sektionen nachgeführt. Bewusst
KEIN zusätzliches role=status (legal.spec-Locator-Falle).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:39:03 +02:00

623 lines
25 KiB
TypeScript

import { DEFAULT_FONTS, extractOutline } from '@dorfteich/shared';
import type { 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 { 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 { 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<unknown>(null);
const [busy, setBusy] = useState(false);
async function create(): Promise<void> {
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 (
<div className="create-missing-page">
<p className="create-missing-page__hint">{t('notFound.hint')}</p>
<FormError error={error} />
<button
type="button"
className="button create-missing-page__button"
disabled={busy}
onClick={() => void create()}
>
{t('notFound.create', { slug: pageSlug })}
</button>
</div>
);
}
function ConnectionIcon({ status }: { status: string }): React.JSX.Element {
if (status === 'connected') return <Wifi aria-hidden />;
if (status === 'offline') return <WifiOff aria-hidden />;
return <RefreshCw aria-hidden />;
}
/** 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;
}
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<Y.Doc | null>(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<void> {
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<PageListItemView[]>(`/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 (
<WikilinkContext.Provider value={wikilinks}>
<PluginBlockContext.Provider value={pluginBlockScope}>
<div className={mode === 'view' ? 'editor-shell editor-shell--reading' : 'editor-shell'}>
<SectionStyleSheets plugins={pondPlugins.data} />
{canEdit && (
<Toolbar editor={editor} sectionStyles={sectionStyles} pluginBlocks={blockInserts} />
)}
{showPageTools && (
<PageToolsPanel plugins={pondPlugins.data} context={pluginBlockScope} />
)}
{showAttachments && (
<AttachmentsPanel
pageId={page.id}
editor={editor}
canEdit={canEdit}
onClose={onCloseAttachments}
/>
)}
{/* 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(
<div
className="editor-connection"
role="status"
data-status={collab.status}
title={t(`connection.${collab.status}`)}
>
<ConnectionIcon status={collab.status} />
<span className="visually-hidden">{t(`connection.${collab.status}`)}</span>
</div>,
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(<PresenceStrip provider={collab.provider} />, presenceElement)}
{collab.localOnly && (
<div className="editor-banner editor-banner--info" role="note">
{t('offline.localOnly')}
</div>
)}
{mode === 'edit' && readOnly && (
<div className="editor-banner editor-banner--info" role="note">
{t('readOnly.notice')}
</div>
)}
{collab.tooLarge && (
<div className="editor-banner editor-banner--error" role="alert">
{t('tooLarge.notice')}
</div>
)}
{collab.accessRevoked && (
<AccessRevokedDialog
editor={editor}
slug={page.slug}
onDiscard={discardLocalAndLeave}
/>
)}
<EditorContent editor={editor} className="editor-content" />
{canEdit && <WikilinkAutocomplete editor={editor} />}
{canEdit && <MentionAutocomplete editor={editor} />}
</div>
</PluginBlockContext.Provider>
</WikilinkContext.Provider>
);
}
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<Mode>('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<PondView>(`/ponds/${pondSlug}`),
});
const page = useQuery({
queryKey: ['page', pond.data?.id, pageSlug],
queryFn: () => apiGet<PageStateView>(`/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 }
: 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<boolean> {
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<void> {
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 (
<>
<FormError error={pond.error ?? page.error} />
{trashed && <Link to={`/p/${pondSlug}/trash`}>{t('trash.restoreLink')}</Link>}
{missing && <CreateMissingPage pondId={pond.data!.id} pageSlug={pageSlug} />}
</>
);
}
if (!resolved) {
return <></>;
}
return (
<PondFontScope fonts={pond.data?.settings.fonts ?? DEFAULT_POND_FONTS}>
{/* The page's actions render as icons in the TopBar (issue #101). */}
{actionsSlot.element &&
createPortal(
<PageActions
pageId={resolved.id}
slug={resolved.slug}
pondSlug={pondSlug}
mode={mode}
onToggleMode={() => 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,
)}
<div className="editor-page">
<div className="editor-page__header">
{/* The visible title is an input; give assistive tech the page
heading it expects on an article view (#166). */}
<h1 className="visually-hidden">{title || t('title.placeholder')}</h1>
<input
type="text"
className="editor-page__title"
value={title}
placeholder={t('title.placeholder')}
aria-label={t('title.label')}
disabled={mode !== 'edit'}
onChange={(event) => setTitle(event.target.value)}
onBlur={() => void saveTitle()}
/>
</div>
{/* Status line between the header and the article (#134): last update,
word count, reading time — reading mode only. */}
{mode === 'view' && page.data && (
<PageStatusBar updatedAt={page.data.updatedAt} wordCount={wordCount} />
)}
<div className="editor-page__body">
<PageEditor
page={resolved}
mode={mode}
pondSlug={pondSlug}
showAttachments={showAttachments}
showPageTools={showPageTools}
onCloseAttachments={() => setShowAttachments(false)}
onWriteAccess={setCanWrite}
/>
{/* Side panels stack vertically in one column (M10 follow-up). */}
{(showLabels || showHistory) && (
<div className="editor-page__panels">
{showLabels && (
<LabelPicker
pageId={resolved.id}
pondId={resolved.pondId}
pondSlug={pondSlug}
onClose={() => setShowLabels(false)}
/>
)}
{showHistory && (
<HistoryPanel pageId={resolved.id} onClose={() => setShowHistory(false)} />
)}
</div>
)}
</div>
{/* "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' && <BacklinksPanel pageId={resolved.id} pondSlug={pondSlug} />}
{mode === 'view' && <CommentsSection pageId={resolved.id} mayComment={mayComment} />}
{mode === 'view' && (
<LocalGraphPanel pageId={resolved.id} pondId={resolved.pondId} pondSlug={pondSlug} />
)}
</div>
</PondFontScope>
);
}