Added settings feature
This commit is contained in:
parent
919afa3c92
commit
37a5248a6e
20 changed files with 361 additions and 20 deletions
4
public/config/desktop.xml
Normal file
4
public/config/desktop.xml
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0"?>
|
||||
<options>
|
||||
<wallpaper>/media/wallpapers/Wallpaper1.png</wallpaper>
|
||||
</options>
|
||||
14
src/App.jsx
14
src/App.jsx
|
|
@ -3,16 +3,20 @@ import { Taskbar } from "./components/task-bar/TaskBar.jsx";
|
|||
import { WindowsManagerProvider } from "./hooks/windows/WindowsManagerContext.js";
|
||||
import { WindowsView } from "./components/windows/WindowsView.jsx";
|
||||
import { VirtualRootProvider } from "./hooks/virtual-drive/VirtualRootContext.js";
|
||||
import { SETTINGS } from "./config/settings.js";
|
||||
import { Desktop } from "./components/desktop/Desktop.jsx";
|
||||
import { SettingsProvider } from "./hooks/settings/SettingsContext.js";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<VirtualRootProvider>
|
||||
<WindowsManagerProvider>
|
||||
<div className={styles.App} style={{ backgroundImage: `url(${SETTINGS.wallpaper})` }}>
|
||||
<Taskbar/>
|
||||
<WindowsView/>
|
||||
</div>
|
||||
<SettingsProvider>
|
||||
<div className={styles.App}>
|
||||
<Taskbar/>
|
||||
<WindowsView/>
|
||||
<Desktop/>
|
||||
</div>
|
||||
</SettingsProvider>
|
||||
</WindowsManagerProvider>
|
||||
</VirtualRootProvider>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -4,6 +4,5 @@
|
|||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-size: cover;
|
||||
text-align: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export function FileExplorer({ startPath }) {
|
|||
const [currentDirectory, setCurrentDirectory] = useState(virtualRoot.navigate(startPath ?? "~"));
|
||||
const [path, setPath] = useState(currentDirectory.path);
|
||||
const windowsManager = useWindowsManager();
|
||||
const [showHidden] = useState(true);
|
||||
|
||||
const changeDirectory = (path, absolute = false) => {
|
||||
const directory = absolute ? virtualRoot.navigate(path) : currentDirectory.navigate(path);
|
||||
|
|
@ -107,7 +108,7 @@ export function FileExplorer({ startPath }) {
|
|||
</button>
|
||||
</div>
|
||||
<div className={styles.Main}>
|
||||
{currentDirectory.files.map((file, index) =>
|
||||
{currentDirectory.getFiles(showHidden).map((file, index) =>
|
||||
<button key={index} title={file.id} className={styles["File-button"]} onClick={(event) => {
|
||||
event.preventDefault();
|
||||
windowsManager.openFile(file);
|
||||
|
|
@ -116,7 +117,7 @@ export function FileExplorer({ startPath }) {
|
|||
<p>{file.id}</p>
|
||||
</button>
|
||||
)}
|
||||
{currentDirectory.subFolders.map(({ name }, index) =>
|
||||
{currentDirectory.getSubFolders(showHidden).map(({ name }, index) =>
|
||||
<button key={index} title={name} className={styles["Folder-button"]} onClick={() => {
|
||||
changeDirectory(name);
|
||||
}}>
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@
|
|||
flex: 1;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-content: flex-start;
|
||||
height: 100%;
|
||||
padding: 0.5rem;
|
||||
overflow: auto;
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function TextEditor({ file, setTitle, close, mode }) {
|
|||
if (currentFile == null)
|
||||
return saveTextAs();
|
||||
|
||||
currentFile.content = content;
|
||||
currentFile.setContent(content);
|
||||
onChange({ target: { value: content } });
|
||||
};
|
||||
|
||||
|
|
|
|||
21
src/components/desktop/Desktop.jsx
Normal file
21
src/components/desktop/Desktop.jsx
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
import { useState } from "react";
|
||||
import { SettingsManager } from "../../features/settings/settings.js";
|
||||
import { useSettings } from "../../hooks/settings/SettingsContext.js";
|
||||
import styles from "./Desktop.module.css";
|
||||
import { useEffect } from "react";
|
||||
|
||||
export function Desktop() {
|
||||
const settingsManager = useSettings();
|
||||
const [wallpaper, setWallpaper] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const settings = settingsManager.get(SettingsManager.VIRTUAL_PATHS.desktop);
|
||||
settings.get("wallpaper", setWallpaper);
|
||||
})();
|
||||
}, [settingsManager]);
|
||||
|
||||
return (
|
||||
<div className={styles.Container} style={{ backgroundImage: `url(${wallpaper})` }}/>
|
||||
);
|
||||
}
|
||||
10
src/components/desktop/Desktop.module.css
Normal file
10
src/components/desktop/Desktop.module.css
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
.Container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: -1;
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
}
|
||||
|
|
@ -11,9 +11,9 @@ export function WindowsView() {
|
|||
return (<div>
|
||||
{windows.sort((windowA, windowB) =>
|
||||
windowA.lastInteraction - windowB.lastInteraction
|
||||
).map(({ id, app, size, position, options }, index) =>
|
||||
).map(({ id, app, size, position, options }) =>
|
||||
<Window
|
||||
onInteract={() => { windowsManager.focus(windows[index]); }}
|
||||
onInteract={() => { windowsManager.focus(id); }}
|
||||
id={id}
|
||||
key={id}
|
||||
app={app}
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export const SETTINGS = {
|
||||
wallpaper: "/media/wallpapers/Wallpaper1.png"
|
||||
};
|
||||
|
|
@ -51,6 +51,7 @@ export default class ApplicationsManager {
|
|||
break;
|
||||
case "txt":
|
||||
case "md":
|
||||
case "xml":
|
||||
app = this.getApplication("text-editor");
|
||||
break;
|
||||
}
|
||||
|
|
|
|||
160
src/features/settings/settings.js
Normal file
160
src/features/settings/settings.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// eslint-disable-next-line no-unused-vars
|
||||
import { VirtualFile } from "../virtual-drive/virtual-file.js";
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { VirtualRoot } from "../virtual-drive/virtual-root.js";
|
||||
|
||||
export class Settings {
|
||||
xmlDoc = null;
|
||||
|
||||
/**
|
||||
* @type {VirtualRoot}
|
||||
*/
|
||||
#virtualRoot = null;
|
||||
|
||||
/**
|
||||
* @param {VirtualRoot} virtualRoot
|
||||
* @param {String} path
|
||||
*/
|
||||
constructor(virtualRoot, path) {
|
||||
this.#virtualRoot = virtualRoot;
|
||||
this.path = path;
|
||||
this.file = this.#virtualRoot.navigate(this.path);
|
||||
|
||||
if (this.file == null) {
|
||||
console.warn(`Unable to read settings from path: ${this.path}\nNo such file or directory.`);
|
||||
return null;
|
||||
} else if (!(this.file instanceof VirtualFile)) {
|
||||
console.warn(`Unable to read settings from path: ${this.path}\nPath does not point to VirtualFile.`);
|
||||
return null;
|
||||
} else if (this.file.extension !== "xml") {
|
||||
console.warn(`Unable to read settings from path: ${this.path}\nFile does not have extension "xml".`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the xml doc from the given path and assigns it to itself
|
||||
* @returns {Promise<Settings>}
|
||||
*/
|
||||
async read() {
|
||||
if (!this.file)
|
||||
return;
|
||||
|
||||
const text = await this.file.read();
|
||||
|
||||
if (!text)
|
||||
return;
|
||||
|
||||
const parser = new DOMParser();
|
||||
const xmlDoc = parser.parseFromString(text, "text/xml");
|
||||
|
||||
this.xmlDoc = xmlDoc;
|
||||
}
|
||||
|
||||
async write() {
|
||||
if (!this.file)
|
||||
return;
|
||||
|
||||
const serializer = new XMLSerializer();
|
||||
const xmlString = serializer.serializeToString(this.xmlDoc);
|
||||
|
||||
this.file.setContent(xmlString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if xml doc is missing
|
||||
* @returns {Promise<Boolean>}
|
||||
*/
|
||||
async isMissingXmlDoc() {
|
||||
if (this.xmlDoc == null)
|
||||
await this.read();
|
||||
|
||||
return (this.xmlDoc == null);
|
||||
}
|
||||
|
||||
/**
|
||||
* @callback valueCallback
|
||||
* @param {String} value
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets a value by a given key if it exists or calls a callback function whenever the value changes
|
||||
* @param {String} key
|
||||
* @param {valueCallback} callback
|
||||
* @returns {Promise<String|null>}
|
||||
*/
|
||||
async get(key, callback) {
|
||||
if (await this.isMissingXmlDoc())
|
||||
return null;
|
||||
|
||||
let value = this.xmlDoc.getElementsByTagName(key)?.[0]?.textContent;
|
||||
|
||||
if (callback) {
|
||||
callback(value);
|
||||
|
||||
this.file.on(VirtualFile.EVENT_NAMES.CONTENT_CHANGE, async () => {
|
||||
await this.read();
|
||||
const newValue = await this.get(key);
|
||||
|
||||
if (newValue !== value) {
|
||||
callback(newValue);
|
||||
value = newValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} key
|
||||
* @param {String} value
|
||||
*/
|
||||
async set(key, value) {
|
||||
if (await this.isMissingXmlDoc())
|
||||
return;
|
||||
|
||||
if (this.xmlDoc.getElementsByTagName(key)?.[0])
|
||||
this.xmlDoc.getElementsByTagName(key)[0].textContent = value;
|
||||
|
||||
await this.write();
|
||||
}
|
||||
}
|
||||
|
||||
export class SettingsManager {
|
||||
/**
|
||||
* @type {Object<String, String>}
|
||||
*/
|
||||
static VIRTUAL_PATHS = {
|
||||
"desktop": "~/.config/desktop.xml"
|
||||
};
|
||||
|
||||
/**
|
||||
* @type {Object<String, Settings>}
|
||||
*/
|
||||
#pathToSettings = {};
|
||||
|
||||
/**
|
||||
* @type {VirtualRoot}
|
||||
*/
|
||||
#virtualRoot = null;
|
||||
|
||||
/**
|
||||
* @param {VirtualRoot} virtualRoot
|
||||
*/
|
||||
constructor(virtualRoot) {
|
||||
this.#virtualRoot = virtualRoot;
|
||||
|
||||
Object.values(SettingsManager.VIRTUAL_PATHS).forEach((path) => {
|
||||
this.#pathToSettings[path] = new Settings(this.#virtualRoot, path);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {String} path
|
||||
* @returns {Settings}
|
||||
*/
|
||||
get(path) {
|
||||
return this.#pathToSettings[path];
|
||||
}
|
||||
}
|
||||
49
src/features/utils/events.js
Normal file
49
src/features/utils/events.js
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
export class EventEmitter {
|
||||
/**
|
||||
* @type {Object<String, String>}
|
||||
*/
|
||||
static EVENT_NAMES = {};
|
||||
|
||||
/**
|
||||
* @type {Object<String, Array<Function>>}
|
||||
*/
|
||||
#events = {}
|
||||
|
||||
/**
|
||||
* Add event listener for an event
|
||||
* @param {EVENT_NAMES} eventName
|
||||
* @param {Function} callback
|
||||
*/
|
||||
on(eventName, callback) {
|
||||
if (!this.#events[eventName]) {
|
||||
this.#events[eventName] = [];
|
||||
}
|
||||
this.#events[eventName].push(callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove event listener for an event
|
||||
* @param {EVENT_NAMES} eventName
|
||||
* @param {Function} callback
|
||||
*/
|
||||
off(eventName, callback) {
|
||||
if (this.#events[eventName]) {
|
||||
this.#events[eventName] = this.#events[eventName].filter(
|
||||
(listener) => listener !== callback
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dispatch event
|
||||
* @param {EVENT_NAMES} eventName
|
||||
* @param {*} data
|
||||
*/
|
||||
emit(eventName, data) {
|
||||
if (this.#events[eventName]) {
|
||||
this.#events[eventName].forEach((listener) => {
|
||||
listener(data);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import { EventEmitter } from "../utils/events.js";
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
import { VirtualRoot } from "./virtual-root.js";
|
||||
|
||||
export class VirtualBase {
|
||||
export class VirtualBase extends EventEmitter {
|
||||
/**
|
||||
* @param {String} name
|
||||
*/
|
||||
constructor(name) {
|
||||
super();
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,14 @@ import { VirtualBase } from "./virtual-base.js";
|
|||
* A virtual file that can be stored inside a folder
|
||||
*/
|
||||
export class VirtualFile extends VirtualBase {
|
||||
static NON_TEXT_EXTENSIONS = [
|
||||
"png"
|
||||
];
|
||||
|
||||
static EVENT_NAMES = {
|
||||
CONTENT_CHANGE: "contentchange"
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {String} name
|
||||
* @param {String=} extension
|
||||
|
|
@ -29,6 +37,7 @@ export class VirtualFile extends VirtualBase {
|
|||
setSource(source) {
|
||||
this.source = source;
|
||||
this.content = null;
|
||||
this.emit(VirtualFile.EVENT_NAMES.CONTENT_CHANGE, this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
@ -40,6 +49,7 @@ export class VirtualFile extends VirtualBase {
|
|||
setContent(content) {
|
||||
this.content = content;
|
||||
this.source = null;
|
||||
this.emit(VirtualFile.EVENT_NAMES.CONTENT_CHANGE, this);
|
||||
return this;
|
||||
}
|
||||
|
||||
|
|
@ -49,4 +59,25 @@ export class VirtualFile extends VirtualBase {
|
|||
|
||||
return `${this.name}.${this.extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {Promise<String|null>}
|
||||
*/
|
||||
async read() {
|
||||
if (this.content != null)
|
||||
return this.content;
|
||||
|
||||
const isText = !VirtualFile.NON_TEXT_EXTENSIONS.includes(this.extension);
|
||||
|
||||
if (!isText) {
|
||||
return this.source;
|
||||
}
|
||||
|
||||
return await fetch(this.source).then((response) =>
|
||||
response.text()
|
||||
).catch((error) => {
|
||||
console.error(`Error while reading file with id "${this.id}":`, error);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -268,4 +268,28 @@ export class VirtualFolder extends VirtualBase {
|
|||
item.delete();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Boolean=false} showHidden
|
||||
*/
|
||||
getSubFolders(showHidden = false) {
|
||||
if (showHidden)
|
||||
return this.subFolders;
|
||||
|
||||
return this.subFolders.filter(({ name }) =>
|
||||
!name.startsWith(".")
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Boolean=false} showHidden
|
||||
*/
|
||||
getFiles(showHidden = false) {
|
||||
if (showHidden)
|
||||
return this.files;
|
||||
|
||||
return this.files.filter(({ name }) =>
|
||||
!name.startsWith(".")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -74,8 +74,17 @@ export default class WindowsManager {
|
|||
/**
|
||||
* @param {Object} window
|
||||
*/
|
||||
focus(window) {
|
||||
focus(windowId) {
|
||||
windowId = windowId.toString();
|
||||
|
||||
if (!this.windowIds.includes(windowId)) {
|
||||
console.log(`Failed to focus window ${windowId}: window not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const window = this.windows[windowId];
|
||||
window.lastInteraction = new Date().valueOf();
|
||||
|
||||
this.updateWindows(this.windows);
|
||||
}
|
||||
|
||||
|
|
|
|||
27
src/hooks/settings/SettingsContext.js
Normal file
27
src/hooks/settings/SettingsContext.js
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { createContext, useContext } from "react";
|
||||
import { SettingsManager } from "../../features/settings/settings.js";
|
||||
import { useVirtualRoot } from "../virtual-drive/VirtualRootContext.js";
|
||||
|
||||
const SettingsContext = createContext();
|
||||
|
||||
/**
|
||||
* Note: needs to be inside a virtual root provider
|
||||
* @returns {React.Provider<SettingsManager>}
|
||||
*/
|
||||
export function SettingsProvider({ children }) {
|
||||
const virtualRoot = useVirtualRoot();
|
||||
const settingsManager = new SettingsManager(virtualRoot);
|
||||
|
||||
return (
|
||||
<SettingsContext.Provider value={settingsManager}>
|
||||
{children}
|
||||
</SettingsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {SettingsManager}
|
||||
*/
|
||||
export function useSettings() {
|
||||
return useContext(SettingsContext);
|
||||
}
|
||||
|
|
@ -57,15 +57,12 @@ export function useScrollWithShadow(options) {
|
|||
if (!element)
|
||||
return;
|
||||
|
||||
console.log(element);
|
||||
|
||||
setScrollStart(horizontal ? element.scrollLeft : element.scrollTop);
|
||||
setScrollLength(horizontal ? element.scrollWidth : element.scrollHeight);
|
||||
setClientLength(horizontal ? element.clientWidth : element.clientHeight);
|
||||
}, [horizontal]);
|
||||
|
||||
const onUpdate = (event) => {
|
||||
console.log(event);
|
||||
updateValues(event.target);
|
||||
};
|
||||
|
||||
|
|
@ -75,7 +72,6 @@ export function useScrollWithShadow(options) {
|
|||
};
|
||||
|
||||
if (ref.current && !initiated) {
|
||||
console.log(ref.current);
|
||||
setInitiated(true);
|
||||
updateValues(ref.current);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,11 @@ function initVirtualRoot(virtualRoot) {
|
|||
virtualRoot.createFolder("home", (folder) => {
|
||||
folder.createFolder("prozilla-os", (folder) => {
|
||||
folder.setAlias("~")
|
||||
.createFolder(".config", (folder) => {
|
||||
folder.createFile("desktop", "xml", (file) => {
|
||||
file.setSource("/config/desktop.xml");
|
||||
});
|
||||
})
|
||||
.createFolder("Images", (folder) => {
|
||||
folder.createFile("Wallpaper1", "png", (file) => {
|
||||
file.setSource("/media/wallpapers/Wallpaper1.png")
|
||||
|
|
|
|||
Loading…
Reference in a new issue