diff --git a/apps/api/src/versions/versions.service.db.test.ts b/apps/api/src/versions/versions.service.db.test.ts index 5ba7c8a..b0b933c 100644 --- a/apps/api/src/versions/versions.service.db.test.ts +++ b/apps/api/src/versions/versions.service.db.test.ts @@ -82,6 +82,15 @@ describe.skipIf(!hasTestDb)('VersionsService (db, issue #41)', () => { 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 () => { const pageId = await createPage(); const authorA = randomUUID(); diff --git a/apps/api/src/versions/versions.service.ts b/apps/api/src/versions/versions.service.ts index 09ecd24..40c9aab 100644 --- a/apps/api/src/versions/versions.service.ts +++ b/apps/api/src/versions/versions.service.ts @@ -192,7 +192,7 @@ export class VersionsService { pageId, ydocSnapshot: snapshot, trigger: 'MANUAL', - label: input.label, + label: input.label ?? null, createdBy: user.id, contributorIds, }, diff --git a/apps/web/e2e/content.spec.ts b/apps/web/e2e/content.spec.ts index c8bb2bf..f24586d 100644 --- a/apps/web/e2e/content.spec.ts +++ b/apps/web/e2e/content.spec.ts @@ -105,6 +105,46 @@ test('editor basics: typing autosaves and undo/redo work', async ({ browser }) = 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 }) => { const context = await contextForUser(browser, BASE_URL, 'fixture-user'); const pond = await personalPond(context); diff --git a/apps/web/src/layout/TopBar.tsx b/apps/web/src/layout/TopBar.tsx index 41092c2..ee9fff5 100644 --- a/apps/web/src/layout/TopBar.tsx +++ b/apps/web/src/layout/TopBar.tsx @@ -7,6 +7,7 @@ import { Link, useNavigate } from 'react-router-dom'; import { useAuth } from '../auth/auth-context'; import { apiGet } from '../lib/api'; +import { isTypingTarget } from '../lib/keyboard'; import { useDismissable } from '../lib/use-dismissable'; import { SearchPalette } from '../search/SearchPalette'; import { NotificationsBell } from '../notifications/NotificationsBell'; @@ -14,13 +15,6 @@ import { usePageActionsSlot } from './page-actions'; import { PondSwitcher } from './PondSwitcher'; 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 { sidebarCollapsed: boolean; onToggleSidebar: () => void; diff --git a/apps/web/src/lib/keyboard.ts b/apps/web/src/lib/keyboard.ts new file mode 100644 index 0000000..b0795c7 --- /dev/null +++ b/apps/web/src/lib/keyboard.ts @@ -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; +} diff --git a/apps/web/src/pages/PageEditorPage.tsx b/apps/web/src/pages/PageEditorPage.tsx index 14098ed..56379bb 100644 --- a/apps/web/src/pages/PageEditorPage.tsx +++ b/apps/web/src/pages/PageEditorPage.tsx @@ -32,6 +32,7 @@ import { WikilinkContext, makeWikilinkResolver } from '../editor/wikilink-contex import { usePageActionsSlot } from '../layout/page-actions'; import { useForceSidebarHidden } from '../layout/sidebar-chrome'; import { ApiError, apiGet, apiPatch, apiPost } from '../lib/api'; +import { hasPrimaryModifier, isTypingTarget } from '../lib/keyboard'; import { PageActions } from './PageActions'; import { recallPage, rememberPage } from '../offline/page-cache'; import { PluginBlockContext } from '../editor/plugin-block-context'; @@ -340,6 +341,7 @@ export function PageEditorPage(): React.JSX.Element { ); const [showPageTools, setShowPageTools] = useState(false); const actionsSlot = usePageActionsSlot(); + const queryClient = useQueryClient(); useForceSidebarHidden(mode === 'edit'); @@ -387,6 +389,58 @@ export function PageEditorPage(): React.JSX.Element { if (resolved) setTitle(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 { + 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 // page-tools visibility live next to the icons, not inside the editor. const comments = useComments(resolved?.id); diff --git a/packages/shared/i18n/de/editor.json b/packages/shared/i18n/de/editor.json index 4a42bcb..343bfd6 100644 --- a/packages/shared/i18n/de/editor.json +++ b/packages/shared/i18n/de/editor.json @@ -111,7 +111,7 @@ "viewTitle": "Vorschau", "trigger": { "auto": "Automatischer Schnappschuss", - "manual": "Benannte Version", + "manual": "Manueller Schnappschuss", "pre_restore": "Vor einer Wiederherstellung" }, "saveVersion": "Version speichern", diff --git a/packages/shared/i18n/en/editor.json b/packages/shared/i18n/en/editor.json index c3060c6..06b6c43 100644 --- a/packages/shared/i18n/en/editor.json +++ b/packages/shared/i18n/en/editor.json @@ -111,7 +111,7 @@ "viewTitle": "Preview", "trigger": { "auto": "Automatic snapshot", - "manual": "Named version", + "manual": "Manual snapshot", "pre_restore": "Before a restore" }, "saveVersion": "Save version", diff --git a/packages/shared/src/pages.ts b/packages/shared/src/pages.ts index 8915b3d..0cea91e 100644 --- a/packages/shared/src/pages.ts +++ b/packages/shared/src/pages.ts @@ -122,7 +122,8 @@ export interface PageListItemView extends PageView { export type PageVersionTrigger = 'auto' | 'manual' | 'pre_restore'; 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;