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:
parent
d23e5dc10c
commit
36cdd4fbca
@ -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);
|
||||
|
||||
66
apps/web/src/components/Toast.tsx
Normal file
66
apps/web/src/components/Toast.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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) => {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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": {
|
||||
|
||||
@ -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": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user