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);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@ -192,7 +192,7 @@ export class VersionsService {
|
||||
pageId,
|
||||
ydocSnapshot: snapshot,
|
||||
trigger: 'MANUAL',
|
||||
label: input.label,
|
||||
label: input.label ?? null,
|
||||
createdBy: user.id,
|
||||
contributorIds,
|
||||
},
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
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 { 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<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
|
||||
// page-tools visibility live next to the icons, not inside the editor.
|
||||
const comments = useComments(resolved?.id);
|
||||
|
||||
@ -111,7 +111,7 @@
|
||||
"viewTitle": "Vorschau",
|
||||
"trigger": {
|
||||
"auto": "Automatischer Schnappschuss",
|
||||
"manual": "Benannte Version",
|
||||
"manual": "Manueller Schnappschuss",
|
||||
"pre_restore": "Vor einer Wiederherstellung"
|
||||
},
|
||||
"saveVersion": "Version speichern",
|
||||
|
||||
@ -111,7 +111,7 @@
|
||||
"viewTitle": "Preview",
|
||||
"trigger": {
|
||||
"auto": "Automatic snapshot",
|
||||
"manual": "Named version",
|
||||
"manual": "Manual snapshot",
|
||||
"pre_restore": "Before a restore"
|
||||
},
|
||||
"saveVersion": "Save version",
|
||||
|
||||
@ -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<typeof createVersionInputSchema>;
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user