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 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');
// 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
.poll(async () => {
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);
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'));
await page.keyboard.press('ControlOrMeta+Shift+s');
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 versions = (await res.json()) as { label: string | null }[];
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,
locale,
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: {
getData: () => dataRef.current,
setData: (params) => {

View File

@ -12,6 +12,8 @@ export interface PluginBlockScope {
pondId?: string;
/** Navigate to a page (backs the `ui.openPage` capability). */
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). */
scrollToHeading?: (headingId: string) => void;
}

View File

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

View File

@ -20,6 +20,7 @@ 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 { apiDelete, apiGet, apiGetText, apiPost } from '../lib/api';
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 {
const { t } = useTranslation('editor');
const queryClient = useQueryClient();
const showToast = useToast();
const [busy, setBusy] = useState(false);
async function save(): Promise<void> {
@ -135,10 +137,12 @@ function SaveVersionButton({ pageId }: { pageId: string }): React.JSX.Element {
if (!label) return;
setBusy(true);
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] });
showToast(t('history.savedToastNamed', { label: shortLabel }));
} catch {
window.alert(t('history.saveFailed'));
showToast(t('history.saveFailed'), 'error');
} finally {
setBusy(false);
}

View File

@ -14,6 +14,7 @@ import { useAuth } from '../auth/auth-context';
import { CommentsPanel } from '../comments/CommentsPanel';
import { unreadCount, useComments } from '../comments/use-comments';
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';
@ -146,6 +147,7 @@ function PageEditor({
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
@ -234,6 +236,8 @@ function PageEditor({
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).
@ -245,7 +249,7 @@ function PageEditor({
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]);
@ -342,6 +346,7 @@ export function PageEditorPage(): React.JSX.Element {
const [showPageTools, setShowPageTools] = useState(false);
const actionsSlot = usePageActionsSlot();
const queryClient = useQueryClient();
const showToast = useToast();
useForceSidebarHidden(mode === 'edit');
@ -402,9 +407,11 @@ export function PageEditorPage(): React.JSX.Element {
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 {
window.alert(t('history.saveFailed'));
showToast(t('history.saveFailed'), 'error');
return false;
}
}
@ -439,7 +446,7 @@ export function PageEditorPage(): React.JSX.Element {
};
window.addEventListener('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
// page-tools visibility live next to the icons, not inside the editor.

View File

@ -3516,3 +3516,48 @@ button {
font-size: 0.75rem;
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
erlaubt) hängst du über das **Büroklammer-Symbol** an die Seite.
- **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 —
mit ihren Mitwirkenden; du kannst jede Version ansehen und
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
to the page via the **paperclip icon**.
- **Named versions:** the **save icon** in edit mode stores a named
snapshot ("before the big rewrite"). The **history icon** lists all
versions — automatic and named — with their contributors; you can view
any version and restore it. Restoring never deletes history.
snapshot ("before the big rewrite"); `Ctrl/Cmd+S` stores an unnamed
one, `Ctrl/Cmd+Shift+S` asks for a name — a short toast confirms every
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

View File

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

View File

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