封装预览画布快捷缩放判定

识别跨平台加减、适配画布与实际尺寸快捷键
限制快捷键作用域并放行可编辑和可操作目标
增加按键变体、重复输入和默认行为回归测试
This commit is contained in:
2026-09-05 19:33:10 +08:00
parent c50960f4c4
commit af8e1df139
2 changed files with 276 additions and 0 deletions
@@ -0,0 +1,100 @@
export type PreviewZoomShortcut =
| 'fit'
| 'actual-size'
| 'zoom-in'
| 'zoom-out';
export type PreviewZoomKeyboardContext = {
hasZoomableViewport: boolean;
isFocused: boolean;
isHovered: boolean;
usesMetaModifier: boolean;
};
export type PreviewZoomKeyboardActions = {
fit: () => void;
resetToActualSize: () => void;
zoomIn: () => void;
zoomOut: () => void;
};
const INTERACTIVE_TARGET_SELECTOR = [
'button',
'a[href]',
'input',
'textarea',
'select',
'summary',
'[contenteditable]:not([contenteditable="false"])',
'[role="button"]',
'[role="link"]',
'[role="textbox"]',
'[role="combobox"]',
'[role="slider"]',
'[role="spinbutton"]',
'[role="checkbox"]',
'[role="radio"]',
'[role="switch"]',
'[role="tab"]',
'[role="menuitem"]',
'[role="option"]',
'[role="dialog"]',
'[aria-modal="true"]',
].join(', ');
export function previewZoomUsesMetaModifier(platform: string) {
return /Mac|iPhone|iPad|iPod/i.test(platform);
}
export function isPreviewZoomInteractiveTarget(target: EventTarget | null) {
const element = target instanceof Element ? target : null;
return Boolean(element?.closest(INTERACTIVE_TARGET_SELECTOR));
}
function hasPlatformModifier(event: KeyboardEvent, usesMetaModifier: boolean) {
return usesMetaModifier
? event.metaKey && !event.ctrlKey
: event.ctrlKey && !event.metaKey;
}
export function resolvePreviewZoomShortcut(
event: KeyboardEvent,
): PreviewZoomShortcut | null {
if (event.altKey) return null;
if (event.key === '0') return 'fit';
if (event.key === '1') return 'actual-size';
if (event.code === 'NumpadAdd' || event.key === '+' || event.key === '=') {
return 'zoom-in';
}
if (event.code === 'NumpadSubtract' || event.key === '-') {
return 'zoom-out';
}
return null;
}
export function handlePreviewZoomKeyDown(
event: KeyboardEvent,
context: PreviewZoomKeyboardContext,
actions: PreviewZoomKeyboardActions,
) {
if (
event.defaultPrevented ||
!context.hasZoomableViewport ||
(!context.isHovered && !context.isFocused) ||
!hasPlatformModifier(event, context.usesMetaModifier) ||
isPreviewZoomInteractiveTarget(event.target)
) {
return false;
}
const shortcut = resolvePreviewZoomShortcut(event);
if (!shortcut) return false;
event.preventDefault();
event.stopPropagation();
if (shortcut === 'fit') actions.fit();
else if (shortcut === 'actual-size') actions.resetToActualSize();
else if (shortcut === 'zoom-in') actions.zoomIn();
else actions.zoomOut();
return true;
}
@@ -0,0 +1,176 @@
// @vitest-environment jsdom
import { fireEvent } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import {
handlePreviewZoomKeyDown,
type PreviewZoomKeyboardActions,
type PreviewZoomKeyboardContext,
previewZoomUsesMetaModifier,
} from '../src/view/ui-editor/components/preview/previewZoomKeyboard';
function createActions(): PreviewZoomKeyboardActions {
return {
fit: vi.fn(),
resetToActualSize: vi.fn(),
zoomIn: vi.fn(),
zoomOut: vi.fn(),
};
}
function dispatchShortcut({
actions = createActions(),
context = {},
event,
target,
}: {
actions?: PreviewZoomKeyboardActions;
context?: Partial<PreviewZoomKeyboardContext>;
event: KeyboardEventInit;
target?: HTMLElement;
}) {
const resolvedTarget = target ?? document.createElement('div');
document.body.append(resolvedTarget);
const handler = vi.fn((keyboardEvent: KeyboardEvent) =>
handlePreviewZoomKeyDown(
keyboardEvent,
{
hasZoomableViewport: true,
isFocused: false,
isHovered: true,
usesMetaModifier: false,
...context,
},
actions,
),
);
resolvedTarget.addEventListener('keydown', handler);
fireEvent.keyDown(resolvedTarget, event);
const keyboardEvent = handler.mock.calls[0]?.[0];
return { actions, handler, keyboardEvent };
}
afterEach(() => {
document.body.replaceChildren();
});
describe('preview zoom keyboard shortcuts', () => {
it.each([
{ key: '+', code: 'Equal' },
{ key: '=', code: 'Equal' },
{ key: '+', code: 'NumpadAdd' },
])('zooms in for the supported main and numpad keys (%o)', (event) => {
const { actions, keyboardEvent } = dispatchShortcut({
event: { ...event, ctrlKey: true, cancelable: true, repeat: true },
});
expect(actions.zoomIn).toHaveBeenCalledTimes(1);
expect(keyboardEvent?.defaultPrevented).toBe(true);
});
it.each([
{ key: '-', code: 'Minus' },
{ key: '-', code: 'NumpadSubtract' },
])('zooms out for the supported main and numpad keys (%o)', (event) => {
const { actions, keyboardEvent } = dispatchShortcut({
event: { ...event, ctrlKey: true, cancelable: true },
});
expect(actions.zoomOut).toHaveBeenCalledTimes(1);
expect(keyboardEvent?.defaultPrevented).toBe(true);
});
it('does not treat underscore as zoom out', () => {
const { actions, keyboardEvent } = dispatchShortcut({
event: {
key: '_',
code: 'Minus',
ctrlKey: true,
shiftKey: true,
cancelable: true,
},
});
expect(actions.zoomOut).not.toHaveBeenCalled();
expect(keyboardEvent?.defaultPrevented).toBe(false);
});
it.each([
{ key: '0', action: 'fit' as const },
{ key: '1', action: 'resetToActualSize' as const },
])('keeps the existing $key shortcut', ({ key, action }) => {
const { actions } = dispatchShortcut({
event: { key, ctrlKey: true, cancelable: true },
});
expect(actions[action]).toHaveBeenCalledTimes(1);
});
it('uses Cmd on Apple platforms and Ctrl elsewhere', () => {
expect(previewZoomUsesMetaModifier('MacIntel')).toBe(true);
expect(previewZoomUsesMetaModifier('iPad')).toBe(true);
expect(previewZoomUsesMetaModifier('Win32')).toBe(false);
const appleActions = createActions();
dispatchShortcut({
actions: appleActions,
context: { usesMetaModifier: true },
event: { key: '=', ctrlKey: true, cancelable: true },
});
expect(appleActions.zoomIn).not.toHaveBeenCalled();
dispatchShortcut({
actions: appleActions,
context: { usesMetaModifier: true },
event: { key: '=', metaKey: true, cancelable: true },
});
expect(appleActions.zoomIn).toHaveBeenCalledTimes(1);
});
it.each([
{ isHovered: false, isFocused: false, hasZoomableViewport: true },
{ isHovered: true, isFocused: false, hasZoomableViewport: false },
])('leaves inactive preview shortcuts to the host (%o)', (context) => {
const { actions, keyboardEvent } = dispatchShortcut({
context,
event: { key: '=', ctrlKey: true, cancelable: true },
});
expect(actions.zoomIn).not.toHaveBeenCalled();
expect(keyboardEvent?.defaultPrevented).toBe(false);
});
it('works while the preview is focused without being hovered', () => {
const { actions } = dispatchShortcut({
context: { isFocused: true, isHovered: false },
event: { key: '=', ctrlKey: true, cancelable: true },
});
expect(actions.zoomIn).toHaveBeenCalledTimes(1);
});
it.each(['input', 'button', 'a'])('ignores interactive %s targets', (tag) => {
const target = document.createElement(tag);
if (target instanceof HTMLAnchorElement) target.href = '#preview';
const { actions, keyboardEvent } = dispatchShortcut({
target,
event: { key: '=', ctrlKey: true, cancelable: true },
});
expect(actions.zoomIn).not.toHaveBeenCalled();
expect(keyboardEvent?.defaultPrevented).toBe(false);
});
it('does not override an event already handled by another control', () => {
const target = document.createElement('div');
target.addEventListener('keydown', (event) => event.preventDefault(), {
once: true,
});
const { actions } = dispatchShortcut({
target,
event: { key: '=', ctrlKey: true, cancelable: true },
});
expect(actions.zoomIn).not.toHaveBeenCalled();
});
});