feat(editor): a computer's files to and from the person's own files (F8.1)
Beside the dashboard only: Java's import window gets "Aus meinen Dateien" (D4) — the dashboard is asked over the bridge for a file of at most Java's 150 000 bytes and hands its bytes over — and a file's menu gets "In meine Dateien speichern" (D5), its bytes handed to the dashboard to save. - Bridge: chooseFile/chosenFile and saveFile/savedFile, each question with its number; an answer from elsewhere, of another version, for no question or of the other kind is no answer. - Whoever reads or finds the project frozen may save, not bring in. - doc fileBytes: a text as UTF-8, a picture as it is, and "missing" while a blob's bytes have not come (read so, the file would be empty). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,10 @@ export const COMMANDS = {
|
||||
SET_LOCALE: 'setLocale',
|
||||
/** `{ theme: 'light' | 'dark', tokens?: { [name]: value } }`: the dashboard's resolved colours. */
|
||||
SET_THEME: 'setTheme',
|
||||
/** `{ request, name, data: ArrayBuffer }` or `{ request, cancelled: true }`: the answer to `chooseFile` (F8, D4). */
|
||||
CHOSEN_FILE: 'chosenFile',
|
||||
/** `{ request, result: 'saved' | 'cancelled' | 'failed', name? }`: the answer to `saveFile` (F8, D5). */
|
||||
SAVED_FILE: 'savedFile',
|
||||
} as const;
|
||||
|
||||
/** Editor → dashboard. */
|
||||
@@ -21,6 +25,10 @@ export const EVENTS = {
|
||||
READY: 'ready',
|
||||
/** `{ people: [{ id, name, color }] }`: the others in the project, each time they change (plan §7.4: the dashboard's header shows them). */
|
||||
PEOPLE: 'people',
|
||||
/** `{ request, maxBytes }`: let the person choose a file of her own files, at most so large (F8, D4). */
|
||||
CHOOSE_FILE: 'chooseFile',
|
||||
/** `{ request, name, data: ArrayBuffer }`: let the person save this file into her own files (F8, D5). */
|
||||
SAVE_FILE: 'saveFile',
|
||||
} as const;
|
||||
|
||||
export interface BridgeHandlers {
|
||||
@@ -29,7 +37,7 @@ export interface BridgeHandlers {
|
||||
}
|
||||
|
||||
interface Window {
|
||||
readonly parent: { postMessage(message: unknown, targetOrigin: string): void };
|
||||
readonly parent: { postMessage(message: unknown, targetOrigin: string, transfer?: Transferable[]): void };
|
||||
addEventListener(type: 'message', listener: (event: { readonly origin: string; readonly data: unknown }) => void): void;
|
||||
removeEventListener(type: 'message', listener: (event: { readonly origin: string; readonly data: unknown }) => void): void;
|
||||
}
|
||||
@@ -55,3 +63,64 @@ export function startBridge(window: Window, parentOrigin: string, handlers: Brid
|
||||
export function tellDashboard(window: Pick<Window, 'parent'>, parentOrigin: string, type: (typeof EVENTS)[keyof typeof EVENTS], fields: Readonly<Record<string, unknown>> = {}): void {
|
||||
window.parent.postMessage({ v: BRIDGE_VERSION, type, ...fields }, parentOrigin);
|
||||
}
|
||||
|
||||
/** A file of the person's own files, as the dashboard hands it over. */
|
||||
export interface ChosenFile {
|
||||
readonly name: string;
|
||||
readonly bytes: Uint8Array;
|
||||
}
|
||||
|
||||
export type SaveResult = 'saved' | 'cancelled' | 'failed';
|
||||
|
||||
/** The two ways between a computer's files and the person's own files in the dashboard (F8: D4, D5). */
|
||||
export interface DashboardFiles {
|
||||
/** A file chosen there, at most `maxBytes` large; none when she cancels. */
|
||||
chooseFile(maxBytes: number): Promise<ChosenFile | null>;
|
||||
/** The bytes saved there under the name (she chooses the folder, and may rename). */
|
||||
saveFile(name: string, bytes: Uint8Array): Promise<{ readonly result: SaveResult; readonly name: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the dashboard at `parentOrigin` for files and to save them; each
|
||||
* question has its number and waits for the answer with it. The bytes go as
|
||||
* a copy (`slice`): the editor keeps its own, the copy is handed over
|
||||
* (`transfer`) rather than copied a second time. An answer from elsewhere, of
|
||||
* another version or for no question is no answer.
|
||||
*/
|
||||
export function dashboardFiles(window: Window, parentOrigin: string): DashboardFiles & { stop(): void } {
|
||||
let next = 0;
|
||||
const waiting = new Map<number, { readonly kind: string; readonly answer: (data: Record<string, unknown>) => void }>();
|
||||
const listener = (event: { readonly origin: string; readonly data: unknown }) => {
|
||||
if (event.origin !== parentOrigin) return;
|
||||
const data = event.data as Record<string, unknown> | null;
|
||||
if (data === null || typeof data !== 'object' || data.v !== BRIDGE_VERSION) return;
|
||||
const question = typeof data.request === 'number' ? waiting.get(data.request) : undefined;
|
||||
// Only the answer of its own kind: "saved" is no answer to "choose".
|
||||
if (question === undefined || question.kind !== data.type) return;
|
||||
waiting.delete(data.request as number);
|
||||
question.answer(data);
|
||||
};
|
||||
window.addEventListener('message', listener);
|
||||
const ask = (type: (typeof EVENTS)[keyof typeof EVENTS], kind: string, fields: Record<string, unknown>, transfer: Transferable[] = []) =>
|
||||
new Promise<Record<string, unknown>>((resolve) => {
|
||||
const request = ++next;
|
||||
waiting.set(request, { kind, answer: resolve });
|
||||
window.parent.postMessage({ v: BRIDGE_VERSION, type, request, ...fields }, parentOrigin, transfer);
|
||||
});
|
||||
return {
|
||||
async chooseFile(maxBytes) {
|
||||
const data = await ask(EVENTS.CHOOSE_FILE, COMMANDS.CHOSEN_FILE, { maxBytes });
|
||||
if (typeof data.name !== 'string' || !(data.data instanceof ArrayBuffer)) return null;
|
||||
return { name: data.name, bytes: new Uint8Array(data.data) };
|
||||
},
|
||||
async saveFile(name, bytes) {
|
||||
const copy = bytes.slice().buffer;
|
||||
const data = await ask(EVENTS.SAVE_FILE, COMMANDS.SAVED_FILE, { name, data: copy }, [copy]);
|
||||
const result = data.result === 'saved' || data.result === 'cancelled' ? data.result : 'failed';
|
||||
return { result, name: typeof data.name === 'string' ? data.name : name };
|
||||
},
|
||||
stop() {
|
||||
window.removeEventListener('message', listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
// The person's own files, as far as this editor may reach them (F8): none on
|
||||
// the development page (no dashboard), no choosing for whoever may not write
|
||||
// (read, frozen) — saving a file one sees is open to everybody who sees it.
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { DashboardFiles } from './dashboardBridge.js';
|
||||
|
||||
export interface FilesLink {
|
||||
/** Choose a file there (D4); missing where the editor may not write. */
|
||||
readonly chooseFile?: DashboardFiles['chooseFile'];
|
||||
/** Save bytes there (D5). */
|
||||
readonly saveFile: DashboardFiles['saveFile'];
|
||||
}
|
||||
|
||||
export const DashboardFilesContext = createContext<FilesLink | null>(null);
|
||||
|
||||
export const useDashboardFiles = (): FilesLink | null => useContext(DashboardFilesContext);
|
||||
@@ -13,9 +13,11 @@
|
||||
|
||||
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 { entryName, IMPORT_MAXIMUM_BYTES } from '@mdg-filius/core';
|
||||
import { fileBytes } from '@mdg-filius/doc';
|
||||
import fileIcon from '../assets/desktop/datei.png';
|
||||
import folderIcon from '../assets/desktop/ordner.png';
|
||||
import { useDashboardFiles } from '../bridge/dashboardFilesContext.js';
|
||||
import { ContextMenu } from '../design/ContextMenu.js';
|
||||
import { useTranslate } from '../i18n/i18n.js';
|
||||
import { fileExplorerWindow, type ExplorerItem, type FileExplorerWindow } from './fileExplorer.js';
|
||||
@@ -49,6 +51,23 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const folder = explorer.folder;
|
||||
const selected = explorer.selected;
|
||||
const files = useDashboardFiles();
|
||||
const selectedFile = selected !== null && explorer.entries().some((entry) => entry.path === selected && entry.kind === 'file');
|
||||
|
||||
/* D5 (F8): the chosen file into the person's own files — not Java's, the dashboard's; she chooses the folder there. */
|
||||
const saveToFiles = async () => {
|
||||
setNotice(null);
|
||||
if (files === null || selected === null) return;
|
||||
const found = fileBytes(doc, node, selected);
|
||||
if (found === 'notFile') return;
|
||||
if (found === 'missing') {
|
||||
setNotice(t('explorer.notHereYet'));
|
||||
return;
|
||||
}
|
||||
const { result, name } = await files.saveFile(found.name, found.bytes);
|
||||
if (result === 'saved') setNotice(t('explorer.savedToFiles', { name }));
|
||||
else if (result === 'failed') setNotice(t('explorer.saveFailed'));
|
||||
};
|
||||
|
||||
const run = (item: ExplorerItem) => {
|
||||
setNotice(null);
|
||||
@@ -102,6 +121,11 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
|
||||
<button type="button" onClick={() => setAsking({ kind: 'import' })}>
|
||||
{t('explorer.import')}
|
||||
</button>
|
||||
{files === null ? null : (
|
||||
<button type="button" data-item="saveToFiles" disabled={!selectedFile} onClick={() => void saveToFiles()}>
|
||||
{t('explorer.saveToFiles')}
|
||||
</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])}
|
||||
@@ -157,7 +181,11 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
|
||||
{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) }))}
|
||||
items={[
|
||||
...explorer.menu(menu.onEntry).map((item) => ({ key: item, label: t(ITEM_KEYS[item]), onChoose: () => run(item) })),
|
||||
// Not Java's: only beside the dashboard, only on a file.
|
||||
...(files !== null && menu.onEntry && selectedFile ? [{ key: 'saveToFiles', label: t('explorer.saveToFiles'), onChoose: () => void saveToFiles() }] : []),
|
||||
]}
|
||||
onClose={() => setMenu(null)}
|
||||
/>
|
||||
)}
|
||||
@@ -198,7 +226,7 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{asking?.kind === 'import' ? <ImportDialog explorer={explorer} onClose={() => setAsking(null)} /> : null}
|
||||
{asking?.kind === 'import' ? <ImportDialog explorer={explorer} chooseFile={files?.chooseFile} onClose={() => setAsking(null)} /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -208,7 +236,15 @@ export function FileExplorerPanel({ doc, node }: { readonly doc: Y.Doc; readonly
|
||||
* 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 }) {
|
||||
function ImportDialog({
|
||||
explorer,
|
||||
chooseFile,
|
||||
onClose,
|
||||
}: {
|
||||
readonly explorer: FileExplorerWindow;
|
||||
readonly chooseFile: ((maxBytes: number) => Promise<{ readonly name: string; readonly bytes: Uint8Array } | null>) | undefined;
|
||||
readonly onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslate();
|
||||
const picker = useRef<HTMLInputElement>(null);
|
||||
const [local, setLocal] = useState<{ readonly name: string; readonly bytes: Uint8Array } | null>(null);
|
||||
@@ -232,6 +268,21 @@ function ImportDialog({ explorer, onClose }: { readonly explorer: FileExplorerWi
|
||||
<button type="button" onClick={() => picker.current?.click()}>
|
||||
{t('explorer.chooseFile')}
|
||||
</button>
|
||||
{/* D4 (F8): the same window, the file from the person's own files — Java's limit holds there too, the dashboard says it before fetching. */}
|
||||
{chooseFile === undefined ? null : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={async () => {
|
||||
const chosen = await chooseFile(IMPORT_MAXIMUM_BYTES);
|
||||
if (chosen === null) return;
|
||||
setLocal(chosen);
|
||||
setName(chosen.name);
|
||||
setOutput('');
|
||||
}}
|
||||
>
|
||||
{t('explorer.fromFiles')}
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
ref={picker}
|
||||
|
||||
@@ -208,6 +208,7 @@
|
||||
"explorer.entries": "Inhalt des Ordners",
|
||||
"explorer.file": "Datei",
|
||||
"explorer.folder": "Ordner",
|
||||
"explorer.fromFiles": "Aus meinen Dateien …",
|
||||
"explorer.import": "Importieren",
|
||||
"explorer.importButton": "Datei importieren",
|
||||
"explorer.importMissing": "Eingabe- und Ausgabedatei müssen angegeben werden!",
|
||||
@@ -222,12 +223,16 @@
|
||||
"explorer.newName": "Neuer Name:",
|
||||
"explorer.no": "Nein",
|
||||
"explorer.noName": "Geben Sie einen Namen ein.",
|
||||
"explorer.notHereYet": "Die Datei ist in diesem Browser noch nicht ganz angekommen. Versuch es gleich noch einmal.",
|
||||
"explorer.notImported": "Datei konnte nicht importiert werden",
|
||||
"explorer.ok": "OK",
|
||||
"explorer.paste": "Einfügen",
|
||||
"explorer.pasteMissing": "Was eingefügt werden sollte, gibt es nicht mehr.",
|
||||
"explorer.pasteTaken": "In diesem Ordner gibt es „{name}“ schon.",
|
||||
"explorer.rename": "Umbenennen",
|
||||
"explorer.saveFailed": "Die Datei ließ sich nicht in deine Dateien speichern.",
|
||||
"explorer.saveToFiles": "In meine Dateien speichern …",
|
||||
"explorer.savedToFiles": "„{name}“ steht jetzt in deinen Dateien.",
|
||||
"explorer.sure": "Sicher?",
|
||||
"explorer.tooLarge": "Datei existiert nicht oder ist zu groß (> 150KB)",
|
||||
"explorer.tree": "Ordner und Dateien",
|
||||
|
||||
@@ -208,6 +208,7 @@
|
||||
"explorer.entries": "Contents of the folder",
|
||||
"explorer.file": "File",
|
||||
"explorer.folder": "Folder",
|
||||
"explorer.fromFiles": "From my files …",
|
||||
"explorer.import": "Import",
|
||||
"explorer.importButton": "Import file",
|
||||
"explorer.importMissing": "Select input and output file!",
|
||||
@@ -222,12 +223,16 @@
|
||||
"explorer.newName": "New name:",
|
||||
"explorer.no": "No",
|
||||
"explorer.noName": "Enter a name.",
|
||||
"explorer.notHereYet": "The file has not fully arrived in this browser yet. Try again in a moment.",
|
||||
"explorer.notImported": "File could not be imported.",
|
||||
"explorer.ok": "OK",
|
||||
"explorer.paste": "Paste",
|
||||
"explorer.pasteMissing": "What was to be pasted is no longer there.",
|
||||
"explorer.pasteTaken": "This folder already has an entry “{name}”.",
|
||||
"explorer.rename": "Rename",
|
||||
"explorer.saveFailed": "The file could not be saved to your files.",
|
||||
"explorer.saveToFiles": "Save to my files …",
|
||||
"explorer.savedToFiles": "“{name}” is in your files now.",
|
||||
"explorer.sure": "Sure?",
|
||||
"explorer.tooLarge": "File does not exist or is too large (> 150KB)",
|
||||
"explorer.tree": "Folders and files",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// The editor's page. On a project of server F (`?project=<uuid>`, and
|
||||
// `&server=<address>` when F is not where the page comes from) it opens the
|
||||
// shared document and the room there (SharedProject); embedded in the
|
||||
// dashboard (F6) it gets its language and theme over the bridge. On its own
|
||||
// dashboard (F6) it gets its language and theme over the bridge, and reaches
|
||||
// the person's own files through it (F8). On its own
|
||||
// — for development and the browser tests — it opens one of FILIUS'
|
||||
// examples, an empty project or a file (`?example=<name>` opens that example
|
||||
// at once, `?example=new` an empty project, `?lang=en` and `?theme=dark` set
|
||||
@@ -12,7 +13,8 @@ import { createRoot } from 'react-dom/client';
|
||||
import * as Y from 'yjs';
|
||||
import { changeFiles, desktopView, deviceSheet, emptyProject, fileText, topologyView, writeProject } from '@mdg-filius/doc';
|
||||
import { openProjectArchive } from '@mdg-filius/fls';
|
||||
import { EVENTS, startBridge, tellDashboard } from './bridge/dashboardBridge.js';
|
||||
import { dashboardFiles, EVENTS, startBridge, tellDashboard } from './bridge/dashboardBridge.js';
|
||||
import { DashboardFilesContext, type FilesLink } from './bridge/dashboardFilesContext.js';
|
||||
import { Editor } from './Editor.js';
|
||||
import { SharedProject } from './remote/SharedProject.js';
|
||||
import type { PresenceUser } from './remote/presence.js';
|
||||
@@ -39,6 +41,9 @@ function App() {
|
||||
const [example, setExample] = useState(params.get('example') ?? '');
|
||||
const project = params.get('project');
|
||||
const parent = params.get('parent');
|
||||
/* The person's own files (F8), only beside the dashboard. */
|
||||
const [files] = useState<(FilesLink & { stop(): void }) | null>(() => (parent === null ? null : dashboardFiles(window, parent)));
|
||||
useEffect(() => () => files?.stop(), [files]);
|
||||
|
||||
useEffect(() => {
|
||||
if (params.get('theme') === 'dark') document.documentElement.dataset.theme = 'dark';
|
||||
@@ -96,11 +101,13 @@ function App() {
|
||||
return (
|
||||
<LanguageProvider language={language}>
|
||||
<main style={{ height: '100%' }}>
|
||||
<SharedProject
|
||||
server={params.get('server') ?? window.location.origin}
|
||||
project={project}
|
||||
{...(parent === null ? {} : { onPeople: (people: readonly PresenceUser[]) => tellDashboard(window, parent, EVENTS.PEOPLE, { people }) })}
|
||||
/>
|
||||
<DashboardFilesContext.Provider value={files}>
|
||||
<SharedProject
|
||||
server={params.get('server') ?? window.location.origin}
|
||||
project={project}
|
||||
{...(parent === null ? {} : { onPeople: (people: readonly PresenceUser[]) => tellDashboard(window, parent, EVENTS.PEOPLE, { people }) })}
|
||||
/>
|
||||
</DashboardFilesContext.Provider>
|
||||
</main>
|
||||
</LanguageProvider>
|
||||
);
|
||||
|
||||
@@ -7,10 +7,12 @@
|
||||
// embeds it (`onPeople`: the dashboard's header, over the bridge). The
|
||||
// document's large binary files come from F and go to it (remoteBlobs):
|
||||
// fetched as the document names them; one that could not be taken to F is
|
||||
// said.
|
||||
// said. The person's own files (F8) reach a computer only where she may
|
||||
// write: whoever reads or finds the project frozen may save, not choose.
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { attachBlobs } from '@mdg-filius/doc';
|
||||
import { DashboardFilesContext, useDashboardFiles } from '../bridge/dashboardFilesContext.js';
|
||||
import { remoteBlobs, type RemoteBlobs } from './remoteBlobs.js';
|
||||
import { peopleOf, type PresenceUser } from './presence.js';
|
||||
import { usePresence } from './usePresence.js';
|
||||
@@ -45,7 +47,14 @@ function ReadyEditor({
|
||||
const presence = usePresence(state.awareness, state.you);
|
||||
const key = JSON.stringify(peopleOf(presence.peers, state.you.id));
|
||||
useEffect(() => onPeople?.(JSON.parse(key) as PresenceUser[]), [key, onPeople]);
|
||||
return <Editor doc={state.doc} createSimHost={createSimHost} readOnly={state.readOnly} frozen={state.frozen} presence={presence} />;
|
||||
const files = useDashboardFiles();
|
||||
const writing = !state.readOnly && !state.frozen;
|
||||
const reach = useMemo(() => (files === null || writing ? files : { saveFile: files.saveFile }), [files, writing]);
|
||||
return (
|
||||
<DashboardFilesContext.Provider value={reach}>
|
||||
<Editor doc={state.doc} createSimHost={createSimHost} readOnly={state.readOnly} frozen={state.frozen} presence={presence} />
|
||||
</DashboardFilesContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export interface SharedProjectProps {
|
||||
|
||||
Reference in New Issue
Block a user