Updated desktop context menu

This commit is contained in:
Prozilla 2023-12-19 13:45:15 +01:00
parent ed80c17dfa
commit f51d938a09
No known key found for this signature in database
GPG key ID: 5858DFE71CAF31EE
18 changed files with 249 additions and 54 deletions

View file

@ -1,3 +1,4 @@
<options>
<wallpaper>/assets/wallpapers/vibrant-wallpaper-purple-yellow.png</wallpaper>
<show-icons>true</show-icons>
</options>

View file

@ -1,8 +1,7 @@
import { Children, cloneElement, isValidElement, useEffect, useRef, useState } from "react";
import { Children, cloneElement, isValidElement } from "react";
import { useShortcuts } from "../../hooks/utils/keyboard.js";
import styles from "./Actions.module.css";
import { useScreenDimensions } from "../../hooks/utils/screen.js";
import { TASKBAR_HEIGHT } from "../../constants/taskBar.js";
import { useScreenBounds } from "../../hooks/utils/screen.js";
export const STYLES = {
CONTEXT_MENU: styles["Context-menu"],
@ -34,11 +33,7 @@ export const STYLES = {
export function Actions({ children, className, onAnyTrigger, triggerParams, avoidTaskbar = true }) {
const isListener = (className === STYLES.SHORTCUTS_LISTENER);
const ref = useRef(null);
const [initiated, setInitiated] = useState(false);
const [alignLeft, setAlignLeft] = useState(false);
const [alignTop, setAlignTop] = useState(false);
const [screenWidth, screenHeight] = useScreenDimensions();
const { ref, initiated, alignLeft, alignTop } = useScreenBounds({ avoidTaskbar });
const options = {};
const shortcuts = {};
@ -68,9 +63,9 @@ export function Actions({ children, className, onAnyTrigger, triggerParams, avoi
...child.props,
actionId,
children: iterateOverChildren(child.props.children),
onTrigger: (event) => {
onAnyTrigger?.(event, triggerParams);
onTrigger?.(event, triggerParams);
onTrigger: (event, ...args) => {
onAnyTrigger?.(event, triggerParams, ...args);
onTrigger?.(event, triggerParams, ...args);
}
});
});
@ -80,28 +75,6 @@ export function Actions({ children, className, onAnyTrigger, triggerParams, avoi
useShortcuts({ options, shortcuts, useCategories: false });
useEffect(() => {
if (ref.current == null || screenWidth == null || screenHeight == null)
return;
const rect = ref.current.getBoundingClientRect();
const maxX = screenWidth;
let maxY = screenHeight;
if (avoidTaskbar)
maxY -= TASKBAR_HEIGHT;
const isOverflowingRight = (rect.x + rect.width > maxX);
const isOverflowingBottom = (rect.y + rect.height > maxY);
if (isOverflowingRight)
setAlignLeft(true);
if (isOverflowingBottom)
setAlignTop(true);
setInitiated(true);
}, [alignLeft, avoidTaskbar, screenHeight, screenWidth]);
if (isListener)
return iterateOverChildren(children);

View file

@ -32,8 +32,9 @@
.Context-menu.Container {
--border-radius: 0.5rem;
--padding: 0.375rem;
padding: 0.375rem;
padding: var(--padding);
border-top-left-radius: calc((1 - var(--right) * var(--bottom)) * var(--border-radius)) !important;
border-top-right-radius: calc((1 - var(--left) * var(--bottom)) * var(--border-radius)) !important;
border-bottom-left-radius: calc((1 - var(--right) * var(--top)) * var(--border-radius)) !important;
@ -41,7 +42,8 @@
background-color: var(--background-color-b) !important;
}
.Context-menu .Button {
.Context-menu .Button,
.Context-menu .Dropdown {
display: flex;
gap: 0.75rem;
justify-content: space-between;
@ -58,7 +60,9 @@
}
.Context-menu .Button:hover,
.Context-menu .Button:focus-visible {
.Context-menu .Button:focus-visible,
.Context-menu .Dropdown:hover,
.Context-menu .Dropdown:focus-visible {
background-color: hsla(var(--background-color-a-hsl), 75%);
}
@ -88,4 +92,34 @@
.Context-menu .Shortcut {
color: var(--foreground-color-b);
}
.Context-menu .Dropdown {
position: relative;
}
.Context-menu .Dropdown .Dropdown-content {
opacity: 1;
position: absolute;
top: calc(var(--padding) * -1);
left: 100%;
padding: var(--padding);
border-radius: 0.5rem;
border-top-left-radius: 0;
background-color: var(--background-color-b);
transition: opacity 100ms ease-out;
cursor: auto;
}
.Context-menu .Dropdown:not(.Active) .Dropdown-content {
opacity: 0;
pointer-events: none;
}
.Context-menu .Divider {
width: calc(100% - 0.5rem);
height: 0.1rem;
border-radius: 1rem;
background-color: var(--foreground-color-c);
margin: 0.5rem auto;
}

View file

@ -0,0 +1,5 @@
import styles from "../Actions.module.css";
export function Divider() {
return <div className={styles.Divider}/>;
}

View file

@ -0,0 +1,29 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import styles from "../Actions.module.css";
import { faCaretRight } from "@fortawesome/free-solid-svg-icons";
import { useState } from "react";
export function DropdownAction({ label, icon, children }) {
const [showContent, setShowContent] = useState(false);
const classNames = [styles.Dropdown];
if (showContent)
classNames.push(styles.Active);
return (<div
key={label}
className={classNames.join(" ")}
tabIndex={0}
onMouseEnter={() => { setShowContent(true); }}
onMouseLeave={() => { setShowContent(false); }}
>
<span className={styles.Label}>
{icon && <div className={styles.Icon}><FontAwesomeIcon icon={icon}/></div>}
<p>{label}</p>
</span>
<div className={styles["Dropdown-arrow"]}><FontAwesomeIcon icon={faCaretRight}/></div>
<div className={styles["Dropdown-content"]}>
{children}
</div>
</div>);
}

View file

@ -0,0 +1,40 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatShortcut } from "../../../features/utils/string.js";
import styles from "../Actions.module.css";
import { faCircleDot } from "@fortawesome/free-solid-svg-icons";
import { useState } from "react";
import { faCircle } from "@fortawesome/free-regular-svg-icons";
/**
* @param {object} props
* @param {string} props.actionId
* @param {{
* label: string,
* shortcut: string[]
* }[]} props.options
* @param {number} props.initialIndex
* @param {Function} props.onTrigger
*/
export function RadioAction({ actionId, options, initialIndex, onTrigger }) {
const [activeIndex, setActiveIndex] = useState(initialIndex ?? 0);
return (<div key={actionId}>
{options.map(({ label, shortcut }, index) =>
<button key={label} className={styles.Button} tabIndex={0} onClick={(event) => {
setActiveIndex(index);
onTrigger(event, index);
}}>
<span className={styles.Label}>
<div className={styles.Icon}>
{activeIndex === index
? <FontAwesomeIcon icon={faCircleDot}/>
: <FontAwesomeIcon icon={faCircle}/>
}
</div>
<p>{label}</p>
</span>
{shortcut && <p className={styles.Shortcut}>{formatShortcut(shortcut)}</p>}
</button>
)}
</div>);
}

View file

@ -0,0 +1,26 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { formatShortcut } from "../../../features/utils/string.js";
import styles from "../Actions.module.css";
import { useState } from "react";
import { faSquare } from "@fortawesome/free-regular-svg-icons";
import { faSquareCheck } from "@fortawesome/free-solid-svg-icons";
export function ToggleAction({ actionId, label, shortcut, initialValue, onTrigger }) {
const [active, setActive] = useState(initialValue ?? false);
return (<button key={actionId} className={styles.Button} tabIndex={0} onClick={(event) => {
onTrigger(event, !active);
setActive(!active);
}}>
<span className={styles.Label}>
<div className={styles.Icon}>
{active
? <FontAwesomeIcon icon={faSquareCheck}/>
: <FontAwesomeIcon icon={faSquare}/>
}
</div>
<p>{label}</p>
</span>
{shortcut && <p className={styles.Shortcut}>{formatShortcut(shortcut)}</p>}
</button>);
}

View file

@ -217,7 +217,10 @@ export function FileExplorer({ startPath, app, modalsManager }) {
showHidden={showHidden}
onClickFile={(event, file) => {
event.preventDefault();
windowsManager.openFile(file, { mode: "view" });
const options = {};
if (file.extension === "md")
options.mode = "view";
windowsManager.openFile(file, options);
}}
onClickFolder={(event, folder) => {
changeDirectory(folder.linkedPath ?? folder.name);

View file

@ -122,6 +122,8 @@
}
.Main {
--scale: inherit !important;
position: relative;
flex: 1;
display: flex;

View file

@ -63,15 +63,11 @@ export function DirectoryList({ directory, showHidden = false, folderClassName,
};
document.addEventListener("mousemove", onMoveRectSelect);
document.addEventListener("pointermove", onMoveRectSelect);
document.addEventListener("mouseup", onStopRectSelect);
document.addEventListener("pointerup", onStopRectSelect);
return () => {
document.removeEventListener("mousemove", onMoveRectSelect);
document.removeEventListener("pointermove", onMoveRectSelect);
document.removeEventListener("mouseup", onStopRectSelect);
document.removeEventListener("pointerup", onStopRectSelect);
};
});
@ -140,7 +136,6 @@ export function DirectoryList({ directory, showHidden = false, folderClassName,
className={classNames.join(" ")}
onClick={clearSelection}
onMouseDown={onStartRectSelect}
onPointerDown={onStartRectSelect}
{...props}
>
{rectSelectStart != null && rectSelectEnd != null

View file

@ -1,4 +1,6 @@
.Container {
--scale: 1rem;
position: relative;
width: 100%;
height: 100%;

View file

@ -7,7 +7,7 @@ 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";
import { FALLBACK_WALLPAPER } from "../../constants/desktop.js";
import { FALLBACK_ICON_SIZE, FALLBACK_WALLPAPER } from "../../constants/desktop.js";
import { reloadViewport } from "../../features/utils/browser.js";
import { useVirtualRoot } from "../../hooks/virtual-drive/virtualRootContext.js";
import { DirectoryList } from "../applications/file-explorer/directory-list/DirectoryList.jsx";
@ -15,7 +15,12 @@ import { APPS, APP_NAMES } from "../../constants/applications.js";
import Vector2 from "../../features/math/vector2.js";
import { Actions } from "../actions/Actions.jsx";
import { ClickAction } from "../actions/actions/ClickAction.jsx";
import { faArrowsRotate, faFolder, faPaintBrush, faTrash } from "@fortawesome/free-solid-svg-icons";
import { faArrowsRotate, faEye, faFolder, faPaintBrush, faTrash } from "@fortawesome/free-solid-svg-icons";
import { ToggleAction } from "../actions/actions/ToggleAction.jsx";
import { DropdownAction } from "../actions/actions/DropdownAction.jsx";
import { RadioAction } from "../actions/actions/RadioAction.jsx";
import { Divider } from "../actions/actions/Divider.jsx";
import { isValidInteger } from "../../features/utils/number.js";
export const Desktop = memo(() => {
const settingsManager = useSettingsManager();
@ -23,9 +28,26 @@ export const Desktop = memo(() => {
const [modalsManager, modals] = useModals();
const windowsManager = useWindowsManager();
const virtualRoot = useVirtualRoot();
const [showIcons, setShowIcons] = useState(false);
const [iconSize, setIconSize] = useState(FALLBACK_ICON_SIZE);
const { onContextMenu, ShortcutsListener } = useContextMenu({ modalsManager, Actions: (props) =>
<Actions {...props}>
<DropdownAction label="View" icon={faEye}>
<RadioAction initialIndex={iconSize} onTrigger={(event, params, value) => {
const settings = settingsManager.get(SettingsManager.VIRTUAL_PATHS.desktop);
settings.set("icon-size", parseInt(value));
}} options={[
{ label: "Small icons" },
{ label: "Medium icons" },
{ label: "Large icons" }
]}/>
<Divider/>
<ToggleAction label="Show dekstop icons" initialValue={showIcons} onTrigger={(event, params, value) => {
const settings = settingsManager.get(SettingsManager.VIRTUAL_PATHS.desktop);
settings.set("show-icons", (!showIcons).toString());
}}/>
</DropdownAction>
<ClickAction label="Reload" shortcut={["Control", "r"]} icon={faArrowsRotate} onTrigger={() => {
reloadViewport();
}}/>
@ -68,6 +90,11 @@ export const Desktop = memo(() => {
(async () => {
const settings = settingsManager.get(SettingsManager.VIRTUAL_PATHS.desktop);
settings.get("wallpaper", setWallpaper);
settings.get("show-icons", (value) => { setShowIcons(value === "true"); });
settings.get("icon-size", (value) => {
if (isValidInteger(value))
setIconSize(parseInt(value));
});
})();
}, [settingsManager]);
@ -78,6 +105,8 @@ export const Desktop = memo(() => {
settings.set("wallpaper", FALLBACK_WALLPAPER);
};
const iconScale = 1 + ((isValidInteger(iconSize) ? iconSize : FALLBACK_ICON_SIZE) - 1) / 4;
return (<>
<ShortcutsListener/>
<div
@ -85,18 +114,22 @@ export const Desktop = memo(() => {
onContextMenu={onContextMenu}
>
<ModalsView modalsManager={modalsManager} modals={modals}/>
<DirectoryList
{showIcons && <DirectoryList
directory={directory}
className={styles.Content}
style={{
"--scale": `${iconScale}rem`
}}
fileClassName={styles["Item"]}
folderClassName={styles["Item"]}
onClickFile={(event, file) => {
event.preventDefault();
const options = { mode: "view" };
if (file.name === "info.md") {
const options = {};
if (file.name === "info.md")
options.size = new Vector2(575, 675);
}
if (file.extension === "md")
options.mode = "view";
windowsManager.openFile(file, options);
}}
@ -105,7 +138,7 @@ export const Desktop = memo(() => {
}}
onContextMenuFile={onContextMenuFile}
onContextMenuFolder={onContextMenuFolder}
/>
/>}
{wallpaper
? <img src={wallpaper} className={styles.Wallpaper} alt="Desktop wallpaper" onError={onError}/>
: null

View file

@ -18,8 +18,6 @@
}
.Content {
--scale: 1rem;
position: absolute;
display: flex;
flex-direction: column;

View file

@ -9,4 +9,5 @@ export const WALLPAPERS = [
"/assets/wallpapers/wave-abstract-wallpaper-teal.png",
];
export const FALLBACK_WALLPAPER = WALLPAPERS[6];
export const FALLBACK_WALLPAPER = WALLPAPERS[6];
export const FALLBACK_ICON_SIZE = 1;

View file

@ -1,6 +1,8 @@
import { VirtualFile } from "../virtual-drive/virtualFile.js";
import { VirtualRoot } from "../virtual-drive/virtualRoot.js";
const PARENT_NODE = "options";
export class Settings {
xmlDoc = null;
@ -112,8 +114,13 @@ export class Settings {
if (await this.isMissingXmlDoc())
return;
if (this.xmlDoc.getElementsByTagName(key)?.[0])
if (this.xmlDoc.getElementsByTagName(key).length > 0) {
this.xmlDoc.getElementsByTagName(key)[0].textContent = value;
} else if (this.xmlDoc.getElementsByTagName(PARENT_NODE).length > 0) {
const newOption = this.xmlDoc.createElement(key);
newOption.textContent = value;
this.xmlDoc.getElementsByTagName(PARENT_NODE)[0].appendChild(newOption);
}
await this.write();
}

View file

@ -0,0 +1,3 @@
export function isValidInteger(number) {
return (parseInt(number) || parseInt(number) === 0);
}

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { TASKBAR_HEIGHT } from "../../constants/taskBar.js";
/**
* @returns {[number, number]}
@ -17,4 +18,46 @@ export function useScreenDimensions() {
}, []);
return [screenWidth, screenHeight];
}
/**
* @param {object} props
* @param {boolean} props.avoidTaskbar
* @returns {{
* ref,
* initiated: boolean,
* alignLeft: boolean,
* alignTop: boolean
* }}
*/
export function useScreenBounds({ avoidTaskbar = true }) {
const ref = useRef(null);
const [initiated, setInitiated] = useState(false);
const [alignLeft, setAlignLeft] = useState(false);
const [alignTop, setAlignTop] = useState(false);
const [screenWidth, screenHeight] = useScreenDimensions();
useEffect(() => {
if (ref.current == null || screenWidth == null || screenHeight == null)
return;
const rect = ref.current.getBoundingClientRect();
const maxX = screenWidth;
let maxY = screenHeight;
if (avoidTaskbar)
maxY -= TASKBAR_HEIGHT;
const isOverflowingRight = (rect.x + rect.width > maxX);
const isOverflowingBottom = (rect.y + rect.height > maxY);
if (isOverflowingRight)
setAlignLeft(true);
if (isOverflowingBottom)
setAlignTop(true);
setInitiated(true);
}, [alignLeft, avoidTaskbar, screenHeight, screenWidth]);
return { ref, initiated, alignLeft, alignTop };
}