feat: F4.6f3 the file explorer and the image viewer
Oracle FileExplorerWindowRun uses Java's explorer and viewer windows as a person does (the right-click menu, Java's questions answered, the import window, the viewer's open). Both windows work on the document's files through the core's operations: the tree of folders and files, the chosen folder's entries, Java's menu (also as buttons, no right click needed), copy that stays and cut that goes once pasted, the import; the viewer's picture and title. Not taken from Java: a file chosen in the tree taken for a folder, pictures laid over each other, a text file as an empty picture. writeFiles numbers the entries in the file system's order again when a rename made a new key. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,8 +13,10 @@
|
||||
// icon opens its window over the desktop (ProgramFrame), the home button
|
||||
// leaves it; so far the command line (F4.6b), the web browser and the web
|
||||
// server (F4.6d), the e-mail program and server (F4.6e), the text editor
|
||||
// (F4.6f2) have one, the others follow. The browser's window takes its title
|
||||
// from its page, the text editor's from its open file, as Java's do.
|
||||
// (F4.6f2), the file explorer and the image viewer (F4.6f3) have one, the
|
||||
// others follow. The browser's window takes its title from its page, the
|
||||
// text editor's from its open file, the image viewer's from its picture, as
|
||||
// Java's do.
|
||||
|
||||
import { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
@@ -35,6 +37,8 @@ import { pageTitle } from './browserPage.js';
|
||||
import { InstallationPanel } from './InstallationPanel.js';
|
||||
import { TerminalPanel } from './TerminalPanel.js';
|
||||
import { TextEditorPanel, useTextEditorWindow } from './TextEditorPanel.js';
|
||||
import { FileExplorerPanel } from './FileExplorerPanel.js';
|
||||
import { ImageViewerPanel, useImageViewer } from './ImageViewerPanel.js';
|
||||
import { WebServerPanel } from './WebServerPanel.js';
|
||||
import './desktop.css';
|
||||
|
||||
@@ -64,7 +68,7 @@ export interface DesktopWindowProps {
|
||||
export type DesktopWindowName = DesktopProgram | 'installation';
|
||||
|
||||
/** The programs whose windows are built so far. */
|
||||
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor']);
|
||||
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor', 'fileExplorer', 'imageViewer']);
|
||||
|
||||
/** The document's desktop of a computer, read anew after every change (a program installed, the label changed). */
|
||||
function useDesktopView(doc: Y.Doc, id: string) {
|
||||
@@ -83,6 +87,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
const language = useLanguage();
|
||||
const view = useDesktopView(doc, id);
|
||||
const textEditor = useTextEditorWindow(doc, id);
|
||||
const imageViewer = useImageViewer(doc, id);
|
||||
const { watch } = simulation;
|
||||
useEffect(() => watch([`node:${id}`]), [watch, id]);
|
||||
const settings = simulation.nodes.get(id);
|
||||
@@ -164,7 +169,9 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
? browserTitle(browserView(simulation, id), t('program.webBrowser'), (page) => pageTitle(page, t))
|
||||
: shown === 'textEditor'
|
||||
? textEditor.title(t('program.textEditor'))
|
||||
: t(`program.${shown}`)
|
||||
: shown === 'imageViewer'
|
||||
? imageViewer.title(t('program.imageViewer'))
|
||||
: t(`program.${shown}`)
|
||||
}
|
||||
program={shown}
|
||||
>
|
||||
@@ -174,6 +181,8 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
{shown === 'emailClient' ? <EmailClientPanel node={id} simulation={simulation} /> : null}
|
||||
{shown === 'emailServer' ? <EmailServerPanel node={id} simulation={simulation} /> : null}
|
||||
{shown === 'textEditor' ? <TextEditorPanel doc={doc} node={id} /> : null}
|
||||
{shown === 'fileExplorer' ? <FileExplorerPanel doc={doc} node={id} /> : null}
|
||||
{shown === 'imageViewer' ? <ImageViewerPanel doc={doc} node={id} /> : null}
|
||||
</ProgramFrame>
|
||||
)}
|
||||
{info ? (
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
// A computer's file explorer, after Java's GUIApplicationFileExplorerWindow:
|
||||
// the tree of folders and files on the left, the chosen folder's entries on
|
||||
// the right with Java's icons, Java's menu on a right click (on an entry:
|
||||
// delete, cut, copy, rename; on the free space: new folder, paste), its
|
||||
// questions — a name for a new folder, the new name, "Sicher?" before a
|
||||
// delete — and its import window. What it does is the window's
|
||||
// (FileExplorerWindow, fileExplorer.ts, with what it does better than Java).
|
||||
//
|
||||
// Better than Java: the menu's actions are buttons above the list too, so a
|
||||
// finger needs no right click (plan F4: no place needs one); a folder of the
|
||||
// list opens with a double click; the new name starts as the old one; what
|
||||
// an action refuses is said.
|
||||
|
||||
import { useRef, useState, useSyncExternalStore, type FormEvent, type MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
import { entryName } from '@mdg-filius/core';
|
||||
import fileIcon from '../assets/desktop/datei.png';
|
||||
import folderIcon from '../assets/desktop/ordner.png';
|
||||
import { ContextMenu } from '../design/ContextMenu.js';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { fileExplorerWindow, type ExplorerItem, type FileExplorerWindow } from './fileExplorer.js';
|
||||
|
||||
const ITEM_KEYS: Readonly<Record<ExplorerItem, string>> = {
|
||||
newFolder: 'explorer.newFolder',
|
||||
delete: 'explorer.delete',
|
||||
cut: 'explorer.cut',
|
||||
copy: 'explorer.copy',
|
||||
paste: 'explorer.paste',
|
||||
rename: 'explorer.rename',
|
||||
};
|
||||
|
||||
function useExplorer(doc: Y.Doc, node: string): FileExplorerWindow {
|
||||
const window = fileExplorerWindow(doc, node);
|
||||
useSyncExternalStore(
|
||||
(listener) => window.subscribe(listener),
|
||||
() => window.version,
|
||||
);
|
||||
return window;
|
||||
}
|
||||
|
||||
type Asking = { readonly kind: 'newFolder' | 'rename'; readonly name: string; readonly problem: string | null } | { readonly kind: 'delete' } | { readonly kind: 'import' };
|
||||
|
||||
export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly node: string }) {
|
||||
const t = useTranslate();
|
||||
const explorer = useExplorer(doc, node);
|
||||
const panel = useRef<HTMLDivElement>(null);
|
||||
const [menu, setMenu] = useState<{ readonly x: number; readonly y: number; readonly onEntry: boolean } | null>(null);
|
||||
const [asking, setAsking] = useState<Asking | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const folder = explorer.folder;
|
||||
const selected = explorer.selected;
|
||||
|
||||
const run = (item: ExplorerItem) => {
|
||||
setNotice(null);
|
||||
switch (item) {
|
||||
case 'newFolder':
|
||||
setAsking({ kind: 'newFolder', name: '', problem: null });
|
||||
return;
|
||||
case 'rename':
|
||||
if (selected !== null) setAsking({ kind: 'rename', name: entryName(selected), problem: null });
|
||||
return;
|
||||
case 'delete':
|
||||
if (selected !== null) setAsking({ kind: 'delete' });
|
||||
return;
|
||||
case 'cut':
|
||||
case 'copy':
|
||||
explorer.take(item === 'cut');
|
||||
return;
|
||||
case 'paste': {
|
||||
const clipboard = explorer.clipboard;
|
||||
const result = explorer.paste();
|
||||
if (result === 'invalidTarget') setNotice(t('explorer.invalidTarget'));
|
||||
else if (result === 'nameTaken') setNotice(t('explorer.pasteTaken', { name: clipboard === null ? '' : entryName(clipboard.path) }));
|
||||
else if (result === 'missing') setNotice(t('explorer.pasteMissing'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const enabled = (item: ExplorerItem) => (item === 'newFolder' ? true : item === 'paste' ? explorer.clipboard !== null : selected !== null);
|
||||
|
||||
const openMenu = (event: ReactMouseEvent, path: string | null) => {
|
||||
event.preventDefault();
|
||||
explorer.choose(path);
|
||||
const box = panel.current!.getBoundingClientRect();
|
||||
setMenu({ x: event.clientX - box.left, y: event.clientY - box.top, onEntry: path !== null });
|
||||
};
|
||||
|
||||
const submitName = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (asking === null || (asking.kind !== 'newFolder' && asking.kind !== 'rename')) return;
|
||||
const result = asking.kind === 'newFolder' ? explorer.newFolder(asking.name) : explorer.rename(asking.name);
|
||||
if (result === 'made' || result === 'renamed') setAsking(null);
|
||||
else setAsking({ ...asking, problem: t(`explorer.${result}`, { name: asking.name }) });
|
||||
};
|
||||
|
||||
const tree = explorer.tree();
|
||||
const entries = explorer.entries();
|
||||
return (
|
||||
<div className="file-explorer" ref={panel}>
|
||||
<div className="file-explorer-bar" role="toolbar" aria-label={t('explorer.menu')}>
|
||||
<button type="button" onClick={() => setAsking({ kind: 'import' })}>
|
||||
{t('explorer.import')}
|
||||
</button>
|
||||
{(['newFolder', 'delete', 'cut', 'copy', 'paste', 'rename'] as const).map((item) => (
|
||||
<button key={item} type="button" data-item={item} disabled={!enabled(item)} onClick={() => run(item)}>
|
||||
{t(ITEM_KEYS[item])}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
{notice === null ? null : (
|
||||
<p className="mail-notice" role="alert">
|
||||
{notice}
|
||||
</p>
|
||||
)}
|
||||
<div className="file-explorer-body">
|
||||
<ul className="file-explorer-tree" aria-label={t('explorer.tree')}>
|
||||
{tree.map((row) => (
|
||||
<li key={row.path}>
|
||||
<button
|
||||
type="button"
|
||||
data-path={row.path}
|
||||
style={{ paddingLeft: 6 + row.depth * 16 }}
|
||||
aria-current={row.path === folder ? 'true' : undefined}
|
||||
aria-pressed={row.kind === 'file' ? row.path === selected : undefined}
|
||||
onClick={() => explorer.chooseInTree(row.path)}
|
||||
>
|
||||
<img src={row.kind === 'folder' ? folderIcon : fileIcon} alt="" />
|
||||
<span>{row.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ul
|
||||
className="file-explorer-entries"
|
||||
aria-label={t('explorer.entries')}
|
||||
onClick={(event) => event.target === event.currentTarget && explorer.choose(null)}
|
||||
onContextMenu={(event) => event.target === event.currentTarget && openMenu(event, null)}
|
||||
>
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.path}>
|
||||
<button
|
||||
type="button"
|
||||
data-kind={entry.kind}
|
||||
aria-pressed={entry.path === selected}
|
||||
onClick={() => explorer.choose(entry.path)}
|
||||
onDoubleClick={() => entry.kind === 'folder' && explorer.open(entry.path)}
|
||||
onContextMenu={(event) => openMenu(event, entry.path)}
|
||||
>
|
||||
<img src={entry.kind === 'folder' ? folderIcon : fileIcon} alt="" />
|
||||
<span>{entry.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
{menu === null ? null : (
|
||||
<ContextMenu
|
||||
at={{ x: menu.x, y: menu.y }}
|
||||
items={explorer.menu(menu.onEntry).map((item) => ({ key: item, label: t(ITEM_KEYS[item]), onChoose: () => run(item) }))}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
)}
|
||||
{asking?.kind === 'newFolder' || asking?.kind === 'rename' ? (
|
||||
<form className="mail-dialog" role="dialog" aria-label={t(ITEM_KEYS[asking.kind])} onSubmit={submitName}>
|
||||
<h4>{t(ITEM_KEYS[asking.kind])}</h4>
|
||||
<label>
|
||||
<span>{t('explorer.newName')}</span>
|
||||
<input value={asking.name} autoFocus autoCapitalize="off" autoCorrect="off" spellCheck={false} onChange={(event) => setAsking({ ...asking, name: event.target.value, problem: null })} />
|
||||
</label>
|
||||
{asking.problem === null ? null : (
|
||||
<p className="mail-notice" role="alert">
|
||||
{asking.problem}
|
||||
</p>
|
||||
)}
|
||||
<div className="mail-dialog-buttons">
|
||||
<button type="submit">{t('explorer.ok')}</button>
|
||||
<button type="button" onClick={() => setAsking(null)}>
|
||||
{t('explorer.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : null}
|
||||
{asking?.kind === 'delete' ? (
|
||||
<div className="mail-question" role="alertdialog" aria-label={t('explorer.sure')}>
|
||||
<p>{t('explorer.sure')}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
explorer.delete();
|
||||
setAsking(null);
|
||||
}}
|
||||
>
|
||||
{t('explorer.yes')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setAsking(null)}>
|
||||
{t('explorer.no')}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{asking?.kind === 'import' ? <ImportDialog explorer={explorer} onClose={() => setAsking(null)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Java's import window (fileImport): the local file (read only) and "Datei
|
||||
* wählen", the new name (the local file's at first), "Datei importieren",
|
||||
* the result, "Schließen".
|
||||
*/
|
||||
function ImportDialog({ explorer, onClose }: { readonly explorer: FileExplorerWindow; readonly onClose: () => void }) {
|
||||
const t = useTranslate();
|
||||
const picker = useRef<HTMLInputElement>(null);
|
||||
const [local, setLocal] = useState<{ readonly name: string; readonly bytes: Uint8Array } | null>(null);
|
||||
const [name, setName] = useState('');
|
||||
const [output, setOutput] = useState('');
|
||||
const importNow = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
if (local === null || name === '') {
|
||||
setOutput(t('explorer.importMissing'));
|
||||
return;
|
||||
}
|
||||
const result = explorer.importLocal(name, local.name, local.bytes);
|
||||
setOutput(result === 'imported' ? t('explorer.imported') : result === 'tooLarge' ? `${t('explorer.notImported')}\n${t('explorer.tooLarge')}` : t('explorer.notImported'));
|
||||
};
|
||||
return (
|
||||
<form className="mail-dialog file-import" role="dialog" aria-label={t('explorer.importTitle')} onSubmit={importNow}>
|
||||
<h4>{t('explorer.importTitle')}</h4>
|
||||
<label>
|
||||
<span>{t('explorer.localFile')}</span>
|
||||
<input value={local?.name ?? ''} readOnly />
|
||||
<button type="button" onClick={() => picker.current?.click()}>
|
||||
{t('explorer.chooseFile')}
|
||||
</button>
|
||||
</label>
|
||||
<input
|
||||
ref={picker}
|
||||
type="file"
|
||||
hidden
|
||||
onChange={async (event) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (file === undefined) return;
|
||||
setLocal({ name: file.name, bytes: new Uint8Array(await file.arrayBuffer()) });
|
||||
setName(file.name);
|
||||
setOutput('');
|
||||
}}
|
||||
/>
|
||||
<label>
|
||||
<span>{t('explorer.newName')}</span>
|
||||
<input value={name} autoCapitalize="off" autoCorrect="off" spellCheck={false} onChange={(event) => setName(event.target.value)} />
|
||||
<button type="submit">{t('explorer.importButton')}</button>
|
||||
</label>
|
||||
<p className="file-import-output" role="status">
|
||||
{output}
|
||||
</p>
|
||||
<div className="mail-dialog-buttons">
|
||||
<button type="button" onClick={onClose}>
|
||||
{t('explorer.close')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// A computer's image viewer, after Java's GUIApplicationImageViewerWindow:
|
||||
// "Öffnen" above, Java's file dialog over it (FileDialog), the picture below
|
||||
// as the document has it. What it does is the window's (ImageViewerWindow,
|
||||
// fileExplorer.ts, with what it does better than Java).
|
||||
|
||||
import { useEffect, useState, useSyncExternalStore } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
import { base64Encode } from '@mdg-filius/core';
|
||||
import { nodeFiles } from '@mdg-filius/doc';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { FileDialog } from './FileDialog.js';
|
||||
import { imageViewerWindow, type ImageViewerWindow } from './fileExplorer.js';
|
||||
|
||||
/** A computer's image viewer window, drawn anew with each change of it. */
|
||||
export function useImageViewer(doc: Y.Doc, node: string): ImageViewerWindow {
|
||||
const window = imageViewerWindow(doc, node);
|
||||
useSyncExternalStore(
|
||||
(listener) => window.subscribe(listener),
|
||||
() => window.version,
|
||||
);
|
||||
return window;
|
||||
}
|
||||
|
||||
export function ImageViewerPanel({ doc, node }: { readonly doc: Y.Doc; readonly node: string }) {
|
||||
const t = useTranslate();
|
||||
const viewer = useImageViewer(doc, node);
|
||||
const [opening, setOpening] = useState(false);
|
||||
const [, setVersion] = useState(0);
|
||||
// The dialog lists the files as they are now.
|
||||
useEffect(() => {
|
||||
if (!opening) return;
|
||||
const update = () => setVersion((version) => version + 1);
|
||||
doc.on('update', update);
|
||||
return () => doc.off('update', update);
|
||||
}, [doc, opening]);
|
||||
const picture = viewer.picture;
|
||||
const files = opening ? nodeFiles(doc, node) : undefined;
|
||||
return (
|
||||
<div className="image-viewer">
|
||||
<div className="text-editor-bar">
|
||||
<button type="button" onClick={() => setOpening(true)}>
|
||||
{t('viewer.open')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="image-viewer-picture">{picture === undefined ? null : <img src={`data:${picture.mediaType};base64,${base64Encode(picture.data)}`} alt={t('viewer.picture')} />}</div>
|
||||
{files === undefined ? null : (
|
||||
<FileDialog
|
||||
kind="open"
|
||||
files={files}
|
||||
onChoose={(folder, name) => {
|
||||
const result = viewer.open(folder, name);
|
||||
if (result === 'opened') {
|
||||
setOpening(false);
|
||||
return null;
|
||||
}
|
||||
return t(`viewer.${result}`, { name });
|
||||
}}
|
||||
onCancel={() => setOpening(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -905,3 +905,124 @@
|
||||
.file-dialog-entries button[aria-pressed='true'] {
|
||||
background: #b8cfe5;
|
||||
}
|
||||
|
||||
/* The file explorer (GUIApplicationFileExplorerWindow): the menu's actions
|
||||
* as buttons, the tree beside the folder's entries (one above the other on
|
||||
* a narrow screen). */
|
||||
.file-explorer {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
background: #eeeeee;
|
||||
}
|
||||
|
||||
.file-explorer-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.file-explorer-bar button {
|
||||
min-width: 44px;
|
||||
min-height: 40px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid #a0a0a0;
|
||||
border-radius: 6px;
|
||||
background: #f8f8f8;
|
||||
color: #000000;
|
||||
font: 13px Dialog, sans-serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-explorer-bar button:disabled {
|
||||
color: #a0a0a0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.file-explorer-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.file-explorer-tree,
|
||||
.file-explorer-entries {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #a0a0a0;
|
||||
background: #ffffff;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.file-explorer-tree button,
|
||||
.file-explorer-entries button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 0 6px;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
color: #000000;
|
||||
font: 13px Dialog, sans-serif;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.file-explorer-tree button[aria-current='true'],
|
||||
.file-explorer-tree button[aria-pressed='true'],
|
||||
.file-explorer-entries button[aria-pressed='true'] {
|
||||
background: #b8cfe5;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.file-explorer-body {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.file-import label button {
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.file-import-output {
|
||||
min-height: 1.5em;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
/* The image viewer (GUIApplicationImageViewerWindow): "Öffnen" above, the picture below. */
|
||||
.image-viewer {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
background: #eeeeee;
|
||||
}
|
||||
|
||||
.image-viewer-picture {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.image-viewer-picture img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
// A computer's file explorer and image viewer windows, after Java's
|
||||
// GUIApplicationFileExplorerWindow and GUIApplicationImageViewerWindow
|
||||
// (measured with tools/java-oracle's FileExplorerWindowRun). Without React,
|
||||
// so that the windows' steps can be played against Java's
|
||||
// (test/fileExplorer.test.ts); the panels draw them. The files are the
|
||||
// document's, changed through the core's operations (filePrograms) with the
|
||||
// window's origin; while the simulation runs, the room takes them into the
|
||||
// engine.
|
||||
//
|
||||
// The explorer: the tree of all folders and files, the chosen folder's
|
||||
// entries, the menu of an entry (delete, cut, copy, rename) and of the free
|
||||
// space (new folder, paste while something is copied or cut — a copy stays
|
||||
// to paste again, a cut is gone once pasted), the import of a local file.
|
||||
// Better than Java (each measured):
|
||||
// - the tree and the list follow the files at once (Java's list shows what
|
||||
// the command line did only after "Aktualisieren", which is left out);
|
||||
// - a file chosen in the tree chooses it in its folder (Java takes the file
|
||||
// for a folder: the list is empty, and a new folder lands below the file);
|
||||
// - what an operation refuses is said (Java's rename of a name there already
|
||||
// does nothing); what the core does better (filePrograms.ts) holds here
|
||||
// too — a paste where the name is there already, a rename to "/".
|
||||
//
|
||||
// The image viewer: "Öffnen" over Java's file dialog, the picture, its name
|
||||
// as the title (Java's title is the file's name alone). Better than Java:
|
||||
// one picture (Java lays each one opened over the last), a file that is no
|
||||
// picture is said (Java shows an empty one), as one that is not there.
|
||||
|
||||
import type * as Y from 'yjs';
|
||||
import {
|
||||
deleteEntry,
|
||||
entryName,
|
||||
folderEntries,
|
||||
importFile,
|
||||
makeFolder,
|
||||
openImage,
|
||||
parentPath,
|
||||
pasteEntry,
|
||||
renameEntry,
|
||||
VirtualFileSystem,
|
||||
type Clipboard,
|
||||
type FolderEntry,
|
||||
type Id,
|
||||
} from '@mdg-filius/core';
|
||||
import { changeFiles, nodeFiles } from '@mdg-filius/doc';
|
||||
import { WindowState, windowOf } from './windowState.js';
|
||||
|
||||
/** A row of the explorer's tree: every folder and file, each folder's entries after it in the order they were made. */
|
||||
export interface TreeRow {
|
||||
readonly path: string;
|
||||
readonly name: string;
|
||||
readonly kind: 'file' | 'folder';
|
||||
readonly depth: number;
|
||||
}
|
||||
|
||||
/** The items of the explorer's menu (Java's popup): on an entry, or on the free space. */
|
||||
export type ExplorerItem = 'newFolder' | 'delete' | 'cut' | 'copy' | 'paste' | 'rename';
|
||||
|
||||
export type NewFolderResult = 'made' | 'noName' | 'invalidName';
|
||||
export type PasteResult = 'pasted' | 'invalidTarget' | 'nameTaken' | 'missing';
|
||||
export type RenameResult = 'renamed' | 'noName' | 'nameTaken' | 'invalidName';
|
||||
export type ImportResult = 'imported' | 'tooLarge' | 'notImported';
|
||||
|
||||
export class FileExplorerWindow extends WindowState {
|
||||
private folderValue = '/';
|
||||
private selectedValue: string | null = null;
|
||||
private clipboardValue: Clipboard | null = null;
|
||||
/** The window's origin for what it changes in the document. */
|
||||
readonly origin: object = {};
|
||||
|
||||
constructor(
|
||||
private readonly doc: Y.Doc,
|
||||
private readonly node: Id,
|
||||
) {
|
||||
super();
|
||||
doc.on('update', () => this.changed());
|
||||
}
|
||||
|
||||
/** The computer's files as they are now. */
|
||||
files(): VirtualFileSystem {
|
||||
return nodeFiles(this.doc, this.node) ?? new VirtualFileSystem();
|
||||
}
|
||||
|
||||
/** The chosen folder; the nearest folder above it that is there when another removed it. */
|
||||
get folder(): string {
|
||||
const files = this.files();
|
||||
let folder = this.folderValue;
|
||||
while (folder !== '/' && !(files.contains(folder) && files.entry(folder).content.kind === 'directory')) folder = parentPath(folder);
|
||||
return folder === '/' ? '/' : files.entry(folder).path;
|
||||
}
|
||||
|
||||
/** The chosen entry of the folder; none when another removed it. */
|
||||
get selected(): string | null {
|
||||
const selected = this.selectedValue;
|
||||
if (selected === null) return null;
|
||||
const files = this.files();
|
||||
return files.contains(selected) ? files.entry(selected).path : null;
|
||||
}
|
||||
|
||||
get clipboard(): Clipboard | null {
|
||||
return this.clipboardValue;
|
||||
}
|
||||
|
||||
tree(): TreeRow[] {
|
||||
const files = this.files();
|
||||
const below = (folder: string, depth: number): TreeRow[] =>
|
||||
folderEntries(files, folder).flatMap((entry) => [{ path: entry.path, name: entry.name, kind: entry.kind, depth }, ...(entry.kind === 'folder' ? below(entry.path, depth + 1) : [])]);
|
||||
return [{ path: '/', name: '/', kind: 'folder', depth: 0 }, ...below('/', 1)];
|
||||
}
|
||||
|
||||
entries(): FolderEntry[] {
|
||||
return folderEntries(this.files(), this.folder);
|
||||
}
|
||||
|
||||
/** A row of the tree chosen: a folder is the folder then, a file is chosen in its folder. */
|
||||
chooseInTree(path: string): void {
|
||||
const files = this.files();
|
||||
if (!files.contains(path)) return;
|
||||
const entry = files.entry(path);
|
||||
if (entry.content.kind === 'directory') {
|
||||
this.folderValue = entry.path;
|
||||
this.selectedValue = null;
|
||||
} else {
|
||||
this.folderValue = parentPath(entry.path);
|
||||
this.selectedValue = entry.path;
|
||||
}
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/** An entry of the list chosen, or none (the free space). */
|
||||
choose(path: string | null): void {
|
||||
this.selectedValue = path;
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/** A folder of the list opened (a double click; Java's list opens nothing). */
|
||||
open(path: string): void {
|
||||
this.chooseInTree(path);
|
||||
}
|
||||
|
||||
/** Java's menu: on an entry delete, cut, copy, rename; on the free space a new folder and, with something to paste, paste. */
|
||||
menu(onEntry: boolean): ExplorerItem[] {
|
||||
return onEntry ? ['delete', 'cut', 'copy', 'rename'] : ['newFolder', ...(this.clipboardValue === null ? [] : (['paste'] as const))];
|
||||
}
|
||||
|
||||
private change<T>(operation: (files: VirtualFileSystem) => T): T {
|
||||
return changeFiles(this.doc, this.node, this.origin, operation) as T;
|
||||
}
|
||||
|
||||
/** "Neuer Ordner": the name as a path from the folder, nothing for a name there already (makeFolder). */
|
||||
newFolder(name: string): NewFolderResult {
|
||||
if (name.trim() === '') return 'noName';
|
||||
const folder = this.folder;
|
||||
return this.change((files) => makeFolder(files, folder, name)) ? 'made' : 'invalidName';
|
||||
}
|
||||
|
||||
/** "Löschen" of the chosen entry, a folder with all in it (after Java's question, which the panel asks). */
|
||||
delete(): boolean {
|
||||
const selected = this.selected;
|
||||
if (selected === null) return false;
|
||||
const folder = this.folder;
|
||||
const done = this.change((files) => deleteEntry(files, folder, entryName(selected)));
|
||||
this.changed();
|
||||
return done;
|
||||
}
|
||||
|
||||
/** "Ausschneiden" and "Kopieren": the chosen entry to paste. */
|
||||
take(cut: boolean): void {
|
||||
const selected = this.selected;
|
||||
if (selected === null) return;
|
||||
this.clipboardValue = { path: selected, cut };
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/** "Einfügen" into the folder; what was cut is gone from the clipboard once pasted, a copy stays. */
|
||||
paste(): PasteResult {
|
||||
const clipboard = this.clipboardValue;
|
||||
if (clipboard === null) return 'missing';
|
||||
const folder = this.folder;
|
||||
const result = this.change((files) => pasteEntry(files, clipboard, folder));
|
||||
if (result === 'pasted' && clipboard.cut) this.clipboardValue = null;
|
||||
this.changed();
|
||||
return result;
|
||||
}
|
||||
|
||||
/** "Umbenennen" of the chosen entry: not to no name, not to a name another entry of the folder has. */
|
||||
rename(newName: string): RenameResult {
|
||||
const selected = this.selected;
|
||||
if (selected === null) return 'invalidName';
|
||||
if (newName.trim() === '') return 'noName';
|
||||
const folder = this.folder;
|
||||
const taken = this.entries().some((entry) => entry.path !== selected && entry.name.toLowerCase() === newName.toLowerCase());
|
||||
if (taken) return 'nameTaken';
|
||||
const done = this.change((files) => renameEntry(files, folder, entryName(selected), newName));
|
||||
if (done) this.selectedValue = `${folder === '/' ? '' : folder}/${newName}`;
|
||||
this.changed();
|
||||
return done ? 'renamed' : 'invalidName';
|
||||
}
|
||||
|
||||
/** "Datei importieren": a local file into the folder under the new name (importFile). */
|
||||
importLocal(newName: string, localName: string, bytes: Uint8Array): ImportResult {
|
||||
const folder = this.folder;
|
||||
return this.change((files) => importFile(files, folder, newName, localName, bytes));
|
||||
}
|
||||
}
|
||||
|
||||
export type ViewerOpenResult = 'opened' | 'notFound' | 'notImage';
|
||||
|
||||
export class ImageViewerWindow extends WindowState {
|
||||
private pathValue: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly doc: Y.Doc,
|
||||
private readonly node: Id,
|
||||
) {
|
||||
super();
|
||||
doc.on('update', () => this.pathValue !== null && this.changed());
|
||||
}
|
||||
|
||||
private files(): VirtualFileSystem {
|
||||
return nodeFiles(this.doc, this.node) ?? new VirtualFileSystem();
|
||||
}
|
||||
|
||||
/** The picture shown, as the document has it now; none before one is opened or when another removed it. */
|
||||
get picture(): { readonly path: string; readonly data: Uint8Array; readonly mediaType: string } | undefined {
|
||||
if (this.pathValue === null) return undefined;
|
||||
const found = openImage(this.files(), '/', this.pathValue);
|
||||
return typeof found === 'string' ? undefined : found;
|
||||
}
|
||||
|
||||
/** The program's name, the picture's name once one is shown (Java's setTitle with the file's name). */
|
||||
title(programName: string): string {
|
||||
const picture = this.picture;
|
||||
return picture === undefined ? programName : entryName(picture.path);
|
||||
}
|
||||
|
||||
/** "Öffnen" with the dialog's folder and name. */
|
||||
open(folder: string, name: string): ViewerOpenResult {
|
||||
const found = openImage(this.files(), folder, name);
|
||||
if (typeof found === 'string') return found;
|
||||
this.pathValue = found.path;
|
||||
this.changed();
|
||||
return 'opened';
|
||||
}
|
||||
}
|
||||
|
||||
export function fileExplorerWindow(doc: Y.Doc, node: Id): FileExplorerWindow {
|
||||
return windowOf(doc, node, 'fileExplorer', () => new FileExplorerWindow(doc, node));
|
||||
}
|
||||
|
||||
export function imageViewerWindow(doc: Y.Doc, node: Id): ImageViewerWindow {
|
||||
return windowOf(doc, node, 'imageViewer', () => new ImageViewerWindow(doc, node));
|
||||
}
|
||||
@@ -26,20 +26,19 @@
|
||||
import * as Y from 'yjs';
|
||||
import { entryName, openText, saveTextAs, type Id } from '@mdg-filius/core';
|
||||
import { changeFiles, fileText, nodeFiles, replaceText } from '@mdg-filius/doc';
|
||||
import { WindowState, windowOf } from './windowState.js';
|
||||
|
||||
export type OpenResult = 'opened' | 'notFound' | 'notText';
|
||||
export type SaveResult = 'saved' | 'askName';
|
||||
export type SaveAsResult = 'saved' | 'folderMissing' | 'isFolder' | 'invalidName';
|
||||
|
||||
export class TextEditorWindow {
|
||||
export class TextEditorWindow extends WindowState {
|
||||
private pathValue: string | null = null;
|
||||
private draft = '';
|
||||
private goneValue: string | null = null;
|
||||
private bound: Y.Text | undefined;
|
||||
private lastText = '';
|
||||
private undoManager: Y.UndoManager | undefined;
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private versionValue = 0;
|
||||
/** The window's origin: what it writes into the document, its own undo takes back. */
|
||||
readonly origin: object = {};
|
||||
|
||||
@@ -47,6 +46,7 @@ export class TextEditorWindow {
|
||||
private readonly doc: Y.Doc,
|
||||
private readonly node: Id,
|
||||
) {
|
||||
super();
|
||||
doc.on('update', () => this.refresh());
|
||||
}
|
||||
|
||||
@@ -69,21 +69,6 @@ export class TextEditorWindow {
|
||||
return this.bound === undefined ? this.draft : this.bound.toString();
|
||||
}
|
||||
|
||||
/** Changes with each change of what the window shows (for React). */
|
||||
get version(): number {
|
||||
return this.versionValue;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
private changed(): void {
|
||||
this.versionValue += 1;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
|
||||
/** GUIApplicationWindow.setTitle: the program's name, " - " and the open file's name. */
|
||||
title(programName: string): string {
|
||||
return this.pathValue === null ? programName : `${programName} - ${entryName(this.pathValue)}`;
|
||||
@@ -179,13 +164,7 @@ export class TextEditorWindow {
|
||||
}
|
||||
}
|
||||
|
||||
const windows = new WeakMap<Y.Doc, Map<Id, TextEditorWindow>>();
|
||||
|
||||
/** A computer's text editor window, the same while the editor runs (Java's desktop keeps its windows). */
|
||||
export function textEditorWindow(doc: Y.Doc, node: Id): TextEditorWindow {
|
||||
let ofDoc = windows.get(doc);
|
||||
if (ofDoc === undefined) windows.set(doc, (ofDoc = new Map()));
|
||||
let window = ofDoc.get(node);
|
||||
if (window === undefined) ofDoc.set(node, (window = new TextEditorWindow(doc, node)));
|
||||
return window;
|
||||
return windowOf(doc, node, 'textEditor', () => new TextEditorWindow(doc, node));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
// What the windows of a computer's local programs share (text editor, file
|
||||
// explorer, image viewer): their state is the window's own, without React,
|
||||
// so that their steps can be played against Java's; React draws them anew
|
||||
// with each change (version). One window of each program per computer while
|
||||
// the editor runs, as Java's desktop keeps its windows.
|
||||
|
||||
import type * as Y from 'yjs';
|
||||
import type { Id } from '@mdg-filius/core';
|
||||
|
||||
export class WindowState {
|
||||
private readonly listeners = new Set<() => void>();
|
||||
private versionValue = 0;
|
||||
|
||||
/** Changes with each change of what the window shows (for React). */
|
||||
get version(): number {
|
||||
return this.versionValue;
|
||||
}
|
||||
|
||||
subscribe(listener: () => void): () => void {
|
||||
this.listeners.add(listener);
|
||||
return () => this.listeners.delete(listener);
|
||||
}
|
||||
|
||||
protected changed(): void {
|
||||
this.versionValue += 1;
|
||||
for (const listener of this.listeners) listener();
|
||||
}
|
||||
}
|
||||
|
||||
const windows = new WeakMap<Y.Doc, Map<string, WindowState>>();
|
||||
|
||||
/** A computer's window of a program, the same while the editor runs. */
|
||||
export function windowOf<T extends WindowState>(doc: Y.Doc, node: Id, program: string, make: () => T): T {
|
||||
let ofDoc = windows.get(doc);
|
||||
if (ofDoc === undefined) windows.set(doc, (ofDoc = new Map()));
|
||||
const key = `${node}/${program}`;
|
||||
let window = ofDoc.get(key);
|
||||
if (window === undefined) ofDoc.set(key, (window = make()));
|
||||
return window as T;
|
||||
}
|
||||
Reference in New Issue
Block a user