Editor shortcuts: "e" edits, the platform chord+S snapshots versions

In reading mode a plain "e" (guarded against typing targets) switches
to edit mode. In edit mode the platform's native chord — Cmd on macOS,
Ctrl elsewhere — +S saves an unnamed manual snapshot in place, and
+Shift+S asks for a name and returns to reading mode; both always
swallow the browser's save dialog. The shared isTypingTarget guard
moves from TopBar into lib/keyboard.ts next to the new modifier helper.

Unnamed snapshots needed the API to accept them: the version label is
optional now (trigger stays MANUAL, label null), and the history list's
existing null-label fallback text becomes "Manueller Schnappschuss" /
"Manual snapshot" — it only ever shows for exactly those. DB test for
the label-less path, e2e coverage in the CI content pack.

Fixes #125

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-15 13:17:09 +02:00
parent 44a90a53ac
commit 0428892ef2
9 changed files with 124 additions and 11 deletions

View File

@ -82,6 +82,15 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => {
expect(row.ydocSnapshot.byteLength).toBeGreaterThan(0); expect(row.ydocSnapshot.byteLength).toBeGreaterThan(0);
}); });
it('creates an unnamed manual snapshot when no label is given (#125)', async () => {
const pageId = await createPage();
const view = await versions.createNamed(owner, pageId, {});
expect(view).toMatchObject({ trigger: 'manual', label: null, createdBy: owner.id });
const row = await prisma.pageVersion.findUniqueOrThrow({ where: { id: view.id } });
expect(row.label).toBeNull();
});
it('captures and clears the pending contributor set', async () => { it('captures and clears the pending contributor set', async () => {
const pageId = await createPage(); const pageId = await createPage();
const authorA = randomUUID(); const authorA = randomUUID();

View File

@ -192,7 +192,7 @@ export class VersionsService {
pageId, pageId,
ydocSnapshot: snapshot, ydocSnapshot: snapshot,
trigger: 'MANUAL', trigger: 'MANUAL',
label: input.label, label: input.label ?? null,
createdBy: user.id, createdBy: user.id,
contributorIds, contributorIds,
}, },

View File

@ -105,6 +105,46 @@ test('editor basics: typing autosaves and undo/redo work', async ({ browser }) =
await context.close(); await context.close();
}); });
test('keyboard shortcuts: "e" enters edit mode and the platform chord snapshots (#125)', async ({
browser,
}) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context);
const created = await context.request.post(`/api/v1/ponds/${pond.id}/pages`, {
data: { title: `Content Pack Shortcuts ${Date.now()}` },
});
const { id, slug } = (await created.json()) as { id: string; slug: string };
const page = await context.newPage();
await page.goto(`/p/${pond.slug}/${slug}`);
await expect(page.locator('.ProseMirror')).toBeVisible();
// Plain "e" flips reading → edit mode.
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.
await page.keyboard.press('ControlOrMeta+s');
await expect
.poll(async () => {
const res = await context.request.get(`/api/v1/pages/${id}/versions`);
const versions = (await res.json()) as { trigger: string; label: string | null }[];
return versions.filter((v) => v.trigger === 'manual').length;
})
.toBe(1);
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'true');
// Ctrl/Cmd+Shift+S asks for a name and returns to reading mode.
page.on('dialog', (dialog) => void dialog.accept('Meilenstein'));
await page.keyboard.press('ControlOrMeta+Shift+s');
await expect(page.locator('.ProseMirror')).toHaveAttribute('contenteditable', 'false');
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);
await context.close();
});
test('image paste: uploads and renders at the cursor', async ({ browser }) => { test('image paste: uploads and renders at the cursor', async ({ browser }) => {
const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const context = await contextForUser(browser, BASE_URL, 'fixture-user');
const pond = await personalPond(context); const pond = await personalPond(context);

View File

@ -7,6 +7,7 @@ import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { isTypingTarget } from '../lib/keyboard';
import { useDismissable } from '../lib/use-dismissable'; import { useDismissable } from '../lib/use-dismissable';
import { SearchPalette } from '../search/SearchPalette'; import { SearchPalette } from '../search/SearchPalette';
import { NotificationsBell } from '../notifications/NotificationsBell'; import { NotificationsBell } from '../notifications/NotificationsBell';
@ -14,13 +15,6 @@ import { usePageActionsSlot } from './page-actions';
import { PondSwitcher } from './PondSwitcher'; import { PondSwitcher } from './PondSwitcher';
import { useCurrentPondRoute } from './use-pond-route'; import { useCurrentPondRoute } from './use-pond-route';
/** True when focus is in a field where "/" should type, not open search. */
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
}
interface TopBarProps { interface TopBarProps {
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
onToggleSidebar: () => void; onToggleSidebar: () => void;

View File

@ -0,0 +1,15 @@
/** Shared keyboard-shortcut helpers (issues #50, #125). */
/** True when focus is in a field where plain keys should type, not act. */
export function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
}
/** True when the platform's primary chord modifier is held Cmd on macOS,
* Ctrl everywhere else (#125: Stefan wants the native feel per OS). */
export function hasPrimaryModifier(event: KeyboardEvent): boolean {
const isMac = navigator.platform.toLowerCase().includes('mac');
return isMac ? event.metaKey : event.ctrlKey;
}

View File

@ -32,6 +32,7 @@ import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-contex
import { usePageActionsSlot } from '../layout/page-actions'; import { usePageActionsSlot } from '../layout/page-actions';
import { useForceSidebarHidden } from '../layout/sidebar-chrome'; import { useForceSidebarHidden } from '../layout/sidebar-chrome';
import { ApiError, apiGet, apiPatch, apiPost } from '../lib/api'; import { ApiError, apiGet, apiPatch, apiPost } from '../lib/api';
import { hasPrimaryModifier, isTypingTarget } from '../lib/keyboard';
import { PageActions } from './PageActions'; import { PageActions } from './PageActions';
import { recallPage, rememberPage } from '../offline/page-cache'; import { recallPage, rememberPage } from '../offline/page-cache';
import { PluginBlockContext } from '../editor/plugin-block-context'; import { PluginBlockContext } from '../editor/plugin-block-context';
@ -340,6 +341,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();
useForceSidebarHidden(mode === 'edit'); useForceSidebarHidden(mode === 'edit');
@ -387,6 +389,58 @@ export function PageEditorPage(): React.JSX.Element {
if (resolved) setTitle(resolved.title); if (resolved) setTitle(resolved.title);
}, [resolved?.id, resolved?.title]); }, [resolved?.id, resolved?.title]);
// Keyboard shortcuts (#125): plain "e" in reading mode enters edit mode;
// in edit mode the platform chord (Cmd on macOS, Ctrl elsewhere) + S
// snapshots an unnamed version in place, and +Shift+S asks for a name and
// returns to reading mode. Chords are seen even while typing in the
// editor (ProseMirror doesn't bind them); the plain "e" is guarded.
const pageId = resolved?.id;
useEffect(() => {
if (!pageId || !user) return undefined;
async function snapshot(label: string | null): Promise<boolean> {
try {
await apiPost(`/pages/${pageId}/versions`, label ? { label } : {});
await queryClient.invalidateQueries({ queryKey: ['versions', pageId] });
return true;
} catch {
window.alert(t('history.saveFailed'));
return false;
}
}
const onKeyDown = (event: KeyboardEvent): void => {
if (mode === 'view') {
if (
event.key === 'e' &&
!event.ctrlKey &&
!event.metaKey &&
!event.altKey &&
!isTypingTarget(event.target)
) {
event.preventDefault();
setMode('edit');
}
return;
}
if (event.key.toLowerCase() !== 's' || !hasPrimaryModifier(event) || event.altKey) return;
// Always swallow the chord in edit mode — the browser's save dialog
// must never appear over the editor.
event.preventDefault();
if (event.shiftKey) {
const label = window.prompt(t('history.savePrompt'))?.trim();
if (!label) return;
void snapshot(label.slice(0, 100)).then((saved) => {
if (saved) setMode('view');
});
} else {
void snapshot(null);
}
};
window.addEventListener('keydown', onKeyDown);
return () => window.removeEventListener('keydown', onKeyDown);
}, [pageId, user, mode, queryClient, t]);
// 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.
const comments = useComments(resolved?.id); const comments = useComments(resolved?.id);

View File

@ -111,7 +111,7 @@
"viewTitle": "Vorschau", "viewTitle": "Vorschau",
"trigger": { "trigger": {
"auto": "Automatischer Schnappschuss", "auto": "Automatischer Schnappschuss",
"manual": "Benannte Version", "manual": "Manueller Schnappschuss",
"pre_restore": "Vor einer Wiederherstellung" "pre_restore": "Vor einer Wiederherstellung"
}, },
"saveVersion": "Version speichern", "saveVersion": "Version speichern",

View File

@ -111,7 +111,7 @@
"viewTitle": "Preview", "viewTitle": "Preview",
"trigger": { "trigger": {
"auto": "Automatic snapshot", "auto": "Automatic snapshot",
"manual": "Named version", "manual": "Manual snapshot",
"pre_restore": "Before a restore" "pre_restore": "Before a restore"
}, },
"saveVersion": "Save version", "saveVersion": "Save version",

View File

@ -122,7 +122,8 @@ export interface PageListItemView extends PageView {
export type PageVersionTrigger = 'auto' | 'manual' | 'pre_restore'; export type PageVersionTrigger = 'auto' | 'manual' | 'pre_restore';
export const createVersionInputSchema = z.object({ export const createVersionInputSchema = z.object({
label: z.string().trim().min(1, 'validation.required').max(100, 'validation.tooLong'), // Optional since #125: Ctrl/Cmd+S snapshots without asking for a name.
label: z.string().trim().min(1, 'validation.required').max(100, 'validation.tooLong').optional(),
}); });
export type CreateVersionInput = z.infer<typeof createVersionInputSchema>; export type CreateVersionInput = z.infer<typeof createVersionInputSchema>;