Merge pull request #5 from Prozilla/modals

Added modals
This commit is contained in:
Prozilla 2023-08-11 20:01:33 +02:00 committed by GitHub
commit 26b7c4369d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
29 changed files with 517 additions and 74 deletions

View file

@ -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)

View 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.

View file

@ -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>

View file

@ -6,4 +6,9 @@
height: 100%;
text-align: center;
overflow: hidden;
pointer-events: none;
}
.App > * {
pointer-events: auto;
}

View file

@ -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}>

View file

@ -1,6 +1,6 @@
.Container {
display: flex;
width: 100%;
height: 1.25rem;
height: 1.5rem;
background-color: var(--background-color-a);
}

View file

@ -1,4 +1,4 @@
import styles from "./Calculator.module.css";
// import styles from "./Calculator.module.css";
export function Calculator() {
return (

View file

@ -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();

View file

@ -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", "+"],

View file

@ -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>
</>);
}

View 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>
);
}

View 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);
}

View 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>
);
}

View file

@ -0,0 +1,3 @@
.Container {
position: relative;
}

View 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>);
}

View 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);
}

View file

@ -138,6 +138,7 @@
height: 100%;
width: min-content;
margin: 0;
padding: 0.4rem;
}
.Util-icons > * > svg {

View file

@ -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;

View file

@ -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>
);
</>);
});

View file

@ -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;
}

View file

@ -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}

View file

@ -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 {

View 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();
}
}

View 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);
}
}

View 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 };
}

View 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];
}

View file

@ -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 });
}

View file

@ -7,7 +7,7 @@ import reportWebVitals from "./reportWebVitals";
const root = ReactDOM.createRoot(document.getElementById("root"));
root.render(
<React.StrictMode>
<App />
<App/>
</React.StrictMode>
);

View file

@ -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 {