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:
parent
44a90a53ac
commit
0428892ef2
@ -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();
|
||||||
|
|||||||
@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
15
apps/web/src/lib/keyboard.ts
Normal file
15
apps/web/src/lib/keyboard.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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>;
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user