diff --git a/docs/features/README.md b/docs/features/README.md index e07ccc9..fe1fb0e 100644 --- a/docs/features/README.md +++ b/docs/features/README.md @@ -13,3 +13,4 @@ Most code for features can be found in the [features](../../src/features) direct - [Windows](windows/README.md) - [Taskbar](taskbar/README.md) - [Settings](settings/README.md) +- [Modals](modals/README.md) diff --git a/docs/features/modals/README.md b/docs/features/modals/README.md new file mode 100644 index 0000000..268f920 --- /dev/null +++ b/docs/features/modals/README.md @@ -0,0 +1,9 @@ +[← Back](../README.md) + +# Modals + +Modals are modular components that can be instantiated by other components. This feature is mainly used for context menus that are instantiated by windows. + +Modals prominently take a position and a callback function as input. When the modal is closed, the callback function is called with optional arguments that usually include whatever the user entered as input while the modal was active. E.g.: A confirmation dialog is usually instantiated at the center of the screen and returns "yes" or "no" to the callback function, depending on which button the user clicked. + +Even though modals are very similar to windows, they are also very different. You can look at modals as mini sub-windows that each have their own styling, as opposed to windows that all have a header with a title and some buttons. \ No newline at end of file diff --git a/src/App.jsx b/src/App.jsx index 68d515d..b704074 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -5,8 +5,22 @@ import { WindowsView } from "./components/windows/WindowsView.jsx"; import { VirtualRootProvider } from "./hooks/virtual-drive/VirtualRootContext.js"; import { Desktop } from "./components/desktop/Desktop.jsx"; import { SettingsProvider } from "./hooks/settings/SettingsContext.js"; +import { ModalsView } from "./components/modals/ModalsView.jsx"; +import { useEffect } from "react"; function App() { + useEffect(() => { + const onContextMenu = (event) => { + event.preventDefault(); + }; + + document.addEventListener("contextmenu", onContextMenu); + + return () => { + document.removeEventListener("contextmenu", onContextMenu); + }; + }, []); + return ( @@ -14,6 +28,7 @@ function App() {
+
diff --git a/src/App.module.css b/src/App.module.css index 0be3495..9b79409 100644 --- a/src/App.module.css +++ b/src/App.module.css @@ -6,4 +6,9 @@ height: 100%; text-align: center; overflow: hidden; + pointer-events: none; +} + +.App > * { + pointer-events: auto; } diff --git a/src/components/applications/.common/HeaderMenu.jsx b/src/components/applications/.common/HeaderMenu.jsx index 627f338..eb800b7 100644 --- a/src/components/applications/.common/HeaderMenu.jsx +++ b/src/components/applications/.common/HeaderMenu.jsx @@ -1,56 +1,14 @@ -import { useState } from "react"; -import { removeFromArray } from "../../../features/utils/array.js"; -import { useKeyboardListener } from "../../../hooks/utils/keyboard.js"; +import { useShortcuts } from "../../../hooks/utils/keyboard.js"; import { DropdownButton } from "../../utils/DropdownButton.jsx"; import styles from "./HeaderMenu.module.css"; /** * @param {Object} props - * @param {Object>} props.options - * @param {Object>} props.shortcuts + * @param {Object>} props.options + * @param {Object>} props.shortcuts */ export function HeaderMenu({ options, shortcuts }) { - const [activeKeys, setActiveKeys] = useState([]); - - const checkShortcuts = (event, allowExecution = true) => { - for (const [category, group] of Object.entries(shortcuts)) { - for (const [name, shortcut] of Object.entries(group)) { - let active = true; - - shortcut.forEach((key) => { - if (!activeKeys.includes(key) && event.key != key) - return active = false; - }); - - if (active) { - event.preventDefault(); - if (shortcut.includes(event.key) && allowExecution) { - options?.[category]?.[name]?.(); - } - } - } - } - } - - const onKeyDown = (event) => { - const isRepeated = activeKeys.includes(event.key); - checkShortcuts(event, isRepeated); - - if (!isRepeated) - setActiveKeys(activeKeys.concat([event.key])); - }; - - const onKeyUp = (event) => { - checkShortcuts(event); - - if (activeKeys.includes(event.key)) { - const keys = [...activeKeys]; - removeFromArray(event.key, keys); - setActiveKeys(keys); - } - }; - - useKeyboardListener({ onKeyDown, onKeyUp }); + useShortcuts({ options, shortcuts }); return (
diff --git a/src/components/applications/.common/HeaderMenu.module.css b/src/components/applications/.common/HeaderMenu.module.css index 237e095..6d293dc 100644 --- a/src/components/applications/.common/HeaderMenu.module.css +++ b/src/components/applications/.common/HeaderMenu.module.css @@ -1,6 +1,6 @@ .Container { display: flex; width: 100%; - height: 1.25rem; + height: 1.5rem; background-color: var(--background-color-a); } \ No newline at end of file diff --git a/src/components/applications/calculator/Calculator.jsx b/src/components/applications/calculator/Calculator.jsx index cfff69e..0ef4897 100644 --- a/src/components/applications/calculator/Calculator.jsx +++ b/src/components/applications/calculator/Calculator.jsx @@ -1,4 +1,4 @@ -import styles from "./Calculator.module.css"; +// import styles from "./Calculator.module.css"; export function Calculator() { return ( diff --git a/src/components/applications/settings/Settings.jsx b/src/components/applications/settings/Settings.jsx index 164f1cd..97d5c0e 100644 --- a/src/components/applications/settings/Settings.jsx +++ b/src/components/applications/settings/Settings.jsx @@ -108,8 +108,11 @@ function AboutTab({ windowsManager, virtualRoot }) { ); } -export function Settings() { - const [tabIndex, setTabIndex] = useState(0); +/** + * @param {number} param0 + */ +export function Settings({ initialTabIndex }) { + const [tabIndex, setTabIndex] = useState(initialTabIndex ?? 0); const virtualRoot = useVirtualRoot(); const settingsManager = useSettings(); const windowsManager = useWindowsManager(); diff --git a/src/components/applications/text-editor/TextEditor.jsx b/src/components/applications/text-editor/TextEditor.jsx index c084648..a17d4e8 100644 --- a/src/components/applications/text-editor/TextEditor.jsx +++ b/src/components/applications/text-editor/TextEditor.jsx @@ -93,7 +93,7 @@ export function TextEditor({ file, setTitle, close, mode, app }) { "New": newText, "Save": saveText, // "Save As": saveTextAs, - "Exit": () => { + "Quit": () => { close(); }, }, @@ -116,7 +116,7 @@ export function TextEditor({ file, setTitle, close, mode, app }) { "File": { "New": ["Control", "e"], "Save": ["Control", "s"], - "Exit": ["Control", "x"], + "Quit": ["Control", "q"], }, "View": { "Zoom In": ["Control", "+"], diff --git a/src/components/desktop/Desktop.jsx b/src/components/desktop/Desktop.jsx index 53d6138..f6ce0e5 100644 --- a/src/components/desktop/Desktop.jsx +++ b/src/components/desktop/Desktop.jsx @@ -3,10 +3,23 @@ import { SettingsManager } from "../../features/settings/settings.js"; import { useSettings } from "../../hooks/settings/SettingsContext.js"; import styles from "./Desktop.module.css"; import { useEffect } from "react"; +import { useModals } from "../../hooks/modals/Modals.js"; +import { ModalsView } from "../modals/ModalsView.jsx"; +import { useWindowsManager } from "../../hooks/windows/WindowsManagerContext.js"; +import { useContextMenu } from "../../hooks/modals/ContextMenu.js"; export function Desktop() { const settingsManager = useSettings(); const [wallpaper, setWallpaper] = useState(null); + const [modalsManager, modals] = useModals(); + const windowsManager = useWindowsManager(); + const { onContextMenu } = useContextMenu({ + modalsManager, + options: { + "Change appearance": () => { windowsManager.open("settings", { initialTabIndex: 0 }); }, + "Open in Files": () => { windowsManager.open("file-explorer", { startPath: "~/Desktop" }); } + } + }); useEffect(() => { (async () => { @@ -15,7 +28,9 @@ export function Desktop() { })(); }, [settingsManager]); - return ( -
- ); + return (<> +
+ +
+ ); } \ No newline at end of file diff --git a/src/components/modals/ModalView.jsx b/src/components/modals/ModalView.jsx new file mode 100644 index 0000000..db21615 --- /dev/null +++ b/src/components/modals/ModalView.jsx @@ -0,0 +1,17 @@ +import { Modal as ModalType } from "../../features/modals/modal.js"; +import OutsideClickListener from "../../hooks/utils/outsideClick.js"; +import styles from "./ModalView.module.css"; + +/** + * @param {object} root + * @param {ModalType} root.modal + */ +export function ModalView({ modal }) { + return ( + { modal.close(); }}> +
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/modals/ModalView.module.css b/src/components/modals/ModalView.module.css new file mode 100644 index 0000000..8863b1a --- /dev/null +++ b/src/components/modals/ModalView.module.css @@ -0,0 +1,14 @@ +.Container { + --position-x: 0; + --position-y: 0; + + position: fixed; + top: calc(var(--position-y) * 1px); + left: calc(var(--position-x) * 1px); +} + +.Container > * { + background-color: var(--background-color-a); + border-radius: 0.5rem; + box-shadow: var(--window-box-shadow); +} \ No newline at end of file diff --git a/src/components/modals/ModalsView.jsx b/src/components/modals/ModalsView.jsx new file mode 100644 index 0000000..5285dc9 --- /dev/null +++ b/src/components/modals/ModalsView.jsx @@ -0,0 +1,21 @@ +import { Modal } from "../../features/modals/modal.js"; +import ModalsManager from "../../features/modals/modals.js"; +import { ModalView } from "./ModalView.jsx"; +import styles from "./ModalsView.module.css"; + +/** + * @param {object} root + * @param {ModalsManager} root.modalsManager + * @param {Modal[]} root.modals + * @param {import("react").CSSProperties} root.style + * @param {import("react").className} root.className + */ +export function ModalsView({ modalsManager, modals, style, className }) { + return ( +
+ {modals?.map((modal) => + + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/modals/ModalsView.module.css b/src/components/modals/ModalsView.module.css new file mode 100644 index 0000000..0197d6b --- /dev/null +++ b/src/components/modals/ModalsView.module.css @@ -0,0 +1,3 @@ +.Container { + position: relative; +} \ No newline at end of file diff --git a/src/components/modals/context-menu/ContextMenu.jsx b/src/components/modals/context-menu/ContextMenu.jsx new file mode 100644 index 0000000..35e6b3e --- /dev/null +++ b/src/components/modals/context-menu/ContextMenu.jsx @@ -0,0 +1,26 @@ +import Modal from "../../../features/modals/modal.js"; +import { formatShortcut } from "../../../features/utils/string.js"; +import styles from "./ContextMenu.module.css"; + +/** + * @param {object} props + * @param {Modal} props.modal + * @param {Object} props.options + * @param {Object} props.shortcuts + */ +export function ContextMenu({ modal, options, shortcuts }) { + return (
+ {Object.entries(options).map(([label, callback]) => + + )} +
); +} \ No newline at end of file diff --git a/src/components/modals/context-menu/ContextMenu.module.css b/src/components/modals/context-menu/ContextMenu.module.css new file mode 100644 index 0000000..1f0f09e --- /dev/null +++ b/src/components/modals/context-menu/ContextMenu.module.css @@ -0,0 +1,33 @@ +.Container { + padding: 0.5rem; + border-top-left-radius: 0; + background-color: var(--background-color-b); +} + +.Button { + display: flex; + gap: 0.75rem; + justify-content: space-between; + width: 100%; + padding: 0.25rem 0.5rem; + background: none; + border: none; + border-radius: 0.5rem; + outline: none; + font-size: 0.875rem; + text-align: start; + white-space: nowrap; + cursor: pointer; +} + +.Button:hover, .Button:focus-visible { + background-color: rgba(255, 255, 255, 5%); +} + +.Label, .Shortcut { + margin: 0; +} + +.Shortcut { + color: var(--foreground-color-b); +} \ No newline at end of file diff --git a/src/components/task-bar/TaskBar.module.css b/src/components/task-bar/TaskBar.module.css index 64ba970..4519c6c 100644 --- a/src/components/task-bar/TaskBar.module.css +++ b/src/components/task-bar/TaskBar.module.css @@ -138,6 +138,7 @@ height: 100%; width: min-content; margin: 0; + padding: 0.4rem; } .Util-icons > * > svg { diff --git a/src/components/utils/DropdownButton.module.css b/src/components/utils/DropdownButton.module.css index 7a02f03..e4eb245 100644 --- a/src/components/utils/DropdownButton.module.css +++ b/src/components/utils/DropdownButton.module.css @@ -1,5 +1,6 @@ .Container { position: relative; + height: 100%; } .Button { @@ -24,7 +25,10 @@ position: absolute; top: 100%; left: 0; + padding: 0.35rem; background-color: var(--background-color-b); + border-bottom-left-radius: 0.5rem; + border-bottom-right-radius: 0.5rem; } .Dropdown > button { @@ -32,11 +36,12 @@ gap: 0.75rem; justify-content: space-between; width: 100%; - padding: 0.125rem 0.4rem; + padding: 0.25rem 0.5rem; background: none; border: none; + border-radius: 0.5rem; outline: none; - font-size: 0.8rem; + font-size: 0.85rem; text-align: start; white-space: nowrap; cursor: pointer; diff --git a/src/components/windows/Window.jsx b/src/components/windows/WindowView.jsx similarity index 84% rename from src/components/windows/Window.jsx rename to src/components/windows/WindowView.jsx index b71cfea..37a739c 100644 --- a/src/components/windows/Window.jsx +++ b/src/components/windows/WindowView.jsx @@ -1,5 +1,5 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; -import styles from "./Window.module.css"; +import styles from "./WindowView.module.css"; import { faMinus, faWindowMaximize as fasWindowMaximize, faXmark } from "@fortawesome/free-solid-svg-icons"; import { ReactSVG } from "react-svg"; import { useWindowsManager } from "../../hooks/windows/WindowsManagerContext.js"; @@ -9,6 +9,9 @@ import Application from "../../features/applications/application.js"; import Vector2 from "../../features/math/vector2.js"; import { faWindowMaximize } from "@fortawesome/free-regular-svg-icons"; import utilStyles from "../../styles/utils.module.css"; +import { useModals } from "../../hooks/modals/Modals.js"; +import { ModalsView } from "../modals/ModalsView.jsx"; +import { useContextMenu } from "../../hooks/modals/ContextMenu.js"; /** * @param {object} props @@ -20,9 +23,10 @@ import utilStyles from "../../styles/utils.module.css"; * @param {Function} props.onInteract * @param {object} props.options */ -export const Window = memo(function Window({ id, app, size, position, focused = false, onInteract, options }) { +export const WindowView = memo(function Window({ id, app, size, position, focused = false, onInteract, options }) { const windowsManager = useWindowsManager(); const nodeRef = useRef(null); + const [modalsManager, modals] = useModals(); const [initialised, setInitialised] = useState(false); const [startSize, setStartSize] = useState(size); @@ -36,6 +40,18 @@ export const Window = memo(function Window({ id, app, size, position, focused = const [title, setTitle] = useState(app.name); + const { onContextMenu } = useContextMenu({ + modalsManager, + options: { + "Maximize": () => { setMaximized(!maximized); }, + "Close": () => { close(); } + }, + shortcuts: { + "Maximize": ["F11"], + "Close": ["Control", "q"] + } + }); + useEffect(() => { const resizeObserver = new ResizeObserver((event) => { setScreenWidth(event[0].contentBoxSize[0].inlineSize); @@ -88,9 +104,8 @@ export const Window = memo(function Window({ id, app, size, position, focused = if (minimized) classNames.push(styles.Minimized); - // console.log(`Rendering window: ${id}`); - - return ( + return (<> + -
+
- ); + ); }); \ No newline at end of file diff --git a/src/components/windows/Window.module.css b/src/components/windows/WindowView.module.css similarity index 89% rename from src/components/windows/Window.module.css rename to src/components/windows/WindowView.module.css index bead889..a4946b6 100644 --- a/src/components/windows/Window.module.css +++ b/src/components/windows/WindowView.module.css @@ -4,10 +4,6 @@ } .Window-container { - --shadow-size: 0.3rem; - --shadow-opacity: 35%; - --shadow-spread: 3; - position: absolute; display: flex; flex-direction: column; @@ -15,7 +11,7 @@ min-height: 150px; background-color: var(--background-color-c); border-radius: 0.5rem; - box-shadow: calc(var(--shadow-size) * -1) var(--shadow-size) calc(var(--shadow-size) * var(--shadow-spread)) 0px rgba(0, 0, 0, var(--shadow-opacity)); + box-shadow: var(--window-box-shadow); resize: both; overflow: hidden; } diff --git a/src/components/windows/WindowsView.jsx b/src/components/windows/WindowsView.jsx index 39b88fa..70256ea 100644 --- a/src/components/windows/WindowsView.jsx +++ b/src/components/windows/WindowsView.jsx @@ -1,7 +1,7 @@ -import { Window } from "./Window.jsx"; import { useWindows } from "../../hooks/windows/WindowsContext.js"; import { useWindowsManager } from "../../hooks/windows/WindowsManagerContext.js"; import { useEffect, useState } from "react"; +import { WindowView } from "./WindowView.jsx"; export function WindowsView() { const windows = useWindows(); @@ -18,7 +18,7 @@ export function WindowsView() { return (
{sortedWindows.map(({ id, app, size, position, options }) => - { windowsManager.focus(id); }} id={id} key={id} diff --git a/src/features/applications/applications.js b/src/features/applications/applications.js index 93a40ce..d79a078 100644 --- a/src/features/applications/applications.js +++ b/src/features/applications/applications.js @@ -5,7 +5,7 @@ import { WebView } from "../../components/applications/.templates/WebView.jsx"; import { Terminal } from "../../components/applications/terminal/Terminal.jsx"; import { TextEditor } from "../../components/applications/text-editor/TextEditor.jsx"; import { Settings } from "../../components/applications/settings/Settings.jsx"; -import { Calculator } from "../../components/applications/calculator/Calculator.jsx"; +// import { Calculator } from "../../components/applications/calculator/Calculator.jsx"; import Vector2 from "../math/vector2.js"; export default class ApplicationsManager { diff --git a/src/features/modals/modal.js b/src/features/modals/modal.js new file mode 100644 index 0000000..6a0fece --- /dev/null +++ b/src/features/modals/modal.js @@ -0,0 +1,99 @@ +import Vector2 from "../math/vector2.js"; +import ModalsManager from "./modals.js"; + +export default class Modal { + /** @type {Vector2} */ + size = new Vector2(400, 200); + /** @type {Vector2} */ + position = new Vector2(300, 300); + /** @type {string | null} */ + icon = null; + /** @type {title | null} */ + title = null; + /** @type {ModalsManager} */ + modalsManager = null; + /** @type {import("react").ReactElement | null} */ + element = null; + /** @type {object} */ + props = {}; + /** @type {Function | null} */ + callback = null; + /** @type {number | null} */ + id = null; + /** @type {boolean} */ + closeOnOutsideClick = true; + + /** + * @param {import("react").ReactElement} element + * @param {Function} callback + */ + constructor(element, callback) { + this.element = element; + this.callback = callback; + this.focus(); + } + + /** + * @param {string} icon + * @returns {Modal} + */ + setIcon(icon) { + this.icon = icon; + return this; + } + + /** + * @param {string} title + * @returns {Modal} + */ + setTitle(title) { + this.title = title; + return this; + } + + /** + * @param {Vector2} position + * @returns {Modal} + */ + setPosition(position) { + this.position = position; + return this; + } + + /** + * @param {object} props + * @returns {Modal} + */ + setProps(props) { + this.props = props; + return this; + } + + /** + * @param {boolean} closeOnOutsideClick + * @returns {Modal} + */ + setCloseOnOutsideClick(closeOnOutsideClick) { + this.closeOnOutsideClick = closeOnOutsideClick; + return this; + } + + focus() { + this.lastInteraction = new Date().valueOf(); + } + + /** + * @param {...any} args + */ + finish(...args) { + if (this.modalsManager == null || this.id == null) + return; + + this.modalsManager.close(this.id); + this.callback?.(...args); + } + + close() { + this.finish(); + } +} \ No newline at end of file diff --git a/src/features/modals/modals.js b/src/features/modals/modals.js new file mode 100644 index 0000000..99cce7f --- /dev/null +++ b/src/features/modals/modals.js @@ -0,0 +1,86 @@ +import Modal from "./modal.js"; + +export default class ModalsManager { + /** + * @type {Modal} + */ + modals = {}; + + /** + * @type {Function} + */ + updateModals = () => {}; + + /** + * @param {Modal} modal + * @param {boolean} closeOthers + */ + open(modal, closeOthers = true) { + if (closeOthers) { + this.modalIds.forEach((id) => { + this.close(id, false); + }); + } + + let id = 0; + while (this.modalIds.includes(id.toString())) { + id++; + } + + modal.id = id; + modal.modalsManager = this; + + this.modals[id] = modal; + this.updateModals(this.modals); + } + + /** + * @param {string} modalId + * @param {boolean} sendModalsUpdate + */ + close(modalId, sendModalsUpdate = true) { + modalId = modalId.toString(); + + if (!this.modalIds.includes(modalId)) { + console.log(`Failed to close modal ${modalId}: modal not found`); + return; + } + + console.log(`Closing modal ${modalId}`); + delete this.modals[modalId]; + + if (sendModalsUpdate) + this.updateModals(this.modals); + } + + /** + * @param {string} modalId + */ + focus(modalId) { + modalId = modalId.toString(); + + if (!this.modalIds.includes(modalId)) { + console.log(`Failed to focus modal ${modalId}: modal not found`); + return; + } + + const modal = this.modals[modalId]; + modal.focus(); + + this.updateModals(this.modals); + } + + /** + * @param {Function} updateModals + */ + setUpdateModals(updateModals) { + this.updateModals = updateModals; + } + + /** + * @returns {string[]} + */ + get modalIds() { + return Object.keys(this.modals); + } +} \ No newline at end of file diff --git a/src/hooks/modals/ContextMenu.js b/src/hooks/modals/ContextMenu.js new file mode 100644 index 0000000..c44731e --- /dev/null +++ b/src/hooks/modals/ContextMenu.js @@ -0,0 +1,24 @@ +import { ContextMenu } from "../../components/modals/context-menu/ContextMenu.jsx"; +import Vector2 from "../../features/math/vector2.js"; +import Modal from "../../features/modals/modal.js"; +import ModalsManager from "../../features/modals/modals.js"; +import { useShortcuts } from "../utils/keyboard.js"; + +/** + * @param {object} props + * @param {ModalsManager} props.modalsManager + * @param {Object>} props.options + * @param {Object>} props.shortcuts + * @returns {{ onContextMenu: Function }} + */ +export function useContextMenu({ modalsManager, options, shortcuts }) { + const onContextMenu = (event) => { + modalsManager.open(new Modal(ContextMenu) + .setPosition(new Vector2(event.clientX, event.clientY)) + .setProps({ options, shortcuts })); + }; + + useShortcuts({ options, shortcuts, useCategories: false }); + + return { onContextMenu }; +} \ No newline at end of file diff --git a/src/hooks/modals/Modals.js b/src/hooks/modals/Modals.js new file mode 100644 index 0000000..bdcb67f --- /dev/null +++ b/src/hooks/modals/Modals.js @@ -0,0 +1,19 @@ +import { useCallback, useState } from "react"; +import ModalsManager from "../../features/modals/modals.js"; +import Modal from "../../features/modals/modal.js"; + +/** + * @returns {[ModalsManager, Modal[]]} + */ +export function useModals() { + const modalsManager = new ModalsManager(); + const [modals, setModals] = useState([]); + + const updateModals = useCallback((updatedModals) => { + setModals(Object.values(updatedModals)); + }, []); + + modalsManager.setUpdateModals(updateModals); + + return [modalsManager, modals]; +} \ No newline at end of file diff --git a/src/hooks/utils/keyboard.js b/src/hooks/utils/keyboard.js index c0dfa4d..04fc219 100644 --- a/src/hooks/utils/keyboard.js +++ b/src/hooks/utils/keyboard.js @@ -1,4 +1,5 @@ -import { useEffect } from "react"; +import { useEffect, useState } from "react"; +import { removeFromArray } from "../../features/utils/array.js"; /** * @param {object} params @@ -19,4 +20,72 @@ export function useKeyboardListener({ onKeyDown, onKeyUp }) { document.removeEventListener("keyup", onKeyUp); }; }, [onKeyDown, onKeyUp]); +} + +/** + * @param {object} props + * @param {Object>} props.options + * @param {Object>} props.shortcuts + * @param {boolean} props.useCategories + */ +export function useShortcuts({ options, shortcuts, useCategories = true }) { + const [activeKeys, setActiveKeys] = useState([]); + + const checkShortcuts = (event, allowExecution = true) => { + if (!shortcuts) + return; + + const checkGroup = (group, category) => { + for (const [name, shortcut] of Object.entries(group)) { + let active = true; + + shortcut.forEach((key) => { + if (!activeKeys.includes(key) && event.key !== key) + return active = false; + }); + + if (!active) + continue; + + event.preventDefault(); + + if (!shortcut.includes(event.key) || !allowExecution) + continue; + + if (category != null) { + options?.[category]?.[name]?.(); + } else { + options?.[name]?.(); + } + } + }; + + if (useCategories) { + for (const [category, group] of Object.entries(shortcuts)) { + checkGroup(group, category); + } + } else { + checkGroup(shortcuts); + } + }; + + const onKeyDown = (event) => { + const isRepeated = activeKeys.includes(event.key); + checkShortcuts(event, isRepeated); + + if (!isRepeated) + setActiveKeys(activeKeys.concat([event.key])); + }; + + const onKeyUp = (event) => { + checkShortcuts(event); + + if (activeKeys.includes(event.key)) { + const keys = [...activeKeys]; + removeFromArray(event.key, keys); + setActiveKeys(keys); + } + }; + + useKeyboardListener({ onKeyDown, onKeyUp }); } \ No newline at end of file diff --git a/src/index.js b/src/index.js index dda47e4..0e3cc9d 100644 --- a/src/index.js +++ b/src/index.js @@ -7,7 +7,7 @@ import reportWebVitals from "./reportWebVitals"; const root = ReactDOM.createRoot(document.getElementById("root")); root.render( - + ); diff --git a/src/styles/global.css b/src/styles/global.css index d0ba01c..af61bc5 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -37,11 +37,19 @@ --background-color-d: var(--dark-grey-e); --scrollbar-color: hsla(211, 29%, 40%, 25%); + + --window-shadow-size: 0.3rem; + --window-shadow-opacity: 35%; + --window-shadow-spread: 3; + --window-box-shadow: calc(var(--window-shadow-size) * -1) var(--window-shadow-size) + calc(var(--window-shadow-size) * var(--window-shadow-spread)) 0px + rgba(0, 0, 0, var(--window-shadow-opacity)); } html, body, #root { width: 100%; height: 100%; + pointer-events: none; } html { @@ -83,6 +91,7 @@ button { outline: none; transition: background-color 100ms ease-in-out; cursor: pointer; + user-select: none; } code {