封装预览画布快捷缩放判定
识别跨平台加减、适配画布与实际尺寸快捷键 限制快捷键作用域并放行可编辑和可操作目标 增加按键变体、重复输入和默认行为回归测试
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