封装预览画布快捷缩放判定
识别跨平台加减、适配画布与实际尺寸快捷键 限制快捷键作用域并放行可编辑和可操作目标 增加按键变体、重复输入和默认行为回归测试
This commit is contained in:
+100
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user