Gemeinsamer useModalFocus-Hook: Initialfokus in den Dialog, Tab/Shift-Tab zyklisch gefangen, Fokus-Rückgabe an den Auslöser (bzw. returnFocusRef, wenn der öffnende Menüpunkt mit dem Menü unmountet). Dialoge tragen jetzt aria-labelledby auf ihre Überschrift und tabindex=-1 als Fokus-Fallback. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
311 lines
10 KiB
TypeScript
311 lines
10 KiB
TypeScript
import { EXPORT_FORMATS, ExportFormat, PageListItemView, PondView } from '@dorfteich/shared';
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|
import {
|
|
BookOpen,
|
|
Copy,
|
|
Download,
|
|
Ellipsis,
|
|
FolderInput,
|
|
History,
|
|
Paperclip,
|
|
Pencil,
|
|
Save,
|
|
Tag,
|
|
Trash2,
|
|
Wrench,
|
|
} from 'lucide-react';
|
|
import { useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useNavigate } from 'react-router-dom';
|
|
|
|
import { IconButton } from '../components/IconButton';
|
|
import { useToast } from '../components/Toast';
|
|
import { useDocumentExport } from '../export/use-document-export';
|
|
import { FavoriteToggle } from '../favorites/FavoriteToggle';
|
|
import { apiDelete, apiGet, apiGetText, apiPost } from '../lib/api';
|
|
import { useDismissable } from '../lib/use-dismissable';
|
|
import { WatchToggle } from '../watches/WatchToggle';
|
|
import { DeletePageDialog } from './DeletePageDialog';
|
|
import { MovePageDialog } from './MovePageDialog';
|
|
|
|
interface PageActionsProps {
|
|
pageId: string;
|
|
slug: string;
|
|
pondSlug: string;
|
|
mode: 'view' | 'edit';
|
|
onToggleMode: () => void;
|
|
showAttachments: boolean;
|
|
onToggleAttachments: () => void;
|
|
hasTools: boolean;
|
|
showPageTools: boolean;
|
|
onTogglePageTools: () => void;
|
|
showLabels: boolean;
|
|
onToggleLabels: () => void;
|
|
showHistory: boolean;
|
|
onToggleHistory: () => void;
|
|
}
|
|
|
|
/**
|
|
* The page's action icons in the TopBar (issue #101), portalled into the
|
|
* page-actions slot while a page route is active. Rarely used and
|
|
* destructive actions live behind the trailing overflow menu.
|
|
*/
|
|
export function PageActions(props: PageActionsProps): React.JSX.Element {
|
|
const { t } = useTranslation('editor');
|
|
|
|
return (
|
|
<div className="page-actions">
|
|
<IconButton
|
|
className="editor-page__mode-toggle"
|
|
label={props.mode === 'edit' ? t('mode.view') : t('mode.edit')}
|
|
onClick={props.onToggleMode}
|
|
>
|
|
{props.mode === 'edit' ? <BookOpen aria-hidden /> : <Pencil aria-hidden />}
|
|
</IconButton>
|
|
{props.mode === 'edit' && <SaveVersionButton pageId={props.pageId} />}
|
|
<WatchToggle targetType="page" targetId={props.pageId} variant="icon" />
|
|
<IconButton
|
|
className="editor-shell__attachments-toggle"
|
|
label={t('files:title')}
|
|
active={props.showAttachments}
|
|
aria-expanded={props.showAttachments}
|
|
onClick={props.onToggleAttachments}
|
|
>
|
|
<Paperclip aria-hidden />
|
|
</IconButton>
|
|
{props.hasTools && (
|
|
<IconButton
|
|
className="editor-shell__page-tools-toggle"
|
|
label={t('plugins:tools.title')}
|
|
active={props.showPageTools}
|
|
aria-expanded={props.showPageTools}
|
|
onClick={props.onTogglePageTools}
|
|
>
|
|
<Wrench aria-hidden />
|
|
</IconButton>
|
|
)}
|
|
<IconButton
|
|
className="editor-page__labels-toggle"
|
|
label={t('labels:picker.open')}
|
|
active={props.showLabels}
|
|
aria-expanded={props.showLabels}
|
|
onClick={props.onToggleLabels}
|
|
>
|
|
<Tag aria-hidden />
|
|
</IconButton>
|
|
{/* Between labels and history by design (issue #132). */}
|
|
<FavoriteToggle pageId={props.pageId} pondSlug={props.pondSlug} />
|
|
<IconButton
|
|
label={t('history.open')}
|
|
active={props.showHistory}
|
|
aria-expanded={props.showHistory}
|
|
onClick={props.onToggleHistory}
|
|
>
|
|
<History aria-hidden />
|
|
</IconButton>
|
|
<PageOverflowMenu pageId={props.pageId} slug={props.slug} pondSlug={props.pondSlug} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Manually snapshot the page as a named version (M10 follow-up; edit mode
|
|
* only). The name comes from a prompt — consistent with the confirm()-level
|
|
* dialogs used for delete/restore. */
|
|
function SaveVersionButton({ pageId }: { pageId: string }): React.JSX.Element {
|
|
const { t } = useTranslation('editor');
|
|
const queryClient = useQueryClient();
|
|
const showToast = useToast();
|
|
const [busy, setBusy] = useState(false);
|
|
|
|
async function save(): Promise<void> {
|
|
const label = window.prompt(t('history.savePrompt'))?.trim();
|
|
if (!label) return;
|
|
setBusy(true);
|
|
try {
|
|
const shortLabel = label.slice(0, 100);
|
|
await apiPost(`/pages/${pageId}/versions`, { label: shortLabel });
|
|
await queryClient.invalidateQueries({ queryKey: ['versions', pageId] });
|
|
showToast(t('history.savedToastNamed', { label: shortLabel }));
|
|
} catch {
|
|
showToast(t('history.saveFailed'), 'error');
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<IconButton
|
|
className="editor-page__save-version"
|
|
label={t('history.saveVersion')}
|
|
disabled={busy}
|
|
onClick={() => void save()}
|
|
>
|
|
<Save aria-hidden />
|
|
</IconButton>
|
|
);
|
|
}
|
|
|
|
/** Overflow "…" menu: Markdown copy/download (#30), office/PDF export
|
|
* (#65/#67), "Move to…" (issue #109), and the destructive move-to-trash
|
|
* (#31; childless pages keep the plain confirm, pages with subpages get
|
|
* the promote-vs-subtree decision dialog). */
|
|
function PageOverflowMenu({
|
|
pageId,
|
|
slug,
|
|
pondSlug,
|
|
}: {
|
|
pageId: string;
|
|
slug: string;
|
|
pondSlug: string;
|
|
}): React.JSX.Element {
|
|
const { t } = useTranslation('editor');
|
|
const navigate = useNavigate();
|
|
const queryClient = useQueryClient();
|
|
const [open, setOpen] = useState(false);
|
|
const [moving, setMoving] = useState(false);
|
|
const [deleting, setDeleting] = useState<{ title: string; childCount: number } | null>(null);
|
|
const [copyStatus, setCopyStatus] = useState<'idle' | 'copied' | 'error'>('idle');
|
|
const { status, exportPage } = useDocumentExport();
|
|
const menuRef = useRef<HTMLDivElement>(null);
|
|
useDismissable(menuRef, open, () => setOpen(false));
|
|
|
|
const pond = useQuery({
|
|
queryKey: ['pond', pondSlug],
|
|
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
|
|
});
|
|
// Shares the sidebar's query (same key) — the children lookup below must
|
|
// be synchronous so the childless path keeps its click→confirm→DELETE
|
|
// rhythm (specs and users rely on the delete firing immediately).
|
|
const pages = useQuery({
|
|
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort],
|
|
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${pond.data!.id}/pages`),
|
|
enabled: Boolean(pond.data),
|
|
});
|
|
|
|
async function copyMarkdown(): Promise<void> {
|
|
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<void> {
|
|
// The decision dialog only appears when there is something to decide:
|
|
// live children per the cached list (issue #109). A stale childless
|
|
// read errs toward the DEFAULT promote semantics — never toward a
|
|
// silent subtree delete — so no blocking fetch is needed here.
|
|
const list = pages.data ?? [];
|
|
const children = list.filter((p) => p.parentId === pageId);
|
|
if (children.length > 0) {
|
|
const title = list.find((p) => p.id === pageId)?.title ?? slug;
|
|
setOpen(false);
|
|
setDeleting({ title, childCount: children.length });
|
|
return;
|
|
}
|
|
if (!window.confirm(t('page.deleteConfirm'))) return;
|
|
await apiDelete(`/pages/${pageId}`);
|
|
await queryClient.invalidateQueries({ queryKey: ['pages'] });
|
|
navigate(`/p/${pondSlug}`);
|
|
}
|
|
|
|
const exportLabel = (format: ExportFormat): string => {
|
|
if (status[format] === 'busy') return t('export:exporting');
|
|
if (status[format] === 'error') return t('export:failed');
|
|
return t(`export:${format}`);
|
|
};
|
|
|
|
return (
|
|
<div className="page-actions__more" ref={menuRef}>
|
|
<IconButton
|
|
label={t('page.moreActions')}
|
|
active={open}
|
|
aria-haspopup="menu"
|
|
aria-expanded={open}
|
|
onClick={() => setOpen((value) => !value)}
|
|
>
|
|
<Ellipsis aria-hidden />
|
|
</IconButton>
|
|
{open && (
|
|
<div className="page-actions__menu" role="menu">
|
|
<button type="button" role="menuitem" onClick={() => void copyMarkdown()}>
|
|
<Copy aria-hidden />
|
|
{copyStatus === 'idle' && t('page.copyMarkdown')}
|
|
{copyStatus === 'copied' && t('page.markdownCopied')}
|
|
{copyStatus === 'error' && t('page.markdownCopyFailed')}
|
|
</button>
|
|
<a
|
|
role="menuitem"
|
|
href={`/api/v1/pages/${pageId}/export/markdown`}
|
|
download={`${slug}.md`}
|
|
onClick={() => setOpen(false)}
|
|
>
|
|
<Download aria-hidden />
|
|
{t('page.downloadMarkdown')}
|
|
</a>
|
|
<span className="editor-page__export">
|
|
{EXPORT_FORMATS.map((format) => (
|
|
<button
|
|
key={format}
|
|
type="button"
|
|
role="menuitem"
|
|
disabled={status[format] === 'busy'}
|
|
onClick={() => exportPage(pageId, slug, format)}
|
|
>
|
|
<Download aria-hidden />
|
|
{exportLabel(format)}
|
|
</button>
|
|
))}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className="page-actions__move"
|
|
onClick={() => {
|
|
setOpen(false);
|
|
setMoving(true);
|
|
}}
|
|
>
|
|
<FolderInput aria-hidden />
|
|
{t('page.moveTo')}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
role="menuitem"
|
|
className="page-actions__menu-danger"
|
|
onClick={() => void deletePage()}
|
|
>
|
|
<Trash2 aria-hidden />
|
|
{t('page.delete')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
{moving && (
|
|
<MovePageDialog
|
|
pageId={pageId}
|
|
pondSlug={pondSlug}
|
|
onClose={() => setMoving(false)}
|
|
returnFocusRef={menuRef}
|
|
/>
|
|
)}
|
|
{deleting && (
|
|
<DeletePageDialog
|
|
pageId={pageId}
|
|
title={deleting.title}
|
|
childCount={deleting.childCount}
|
|
returnFocusRef={menuRef}
|
|
onDeleted={() => {
|
|
setDeleting(null);
|
|
void queryClient.invalidateQueries({ queryKey: ['pages'] });
|
|
navigate(`/p/${pondSlug}`);
|
|
}}
|
|
onClose={() => setDeleting(null)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|