import { DEFAULT_FONTS } from '@dorfteich/shared'; import type { PageListItemView, PageStateView, PondView } from '@dorfteich/shared'; import { useQuery } from '@tanstack/react-query'; import { Collaboration } from '@tiptap/extension-collaboration'; import { EditorContent, useEditor } from '@tiptap/react'; import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; 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 { FormError } from '../components/forms'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; import { AttachmentsPanel } from '../files/AttachmentsPanel'; import { HistoryPanel } from '../editor/HistoryPanel'; import { LabelPicker } from '../labels/LabelPicker'; import { BacklinksPanel } from '../links/BacklinksPanel'; import { collaborationCaretFor } from '../editor/collaboration-caret'; import { documentExtensions } from '../editor/document-extensions'; import { DocumentExportMenu } from '../export/DocumentExportMenu'; 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 { WikilinkAutocomplete } from '../editor/WikilinkAutocomplete'; import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-context'; import { useForceSidebarHidden } from '../layout/sidebar-chrome'; import { ApiError, apiDelete, apiGet, apiGetText, apiPatch } from '../lib/api'; import { recallPage, rememberPage } from '../offline/page-cache'; // 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'; /** 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, }: { page: ResolvedPage; mode: Mode; pondSlug: string; }): React.JSX.Element { const { t } = useTranslation('editor'); const { user } = useAuth(); const navigate = useNavigate(); const [showAttachments, setShowAttachments] = useState(false); // 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'; // 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); }, [editor, canEdit]); // 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]); if (!editor || !ydoc) return <>; return (
{canEdit && }
{showAttachments && ( setShowAttachments(false)} /> )}
{t(`connection.${collab.status}`)}
{collab.localOnly && (
{t('offline.localOnly')}
)} {mode === 'edit' && readOnly && (
{t('readOnly.notice')}
)} {collab.tooLarge && (
{t('tooLarge.notice')}
)} {collab.accessRevoked && ( )} {canEdit && }
); } /** Page-level actions: Markdown export (issue #30, both read from the * server-cached `page_content_cache.markdown` so "copy" and "download" * always agree with each other and the last saved state) and moving the * page to the trash (issue #31) — a soft delete, so this is reversible via * the pond's trash view; a plain `confirm()` is enough given that. */ function PageMenu({ pageId, slug, pondSlug, onToggleHistory, onToggleLabels, }: { pageId: string; slug: string; pondSlug: string; onToggleHistory: () => void; onToggleLabels: () => void; }): React.JSX.Element { const { t } = useTranslation('editor'); const navigate = useNavigate(); const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle'); async function copyMarkdown(): Promise { try { const markdown = await apiGetText(`/pages/${pageId}/export/markdown`); await navigator.clipboard.writeText(markdown); setCopyStatus('copied'); } catch { setCopyStatus('error'); } setTimeout(() => setCopyStatus('idle'), 2000); } async function deletePage(): Promise { if (!window.confirm(t('page.deleteConfirm'))) return; await apiDelete(`/pages/${pageId}`); navigate(`/p/${pondSlug}`); } return (
{t('page.downloadMarkdown')}
); } export function PageEditorPage(): React.JSX.Element { const { t } = useTranslation('editor'); 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); 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), }); // 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]); 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'; return ( <> {trashed && {t('trash.restoreLink')}} ); } if (!resolved) { return <>; } return (
setTitle(event.target.value)} onBlur={() => void saveTitle()} /> setShowHistory((open) => !open)} onToggleLabels={() => setShowLabels((open) => !open)} />
{showLabels && ( setShowLabels(false)} /> )} {showHistory && ( setShowHistory(false)} /> )}
{/* "Linked from" appears below the content in read mode (issue #48). */} {mode === 'view' && }
); }