Merge issue-39 (Refs #39)

# Conflicts:
#	craft/src/styles/editor.css
This commit is contained in:
shadowdao committed 2026-10-07 11:58:26 -07:00
commit 2b41cd46ba
13 files changed
+861 -513

No files matched your search

+14 -4
View File
@@ -4,6 +4,8 @@ import { useSitesmithModal } from '../../state/SitesmithContext';
import { buildSitesmithTarget } from '../../utils/sitesmith-target';
import { regenerateTreeIds } from '../../utils/craft-tree';
import { getClipboardTree, setClipboardTree } from '../../hooks/clipboard';
import { getCommand } from '../../editor/commands';
import { formatShortcut } from '../../utils/shortcuts';
import { useNodeActions } from '../../hooks/useNodeActions';
interface ContextMenuProps {
@@ -25,6 +27,12 @@ interface MenuItem {
dividerAfter?: boolean;
}
/** Shortcut label for a registry command, so the menu never drifts from the keymap. */
const shortcutLabel = (id: string): string | undefined => {
const spec = getCommand(id)?.shortcut;
return spec ? formatShortcut(spec) : undefined;
};
export const ContextMenu: React.FC<ContextMenuProps> = ({
visible,
x,
@@ -179,21 +187,21 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
{
label: 'Duplicate',
icon: 'clone',
shortcut: 'Ctrl+D',
shortcut: shortcutLabel('edit.duplicate'),
action: duplicate,
disabled: isRoot,
},
{
label: 'Copy',
icon: 'files-o',
shortcut: 'Ctrl+C',
shortcut: shortcutLabel('edit.copy'),
action: copyNode,
disabled: isRoot,
},
{
label: 'Paste',
icon: 'clipboard',
shortcut: 'Ctrl+V',
shortcut: shortcutLabel('edit.paste'),
action: pasteNode,
disabled: !getClipboardTree(),
dividerAfter: true,
@@ -201,12 +209,14 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
{
label: 'Move Up',
icon: 'arrow-up',
shortcut: shortcutLabel('edit.move-up'),
action: moveUp,
disabled: isRoot,
},
{
label: 'Move Down',
icon: 'arrow-down',
shortcut: shortcutLabel('edit.move-down'),
action: moveDown,
disabled: isRoot,
},
@@ -225,7 +235,7 @@ export const ContextMenu: React.FC<ContextMenuProps> = ({
{
label: 'Delete',
icon: 'trash',
shortcut: 'Del',
shortcut: shortcutLabel('edit.delete'),
action: deleteNode,
danger: true,
disabled: isRoot,
@@ -0,0 +1,66 @@
import { describe, test, expect, afterEach, beforeEach } from 'vitest';
import React from 'react';
import { createRoot, Root } from 'react-dom/client';
import { act } from 'react-dom/test-utils';
import { ShortcutsModal } from './ShortcutsModal';
import { __resetCommandsForTests, registerCommands, getCommands } from '../../editor/commands';
import { CORE_COMMANDS } from '../../editor/core-commands';
import { formatShortcut } from '../../utils/shortcuts';
let container: HTMLDivElement;
let root: Root;
const show = (open = true) => {
act(() => { root.render(<ShortcutsModal open={open} onClose={() => {}} />); });
};
beforeEach(() => {
__resetCommandsForTests();
container = document.createElement('div');
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
__resetCommandsForTests();
});
const rows = () => Array.from(document.body.querySelectorAll('.shortcuts-row')) as HTMLElement[];
describe('ShortcutsModal', () => {
test('renders nothing when closed', () => {
registerCommands(CORE_COMMANDS);
show(false);
expect(document.body.querySelector('[role="dialog"]')).toBeNull();
});
test('lists every registered command that has a shortcut, grouped, with formatted keys', () => {
registerCommands(CORE_COMMANDS);
registerCommands([
{ id: 'site.save', title: 'Save', group: 'site', shortcut: 'mod+s', run: () => {} },
{ id: 'site.templates', title: 'Templates', group: 'site', run: () => {} },
]);
show();
const expected = getCommands().filter((c) => c.shortcut).map((c) => c.id).sort();
expect(rows().map((r) => r.dataset.command).sort()).toEqual(expected);
expect(rows().some((r) => r.dataset.command === 'site.templates')).toBe(false);
const groups = Array.from(document.body.querySelectorAll('.shortcuts-group')).map((g) => (g as HTMLElement).dataset.group);
expect(groups).toEqual(['edit', 'view', 'site', 'help']);
const save = rows().find((r) => r.dataset.command === 'site.save')!;
expect(save.textContent).toContain('Save');
expect(save.querySelector('kbd')!.textContent).toBe(formatShortcut('mod+s'));
// secondary alternates are shown for redo (mod+y) but not the '?' duplicate of the help key
const help = rows().find((r) => r.dataset.command === 'help.shortcuts')!;
expect(help.querySelectorAll('kbd')).toHaveLength(1);
const redo = rows().find((r) => r.dataset.command === 'edit.redo')!;
expect(redo.querySelectorAll('kbd')).toHaveLength(2);
});
test('has an accessible dialog role and title', () => {
show();
const d = document.body.querySelector('[role="dialog"]')!;
expect(d.getAttribute('aria-label')).toBe('Keyboard shortcuts');
expect(d.textContent).toContain('Keyboard shortcuts');
});
});
@@ -0,0 +1,70 @@
import React, { useEffect, useState, useSyncExternalStore } from 'react';
import { subscribeShortcutsModal } from '../../editor/core-commands';
import { Modal } from '../../ui/Modal';
import { getCommands, subscribeCommands, type CommandGroup } from '../../editor/commands';
import { formatShortcut } from '../../utils/shortcuts';
const GROUP_ORDER: CommandGroup[] = ['edit', 'select', 'view', 'insert', 'page', 'site', 'help'];
const GROUP_LABELS: Record<CommandGroup, string> = {
edit: 'Edit',
select: 'Selection',
view: 'View',
insert: 'Insert',
page: 'Page',
site: 'Site',
help: 'Help',
};
/** Keyboard cheat sheet: every registered command that has a shortcut, grouped. */
export const ShortcutsModal: React.FC<{ open: boolean; onClose: () => void }> = ({ open, onClose }) => {
const commands = useSyncExternalStore(subscribeCommands, getCommands, getCommands);
const groups = GROUP_ORDER.map((g) => ({
id: g,
items: commands.filter((c) => c.group === g && c.shortcut),
})).filter((g) => g.items.length > 0);
return (
<Modal
open={open}
onClose={onClose}
backdropProps={{ role: 'dialog', 'aria-modal': true, 'aria-label': 'Keyboard shortcuts' }}
>
<div className="shortcuts-modal">
<div className="shortcuts-modal-header">
<h2 className="shortcuts-modal-title">Keyboard shortcuts</h2>
<button type="button" className="shortcuts-modal-close" aria-label="Close" onClick={onClose}>
<i className="fa fa-times" aria-hidden="true" />
</button>
</div>
<div className="shortcuts-modal-body">
{groups.map((g) => (
<section key={g.id} className="shortcuts-group" data-group={g.id}>
<h3 className="shortcuts-group-title">{GROUP_LABELS[g.id]}</h3>
<ul className="shortcuts-list">
{g.items.map((c) => (
<li key={c.id} className="shortcuts-row" data-command={c.id}>
<span className="shortcuts-row-title">{c.title}</span>
<span className="shortcuts-row-keys">
{[c.shortcut!, ...(c.shortcuts ?? [])]
.filter((s, i, a) => i === 0 || (!/shift\+[?/]$/.test(s) && a.indexOf(s) === i))
.map((s) => (
<kbd key={s}>{formatShortcut(s)}</kbd>
))}
</span>
</li>
))}
</ul>
</section>
))}
</div>
</div>
</Modal>
);
};
/** Owns the open state: `help.shortcuts` (Cmd+/) opens it through core-commands' channel. */
export const ShortcutsModalHost: React.FC = () => {
const [open, setOpen] = useState(false);
useEffect(() => subscribeShortcutsModal(() => setOpen(true)), []);
return <ShortcutsModal open={open} onClose={() => setOpen(false)} />;
};
+18
View File
@@ -20,6 +20,8 @@ import { SaveStatusPill, SaveStatus } from './SaveStatusPill';
import { useSaveGuard } from '../../state/SaveGuardContext';
import { SitesmithButton } from '../sitesmith/SitesmithButton';
import { useSitesmithModal } from '../../state/SitesmithContext';
import { useRegisterCommands } from '../../editor/commands';
import { TopBarCommandMenu } from './TopBarCommandMenu';
interface TopBarProps {
/** Item 10: canvas dashed guide outlines, default ON. */
@@ -295,6 +297,20 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
}
}, [query, headerPage, footerPage, whpConfig, design, pages, activePageId]);
// Site-level commands: the keyboard layer (Cmd+S / Cmd+Shift+P / Cmd+P), the
// "..." command menu and the shortcuts cheat sheet all read these. Re-registered
// when the handlers change so `run` never calls a stale closure. Save / preview /
// publish also fire while a text field has focus (the browser would otherwise
// open its own Save / Print dialog).
useRegisterCommands([
{ id: 'site.save', title: 'Save', group: 'site', shortcut: 'mod+s', allowWhileTyping: true, when: () => canSave, run: () => handleSave() },
...(isWHP ? [{ id: 'site.publish', title: 'Publish', group: 'site' as const, shortcut: 'mod+shift+p', allowWhileTyping: true, when: () => canSave, run: () => handlePublish() }] : []),
{ id: 'site.preview', title: 'Preview', group: 'site', shortcut: 'mod+p', allowWhileTyping: true, run: () => handlePreview() },
{ id: 'site.templates', title: 'Templates', group: 'site', when: () => canSave, run: ({ openTemplates }) => openTemplates() },
{ id: 'site.head-code', title: 'Custom head code', group: 'site', when: () => canSave, run: ({ openHeadCode }) => openHeadCode() },
{ id: 'site.report-issue', title: 'Report an issue', group: 'site', run: () => setReportOpen(true) },
], [canSave, isWHP, handleSave, handlePublish, handlePreview]);
// Phase A: at ≤768px the topbar collapses to only the essentials --
// back arrow, Undo/Redo, Save (+Publish) -- with Templates, Sitesmith,
// Preview, Head Code, the guides toggle, and the device switcher moved
@@ -472,6 +488,8 @@ export const TopBar: React.FC<TopBarProps> = ({ showGuides, onToggleGuides }) =>
<i className="fa fa-bug" />
</button>
<TopBarCommandMenu />
{/* Draft badge: saved changes that aren't live yet */}
{isWHP && isDraft && !published && (
<span className="publish-badge draft">