diff --git a/packages/design-system/package.json b/packages/design-system/package.json index 53e80f6d22a8..ec48b319740f 100644 --- a/packages/design-system/package.json +++ b/packages/design-system/package.json @@ -26,13 +26,13 @@ }, "customExports": { ".": { - "default": "./src/index.ts" + "default": "./src/index.js" } }, "main": "dist/index.js", "module": "dist-module/index.js", - "types": "dist-types/index.d.ts", - "source": "src/index.ts", + "types": "dist-types/types.d.ts", + "source": "src/index.js", "publishConfig": { "access": "public" }, diff --git a/packages/design-system/src/components/keyboard/context.js b/packages/design-system/src/components/keyboard/context.ts similarity index 100% rename from packages/design-system/src/components/keyboard/context.js rename to packages/design-system/src/components/keyboard/context.ts diff --git a/packages/design-system/src/components/keyboard/index.js b/packages/design-system/src/components/keyboard/index.js deleted file mode 100644 index a5cd319a3375..000000000000 --- a/packages/design-system/src/components/keyboard/index.js +++ /dev/null @@ -1,527 +0,0 @@ -/* - * Copyright 2020 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * https://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -/** - * External dependencies - */ -import Mousetrap from 'mousetrap'; -import PropTypes from 'prop-types'; -import { - useEffect, - createRef, - useState, - useContext, - useBatchingCallback, - useCallback, -} from '@googleforcreators/react'; -/** - * Internal dependencies - */ -import { __ } from '@googleforcreators/i18n'; -import Context from './context'; - -const PROP = '__WEB_STORIES_MT__'; -const NON_EDITABLE_INPUT_TYPES = [ - 'submit', - 'button', - 'checkbox', - 'radio', - 'image', - 'file', - 'range', - 'reset', - 'hidden', -]; -const CLICKABLE_INPUT_TYPES = [ - 'submit', - 'button', - 'checkbox', - 'radio', - 'image', - 'file', - 'reset', -]; - -const globalRef = createRef(); - -function setGlobalRef() { - if (!globalRef.current) { - globalRef.current = document.documentElement; - } -} - -/** - * @callback KeyEffectCallback - * @param {KeyboardEvent} event Event. - */ - -/** - * See https://craig.is/killing/mice#keys for the supported key codes. - * - * @param {Node|{current: Node}} refOrNode Node or reference to one. - * @param {string|Array|Object} keyNameOrSpec Single key name or key spec. - * @param {string|undefined} type Event type, either 'keydown', 'keyup', or undefined to automatically determine it. - * @param {KeyEffectCallback} callback Callback. - * @param {Array|undefined} deps The effect's dependencies. - */ -function useKeyEffectInternal( - refOrNode, - keyNameOrSpec, - type, - callback, - deps = undefined -) { - const { keys } = useContext(Context); - //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. - const batchingCallback = useBatchingCallback(callback, deps || []); - useEffect( - () => { - const node = - typeof refOrNode?.current !== 'undefined' - ? refOrNode.current - : refOrNode; - if (!node) { - return undefined; - } - if ( - node.nodeType !== /* ELEMENT */ 1 && - node.nodeType !== /* DOCUMENT */ 9 - ) { - throw new Error('only an element or a document node can be used'); - } - - const keySpec = resolveKeySpec(keys, keyNameOrSpec); - if (keySpec.key.length === 1 && keySpec.key[0] === '') { - return undefined; - } - - const mousetrap = getOrCreateMousetrap(node); - const handler = createKeyHandler(node, keySpec, batchingCallback); - mousetrap.bind(keySpec.key, handler, type); - return () => { - mousetrap.unbind(keySpec.key, type); - }; - }, - // eslint-disable-next-line react-hooks/exhaustive-deps -- Deliberately don't want the other possible deps here. - [batchingCallback, keys] - ); -} - -/** - * Depending on the key spec, this will bind to either the 'keypress' or - * 'keydown' event type, by passing 'undefined' to the event type parameter of - * Mousetrap.bind. - * - * See https://craig.is/killing/mice#api.bind. - * - * @param {Node|{current: Node}} refOrNode Node or reference to one. - * @param {string|Array|Object} keyNameOrSpec Single key name or key spec. - * @param {KeyEffectCallback} callback Callback. - * @param {Array} [deps] The effect's dependencies. - */ -export function useKeyEffect( - refOrNode, - keyNameOrSpec, - callback, - deps = undefined -) { - //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. - useKeyEffectInternal(refOrNode, keyNameOrSpec, undefined, callback, deps); -} - -/** - * @param {Node|{current: Node}} refOrNode Node or reference to one. - * @param {string|Array|Object} keyNameOrSpec Single key name or key spec. - * @param {KeyEffectCallback} callback Callback. - * @param {Array} [deps] The effect's dependencies. - */ -export function useKeyDownEffect( - refOrNode, - keyNameOrSpec, - callback, - deps = undefined -) { - //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. - useKeyEffectInternal(refOrNode, keyNameOrSpec, 'keydown', callback, deps); -} - -/** - * @param {Node|{current: Node}} refOrNode Node or reference to one. - * @param {string|Array|Object} keyNameOrSpec Single key name or key spec. - * @param {KeyEffectCallback} callback Callback. - * @param {Array} [deps] The effect's dependencies. - */ -export function useKeyUpEffect( - refOrNode, - keyNameOrSpec, - callback, - deps = undefined -) { - //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. - useKeyEffectInternal(refOrNode, keyNameOrSpec, 'keyup', callback, deps); -} - -/** - * @param {{current: Node}} refOrNode Node or reference to one. - * @param {string|Array|Object} keyNameOrSpec Single key name or key spec. - * @param {Array} [deps] The effect's dependencies. - * @return {boolean} Stateful boolean that tracks whether key is pressed. - */ -export function useIsKeyPressed(refOrNode, keyNameOrSpec, deps = undefined) { - const [isKeyPressed, setIsKeyPressed] = useState(false); - - const handleBlur = useCallback(() => { - setIsKeyPressed(false); - }, []); - useEffect(() => { - window.addEventListener('blur', handleBlur); - return function () { - window.removeEventListener('blur', handleBlur); - }; - }, [handleBlur]); - - //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. - useKeyDownEffect(refOrNode, keyNameOrSpec, () => setIsKeyPressed(true), deps); - //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. - useKeyUpEffect(refOrNode, keyNameOrSpec, () => setIsKeyPressed(false), deps); - return isKeyPressed; -} - -/** - * @param {string|Array|Object} keyNameOrSpec Single key name or key spec. - * @param {KeyEffectCallback} callback Callback. - * @param {Array} [deps] The effect's dependencies. - */ -export function useGlobalKeyDownEffect( - keyNameOrSpec, - callback, - deps = undefined -) { - setGlobalRef(); - //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. - useKeyDownEffect(globalRef, keyNameOrSpec, callback, deps); -} - -/** - * @param {string|Array|Object} keyNameOrSpec Single key name or key spec. - * @param {KeyEffectCallback} callback Callback. - * @param {Array} [deps] The effect's dependencies. - */ -export function useGlobalKeyUpEffect( - keyNameOrSpec, - callback, - deps = undefined -) { - setGlobalRef(); - //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. - useKeyUpEffect(globalRef, keyNameOrSpec, callback, deps); -} - -/** - * @param {string|Array|Object} keyNameOrSpec Single key name or key spec. - * @param {Array} [deps] The effect's dependencies. - * @return {boolean} Stateful boolean that tracks whether key is pressed. - */ -export function useGlobalIsKeyPressed(keyNameOrSpec, deps = undefined) { - setGlobalRef(); - return useIsKeyPressed(globalRef, keyNameOrSpec, deps); -} - -/** - * @param {Node|{current: Node}} refOrNode Node or reference to one - * @param {Array} [deps] The effect's dependencies. - */ -export function useEscapeToBlurEffect(refOrNode, deps = undefined) { - useKeyDownEffect( - refOrNode, - { key: 'esc', editable: true }, - () => { - const node = - typeof refOrNode?.current !== 'undefined' - ? refOrNode.current - : refOrNode; - const { activeElement } = document; - if (node.contains(activeElement)) { - activeElement.blur(); - } - }, - //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. - deps - ); -} - -/** - * @param {Node} node The DOM node. - * @return {Mousetrap} The Mousetrap object that will be used to intercept - * the keyboard events on the specified node. - */ -function getOrCreateMousetrap(node) { - return node[PROP] || (node[PROP] = new Mousetrap(node)); -} - -/** - * @param {Object} keyDict Key dictionary. - * @param {string|Array|Object} keyNameOrSpec Single key name or key spec. - * @return {Object} Key object. - */ -function resolveKeySpec(keyDict, keyNameOrSpec) { - const keySpec = - typeof keyNameOrSpec === 'string' || Array.isArray(keyNameOrSpec) - ? { key: keyNameOrSpec } - : keyNameOrSpec; - const { - key: keyOrArray, - shift = false, - repeat = true, - clickable = true, - editable = false, - dialog = false, - allowDefault = false, - } = keySpec; - const mappedKeys = [] - .concat(keyOrArray) - .map((key) => keyDict[key] || key) - .flat(); - const allKeys = addMods(mappedKeys, shift); - return { - key: allKeys, - shift, - clickable, - repeat, - editable, - dialog, - allowDefault, - }; -} - -function addMods(keys, shift) { - if (!shift) { - return keys; - } - return keys.concat(keys.map((key) => `shift+${key}`)); -} - -function createKeyHandler( - keyTarget, - { - repeat: repeatAllowed, - editable: editableAllowed, - clickable: clickableAllowed, - dialog: dialogAllowed, - allowDefault = false, - }, - callback -) { - return (evt) => { - const { repeat, target } = evt; - if (!repeatAllowed && repeat) { - return undefined; - } - if (!editableAllowed && isEditableTarget(target)) { - return undefined; - } - if (!clickableAllowed && isClickableTarget(target)) { - return undefined; - } - if (!dialogAllowed && crossesDialogBoundary(target, keyTarget)) { - return undefined; - } - callback(evt); - // The default `false` value instructs Mousetrap to cancel event propagation - // and default behavior. - return allowDefault; - }; -} - -function isClickableTarget({ tagName, type }) { - if (['BUTTON', 'A'].includes(tagName)) { - return true; - } - if (tagName === 'INPUT') { - return CLICKABLE_INPUT_TYPES.includes(type); - } - return false; -} - -function isEditableTarget({ tagName, isContentEditable, type, readOnly }) { - if (readOnly === true) { - return false; - } - if (isContentEditable || tagName === 'TEXTAREA') { - return true; - } - if (tagName === 'INPUT') { - return !NON_EDITABLE_INPUT_TYPES.includes(type); - } - return false; -} - -function crossesDialogBoundary(target, keyTarget) { - if (target.nodeType !== 1) { - // Not an element. Most likely a document node. The dialog search - // does not apply. - return false; - } - // Check if somewhere between `keyTarget` and `target` there's a - // dialog boundary. - const dialog = target.closest('dialog,[role="dialog"]'); - return dialog && keyTarget !== dialog && keyTarget.contains(dialog); -} - -/** - * Determines if the current platform is a Mac or not. - * - * @return {boolean} True if platform is a Mac. - */ -export function isPlatformMacOS() { - const { platform } = window.navigator; - return platform.includes('Mac') || ['iPad', 'iPhone'].includes(platform); -} - -/** - * Get the key specific to operating system. - * - * @param {string} key The key to replace. Options: [alt, ctrl, mod, cmd, shift]. - * @return {string} the mapped key. Returns the argument if key is not in options. - */ -export function getKeyForOS(key) { - const isMacOS = isPlatformMacOS(); - - const replacementKeyMap = { - alt: isMacOS ? '⌥' : 'Alt', - ctrl: isMacOS ? '^' : 'Ctrl', - mod: isMacOS ? '⌘' : 'Ctrl', - cmd: '⌘', - shift: isMacOS ? '⇧' : 'Shift', - }; - - return replacementKeyMap[key] || key; -} - -/** - * Prettifies keyboard shortcuts in a platform-agnostic way. - * - * @param {string} shortcut Keyboard shortcut combination, e.g. 'shift+mod+z'. - * @return {string} Prettified keyboard shortcut. - */ -export function prettifyShortcut(shortcut) { - const isMacOS = isPlatformMacOS(); - - const delimiter = isMacOS ? '' : '+'; - - return shortcut - .toLowerCase() - .replace('alt', getKeyForOS('alt')) - .replace('ctrl', getKeyForOS('ctrl')) - .replace('mod', getKeyForOS('mod')) - .replace('cmd', getKeyForOS('cmd')) - .replace('shift', getKeyForOS('shift')) - .replace('left', '←') - .replace('up', '↑') - .replace('right', '→') - .replace('down', '↓') - .replace('delete', '⌫') - .replace('enter', '⏎') - .split('+') - .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) - .join(delimiter); -} - -/** - * Creates an aria label for a shortcut. - * - * Inspired from the worpress Gutenberg plugin: - * https://github.com/WordPress/gutenberg/blob/3da717b8d0ac7d7821fc6d0475695ccf3ae2829f/packages/keycodes/src/index.js#L240-L277 - * - * @example - * createShortcutAriaLabel('mod alt del'); -> "Command Alt Del" - * @param {string} shortcut The keyboard shortcut. - * @return {string} The aria label. - */ -export function createShortcutAriaLabel(shortcut) { - const isMacOS = isPlatformMacOS(); - - /* translators: Command key on the keyboard */ - const command = __('Command', 'web-stories'); - /* translators: Control key on the keyboard */ - const control = __('Control', 'web-stories'); - /* translators: Option key on the keyboard */ - const option = __('Option', 'web-stories'); - /* translators: Alt key on the keyboard */ - const alt = __('Alt', 'web-stories'); - - const replacementKeyMap = { - alt: isMacOS ? option : alt, - mod: isMacOS ? command : control, - /* translators: Control key on the keyboard */ - ctrl: __('Control', 'web-stories'), - /* translators: shift key on the keyboard */ - shift: __('Shift', 'web-stories'), - /* translators: delete key on the keyboard */ - delete: __('Delete', 'web-stories'), - /* translators: comma character ',' */ - ',': __('Comma', 'web-stories'), - /* translators: period character '.' */ - '.': __('Period', 'web-stories'), - /* translators: backtick character '`' */ - '`': __('Backtick', 'web-stories'), - }; - - const delimiter = isMacOS ? ' ' : '+'; - - return shortcut - .toLowerCase() - .replace('alt', replacementKeyMap.alt) - .replace('ctrl', replacementKeyMap.ctrl) - .replace('mod', replacementKeyMap.mod) - .replace('cmd', replacementKeyMap.cmd) - .replace('shift', replacementKeyMap.shift) - .replace('delete', replacementKeyMap.delete) - .replace(',', replacementKeyMap[',']) - .replace('.', replacementKeyMap['.']) - .replace('`', replacementKeyMap['`']) - .split(/[\s+]/) - .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) - .join(delimiter); -} - -const Kbd = () => ; - -/** - * Returns a prettified shortcut wrapped with a element. - * - * @param {Object} props props - * @param {import('react').Component} props.component Component used to render the shortcuts. Defaults to `` - * @param {string} props.shortcut Keyboard shortcut combination, e.g. 'shift+mod+z'. - * @return {Node} Prettified keyboard shortcut. - */ -export function Shortcut({ component: Component = Kbd, shortcut = '' }) { - const chars = shortcut.split(' '); - - return ( - - {chars.map((char, index) => ( - // eslint-disable-next-line react/no-array-index-key -- Should be OK due to also using the character. - {prettifyShortcut(char)} - ))} - - ); -} - -Shortcut.propTypes = { - component: PropTypes.oneOfType([PropTypes.node, PropTypes.object]), - shortcut: PropTypes.string, -}; diff --git a/packages/story-editor/src/app/history/context.js b/packages/design-system/src/components/keyboard/index.ts similarity index 75% rename from packages/story-editor/src/app/history/context.js rename to packages/design-system/src/components/keyboard/index.ts index 4afad84092f1..2333329dcb9f 100644 --- a/packages/story-editor/src/app/history/context.js +++ b/packages/design-system/src/components/keyboard/index.ts @@ -1,5 +1,5 @@ /* - * Copyright 2020 Google LLC + * Copyright 2022 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -13,10 +13,5 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -/** - * External dependencies - */ -import { createContext } from '@googleforcreators/react'; - -export default createContext({ state: {}, actions: {} }); +export * from './keyboard'; +export * from './utils'; diff --git a/packages/design-system/src/components/keyboard/keyboard.tsx b/packages/design-system/src/components/keyboard/keyboard.tsx new file mode 100644 index 000000000000..e8c35f006063 --- /dev/null +++ b/packages/design-system/src/components/keyboard/keyboard.tsx @@ -0,0 +1,233 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { + useEffect, + useState, + useContext, + useBatchingCallback, + useCallback, +} from '@googleforcreators/react'; +import type { DependencyList } from 'react'; + +/** + * Internal dependencies + */ +import type { + KeyEffectCallback, + KeyNameOrSpec, + RefOrNode, +} from '../../types/keyboard'; +import Context from './context'; +import { + getNodeFromRefOrNode, + getOrCreateMousetrap, + HTMLElementWithMouseTrap, + resolveKeySpec, + createKeyHandler, + createShortcutAriaLabel, + prettifyShortcut, +} from './utils'; + +const globalRef: { current: null | HTMLElement } = { current: null }; + +function setGlobalRef() { + if (!globalRef.current) { + globalRef.current = document.documentElement; + } +} + +/** + * See https://craig.is/killing/mice#keys for the supported key codes. + */ +function useKeyEffectInternal( + refOrNode: RefOrNode, + keyNameOrSpec: KeyNameOrSpec, + type: string | undefined, + callback: KeyEffectCallback, + deps: DependencyList +) { + const { keys } = useContext(Context); + //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. + const batchingCallback = useBatchingCallback(callback, deps || []); + useEffect( + () => { + const nodeEl = getNodeFromRefOrNode(refOrNode); + if (!nodeEl) { + return undefined; + } + if ( + nodeEl.nodeType !== Node.ELEMENT_NODE && + nodeEl.nodeType !== Node.DOCUMENT_NODE + ) { + throw new Error('only an element or a document node can be used'); + } + + const keySpec = resolveKeySpec(keys, keyNameOrSpec); + if (keySpec.key.length === 1 && keySpec.key[0] === '') { + return undefined; + } + + const mousetrap = getOrCreateMousetrap( + nodeEl as HTMLElementWithMouseTrap + ); + const handler = createKeyHandler( + nodeEl as HTMLElement, + keySpec, + batchingCallback + ); + mousetrap.bind(keySpec.key, handler, type); + return () => { + mousetrap.unbind(keySpec.key, type); + }; + }, + // eslint-disable-next-line react-hooks/exhaustive-deps -- Deliberately don't want the other possible deps here. + [batchingCallback, keys] + ); +} + +/** + * Depending on the key spec, this will bind to either the 'keypress' or + * 'keydown' event type, by passing 'undefined' to the event type parameter of + * Mousetrap.bind. + * + * See https://craig.is/killing/mice#api.bind. + */ +export function useKeyEffect( + refOrNode: RefOrNode, + keyNameOrSpec: KeyNameOrSpec, + callback: KeyEffectCallback, + deps: DependencyList +) { + //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. + useKeyEffectInternal(refOrNode, keyNameOrSpec, undefined, callback, deps); +} + +export function useKeyDownEffect( + refOrNode: RefOrNode, + keyNameOrSpec: KeyNameOrSpec, + callback: KeyEffectCallback, + deps: DependencyList +) { + //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. + useKeyEffectInternal(refOrNode, keyNameOrSpec, 'keydown', callback, deps); +} + +export function useKeyUpEffect( + refOrNode: RefOrNode, + keyNameOrSpec: KeyNameOrSpec, + callback: KeyEffectCallback, + deps: DependencyList +) { + //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. + useKeyEffectInternal(refOrNode, keyNameOrSpec, 'keyup', callback, deps); +} + +export function useIsKeyPressed( + refOrNode: RefOrNode, + keyNameOrSpec: KeyNameOrSpec, + deps: DependencyList +) { + const [isKeyPressed, setIsKeyPressed] = useState(false); + + const handleBlur = useCallback(() => { + setIsKeyPressed(false); + }, []); + useEffect(() => { + window.addEventListener('blur', handleBlur); + return function () { + window.removeEventListener('blur', handleBlur); + }; + }, [handleBlur]); + + //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. + useKeyDownEffect(refOrNode, keyNameOrSpec, () => setIsKeyPressed(true), deps); + //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. + useKeyUpEffect(refOrNode, keyNameOrSpec, () => setIsKeyPressed(false), deps); + return isKeyPressed; +} + +export function useGlobalKeyDownEffect( + keyNameOrSpec: KeyNameOrSpec, + callback: KeyEffectCallback, + deps: DependencyList +) { + setGlobalRef(); + //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. + useKeyDownEffect(globalRef, keyNameOrSpec, callback, deps); +} +export function useGlobalKeyUpEffect( + keyNameOrSpec: KeyNameOrSpec, + callback: KeyEffectCallback, + deps: DependencyList +) { + setGlobalRef(); + //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. + useKeyUpEffect(globalRef, keyNameOrSpec, callback, deps); +} + +export function useGlobalIsKeyPressed( + keyNameOrSpec: KeyNameOrSpec, + deps: DependencyList +) { + setGlobalRef(); + return useIsKeyPressed(globalRef, keyNameOrSpec, deps); +} + +export function useEscapeToBlurEffect( + refOrNode: RefOrNode, + deps: DependencyList +) { + useKeyDownEffect( + refOrNode, + { key: 'esc', editable: true }, + () => { + const nodeEl = getNodeFromRefOrNode(refOrNode); + const { activeElement } = document; + if (nodeEl && activeElement && nodeEl.contains(activeElement)) { + (activeElement as HTMLInputElement).blur(); + } + }, + //eslint-disable-next-line react-hooks/exhaustive-deps -- Pass through provided deps. + deps + ); +} + +interface ShortcutProps { + component: React.FC; + shortcut?: string; +} +/** + * Returns a prettified shortcut wrapped with a element. + */ +export function Shortcut({ + component: Component, + shortcut = '', +}: ShortcutProps) { + const chars = shortcut.split(' '); + + return ( + + {chars.map((char, index) => ( + // eslint-disable-next-line react/no-array-index-key -- Should be OK due to also using the character. + {prettifyShortcut(char)} + ))} + + ); +} diff --git a/packages/design-system/src/components/keyboard/keys.js b/packages/design-system/src/components/keyboard/keys.ts similarity index 100% rename from packages/design-system/src/components/keyboard/keys.js rename to packages/design-system/src/components/keyboard/keys.ts diff --git a/packages/design-system/src/components/keyboard/utils.ts b/packages/design-system/src/components/keyboard/utils.ts new file mode 100644 index 000000000000..52cd4b823b28 --- /dev/null +++ b/packages/design-system/src/components/keyboard/utils.ts @@ -0,0 +1,302 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { __ } from '@googleforcreators/i18n'; + +/** + * Internal dependencies + */ +import Mousetrap, { MousetrapInstance } from 'mousetrap'; +import type { + RefOrNode, + KeyEffectCallback, + KeyNameOrSpec, + Keys, +} from '../../types/keyboard'; + +const PROP = '__WEB_STORIES_MT__'; +const NON_EDITABLE_INPUT_TYPES = [ + 'submit', + 'button', + 'checkbox', + 'radio', + 'image', + 'file', + 'range', + 'reset', + 'hidden', +]; +const CLICKABLE_INPUT_TYPES = [ + 'submit', + 'button', + 'checkbox', + 'radio', + 'image', + 'file', + 'reset', +]; + +export interface HTMLElementWithMouseTrap extends HTMLElement { + [PROP]: undefined | MousetrapInstance; +} +/** + * @param node The DOM node. + * @return The Mousetrap object that will be used to intercept + * the keyboard events on the specified node. + */ +export function getOrCreateMousetrap(node: HTMLElementWithMouseTrap) { + return node[PROP] || (node[PROP] = new Mousetrap(node)); +} + +export function getNodeFromRefOrNode(refOrNode: RefOrNode) { + return refOrNode && 'current' in refOrNode ? refOrNode.current : refOrNode; +} + +export function resolveKeySpec(keyDict: Keys, keyNameOrSpec: KeyNameOrSpec) { + const keySpec = + typeof keyNameOrSpec === 'string' || Array.isArray(keyNameOrSpec) + ? { key: keyNameOrSpec } + : keyNameOrSpec; + const { + key: keyOrArray, + shift = false, + repeat = true, + clickable = true, + editable = false, + dialog = false, + allowDefault = false, + } = keySpec; + const mappedKeys = new Array() + .concat(keyOrArray) + .map((key) => keyDict[key as keyof Keys] || key) + .flat(); + const allKeys = addMods(mappedKeys, shift); + return { + key: allKeys, + shift, + clickable, + repeat, + editable, + dialog, + allowDefault, + }; +} + +export function addMods(keys: string[], shift: boolean) { + if (!shift) { + return keys; + } + return keys.concat(keys.map((key) => `shift+${key}`)); +} + +interface KeyHandlerProps { + repeat?: boolean; + editable?: boolean; + clickable?: boolean; + dialog?: boolean; + allowDefault?: boolean; +} + +export function createKeyHandler( + keyTarget: Element, + { + repeat: repeatAllowed, + editable: editableAllowed, + clickable: clickableAllowed, + dialog: dialogAllowed, + allowDefault = false, + }: KeyHandlerProps, + callback: KeyEffectCallback +) { + return (evt: KeyboardEvent) => { + const { repeat, target } = evt; + if (!repeatAllowed && repeat) { + return undefined; + } + if (!editableAllowed && isEditableTarget(target as HTMLInputElement)) { + return undefined; + } + if (!clickableAllowed && isClickableTarget(target as HTMLInputElement)) { + return undefined; + } + if ( + !dialogAllowed && + crossesDialogBoundary(target as HTMLElement, keyTarget) + ) { + return undefined; + } + callback(evt); + // The default `false` value instructs Mousetrap to cancel event propagation + // and default behavior. + return allowDefault; + }; +} + +type ClickableHTMLElement = + | HTMLInputElement + | HTMLAnchorElement + | HTMLButtonElement + | HTMLTextAreaElement; +export function isClickableTarget({ tagName, type }: ClickableHTMLElement) { + if (['BUTTON', 'A'].includes(tagName)) { + return true; + } + if (tagName === 'INPUT') { + return CLICKABLE_INPUT_TYPES.includes(type); + } + return false; +} + +export function isEditableTarget({ + tagName, + isContentEditable, + type, + ...rest +}: ClickableHTMLElement) { + if ('readOnly' in rest && rest.readOnly === true) { + return false; + } + if (isContentEditable || tagName === 'TEXTAREA') { + return true; + } + if (tagName === 'INPUT') { + return !NON_EDITABLE_INPUT_TYPES.includes(type); + } + return false; +} + +export function crossesDialogBoundary(target: Element, keyTarget: Element) { + if (target.nodeType !== 1) { + // Not an element. Most likely a document node. The dialog search + // does not apply. + return false; + } + // Check if somewhere between `keyTarget` and `target` there's a + // dialog boundary. + const dialog = target.closest('dialog,[role="dialog"]'); + return dialog && keyTarget !== dialog && keyTarget.contains(dialog); +} + +/** + * Determines if the current platform is a Mac or not. + */ +export function isPlatformMacOS() { + const { platform } = window.navigator; + return platform.includes('Mac') || ['iPad', 'iPhone'].includes(platform); +} + +/** + * Get the key specific to operating system. + */ +export function getKeyForOS(key: string) { + const isMacOS = isPlatformMacOS(); + + const replacementKeyMap: Record = { + alt: isMacOS ? '⌥' : 'Alt', + ctrl: isMacOS ? '^' : 'Ctrl', + mod: isMacOS ? '⌘' : 'Ctrl', + cmd: '⌘', + shift: isMacOS ? '⇧' : 'Shift', + }; + + return replacementKeyMap[key] || key; +} + +/** + * Creates an aria label for a shortcut. + * + * Inspired from the worpress Gutenberg plugin: + * https://github.com/WordPress/gutenberg/blob/3da717b8d0ac7d7821fc6d0475695ccf3ae2829f/packages/keycodes/src/index.js#L240-L277 + * + * @example + * createShortcutAriaLabel('mod alt del'); -> "Command Alt Del" + */ +export function createShortcutAriaLabel(shortcut: string) { + const isMacOS = isPlatformMacOS(); + + /* translators: Command key on the keyboard */ + const command = __('Command', 'web-stories'); + /* translators: Control key on the keyboard */ + const control = __('Control', 'web-stories'); + /* translators: Option key on the keyboard */ + const option = __('Option', 'web-stories'); + /* translators: Alt key on the keyboard */ + const alt = __('Alt', 'web-stories'); + + const replacementKeyMap = { + alt: isMacOS ? option : alt, + mod: isMacOS ? command : control, + /* translators: Control key on the keyboard */ + ctrl: __('Control', 'web-stories'), + /* translators: shift key on the keyboard */ + shift: __('Shift', 'web-stories'), + /* translators: delete key on the keyboard */ + delete: __('Delete', 'web-stories'), + cmd: command, + /* translators: comma character ',' */ + ',': __('Comma', 'web-stories'), + /* translators: period character '.' */ + '.': __('Period', 'web-stories'), + /* translators: backtick character '`' */ + '`': __('Backtick', 'web-stories'), + }; + + const delimiter = isMacOS ? ' ' : '+'; + + return shortcut + .toLowerCase() + .replace('alt', replacementKeyMap.alt) + .replace('ctrl', replacementKeyMap.ctrl) + .replace('mod', replacementKeyMap.mod) + .replace('cmd', replacementKeyMap.cmd) + .replace('shift', replacementKeyMap.shift) + .replace('delete', replacementKeyMap.delete) + .replace(',', replacementKeyMap[',']) + .replace('.', replacementKeyMap['.']) + .replace('`', replacementKeyMap['`']) + .split(/[\s+]/) + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(delimiter); +} + +/** + * Prettifies keyboard shortcuts in a platform-agnostic way. + */ +export function prettifyShortcut(shortcut: string) { + const isMacOS = isPlatformMacOS(); + + const delimiter = isMacOS ? '' : '+'; + + return shortcut + .toLowerCase() + .replace('alt', getKeyForOS('alt')) + .replace('ctrl', getKeyForOS('ctrl')) + .replace('mod', getKeyForOS('mod')) + .replace('cmd', getKeyForOS('cmd')) + .replace('shift', getKeyForOS('shift')) + .replace('left', '←') + .replace('up', '↑') + .replace('right', '→') + .replace('down', '↓') + .replace('delete', '⌫') + .replace('enter', '⏎') + .split('+') + .map((s) => s.charAt(0).toUpperCase() + s.slice(1)) + .join(delimiter); +} diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.js similarity index 100% rename from packages/design-system/src/index.ts rename to packages/design-system/src/index.js diff --git a/packages/design-system/src/theme/index.ts b/packages/design-system/src/theme/index.js similarity index 90% rename from packages/design-system/src/theme/index.ts rename to packages/design-system/src/theme/index.js index 53ce897a429d..7618d25107d5 100644 --- a/packages/design-system/src/theme/index.ts +++ b/packages/design-system/src/theme/index.js @@ -14,11 +14,6 @@ * limitations under the License. */ -/** - * External dependencies - */ -import type { DefaultTheme } from 'styled-components'; - /** * Internal dependencies */ @@ -30,7 +25,7 @@ import { typography } from './typography'; import { borders } from './borders'; import { breakpoint, raw } from './breakpoint'; -export const theme: DefaultTheme = { +export const theme = { borders, typography, colors: { ...darkMode }, diff --git a/packages/design-system/src/types.ts b/packages/design-system/src/types.ts new file mode 100644 index 000000000000..1f1bcd1f7b0b --- /dev/null +++ b/packages/design-system/src/types.ts @@ -0,0 +1,24 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Temporary workaround while this package is not fully converted yet. +// Adjust tsconfig.json and "types" field in package.json and then +// delete this file once complete. + +export * from './components/keyboard'; +export * from './types/keyboard'; + +export {}; diff --git a/packages/design-system/src/types/keyboard.ts b/packages/design-system/src/types/keyboard.ts new file mode 100644 index 000000000000..74642f6e5f85 --- /dev/null +++ b/packages/design-system/src/types/keyboard.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import type { RefObject } from 'react'; + +/** + * Internal dependencies + */ +import type keys from '../components/keyboard/keys'; + +export interface KeySpec { + key: string | string[]; + shift?: boolean; + clickable?: boolean; + dialog?: boolean; + repeat?: boolean; + editable?: boolean; + allowDefault?: boolean; +} +export type Keys = typeof keys; +export type KeyEffectCallback = (event: KeyboardEvent) => void; +export type KeyNameOrSpec = KeySpec | string | string[]; + +export type RefOrNode = Element | RefObject | null; diff --git a/packages/design-system/tsconfig.json b/packages/design-system/tsconfig.json index c9d87fc4143c..c6f90366c2bf 100644 --- a/packages/design-system/tsconfig.json +++ b/packages/design-system/tsconfig.json @@ -4,5 +4,11 @@ "rootDir": "src", "declarationDir": "dist-types" }, - "include": ["src/**/*"] + "references": [ + { "path": "../i18n" }, + { "path": "../patterns" }, + { "path": "../react" }, + { "path": "../tracking" } + ], + "include": ["src/types.ts", "src/components/keyboard/*", "src/types/*"] } diff --git a/packages/react/src/useBatchingCallback.ts b/packages/react/src/useBatchingCallback.ts index 316cdc341f3a..8cd81679749b 100644 --- a/packages/react/src/useBatchingCallback.ts +++ b/packages/react/src/useBatchingCallback.ts @@ -31,7 +31,19 @@ import type { DependencyList } from 'react'; * @param [deps] The optional callback dependencies. * @return The memoized batching function. */ -function useBatchingCallback unknown>( +function useBatchingCallback( + callback: (arg: T) => void, + deps: DependencyList +): (arg: T) => void; +function useBatchingCallback( + callback: (arg1: T, arg2: U) => void, + deps: DependencyList +): (arg1: T, arg2: U) => void; +function useBatchingCallback( + callback: (arg1: T, arg2: U, arg3: V) => void, + deps: DependencyList +): (arg1: T, arg2: U, arg3: V) => void; +function useBatchingCallback void>( callback: T, deps: DependencyList ) { diff --git a/packages/story-editor/src/app/history/context.ts b/packages/story-editor/src/app/history/context.ts new file mode 100644 index 000000000000..96dabc85b525 --- /dev/null +++ b/packages/story-editor/src/app/history/context.ts @@ -0,0 +1,52 @@ +/* + * Copyright 2020 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import { createContext } from '@googleforcreators/react'; + +/** + * Internal dependencies + */ +import type { HistoryState } from '../../types/historyProvider'; + +export default createContext({ + state: { + currentEntry: { + selection: [], + capabilities: {}, + story: { + version: 0, + pages: [], + }, + pages: [], + current: null, + }, + hasNewChanges: false, + requestedState: null, + canUndo: false, + canRedo: false, + versionNumber: 0, + }, + actions: { + stateToHistory: () => null, + clearHistory: () => null, + resetNewChanges: () => null, + undo: () => false, + redo: () => false, + }, +}); diff --git a/packages/story-editor/src/app/history/historyProvider.js b/packages/story-editor/src/app/history/historyProvider.tsx similarity index 91% rename from packages/story-editor/src/app/history/historyProvider.js rename to packages/story-editor/src/app/history/historyProvider.tsx index 440f3677d35c..1bd4e9d6c764 100644 --- a/packages/story-editor/src/app/history/historyProvider.js +++ b/packages/story-editor/src/app/history/historyProvider.tsx @@ -17,7 +17,6 @@ /** * External dependencies */ -import PropTypes from 'prop-types'; import { useCallback, useEffect, @@ -25,6 +24,7 @@ import { useRef, } from '@googleforcreators/react'; import { useGlobalKeyDownEffect } from '@googleforcreators/design-system'; +import type { PropsWithChildren } from 'react'; /** * Internal dependencies @@ -33,7 +33,10 @@ import usePreventWindowUnload from '../../utils/usePreventWindowUnload'; import useHistoryReducer from './useHistoryReducer'; import Context from './context'; -function HistoryProvider({ children, size }) { +function HistoryProvider({ + children, + size = 50, +}: PropsWithChildren<{ size?: number }>) { const { requestedState, stateToHistory, @@ -90,13 +93,4 @@ function HistoryProvider({ children, size }) { return {children}; } -HistoryProvider.propTypes = { - children: PropTypes.node, - size: PropTypes.number, -}; - -HistoryProvider.defaultProps = { - size: 50, -}; - export default HistoryProvider; diff --git a/packages/story-editor/src/app/history/index.js b/packages/story-editor/src/app/history/index.ts similarity index 100% rename from packages/story-editor/src/app/history/index.js rename to packages/story-editor/src/app/history/index.ts diff --git a/packages/story-editor/src/app/history/reducer.js b/packages/story-editor/src/app/history/reducer.ts similarity index 76% rename from packages/story-editor/src/app/history/reducer.js rename to packages/story-editor/src/app/history/reducer.ts index 372b53b48d2b..78199e1a7522 100644 --- a/packages/story-editor/src/app/history/reducer.js +++ b/packages/story-editor/src/app/history/reducer.ts @@ -13,9 +13,21 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -export const SET_CURRENT_STATE = 'set_state'; -export const CLEAR_HISTORY = 'clear'; -export const REPLAY = 'replay'; + +/** + * Internal dependencies + */ +import type { + ReducerState, + ReducerProps, + HistoryEntry, +} from '../../types/historyProvider'; + +export enum ActionType { + SetCurrentState = 'set_state', + ClearHistory = 'clear', + Replay = 'replay', +} export const EMPTY_STATE = { entries: [], @@ -25,17 +37,24 @@ export const EMPTY_STATE = { }; const reducer = - (size) => - (state, { type, payload }) => { + (size: number) => + ( + state: ReducerState, + { type, payload }: ReducerProps + ): ReducerState | never => { const currentEntry = state.entries[state.offset]; switch (type) { - case SET_CURRENT_STATE: + case ActionType.SetCurrentState: // First check if everything in payload matches the current `requestedState`, // if so, update `offset` to match the state in entries and clear `requestedState` // and of course leave entries unchanged. if (state.requestedState) { const isReplay = Object.keys(state.requestedState).every( - (key) => state.requestedState[key] === payload[key] + (key) => + // TS complains about this potentially being `null` despite of the check above. + state.requestedState && + state.requestedState[key as keyof HistoryEntry] === + payload[key as keyof HistoryEntry] ); if (isReplay) { @@ -48,7 +67,10 @@ const reducer = currentEntry.current !== state.requestedState.current ) { const changedPage = currentEntry.pages.filter((page, index) => { - return page !== state.requestedState.pages[index]; + return ( + state.requestedState && + page !== state.requestedState.pages[index] + ); }); // If a changed page was found. if (changedPage.length === 1) { @@ -83,20 +105,20 @@ const reducer = requestedState: null, }; - case REPLAY: + case ActionType.Replay: return { ...state, versionNumber: state.versionNumber + (state.offset - payload), requestedState: state.entries[payload], }; - case CLEAR_HISTORY: + case ActionType.ClearHistory: return { ...EMPTY_STATE, }; default: - throw new Error(`Unknown history reducer action: ${type}`); + throw new Error(`Unknown history reducer action: ${type as string}`); } }; diff --git a/packages/story-editor/src/app/history/test/reducer.js b/packages/story-editor/src/app/history/test/reducer.js index 2a92c6f8bb61..4551696df3d8 100644 --- a/packages/story-editor/src/app/history/test/reducer.js +++ b/packages/story-editor/src/app/history/test/reducer.js @@ -17,13 +17,7 @@ /** * Internal dependencies */ -import { - default as useReducer, - SET_CURRENT_STATE, - CLEAR_HISTORY, - EMPTY_STATE, - REPLAY, -} from '../reducer'; +import { default as useReducer, ActionType, EMPTY_STATE } from '../reducer'; describe('reducer', () => { const size = 5; @@ -46,7 +40,7 @@ describe('reducer', () => { const newEntry = { id: 2 }; const result = reducer(initialState, { - type: SET_CURRENT_STATE, + type: ActionType.SetCurrentState, payload: newEntry, }); @@ -61,7 +55,7 @@ describe('reducer', () => { }; const newEntry = { id: 4 }; const result = reducer(initialState, { - type: SET_CURRENT_STATE, + type: ActionType.SetCurrentState, payload: newEntry, }); @@ -79,7 +73,7 @@ describe('reducer', () => { }; const newEntry = { id: 2 }; const result = reducer(initialState, { - type: SET_CURRENT_STATE, + type: ActionType.SetCurrentState, payload: newEntry, }); @@ -97,7 +91,7 @@ describe('reducer', () => { }; const newEntry = { id: 7 }; const result = reducer(initialState, { - type: SET_CURRENT_STATE, + type: ActionType.SetCurrentState, payload: newEntry, }); expect(result.entries).toHaveLength(size); @@ -112,7 +106,7 @@ describe('reducer', () => { }; const result = reducer(initialState, { - type: SET_CURRENT_STATE, + type: ActionType.SetCurrentState, payload: requestedState, }); @@ -133,7 +127,7 @@ describe('reducer', () => { }; const result = reducer(initialState, { - type: SET_CURRENT_STATE, + type: ActionType.SetCurrentState, payload: requestedState, }); @@ -165,7 +159,7 @@ describe('reducer', () => { }; const result = reducer(initialState, { - type: SET_CURRENT_STATE, + type: ActionType.SetCurrentState, payload: requestedState, }); @@ -191,7 +185,7 @@ describe('reducer', () => { }; const result = reducer(initialState, { - type: SET_CURRENT_STATE, + type: ActionType.SetCurrentState, payload: requestedState, }); @@ -210,7 +204,7 @@ describe('reducer', () => { }; const result = reducer(initialState, { - type: CLEAR_HISTORY, + type: ActionType.ClearHistory, }); expect(result).toMatchObject(EMPTY_STATE); @@ -226,7 +220,7 @@ describe('reducer', () => { }; const result = reducer(initialState, { - type: REPLAY, + type: ActionType.Replay, payload: 1, }); @@ -243,14 +237,14 @@ describe('reducer', () => { }; const result1 = reducer(initialState, { - type: REPLAY, + type: ActionType.Replay, payload: 2, }); expect(result1.versionNumber).toBe(5); const result2 = reducer(initialState, { - type: REPLAY, + type: ActionType.Replay, payload: 5, }); expect(result2.versionNumber).toBe(2); diff --git a/packages/story-editor/src/app/history/useHistory.js b/packages/story-editor/src/app/history/useHistory.ts similarity index 76% rename from packages/story-editor/src/app/history/useHistory.js rename to packages/story-editor/src/app/history/useHistory.ts index 267144b5358d..c59db229fa31 100644 --- a/packages/story-editor/src/app/history/useHistory.js +++ b/packages/story-editor/src/app/history/useHistory.ts @@ -21,10 +21,14 @@ import { identity, useContextSelector } from '@googleforcreators/react'; /** * Internal dependencies */ +import type { HistoryState } from '../../types/historyProvider'; import Context from './context'; -function useHistory(selector) { - return useContextSelector(Context, selector ?? identity); +function useHistory(): HistoryState; +function useHistory( + selector: (state: HistoryState) => T | HistoryState = identity +) { + return useContextSelector(Context, selector); } export default useHistory; diff --git a/packages/story-editor/src/app/history/useHistoryReducer.js b/packages/story-editor/src/app/history/useHistoryReducer.ts similarity index 86% rename from packages/story-editor/src/app/history/useHistoryReducer.js rename to packages/story-editor/src/app/history/useHistoryReducer.ts index fb9608bb0bc0..b9dbcbcab7c5 100644 --- a/packages/story-editor/src/app/history/useHistoryReducer.js +++ b/packages/story-editor/src/app/history/useHistoryReducer.ts @@ -22,14 +22,10 @@ import { useReducer, useCallback } from '@googleforcreators/react'; /** * Internal dependencies */ -import reducer, { - SET_CURRENT_STATE, - CLEAR_HISTORY, - REPLAY, - EMPTY_STATE, -} from './reducer'; +import type { HistoryEntry } from '../../types/historyProvider'; +import reducer, { ActionType, EMPTY_STATE } from './reducer'; -function useHistoryReducer(size) { +function useHistoryReducer(size: number) { // State has 4 parts: // // `state.entries` is an array of the last changes (up to `size`) to @@ -56,13 +52,13 @@ function useHistoryReducer(size) { // It appears the only reason for deps here is to return boolean from this // method, which is otherwise appears to be unused. const replay = useCallback( - (deltaOffset) => { + (deltaOffset: number) => { const newOffset = offset + deltaOffset; if (newOffset < 0 || newOffset > historyLength - 1) { return false; } - dispatch({ type: REPLAY, payload: newOffset }); + dispatch({ type: ActionType.Replay, payload: newOffset }); return true; }, [dispatch, offset, historyLength] @@ -83,12 +79,12 @@ function useHistoryReducer(size) { ); const clearHistory = useCallback(() => { - return dispatch({ type: CLEAR_HISTORY }); + return dispatch({ type: ActionType.ClearHistory }); }, [dispatch]); const stateToHistory = useCallback( - (entry) => { - dispatch({ type: SET_CURRENT_STATE, payload: entry }); + (entry: HistoryEntry) => { + dispatch({ type: ActionType.SetCurrentState, payload: entry }); }, [dispatch] ); diff --git a/packages/story-editor/src/types/historyProvider.ts b/packages/story-editor/src/types/historyProvider.ts new file mode 100644 index 000000000000..ecf5ace17da5 --- /dev/null +++ b/packages/story-editor/src/types/historyProvider.ts @@ -0,0 +1,79 @@ +/* + * Copyright 2022 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * External dependencies + */ +import type { Page, Story } from '@googleforcreators/elements'; + +/** + * Internal dependencies + */ +import type { ActionType } from '../app/history/reducer'; + +export interface HistoryEntry { + story: Story; + selection: string[]; + current: string | null; + pages: Page[]; + capabilities: Record; +} + +interface SetCurrentStateProps { + type: ActionType.SetCurrentState; + payload: HistoryEntry; +} + +interface ClearHistoryProps { + type: ActionType.ClearHistory; + payload?: null; +} + +interface ReplayProps { + type: ActionType.Replay; + payload: number; +} +export type ReducerProps = + | SetCurrentStateProps + | ClearHistoryProps + | ReplayProps; + +export interface ReducerState { + entries: HistoryEntry[]; + offset: number; + requestedState: null | HistoryEntry; + versionNumber: number; +} + +export interface Actions { + stateToHistory: (state: HistoryEntry) => void; + clearHistory: () => void; + resetNewChanges: () => void; + undo: (offset: number) => boolean; + redo: (offset: number) => boolean; +} +export interface State { + currentEntry: HistoryEntry; + hasNewChanges: boolean; + requestedState: HistoryEntry | null; + canUndo: boolean; + canRedo: boolean; + versionNumber: number; +} +export interface HistoryState { + state: State; + actions: Actions; +} diff --git a/packages/story-editor/src/utils/usePreventWindowUnload.js b/packages/story-editor/src/utils/usePreventWindowUnload.ts similarity index 62% rename from packages/story-editor/src/utils/usePreventWindowUnload.js rename to packages/story-editor/src/utils/usePreventWindowUnload.ts index bb51479916e3..16bee622ba8a 100644 --- a/packages/story-editor/src/utils/usePreventWindowUnload.js +++ b/packages/story-editor/src/utils/usePreventWindowUnload.ts @@ -23,15 +23,18 @@ import { useContext, } from '@googleforcreators/react'; -const PreventUnloadContext = createContext({ listeners: new Map() }); +type EventListener = (event: BeforeUnloadEvent) => void; +interface PreventUnloadContextState { + listeners: Map; +} +const PreventUnloadContext = createContext({ + listeners: new Map(), +}); /** * This is a helper that to compliant the correct register/unregister system of `beforeunload` event - * - * @param {Event} event beforeunload Event object - * @param {string} id Identifier to register beforeunload Event in the onbeforeunload listener */ -const beforeUnloadListener = (event, id) => { +const beforeUnloadListener = (event: BeforeUnloadEvent, id: string) => { event.preventDefault(); event.returnValue = id; }; @@ -39,16 +42,23 @@ const beforeUnloadListener = (event, id) => { function usePreventWindowUnload() { const context = useContext(PreventUnloadContext); const setPreventUnload = useCallback( - (id, value) => { + (id: string, value: boolean) => { + const listener = context.listeners.get(id); if (value) { // Register beforeunload by scope if (!context.listeners.has(id)) { - context.listeners.set(id, (event) => beforeUnloadListener(event, id)); + context.listeners.set(id, (event: BeforeUnloadEvent) => + beforeUnloadListener(event, id) + ); + } + if (listener) { + window.addEventListener('beforeunload', listener); } - window.addEventListener('beforeunload', context.listeners.get(id)); } else { // Unregister beforeunload by scope - window.removeEventListener('beforeunload', context.listeners.get(id)); + if (listener) { + window.removeEventListener('beforeunload', listener); + } context.listeners.delete(id); } }, @@ -57,7 +67,10 @@ function usePreventWindowUnload() { return setPreventUnload; } +declare const WEB_STORIES_DISABLE_PREVENT: string; const shouldDisablePrevent = typeof WEB_STORIES_DISABLE_PREVENT !== 'undefined' && WEB_STORIES_DISABLE_PREVENT === 'true'; -export default shouldDisablePrevent ? () => () => {} : usePreventWindowUnload; +export default shouldDisablePrevent + ? () => () => undefined + : usePreventWindowUnload; diff --git a/packages/story-editor/tsconfig.json b/packages/story-editor/tsconfig.json index 8d2fd318c0ed..e7cde9c84881 100644 --- a/packages/story-editor/tsconfig.json +++ b/packages/story-editor/tsconfig.json @@ -4,9 +4,16 @@ "rootDir": "src", "declarationDir": "dist-types" }, + "references": [ + { "path": "../design-system" }, + { "path": "../elements" }, + { "path": "../react" } + ], "include": [ + "src/app/api/*", "src/app/config/*", + "src/app/history", "src/types", - "src/app/api/*" + "src/utils/usePreventWindowUnload.ts" ] } diff --git a/tsconfig.json b/tsconfig.json index 8471b28b5079..796ad917cf9d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ { "path": "packages/activation-notice" }, { "path": "packages/element-library" }, { "path": "packages/dashboard" }, + { "path": "packages/design-system" }, { "path": "packages/elements" }, { "path": "packages/fonts" }, { "path": "packages/i18n" },