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
623 lines
25 KiB
TypeScript
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>
|
|
);
|
|
}
|