Added keyboard shortcuts for header menu

This commit is contained in:
Prozilla 2023-07-31 20:51:12 +02:00
parent abb23252a6
commit 8a6aa0bae7
No known key found for this signature in database
GPG key ID: 5858DFE71CAF31EE
8 changed files with 176 additions and 65 deletions

View file

@ -1,36 +1,62 @@
import { useState } from "react";
import { removeFromArray } from "../../../features/utils/array.js";
import { useKeyboardListener } from "../../../hooks/utils/keyboard.js";
import { DropdownButton } from "../../utils/DropdownButton.jsx"; import { DropdownButton } from "../../utils/DropdownButton.jsx";
import styles from "./HeaderMenu.module.css"; import styles from "./HeaderMenu.module.css";
/** /**
* @param {Object} props * @param {Object} props
* @param {Function} props.onNew * @param {Object<String, Object<String, Function>>} props.options
* @param {Function} props.onOpen * @param {Object<String, Object<String, Array<String>>>} props.shortcuts
* @param {Function} props.onSave
* @param {Function} props.onSaveAs
* @param {Function} props.onExit
*/ */
export function HeaderMenu({ onNew, onOpen, onSave, onSaveAs, onExit }) { 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 });
return ( return (
<div className={styles.Container}> <div className={styles.Container}>
<DropdownButton label="File" options={{ {Object.entries(options).map(([key, value]) =>
"New": () => { <DropdownButton key={key} label={key} options={value} shortcuts={shortcuts[key]}/>
onNew?.(); )}
},
"Open": () => {
onOpen?.();
},
"Save": () => {
onSave?.();
},
"Save as": () => {
onSaveAs?.();
},
"Exit": () => {
onExit?.();
},
}}/>
<DropdownButton label="Edit"/>
<DropdownButton label="View"/>
</div> </div>
); );
} }

View file

@ -5,6 +5,9 @@ import styles from "./TextEditor.module.css";
import { HeaderMenu } from "../.common/HeaderMenu.jsx"; import { HeaderMenu } from "../.common/HeaderMenu.jsx";
import Markdown from "markdown-to-jsx"; import Markdown from "markdown-to-jsx";
const defaultZoom = 16;
const zoomSpeed = 4;
/** /**
* @param {Object} props * @param {Object} props
* @param {VirtualFile} props.file * @param {VirtualFile} props.file
@ -13,7 +16,8 @@ export function TextEditor({ file, setTitle, close, mode }) {
const [currentFile, setCurrentFile] = useState(file); const [currentFile, setCurrentFile] = useState(file);
const [currentMode, setCurrentMode] = useState(mode); const [currentMode, setCurrentMode] = useState(mode);
const [content, setContent] = useState(file?.content ?? ""); const [content, setContent] = useState(file?.content ?? "");
const [unsavedChanges, setUnsavedChanges] = useState(false); const [unsavedChanges, setUnsavedChanges] = useState(file == null);
const [zoom, setZoom] = useState(defaultZoom);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
@ -50,19 +54,20 @@ export function TextEditor({ file, setTitle, close, mode }) {
const newText = () => { const newText = () => {
setCurrentFile(null); setCurrentFile(null);
setCurrentMode("edit"); setCurrentMode("edit");
} setUnsavedChanges(true);
};
const saveTextAs = () => { const saveTextAs = () => {
setUnsavedChanges(false); onChange({ target: { value: content } });
} };
const saveText = () => { const saveText = () => {
if (currentFile == null) if (currentFile == null)
return saveTextAs(); return saveTextAs();
currentFile.content = content; currentFile.content = content;
setUnsavedChanges(false); onChange({ target: { value: content } });
} };
const onChange = (event) => { const onChange = (event) => {
const value = event.target.value; const value = event.target.value;
@ -70,44 +75,71 @@ export function TextEditor({ file, setTitle, close, mode }) {
if (currentFile != null) { if (currentFile != null) {
setUnsavedChanges(currentFile.content !== value); setUnsavedChanges(currentFile.content !== value);
} else { } else {
setUnsavedChanges(value !== ""); setUnsavedChanges(true);
} }
return setContent(value); return setContent(value);
}; };
const onKeyDown = (event) => {
if (event.key === "s" && event.ctrlKey) {
event.preventDefault();
saveText();
}
};
return ( return (
<div className={styles.Container}> <div className={styles.Container} style={{ fontSize: zoom }}>
<HeaderMenu <HeaderMenu
onNew={newText} options={{
onSave={saveText} "File": {
onSaveAs={saveTextAs} "New": newText,
onExit={() => { close(); }} "Save": saveText,
// "Save As": saveTextAs,
"Exit": () => {
close();
},
},
"View": {
[currentMode === "view" ? "Edit mode" : "Preview mode"]: () => {
setCurrentMode(currentMode === "view" ? "edit" : "view");
},
"Zoom In": () => {
setZoom(zoom + zoomSpeed);
},
"Zoom Out": () => {
setZoom(zoom - zoomSpeed);
},
"Reset Zoom": () => {
setZoom(defaultZoom);
}
}
}}
shortcuts={{
"File": {
"New": ["Control", "e"],
"Save": ["Control", "s"],
"Exit": ["Control", "x"],
},
"View": {
"Zoom In": ["Control", "+"],
"Zoom Out": ["Control", "-"],
"Reset Zoom": ["Control", "0"],
}
}}
/> />
{currentMode === "view" {currentMode === "view"
? <div className={styles.View}> ? <div className={styles.View}>
<Markdown options={{ overrides: { {file.extension === "md"
a: { ? <Markdown options={{ overrides: {
props: { a: {
target: "_blank" props: {
target: "_blank"
}
} }
} } }}>
} }}> {content}
{content} </Markdown>
</Markdown> : <p>{content}</p>
}
</div> </div>
: <textarea : <textarea
className={styles.View} className={styles.View}
value={content} value={content}
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown}
spellCheck={false} spellCheck={false}
autoComplete="off" autoComplete="off"
autoFocus autoFocus

View file

@ -6,6 +6,13 @@
text-align: start; text-align: start;
} }
.Container p,
.Container div,
.Container span,
.Container textarea {
font-size: 1em;
}
.View { .View {
width: 100%; width: 100%;
height: 100%; height: 100%;

View file

@ -1,14 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import styles from "./DropdownButton.module.css"; import styles from "./DropdownButton.module.css";
import OutsideClickListener from "../../hooks/utils/outsideClick.js"; import OutsideClickListener from "../../hooks/utils/outsideClick.js";
import { formatShortcut } from "../../features/utils/string.js";
/** /**
* @param {Object} props * @param {Object} props
* @param {String} props.label * @param {String} props.label
* @param {Object.<string, Function>} props.options * @param {Object.<string, Function>} props.options
* @returns
*/ */
export function DropdownButton({ label, options }) { export function DropdownButton({ label, options, shortcuts }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
return ( return (
@ -24,7 +24,11 @@ export function DropdownButton({ label, options }) {
setOpen(false); setOpen(false);
callback(); callback();
}}> }}>
{label} <p className={styles.Label}>{label}</p>
{Object.keys(shortcuts).includes(label)
? <p className={styles.Shortcut}>{formatShortcut(shortcuts[label])}</p>
: null
}
</button> </button>
)} )}
</div>) </div>)

View file

@ -27,7 +27,10 @@
background-color: var(--background-color-b); background-color: var(--background-color-b);
} }
.Dropdown button { .Dropdown > button {
display: flex;
gap: 0.75rem;
justify-content: space-between;
width: 100%; width: 100%;
padding: 0.125rem 0.4rem; padding: 0.125rem 0.4rem;
background: none; background: none;
@ -39,6 +42,14 @@
cursor: pointer; cursor: pointer;
} }
.Dropdown button:hover { .Dropdown > button:hover {
background-color: rgba(255, 255, 255, 5%); background-color: rgba(255, 255, 255, 5%);
}
.Dropdown > button > p {
margin: 0;
}
.Shortcut {
color: var(--foreground-color-b);
} }

View file

@ -0,0 +1,31 @@
export function formatShortcut(shortcut) {
const specialKeys = [];
const singleKeys = [];
shortcut.forEach((key) => {
if (key.length > 1) {
switch (key) {
case "Control":
specialKeys.push("Ctrl");
break;
default:
specialKeys.push(key);
break;
}
} else {
switch (key) {
case "+":
singleKeys.push("Plus");
break;
case "-":
singleKeys.push("Minus");
break;
default:
singleKeys.push(key.toUpperCase());
break;
}
}
});
return specialKeys.concat(singleKeys).join("+");
}

View file

@ -3,22 +3,18 @@ import { useEffect } from "react";
/** /**
* Hook that alerts clicks outside of the passed ref * Hook that alerts clicks outside of the passed ref
*/ */
export function useKeyboardListener({ onKeyDown, onKeyUp, onKeyPress }) { export function useKeyboardListener({ onKeyDown, onKeyUp }) {
useEffect(() => { useEffect(() => {
if (onKeyDown) if (onKeyDown)
document.addEventListener("keydown", onKeyDown); document.addEventListener("keydown", onKeyDown);
if (onKeyUp) if (onKeyUp)
document.addEventListener("keyup", onKeyUp); document.addEventListener("keyup", onKeyUp);
if (onKeyPress)
document.addEventListener("keypress", onKeyPress);
return () => { return () => {
if (onKeyDown) if (onKeyDown)
document.removeEventListener("keydown", onKeyDown); document.removeEventListener("keydown", onKeyDown);
if (onKeyUp) if (onKeyUp)
document.removeEventListener("keyup", onKeyUp); document.removeEventListener("keyup", onKeyUp);
if (onKeyPress)
document.removeEventListener("keypress", onKeyPress);
}; };
}, [onKeyDown, onKeyUp, onKeyPress]); }, [onKeyDown, onKeyUp]);
} }

View file

@ -23,6 +23,9 @@
--dark-grey-d: hsl(212, 14%, 10%); --dark-grey-d: hsl(212, 14%, 10%);
--foreground-color-a: #fff; --foreground-color-a: #fff;
--foreground-color-b: var(--grey-a);
--foreground-color-c: var(--grey-b);
--background-color-a: var(--dark-grey-b); --background-color-a: var(--dark-grey-b);
--background-color-b: var(--dark-grey-c); --background-color-b: var(--dark-grey-c);
--background-color-c: var(--dark-grey-d); --background-color-c: var(--dark-grey-d);
@ -91,6 +94,7 @@ html, body, #root {
html { html {
overflow: hidden; overflow: hidden;
background-color: var(--background-color-c); background-color: var(--background-color-c);
font-size: 16px;
} }
body { body {
@ -107,7 +111,7 @@ body {
p, a, button, input, h1, h2, h3, h4, h5, h6 { p, a, button, input, h1, h2, h3, h4, h5, h6 {
font-family: var(--body-font-family); font-family: var(--body-font-family);
letter-spacing: 0.01rem; letter-spacing: 0.01em;
} }
code { code {