commit
26b7c4369d
29 changed files with 517 additions and 74 deletions
|
|
@ -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)
|
||||
|
|
|
|||
9
docs/features/modals/README.md
Normal file
9
docs/features/modals/README.md
Normal file
|
|
@ -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.
|
||||
15
src/App.jsx
15
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 (
|
||||
<VirtualRootProvider>
|
||||
<WindowsManagerProvider>
|
||||
|
|
@ -14,6 +28,7 @@ function App() {
|
|||
<div className={styles.App}>
|
||||
<Taskbar/>
|
||||
<WindowsView/>
|
||||
<ModalsView/>
|
||||
<Desktop/>
|
||||
</div>
|
||||
</SettingsProvider>
|
||||
|
|
|
|||
|
|
@ -6,4 +6,9 @@
|
|||
height: 100%;
|
||||
text-align: center;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.App > * {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<String, Object<string, function>>} props.options
|
||||
* @param {Object<String, Object<string, string[]>>} props.shortcuts
|
||||
* @param {Object<string, Object<string, Function>>} props.options
|
||||
* @param {Object<string, Object<string, string[]>>} 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 (
|
||||
<div className={styles.Container}>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
.Container {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 1.25rem;
|
||||
height: 1.5rem;
|
||||
background-color: var(--background-color-a);
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import styles from "./Calculator.module.css";
|
||||
// import styles from "./Calculator.module.css";
|
||||
|
||||
export function Calculator() {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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", "+"],
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={styles.Container} style={{ backgroundImage: `url(${wallpaper})` }}/>
|
||||
);
|
||||
return (<>
|
||||
<div className={styles.Container} style={{ backgroundImage: `url(${wallpaper})` }} onContextMenu={onContextMenu}>
|
||||
<ModalsView modalsManager={modalsManager} modals={modals}/>
|
||||
</div>
|
||||
</>);
|
||||
}
|
||||
17
src/components/modals/ModalView.jsx
Normal file
17
src/components/modals/ModalView.jsx
Normal file
|
|
@ -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 (
|
||||
<OutsideClickListener onOutsideClick={() => { modal.close(); }}>
|
||||
<div className={styles.Container} style={{ "--position-x": modal.position.x, "--position-y": modal.position.y }}>
|
||||
<modal.element modal={modal} {...modal.props}/>
|
||||
</div>
|
||||
</OutsideClickListener>
|
||||
);
|
||||
}
|
||||
14
src/components/modals/ModalView.module.css
Normal file
14
src/components/modals/ModalView.module.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
21
src/components/modals/ModalsView.jsx
Normal file
21
src/components/modals/ModalsView.jsx
Normal file
|
|
@ -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 (
|
||||
<div style={style} className={`${styles.Container} ${className}`}>
|
||||
{modals?.map((modal) =>
|
||||
<ModalView key={modal.id} modal={modal}/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
3
src/components/modals/ModalsView.module.css
Normal file
3
src/components/modals/ModalsView.module.css
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
.Container {
|
||||
position: relative;
|
||||
}
|
||||
26
src/components/modals/context-menu/ContextMenu.jsx
Normal file
26
src/components/modals/context-menu/ContextMenu.jsx
Normal file
|
|
@ -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<string, Function>} props.options
|
||||
* @param {Object<string, string[]>} props.shortcuts
|
||||
*/
|
||||
export function ContextMenu({ modal, options, shortcuts }) {
|
||||
return (<div className={styles.Container}>
|
||||
{Object.entries(options).map(([label, callback]) =>
|
||||
<button className={styles.Button} key={label} tabIndex={0} onClick={() => {
|
||||
modal.close();
|
||||
callback();
|
||||
}}>
|
||||
<p className={styles.Label}>{label}</p>
|
||||
{shortcuts && Object.keys(shortcuts).includes(label)
|
||||
? <p className={styles.Shortcut}>{formatShortcut(shortcuts[label])}</p>
|
||||
: null
|
||||
}
|
||||
</button>
|
||||
)}
|
||||
</div>);
|
||||
}
|
||||
33
src/components/modals/context-menu/ContextMenu.module.css
Normal file
33
src/components/modals/context-menu/ContextMenu.module.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -138,6 +138,7 @@
|
|||
height: 100%;
|
||||
width: min-content;
|
||||
margin: 0;
|
||||
padding: 0.4rem;
|
||||
}
|
||||
|
||||
.Util-icons > * > svg {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 (<>
|
||||
<ModalsView modalsManager={modalsManager} modals={modals} style={{ zIndex: 1 }}/>
|
||||
<Draggable
|
||||
key={id}
|
||||
axis="both"
|
||||
|
|
@ -118,7 +133,7 @@ export const Window = memo(function Window({ id, app, size, position, focused =
|
|||
}}
|
||||
onClick={focus}
|
||||
>
|
||||
<div className={`${styles.Header} Handle`}>
|
||||
<div className={`${styles.Header} Handle`} onContextMenu={onContextMenu}>
|
||||
<ReactSVG
|
||||
className={styles["Window-icon"]}
|
||||
src={process.env.PUBLIC_URL + `/media/applications/icons/${app.id}.svg`}
|
||||
|
|
@ -150,5 +165,5 @@ export const Window = memo(function Window({ id, app, size, position, focused =
|
|||
</div>
|
||||
</div>
|
||||
</Draggable>
|
||||
);
|
||||
</>);
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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 (<div>
|
||||
{sortedWindows.map(({ id, app, size, position, options }) =>
|
||||
<Window
|
||||
<WindowView
|
||||
onInteract={() => { windowsManager.focus(id); }}
|
||||
id={id}
|
||||
key={id}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
99
src/features/modals/modal.js
Normal file
99
src/features/modals/modal.js
Normal file
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
86
src/features/modals/modals.js
Normal file
86
src/features/modals/modals.js
Normal file
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
24
src/hooks/modals/ContextMenu.js
Normal file
24
src/hooks/modals/ContextMenu.js
Normal file
|
|
@ -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<string, Object<string, Function>>} props.options
|
||||
* @param {Object<string, Object<string, string[]>>} 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 };
|
||||
}
|
||||
19
src/hooks/modals/Modals.js
Normal file
19
src/hooks/modals/Modals.js
Normal file
|
|
@ -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];
|
||||
}
|
||||
|
|
@ -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<string, Object<string, Function>>} props.options
|
||||
* @param {Object<string, Object<string, string[]>>} 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 });
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import reportWebVitals from "./reportWebVitals";
|
|||
const root = ReactDOM.createRoot(document.getElementById("root"));
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
<App/>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Reference in a new issue