Editor: confirm snapshots with a toast; wire ui.toast for plugins (#130)

Cmd/Ctrl+S used to snapshot silently. A new app-wide ToastProvider
(components/Toast.tsx) owns a bottom-center stack — permanent polite
live region, auto-dismiss after 2.5 s, click to dismiss early, error
variant. Both snapshot paths (the keyboard chords in PageEditorPage and
the save-version TopBar button) now confirm with the version name when
there is one, and their failure alert becomes an error toast.

The plugin host capability ui.toast (declared since #74, wired
nowhere) connects to the same stack: PluginBlockScope carries the
showToast handle, plugin-block passes it into the sandbox context.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fb2VzvcoBPHkjh8bZ6PzQn
This commit is contained in:
Claude Fable 5 2026-07-16 12:03:17 +02:00
parent d23e5dc10c
commit 36cdd4fbca
12 changed files with 161 additions and 15 deletions

View File

@ -123,8 +123,12 @@ test('keyboard shortcuts: "e" enters edit mode and the platform chord snapshots
await page.keyboard.press('e'); await page.keyboard.press('e');
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
// Ctrl/Cmd+S snapshots an unnamed manual version and stays in edit mode. // Ctrl/Cmd+S snapshots an unnamed manual version, confirms it with a
// toast (#130), and stays in edit mode.
await page.keyboard.press('ControlOrMeta+s'); await page.keyboard.press('ControlOrMeta+s');
// Assert the toast FIRST — it auto-dismisses after ~2.5s, so it must be
// caught before the version poll spends its budget.
await expect(page.locator('.toast')).toHaveText(/Version (gespeichert|saved)/);
await expect await expect
.poll(async () => { .poll(async () => {
const res = await context.request.get(`/api/v1/pages/${id}/versions`); const res = await context.request.get(`/api/v1/pages/${id}/versions`);
@ -134,10 +138,12 @@ test('keyboard shortcuts: "e" enters edit mode and the platform chord snapshots
.toBe(1); .toBe(1);
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true'); await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
// Ctrl/Cmd+Shift+S asks for a name and returns to reading mode. // Ctrl/Cmd+Shift+S asks for a name and returns to reading mode; the toast
// names the saved version.
page.on('dialog', (dialog) => void dialog.accept('Meilenstein')); page.on('dialog', (dialog) => void dialog.accept('Meilenstein'));
await page.keyboard.press('ControlOrMeta+Shift+s'); await page.keyboard.press('ControlOrMeta+Shift+s');
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'false'); await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'false');
await expect(page.locator('.toast').last()).toContainText('Meilenstein');
const res = await context.request.get(`/api/v1/pages/${id}/versions`); const res = await context.request.get(`/api/v1/pages/${id}/versions`);
const versions = (await res.json()) as { label: string | null }[]; const versions = (await res.json()) as { label: string | null }[];
expect(versions.some((v) => v.label === 'Meilenstein')).toBe(true); expect(versions.some((v) => v.label === 'Meilenstein')).toBe(true);

View File

@ -0,0 +1,66 @@
import { createContext, useCallback, useContext, useRef, useState } from 'react';
/**
* App-wide toast notifications (issue #130). One provider near the root owns
* the stack; `useToast()` hands out a fire-and-forget `showToast`. Toasts are
* announced politely (`role="status"` on the stack container), stack bottom
* center above the footer, and dismiss themselves clicking one dismisses
* it early. The plugin host's `ui.toast` capability feeds the same stack.
*/
export type ToastVariant = 'success' | 'error';
export type ShowToast = (message: string, variant?: ToastVariant) => void;
interface ToastItem {
id: number;
message: string;
variant: ToastVariant;
}
const TOAST_DURATION_MS = 2500;
const ToastContext = createContext<ShowToast>(() => {});
export function useToast(): ShowToast {
return useContext(ToastContext);
}
export function ToastProvider({ children }: { children: React.ReactNode }): React.JSX.Element {
const [toasts, setToasts] = useState<ToastItem[]>([]);
const nextId = useRef(0);
const dismiss = useCallback((id: number): void => {
setToasts((prev) => prev.filter((toast) => toast.id !== id));
}, []);
const showToast = useCallback<ShowToast>(
(message, variant = 'success') => {
nextId.current += 1;
const id = nextId.current;
setToasts((prev) => [...prev, { id, message, variant }]);
window.setTimeout(() => dismiss(id), TOAST_DURATION_MS);
},
[dismiss],
);
return (
<ToastContext.Provider value={showToast}>
{children}
{/* The live region exists permanently, so screen readers pick up
toasts added to it; individual toasts must not carry their own
role="status" (a region appearing WITH its content isn't read). */}
<div className="toast-stack" role="status" aria-live="polite">
{toasts.map((toast) => (
<div
key={toast.id}
className={`toast toast--${toast.variant}`}
onClick={() => dismiss(toast.id)}
>
{toast.message}
</div>
))}
</div>
</ToastContext.Provider>
);
}

View File

@ -82,7 +82,12 @@ function ActivePluginBlock({
extensionPointId: blockType, extensionPointId: blockType,
locale, locale,
container, container,
context: { pageId: scope.pageId, pondId: scope.pondId, openPage: scope.openPage }, context: {
pageId: scope.pageId,
pondId: scope.pondId,
openPage: scope.openPage,
toast: scope.toast,
},
capabilities: { capabilities: {
getData: () => dataRef.current, getData: () => dataRef.current,
setData: (params) => { setData: (params) => {

View File

@ -12,6 +12,8 @@ export interface PluginBlockScope {
pondId?: string; pondId?: string;
/** Navigate to a page (backs the `ui.openPage` capability). */ /** Navigate to a page (backs the `ui.openPage` capability). */
openPage?: (pageId: string) => void; openPage?: (pageId: string) => void;
/** Show a message in the app's toast stack (`ui.toast`, #130). */
toast?: (message: string) => void;
/** Scroll the content to a heading by outline id (`ui.scrollToHeading`, #77). */ /** Scroll the content to a heading by outline id (`ui.scrollToHeading`, #77). */
scrollToHeading?: (headingId: string) => void; scrollToHeading?: (headingId: string) => void;
} }

View File

@ -5,6 +5,7 @@ import { BrowserRouter } from 'react-router-dom';
import { App } from './App'; import { App } from './App';
import { AuthProvider } from './auth/auth-context'; import { AuthProvider } from './auth/auth-context';
import { ToastProvider } from './components/Toast';
import './i18n'; import './i18n';
import { ApiError } from './lib/api'; import { ApiError } from './lib/api';
import './styles/tokens.css'; import './styles/tokens.css';
@ -32,9 +33,11 @@ createRoot(container).render(
<StrictMode> <StrictMode>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<AuthProvider> <AuthProvider>
<ToastProvider>
<BrowserRouter> <BrowserRouter>
<App /> <App />
</BrowserRouter> </BrowserRouter>
</ToastProvider>
</AuthProvider> </AuthProvider>
</QueryClientProvider> </QueryClientProvider>
</StrictMode>, </StrictMode>,

View File

@ -20,6 +20,7 @@ import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { IconButton } from '../components/IconButton'; import { IconButton } from '../components/IconButton';
import { useToast } from '../components/Toast';
import { useDocumentExport } from '../export/use-document-export'; import { useDocumentExport } from '../export/use-document-export';
import { apiDelete, apiGet, apiGetText, apiPost } from '../lib/api'; import { apiDelete, apiGet, apiGetText, apiPost } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable'; import { useDismissable } from '../lib/use-dismissable';
@ -128,6 +129,7 @@ export function PageActions(props: PageActionsProps): React.JSX.Element {
function SaveVersionButton({ pageId }: { pageId: string }): React.JSX.Element { function SaveVersionButton({ pageId }: { pageId: string }): React.JSX.Element {
const { t } = useTranslation('editor'); const { t } = useTranslation('editor');
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const showToast = useToast();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
async function save(): Promise<void> { async function save(): Promise<void> {
@ -135,10 +137,12 @@ function SaveVersionButton({ pageId }: { pageId: string }): React.JSX.Element {
if (!label) return; if (!label) return;
setBusy(true); setBusy(true);
try { try {
await apiPost(`/pages/${pageId}/versions`, { label: label.slice(0, 100) }); const shortLabel = label.slice(0, 100);
await apiPost(`/pages/${pageId}/versions`, { label: shortLabel });
await queryClient.invalidateQueries({ queryKey: ['versions', pageId] }); await queryClient.invalidateQueries({ queryKey: ['versions', pageId] });
showToast(t('history.savedToastNamed', { label: shortLabel }));
} catch { } catch {
window.alert(t('history.saveFailed')); showToast(t('history.saveFailed'), 'error');
} finally { } finally {
setBusy(false); setBusy(false);
} }

View File

@ -14,6 +14,7 @@ import { useAuth } from '../auth/auth-context';
import { CommentsPanel } from '../comments/CommentsPanel'; import { CommentsPanel } from '../comments/CommentsPanel';
import { unreadCount, useComments } from '../comments/use-comments'; import { unreadCount, useComments } from '../comments/use-comments';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { useToast } from '../components/Toast';
import { AccessRevokedDialog } from '../editor/AccessRevokedDialog'; import { AccessRevokedDialog } from '../editor/AccessRevokedDialog';
import { AttachmentsPanel } from '../files/AttachmentsPanel'; import { AttachmentsPanel } from '../files/AttachmentsPanel';
import { HistoryPanel } from '../editor/HistoryPanel'; import { HistoryPanel } from '../editor/HistoryPanel';
@ -146,6 +147,7 @@ function PageEditor({
const { user } = useAuth(); const { user } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { presenceElement, statusElement } = usePageActionsSlot(); const { presenceElement, statusElement } = usePageActionsSlot();
const showToast = useToast();
// Created and destroyed within the same effect (not `useMemo` + a separate // Created and destroyed within the same effect (not `useMemo` + a separate
// cleanup effect): React StrictMode's dev-only mount→cleanup→remount would // cleanup effect): React StrictMode's dev-only mount→cleanup→remount would
@ -234,6 +236,8 @@ function PageEditor({
const target = (pondPagesData ?? []).find((p) => p.id === pageId); const target = (pondPagesData ?? []).find((p) => p.id === pageId);
if (target) navigate(`/p/${pondSlug}/${target.slug}`); 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 // Outline ids are derived from the doc (extractOutline), never stamped
// into the DOM — so resolve the id to its heading *position* and scroll // into the DOM — so resolve the id to its heading *position* and scroll
// the matching rendered heading (#77). // the matching rendered heading (#77).
@ -245,7 +249,7 @@ function PageEditor({
headings[index]?.scrollIntoView({ behavior: 'smooth', block: 'start' }); headings[index]?.scrollIntoView({ behavior: 'smooth', block: 'start' });
}, },
}), }),
[page.id, page.pondId, pondPagesData, pondSlug, navigate, editor], [page.id, page.pondId, pondPagesData, pondSlug, navigate, editor, showToast],
); );
const blockInserts = useMemo(() => pluginBlockOptions(pondPlugins.data), [pondPlugins.data]); const blockInserts = useMemo(() => pluginBlockOptions(pondPlugins.data), [pondPlugins.data]);
@ -342,6 +346,7 @@ export function PageEditorPage(): React.JSX.Element {
const [showPageTools, setShowPageTools] = useState(false); const [showPageTools, setShowPageTools] = useState(false);
const actionsSlot = usePageActionsSlot(); const actionsSlot = usePageActionsSlot();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const showToast = useToast();
useForceSidebarHidden(mode === 'edit'); useForceSidebarHidden(mode === 'edit');
@ -402,9 +407,11 @@ export function PageEditorPage(): React.JSX.Element {
try { try {
await apiPost(`/pages/${pageId}/versions`, label ? { label } : {}); await apiPost(`/pages/${pageId}/versions`, label ? { label } : {});
await queryClient.invalidateQueries({ queryKey: ['versions', pageId] }); 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; return true;
} catch { } catch {
window.alert(t('history.saveFailed')); showToast(t('history.saveFailed'), 'error');
return false; return false;
} }
} }
@ -439,7 +446,7 @@ export function PageEditorPage(): React.JSX.Element {
}; };
window.addEventListener('keydown', onKeyDown); window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown);
}, [pageId, user, mode, queryClient, t]); }, [pageId, user, mode, queryClient, t, showToast]);
// TopBar action data (issue #101): the comments badge and the plugin // TopBar action data (issue #101): the comments badge and the plugin
// page-tools visibility live next to the icons, not inside the editor. // page-tools visibility live next to the icons, not inside the editor.

View File

@ -3516,3 +3516,48 @@ button {
font-size: 0.75rem; font-size: 0.75rem;
font-weight: 400; font-weight: 400;
} }
/* Toast notifications (issue #130): bottom-center stack above the footer,
over everything including modal overlays (z 1100). */
.toast-stack {
position: fixed;
bottom: var(--space-8);
left: 50%;
transform: translateX(-50%);
z-index: 1200;
display: flex;
flex-direction: column;
align-items: center;
gap: var(--space-2);
pointer-events: none;
}
.toast {
pointer-events: auto;
cursor: pointer;
max-width: 28rem;
padding: var(--space-2) var(--space-4);
border-radius: var(--radius);
background: var(--color-text);
color: var(--color-bg);
font-size: 0.9rem;
box-shadow: 0 4px 12px rgb(0 0 0 / 20%);
animation: toast-in 0.15s ease-out;
}
.toast--error {
background: var(--color-danger);
color: var(--color-accent-contrast);
}
@keyframes toast-in {
from {
opacity: 0;
transform: translateY(0.5rem);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@ -90,7 +90,9 @@ Die Werkzeugleiste bleibt beim Scrollen sichtbar.
den Text ein. Andere Dateitypen (PDFs usw., soweit die Instanz sie den Text ein. Andere Dateitypen (PDFs usw., soweit die Instanz sie
erlaubt) hängst du über das **Büroklammer-Symbol** an die Seite. erlaubt) hängst du über das **Büroklammer-Symbol** an die Seite.
- **Benannte Versionen:** Das **Speichern-Symbol** im Bearbeitungsmodus - **Benannte Versionen:** Das **Speichern-Symbol** im Bearbeitungsmodus
legt einen benannten Schnappschuss an („vor dem großen Umbau"). Das legt einen benannten Schnappschuss an („vor dem großen Umbau");
`Strg/Cmd+S` speichert einen unbenannten, `Strg/Cmd+Shift+S` fragt
nach einem Namen — ein kurzer Toast bestätigt jedes Speichern. Das
**Verlauf-Symbol** listet alle Versionen — automatische und benannte — **Verlauf-Symbol** listet alle Versionen — automatische und benannte —
mit ihren Mitwirkenden; du kannst jede Version ansehen und mit ihren Mitwirkenden; du kannst jede Version ansehen und
wiederherstellen. Wiederherstellen löscht nie den Verlauf. wiederherstellen. Wiederherstellen löscht nie den Verlauf.

View File

@ -78,9 +78,11 @@ toolbar stays visible while you scroll.
text. Other file types (PDFs etc., as allowed by the instance) attach text. Other file types (PDFs etc., as allowed by the instance) attach
to the page via the **paperclip icon**. to the page via the **paperclip icon**.
- **Named versions:** the **save icon** in edit mode stores a named - **Named versions:** the **save icon** in edit mode stores a named
snapshot ("before the big rewrite"). The **history icon** lists all snapshot ("before the big rewrite"); `Ctrl/Cmd+S` stores an unnamed
versions — automatic and named — with their contributors; you can view one, `Ctrl/Cmd+Shift+S` asks for a name — a short toast confirms every
any version and restore it. Restoring never deletes history. save. The **history icon** lists all versions — automatic and named —
with their contributors; you can view any version and restore it.
Restoring never deletes history.
## The top-bar page actions ## The top-bar page actions

View File

@ -117,6 +117,8 @@
"saveVersion": "Version speichern", "saveVersion": "Version speichern",
"savePrompt": "Name der Version:", "savePrompt": "Name der Version:",
"saveFailed": "Version konnte nicht gespeichert werden.", "saveFailed": "Version konnte nicht gespeichert werden.",
"savedToast": "Version gespeichert",
"savedToastNamed": "Version „{{label}}“ gespeichert",
"showAllContributors": "Alle Mitwirkenden anzeigen" "showAllContributors": "Alle Mitwirkenden anzeigen"
}, },
"page": { "page": {

View File

@ -117,6 +117,8 @@
"saveVersion": "Save version", "saveVersion": "Save version",
"savePrompt": "Version name:", "savePrompt": "Version name:",
"saveFailed": "The version could not be saved.", "saveFailed": "The version could not be saved.",
"savedToast": "Version saved",
"savedToastNamed": "Version “{{label}}” saved",
"showAllContributors": "Show all contributors" "showAllContributors": "Show all contributors"
}, },
"page": { "page": {