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" },