feat: F4.6f2 the text editor on the document's Y.Text, files both ways while the simulation runs
Oracle TextEditorWindowRun uses Java's text editor window and its file dialog as a person does. The open file's text is its Y.Text in the document (plan 7.3): typed is in the file at once, for everyone and for the running engine. Core openText/saveTextAs without Java's mistakes (lost saves after save-as onto a file or a removed file, files named 'ordner/d.txt' or ''); doc files.ts (changeFiles, fileText, nodeFiles, readFilesInto); the room takes a person's files into the engine at once and writes the engine's files back at the tick they changed; the editor's window, Java's dialog, the text area following others' changes with the caret in place, its own undo. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 369 B |
Binary file not shown.
|
After Width: | Height: | Size: 480 B |
@@ -12,9 +12,9 @@
|
||||
// bar, as Java's windows do; on a phone it fills the surface. A program's
|
||||
// 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) have one, the others
|
||||
// follow. The browser's window takes its
|
||||
// title from its page, as Java's does.
|
||||
// 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.
|
||||
|
||||
import { useCallback, useEffect, useState, type PointerEvent as ReactPointerEvent } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
@@ -34,6 +34,7 @@ import { EmailServerPanel } from './EmailServerPanel.js';
|
||||
import { pageTitle } from './browserPage.js';
|
||||
import { InstallationPanel } from './InstallationPanel.js';
|
||||
import { TerminalPanel } from './TerminalPanel.js';
|
||||
import { TextEditorPanel, useTextEditorWindow } from './TextEditorPanel.js';
|
||||
import { WebServerPanel } from './WebServerPanel.js';
|
||||
import './desktop.css';
|
||||
|
||||
@@ -63,7 +64,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']);
|
||||
const WITH_WINDOW: ReadonlySet<DesktopProgram> = new Set(['commandPrompt', 'webBrowser', 'webServer', 'emailClient', 'emailServer', 'textEditor']);
|
||||
|
||||
/** 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) {
|
||||
@@ -81,6 +82,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
const t = useTranslate();
|
||||
const language = useLanguage();
|
||||
const view = useDesktopView(doc, id);
|
||||
const textEditor = useTextEditorWindow(doc, id);
|
||||
const { watch } = simulation;
|
||||
useEffect(() => watch([`node:${id}`]), [watch, id]);
|
||||
const settings = simulation.nodes.get(id);
|
||||
@@ -157,7 +159,13 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
) : shown === null ? null : (
|
||||
<ProgramFrame
|
||||
icon={JAVA_PROGRAMS[shown].icon}
|
||||
title={shown === 'webBrowser' ? browserTitle(browserView(simulation, id), t('program.webBrowser'), (page) => pageTitle(page, t)) : t(`program.${shown}`)}
|
||||
title={
|
||||
shown === 'webBrowser'
|
||||
? browserTitle(browserView(simulation, id), t('program.webBrowser'), (page) => pageTitle(page, t))
|
||||
: shown === 'textEditor'
|
||||
? textEditor.title(t('program.textEditor'))
|
||||
: t(`program.${shown}`)
|
||||
}
|
||||
program={shown}
|
||||
>
|
||||
{shown === 'commandPrompt' ? <TerminalPanel node={id} simulation={simulation} onExit={home} /> : null}
|
||||
@@ -165,6 +173,7 @@ export function DesktopWindow({ doc, id, simulation, active, x, y, z, program: o
|
||||
{shown === 'webServer' ? <WebServerPanel node={id} simulation={simulation} /> : null}
|
||||
{shown === 'emailClient' ? <EmailClientPanel node={id} simulation={simulation} /> : null}
|
||||
{shown === 'emailServer' ? <EmailServerPanel node={id} simulation={simulation} /> : null}
|
||||
{shown === 'textEditor' ? <TextEditorPanel doc={doc} node={id} /> : null}
|
||||
</ProgramFrame>
|
||||
)}
|
||||
{info ? (
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
// Java's file dialog for a program's "open" and "save" (DMTNFileChooser),
|
||||
// over the program's window: its title, the folder and „Ordner aufwärts“,
|
||||
// the folder's entries in the order they were made with Java's icons, the
|
||||
// name field, the dialog's own button and „Abbrechen“. It starts in the
|
||||
// root, as Java's does.
|
||||
//
|
||||
// Better than Java: the folder is shown by its path (Java's label shows its
|
||||
// name only, the root as "root"); a single tap opens a folder or takes a
|
||||
// file's name (Java wants a double click, which a finger rarely makes); what
|
||||
// the program refuses is said in the dialog, which stays open.
|
||||
|
||||
import { useState, type FormEvent } from 'react';
|
||||
import { folderEntries, parentPath, type VirtualFileSystem } from '@mdg-filius/core';
|
||||
import fileIcon from '../assets/desktop/datei.png';
|
||||
import folderIcon from '../assets/desktop/ordner.png';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
|
||||
export interface FileDialogProps {
|
||||
readonly kind: 'open' | 'save';
|
||||
/** The computer's files, as they are now. */
|
||||
readonly files: VirtualFileSystem;
|
||||
/** The dialog's folder and name chosen: what the program says against it, or null when it is done. */
|
||||
readonly onChoose: (folder: string, name: string) => string | null;
|
||||
readonly onCancel: () => void;
|
||||
}
|
||||
|
||||
export function FileDialog({ kind, files, onChoose, onCancel }: FileDialogProps) {
|
||||
const t = useTranslate();
|
||||
const [folder, setFolder] = useState('/');
|
||||
const [name, setName] = useState('');
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
// A folder removed meanwhile: the root.
|
||||
const shown = files.contains(folder) && files.entry(folder).content.kind === 'directory' ? files.entry(folder).path : '/';
|
||||
const entries = folderEntries(files, shown);
|
||||
const choose = (event: FormEvent) => {
|
||||
event.preventDefault();
|
||||
setProblem(onChoose(shown, name));
|
||||
};
|
||||
const title = t(kind === 'open' ? 'fileDialog.openTitle' : 'fileDialog.saveTitle');
|
||||
return (
|
||||
<form className="mail-dialog file-dialog" role="dialog" aria-label={title} onSubmit={choose}>
|
||||
<h4>{title}</h4>
|
||||
<div className="file-dialog-folder">
|
||||
<span>{`${t('fileDialog.folder')} ${shown}`}</span>
|
||||
<button type="button" disabled={shown === '/'} onClick={() => setFolder(parentPath(shown))}>
|
||||
{t('fileDialog.up')}
|
||||
</button>
|
||||
</div>
|
||||
<ul className="file-dialog-entries" aria-label={t('fileDialog.entries')}>
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.path}>
|
||||
<button
|
||||
type="button"
|
||||
data-kind={entry.kind}
|
||||
aria-pressed={entry.kind === 'file' ? entry.name === name : undefined}
|
||||
onClick={() => {
|
||||
setProblem(null);
|
||||
if (entry.kind === 'folder') setFolder(entry.path);
|
||||
else setName(entry.name);
|
||||
}}
|
||||
>
|
||||
<img src={entry.kind === 'folder' ? folderIcon : fileIcon} alt="" />
|
||||
<span>{entry.name}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<label>
|
||||
<span>{t('fileDialog.name')}</span>
|
||||
<input
|
||||
value={name}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
onChange={(event) => {
|
||||
setName(event.target.value);
|
||||
setProblem(null);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{problem === null ? null : (
|
||||
<p className="mail-notice" role="alert">
|
||||
{problem}
|
||||
</p>
|
||||
)}
|
||||
<div className="mail-dialog-buttons">
|
||||
<button type="submit">{t(kind === 'open' ? 'fileDialog.open' : 'fileDialog.save')}</button>
|
||||
<button type="button" onClick={onCancel}>
|
||||
{t('fileDialog.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
// A computer's text editor, after Java's GUIApplicationTextEditorWindow: the
|
||||
// buttons „Neu“, „Öffnen“, „Speichern“, „Speichern unter“ above, the text
|
||||
// below in Java's Courier, Java's file dialog over it (FileDialog). What it
|
||||
// does is the window's (TextEditorWindow, textEditor.ts, with what it does
|
||||
// better than Java); the text area follows the open file's Y.Text, so that
|
||||
// two who have it open type into the same text. Ctrl+Z and Ctrl+Y (or
|
||||
// Ctrl+Shift+Z) take back and redo this window's own typing in the file.
|
||||
//
|
||||
// The file is the document's, so it opens without the simulation's room;
|
||||
// while the simulation runs, the room takes what is typed into the engine's
|
||||
// files at once.
|
||||
|
||||
import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore, type KeyboardEvent } from 'react';
|
||||
import type * as Y from 'yjs';
|
||||
import { entryName } from '@mdg-filius/core';
|
||||
import { nodeFiles } from '@mdg-filius/doc';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { FileDialog } from './FileDialog.js';
|
||||
import { followText } from './textBinding.js';
|
||||
import { textEditorWindow, type TextEditorWindow } from './textEditor.js';
|
||||
|
||||
/** A computer's text editor window, drawn anew with each change of it. */
|
||||
export function useTextEditorWindow(doc: Y.Doc, node: string): TextEditorWindow {
|
||||
const window = textEditorWindow(doc, node);
|
||||
useSyncExternalStore(
|
||||
(listener) => window.subscribe(listener),
|
||||
() => window.version,
|
||||
);
|
||||
return window;
|
||||
}
|
||||
|
||||
/** The document's files of the computer, read anew after every change while the dialog is open. */
|
||||
function useFiles(doc: Y.Doc, node: string, reading: boolean) {
|
||||
const [, setVersion] = useState(0);
|
||||
useEffect(() => {
|
||||
if (!reading) return;
|
||||
const update = () => setVersion((version) => version + 1);
|
||||
doc.on('update', update);
|
||||
return () => doc.off('update', update);
|
||||
}, [doc, reading]);
|
||||
return reading ? nodeFiles(doc, node) : undefined;
|
||||
}
|
||||
|
||||
export function TextEditorPanel({ doc, node }: { readonly doc: Y.Doc; readonly node: string }) {
|
||||
const t = useTranslate();
|
||||
const window = useTextEditorWindow(doc, node);
|
||||
const [dialog, setDialog] = useState<'open' | 'save' | null>(null);
|
||||
const files = useFiles(doc, node, dialog !== null);
|
||||
const area = useRef<HTMLTextAreaElement>(null);
|
||||
const file = window.file;
|
||||
const path = window.path;
|
||||
|
||||
// The open file followed (bound once per file: what one types is in the area already).
|
||||
useLayoutEffect(() => {
|
||||
const element = area.current;
|
||||
return element === null || file === undefined ? undefined : followText(element, file, window.origin);
|
||||
}, [file, window]);
|
||||
// A new text set when it changes (Neu, a file gone), not while it is typed.
|
||||
useLayoutEffect(() => {
|
||||
const element = area.current;
|
||||
if (element !== null && file === undefined && element.value !== window.text) element.value = window.text;
|
||||
}, [file, window, window.version]);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || window.file === undefined) return;
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'z' && !event.shiftKey) window.undo();
|
||||
else if (key === 'y' || (key === 'z' && event.shiftKey)) window.redo();
|
||||
else return;
|
||||
event.preventDefault();
|
||||
};
|
||||
|
||||
const open = (folder: string, name: string): string | null => {
|
||||
const result = window.open(folder, name);
|
||||
if (result === 'opened') {
|
||||
setDialog(null);
|
||||
return null;
|
||||
}
|
||||
return t(`textEditor.${result}`, { name });
|
||||
};
|
||||
const saveAs = (folder: string, name: string): string | null => {
|
||||
if (name === '') return t('textEditor.noName');
|
||||
const result = window.saveAs(folder, name);
|
||||
if (result === 'saved') {
|
||||
setDialog(null);
|
||||
return null;
|
||||
}
|
||||
return t(`textEditor.${result}`, { name });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="text-editor">
|
||||
<div className="text-editor-bar">
|
||||
<button type="button" onClick={() => window.newText()}>
|
||||
{t('textEditor.new')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setDialog('open')}>
|
||||
{t('textEditor.open')}
|
||||
</button>
|
||||
<button type="button" disabled={path !== null} title={path === null ? undefined : t('textEditor.savedLive')} onClick={() => window.save() === 'askName' && setDialog('save')}>
|
||||
{t('textEditor.save')}
|
||||
</button>
|
||||
<button type="button" onClick={() => setDialog('save')}>
|
||||
{t('textEditor.saveAs')}
|
||||
</button>
|
||||
</div>
|
||||
{window.gone === null ? null : (
|
||||
<p className="mail-notice text-editor-notice" role="status">
|
||||
{t('textEditor.gone', { name: entryName(window.gone) })}
|
||||
</p>
|
||||
)}
|
||||
<textarea
|
||||
ref={area}
|
||||
className="text-editor-text"
|
||||
aria-label={t('textEditor.text')}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
defaultValue={window.text}
|
||||
onInput={(event) => window.input(event.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
/>
|
||||
{dialog === null || files === undefined ? null : <FileDialog kind={dialog} files={files} onChoose={dialog === 'open' ? open : saveAs} onCancel={() => setDialog(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -809,3 +809,99 @@
|
||||
.mail-question button + button {
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
/* The text editor (GUIApplicationTextEditorWindow): Java's four buttons
|
||||
* above, the text below in Courier; 16 pixels, as Safari on the iPad zooms
|
||||
* into smaller fields. */
|
||||
.text-editor {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
padding: 5px;
|
||||
background: #eeeeee;
|
||||
}
|
||||
|
||||
.text-editor-bar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.text-editor-bar button,
|
||||
.file-dialog-folder button {
|
||||
min-width: 44px;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid #a0a0a0;
|
||||
border-radius: 6px;
|
||||
background: #f8f8f8;
|
||||
color: #000000;
|
||||
font: 13px Dialog, sans-serif;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.text-editor-bar button:disabled,
|
||||
.file-dialog-folder button:disabled {
|
||||
color: #a0a0a0;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.text-editor-text {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
resize: none;
|
||||
padding: 4px;
|
||||
border: 1px solid #a0a0a0;
|
||||
background: #ffffff;
|
||||
color: #000000;
|
||||
font: 16px 'Courier New', Courier, monospace;
|
||||
}
|
||||
|
||||
/* Java's file dialog (DMTNFileChooser) over the program. */
|
||||
.file-dialog-folder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.file-dialog-folder span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.file-dialog-entries {
|
||||
flex: 1;
|
||||
min-height: 96px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
overflow: auto;
|
||||
border: 1px solid #a0a0a0;
|
||||
background: #ffffff;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.file-dialog-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-dialog-entries button[aria-pressed='true'] {
|
||||
background: #b8cfe5;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// A text area that follows a document's Y.Text (the text editor's open
|
||||
// file, plan §7.3): what another wrote — another person, the simulation,
|
||||
// an undo — comes into the area, the caret and the selection kept where they
|
||||
// were in the text around them. What one types goes the other way through
|
||||
// the window (TextEditorWindow.input), with the origin this leaves out.
|
||||
|
||||
import type * as Y from 'yjs';
|
||||
|
||||
/** A change of a Y.Text as its event gives it (retain, insert, delete). */
|
||||
export type TextDelta = readonly {
|
||||
readonly retain?: number;
|
||||
readonly insert?: unknown;
|
||||
readonly delete?: number;
|
||||
}[];
|
||||
|
||||
/**
|
||||
* Where a place in the text is after another's change: moved by what was
|
||||
* inserted before it and by what was deleted before it; an insertion just
|
||||
* at it leaves it before the new text (the other's typing goes on after the
|
||||
* caret, not under it).
|
||||
*/
|
||||
export function mapIndex(index: number, delta: TextDelta): number {
|
||||
let old = 0;
|
||||
let shift = 0;
|
||||
for (const op of delta) {
|
||||
if (old >= index) break;
|
||||
if (op.retain !== undefined) old += op.retain;
|
||||
else if (op.insert !== undefined) shift += typeof op.insert === 'string' ? op.insert.length : 1;
|
||||
else if (op.delete !== undefined) {
|
||||
shift -= Math.min(op.delete, index - old);
|
||||
old += op.delete;
|
||||
}
|
||||
}
|
||||
return index + shift;
|
||||
}
|
||||
|
||||
/** Lets the area follow the text, but for the changes of the origin (its own typing); returns the end of it. */
|
||||
export function followText(area: HTMLTextAreaElement, text: Y.Text, origin: object): () => void {
|
||||
area.value = text.toString();
|
||||
const onChange = (event: Y.YTextEvent, transaction: Y.Transaction) => {
|
||||
if (transaction.origin === origin) return;
|
||||
const delta = event.delta as TextDelta;
|
||||
const { selectionStart, selectionEnd, selectionDirection } = area;
|
||||
const focused = area.ownerDocument.activeElement === area;
|
||||
area.value = text.toString();
|
||||
if (focused) area.setSelectionRange(mapIndex(selectionStart, delta), mapIndex(selectionEnd, delta), selectionDirection);
|
||||
};
|
||||
text.observe(onChange);
|
||||
return () => text.unobserve(onChange);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
// A computer's text editor window, after Java's GUIApplicationTextEditorWindow
|
||||
// (measured with tools/java-oracle's TextEditorWindowRun): „Neu“, „Öffnen“,
|
||||
// „Speichern“, „Speichern unter“ over Java's file dialog, the title with the
|
||||
// open file's name. Without React, so that the window's steps can be played
|
||||
// against Java's (test/textEditor.test.ts); the panel draws it.
|
||||
//
|
||||
// The open file's text is its Y.Text in the document (plan §7.3): what one
|
||||
// types is in the file at once, for everyone who has it open and for the
|
||||
// running simulation — Java's editor keeps a copy until „Speichern“ and takes
|
||||
// what another program wrote only while nothing is typed. A new text not yet
|
||||
// saved is the window's own.
|
||||
//
|
||||
// Better than Java (each a mistake of Java's, measured):
|
||||
// - „Speichern unter“ onto a file that is there writes that file and goes on
|
||||
// in it (Java goes on in a copy no folder holds: every later „Speichern“
|
||||
// is lost), the title shows the file's own name (Java's the name typed);
|
||||
// - a file removed while it is open leaves its text as a new text, and
|
||||
// „Speichern“ asks for a name (Java's „Speichern“ is lost);
|
||||
// - „Öffnen“ of a name that is not there, or no name, says so and keeps the
|
||||
// open file (Java forgets it silently), and opens no image (Java shows its
|
||||
// Base64 text);
|
||||
// - „Speichern unter“ refuses an empty name and one with blanks at its ends,
|
||||
// and takes "ordner/d.txt" as the file d.txt in ordner (Java makes files
|
||||
// of such names).
|
||||
|
||||
import * as Y from 'yjs';
|
||||
import { entryName, openText, saveTextAs, type Id } from '@mdg-filius/core';
|
||||
import { changeFiles, fileText, nodeFiles, replaceText } from '@mdg-filius/doc';
|
||||
|
||||
export type OpenResult = 'opened' | 'notFound' | 'notText';
|
||||
export type SaveResult = 'saved' | 'askName';
|
||||
export type SaveAsResult = 'saved' | 'folderMissing' | 'isFolder' | 'invalidName';
|
||||
|
||||
export class TextEditorWindow {
|
||||
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 = {};
|
||||
|
||||
constructor(
|
||||
private readonly doc: Y.Doc,
|
||||
private readonly node: Id,
|
||||
) {
|
||||
doc.on('update', () => this.refresh());
|
||||
}
|
||||
|
||||
/** The open file's path; null for a new text. */
|
||||
get path(): string | null {
|
||||
return this.pathValue;
|
||||
}
|
||||
|
||||
/** The open file's text in the document; none for a new text. */
|
||||
get file(): Y.Text | undefined {
|
||||
return this.bound;
|
||||
}
|
||||
|
||||
/** The path of the open file that was removed while it was open, until the window opens or saves another or starts anew. */
|
||||
get gone(): string | null {
|
||||
return this.goneValue;
|
||||
}
|
||||
|
||||
get text(): string {
|
||||
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)}`;
|
||||
}
|
||||
|
||||
private readonly remember = () => {
|
||||
this.lastText = this.bound?.toString() ?? '';
|
||||
};
|
||||
|
||||
private bind(text: Y.Text | undefined, path: string | null, gone: string | null = null): void {
|
||||
this.goneValue = gone;
|
||||
this.bound?.unobserve(this.remember);
|
||||
this.undoManager?.destroy();
|
||||
this.bound = text;
|
||||
this.pathValue = path;
|
||||
this.undoManager = text === undefined ? undefined : new Y.UndoManager(text, { trackedOrigins: new Set([this.origin]), captureTimeout: 500 });
|
||||
text?.observe(this.remember);
|
||||
this.remember();
|
||||
}
|
||||
|
||||
/** After a change of the document: the open file found again (another may have replaced its entry), or gone. */
|
||||
private refresh(): void {
|
||||
if (this.pathValue === null) return;
|
||||
const now = fileText(this.doc, this.node, this.pathValue);
|
||||
if (now === this.bound) return;
|
||||
if (now === undefined) {
|
||||
this.draft = this.lastText;
|
||||
this.bind(undefined, null, this.pathValue);
|
||||
} else {
|
||||
this.bind(now, this.pathValue);
|
||||
}
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/** What the person typed: into the open file (where it differs), or the new text. */
|
||||
input(next: string): void {
|
||||
if (this.bound === undefined) {
|
||||
if (this.draft === next) return;
|
||||
this.draft = next;
|
||||
} else {
|
||||
const text = this.bound;
|
||||
this.doc.transact(() => replaceText(text, next), this.origin);
|
||||
}
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/** „Neu“: an empty new text. */
|
||||
newText(): void {
|
||||
this.draft = '';
|
||||
this.bind(undefined, null);
|
||||
this.changed();
|
||||
}
|
||||
|
||||
/** „Öffnen“ with the dialog's folder and name. */
|
||||
open(folder: string, name: string): OpenResult {
|
||||
const files = nodeFiles(this.doc, this.node);
|
||||
const found = files === undefined ? 'notFound' : openText(files, folder, name);
|
||||
if (typeof found === 'string') return found;
|
||||
this.bind(fileText(this.doc, this.node, found.path), found.path);
|
||||
this.changed();
|
||||
return 'opened';
|
||||
}
|
||||
|
||||
/** „Speichern“: the open file has the text already; a new text asks for a name („Speichern unter“). */
|
||||
save(): SaveResult {
|
||||
return this.pathValue === null ? 'askName' : 'saved';
|
||||
}
|
||||
|
||||
/** „Speichern unter“ with the dialog's folder and name: the text as that file, which is then the open one. */
|
||||
saveAs(folder: string, name: string): SaveAsResult {
|
||||
const text = this.text;
|
||||
let path: string | undefined;
|
||||
const result =
|
||||
changeFiles(this.doc, this.node, this.origin, (files) => {
|
||||
const saved = saveTextAs(files, folder, name, text);
|
||||
if (saved === 'saved') path = files.entry(`${folder === '/' ? '' : folder}/${name}`).path;
|
||||
return saved;
|
||||
}) ?? 'folderMissing';
|
||||
if (path !== undefined) {
|
||||
this.bind(fileText(this.doc, this.node, path), path);
|
||||
this.changed();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** The window's own undo in the open file (what it typed). */
|
||||
undo(): void {
|
||||
this.undoManager?.undo();
|
||||
}
|
||||
|
||||
redo(): void {
|
||||
this.undoManager?.redo();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -122,6 +122,15 @@
|
||||
"emailServer.username": "Benutzername:",
|
||||
"emailServer.whiteSpace": "Der Benutzername darf keine Leerzeichen enthalten!",
|
||||
"emailServer.yes": "Ja",
|
||||
"fileDialog.cancel": "Abbrechen",
|
||||
"fileDialog.entries": "Inhalt des Ordners",
|
||||
"fileDialog.folder": "Ordner:",
|
||||
"fileDialog.name": "Dateiname:",
|
||||
"fileDialog.open": "Öffnen",
|
||||
"fileDialog.openTitle": "Datei Öffnen",
|
||||
"fileDialog.save": "Speichern",
|
||||
"fileDialog.saveTitle": "Datei Speichern",
|
||||
"fileDialog.up": "Ordner aufwärts",
|
||||
"firewall.accept": "akzeptieren",
|
||||
"firewall.action": "Aktion",
|
||||
"firewall.active": "Firewall aktivieren",
|
||||
@@ -257,6 +266,19 @@
|
||||
"terminal.interrupt": "Befehl abbrechen (Strg+C)",
|
||||
"terminal.next": "Nächster Befehl",
|
||||
"terminal.previous": "Vorheriger Befehl",
|
||||
"textEditor.folderMissing": "Den Ordner für „{name}“ gibt es nicht.",
|
||||
"textEditor.gone": "Die Datei „{name}“ wurde gelöscht. Der Text ist noch da; „Speichern“ legt ihn als neue Datei ab.",
|
||||
"textEditor.invalidName": "„{name}“ ist kein zulässiger Dateiname.",
|
||||
"textEditor.isFolder": "„{name}“ ist ein Ordner.",
|
||||
"textEditor.new": "Neu",
|
||||
"textEditor.noName": "Geben Sie einen Dateinamen ein.",
|
||||
"textEditor.notFound": "Die Datei „{name}“ gibt es nicht.",
|
||||
"textEditor.notText": "„{name}“ ist keine Textdatei.",
|
||||
"textEditor.open": "Öffnen",
|
||||
"textEditor.save": "Speichern",
|
||||
"textEditor.saveAs": "Speichern unter",
|
||||
"textEditor.savedLive": "Was Sie tippen, steht sofort in der Datei.",
|
||||
"textEditor.text": "Text",
|
||||
"webServer.folder": "Unterverzeichnis",
|
||||
"webServer.hostName": "Hostname",
|
||||
"webServer.useVirtualHosts": "Verwende virtuelle Hosts",
|
||||
|
||||
@@ -122,6 +122,15 @@
|
||||
"emailServer.username": "User:",
|
||||
"emailServer.whiteSpace": "User names may not contain whitespace!",
|
||||
"emailServer.yes": "Yes",
|
||||
"fileDialog.cancel": "Cancel",
|
||||
"fileDialog.entries": "Contents of the folder",
|
||||
"fileDialog.folder": "Folder:",
|
||||
"fileDialog.name": "File:",
|
||||
"fileDialog.open": "Open",
|
||||
"fileDialog.openTitle": "Open file",
|
||||
"fileDialog.save": "Save",
|
||||
"fileDialog.saveTitle": "Save file",
|
||||
"fileDialog.up": "Folder up",
|
||||
"firewall.accept": "accept",
|
||||
"firewall.action": "Action",
|
||||
"firewall.active": "Activate firewall",
|
||||
@@ -257,6 +266,19 @@
|
||||
"terminal.interrupt": "Stop the command (Ctrl+C)",
|
||||
"terminal.next": "Next command",
|
||||
"terminal.previous": "Previous command",
|
||||
"textEditor.folderMissing": "There is no folder for “{name}”.",
|
||||
"textEditor.gone": "The file “{name}” was deleted. The text is still here; “Save” stores it as a new file.",
|
||||
"textEditor.invalidName": "“{name}” is not a valid file name.",
|
||||
"textEditor.isFolder": "“{name}” is a folder.",
|
||||
"textEditor.new": "New",
|
||||
"textEditor.noName": "Enter a file name.",
|
||||
"textEditor.notFound": "There is no file “{name}”.",
|
||||
"textEditor.notText": "“{name}” is not a text file.",
|
||||
"textEditor.open": "Open",
|
||||
"textEditor.save": "Save",
|
||||
"textEditor.saveAs": "Save as",
|
||||
"textEditor.savedLive": "What you type is in the file at once.",
|
||||
"textEditor.text": "Text",
|
||||
"webServer.folder": "Subdirectory",
|
||||
"webServer.hostName": "Hostname",
|
||||
"webServer.useVirtualHosts": "Enable virtual hosts",
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
import { StrictMode, useEffect, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import * as Y from 'yjs';
|
||||
import { desktopView, deviceSheet, emptyProject, topologyView, writeProject } from '@mdg-filius/doc';
|
||||
import { desktopView, deviceSheet, emptyProject, fileText, topologyView, writeProject } from '@mdg-filius/doc';
|
||||
import { openProjectArchive } from '@mdg-filius/fls';
|
||||
import { startBridge } from './bridge/dashboardBridge.js';
|
||||
import { Editor } from './Editor.js';
|
||||
@@ -60,9 +60,17 @@ function App() {
|
||||
.then((buffer) => setDoc(docOf(new Uint8Array(buffer))));
|
||||
}, [example]);
|
||||
|
||||
// The development page lets its browser tests read the document's drawing (never the editor embedded in the dashboard).
|
||||
// The development page lets its browser tests read the document's drawing and write into a file as another would (never the editor embedded in the dashboard).
|
||||
useEffect(() => {
|
||||
(window as unknown as { filiusDev?: unknown }).filiusDev = doc === null ? undefined : { view: () => topologyView(doc), sheet: (id: string) => deviceSheet(doc, id), desktop: (id: string) => desktopView(doc, id) };
|
||||
(window as unknown as { filiusDev?: unknown }).filiusDev =
|
||||
doc === null
|
||||
? undefined
|
||||
: {
|
||||
view: () => topologyView(doc),
|
||||
sheet: (id: string) => deviceSheet(doc, id),
|
||||
desktop: (id: string) => desktopView(doc, id),
|
||||
insertText: (id: string, path: string, index: number, text: string) => doc.transact(() => fileText(doc, id, path)?.insert(index, text), 'other'),
|
||||
};
|
||||
}, [doc]);
|
||||
|
||||
const t = (key: string) => translate(language, key);
|
||||
|
||||
Reference in New Issue
Block a user