Added keyboard shortcuts for header menu
This commit is contained in:
parent
abb23252a6
commit
8a6aa0bae7
8 changed files with 176 additions and 65 deletions
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
|
|
@ -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>)
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
31
src/features/utils/string.js
Normal file
31
src/features/utils/string.js
Normal 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("+");
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
}
|
}
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue