Edit a text file where you are already reading it
v2 of the viewer: Edit, on text and Markdown, in the dialog and on the row menu. Save is explicit -- every save mints a new blob, so autosave would burn quota and multiply the conflicts it cannot see. Two people editing one file is the case worth getting right. `saveText` re-reads the node and compares the blob the editor started from: if somebody else saved in the meantime it refuses, says so, and leaves the work in the box to copy out. `ifInState` is the obvious tool and the wrong one -- it is the state of every FileNode in the account, so an unrelated upload in another folder would fail the save, and a warning that cries wolf is a warning people click through. Editing is not offered where saving would lose something: a file truncated for display would have its tail written away, and one that did not decode as UTF-8 would have mojibake written over whatever encoding it really is. Both open read-only and say which. Nor is it offered without mayModifyContent -- a read-only share just has no Edit. Closing or cancelling with unsaved changes asks first, Ctrl+S saves, and mail attachments are unaffected: they pass no onSave, because a message part is not a thing that can be written back.
This commit is contained in:
+241
-50
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Code2, Download, Eye, Printer } from "lucide-react";
|
||||
import { Dialog } from "./dialog";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { Code2, Download, Eye, Pencil, Printer, Save, X } from "lucide-react";
|
||||
import { confirmDialog, Dialog } from "./dialog";
|
||||
import { formatSize } from "@/lib/format";
|
||||
import { previewKind, TEXT_PREVIEW_CHARS, TEXT_PREVIEW_MAX } from "@/lib/preview";
|
||||
import { isMarkdown, renderMarkdown } from "@/lib/markdown";
|
||||
@@ -21,21 +21,128 @@ export interface PreviewFile {
|
||||
inlineUrl: string;
|
||||
}
|
||||
|
||||
type Mode = "rendered" | "source" | "edit";
|
||||
|
||||
/**
|
||||
* Shows a file without downloading it: pictures, PDFs, and anything text.
|
||||
*
|
||||
* Grown out of the attachment preview in MessageView, which is where it still
|
||||
* has one of its two callers -- the other is Files, which until now could only
|
||||
* hand you the bytes.
|
||||
* Grown out of the attachment preview in MessageView, which is still one of its
|
||||
* two callers -- the other is Files, which could only hand you the bytes.
|
||||
*
|
||||
* `onSave` is what makes it an editor. Files passes one; mail does not, because
|
||||
* a message part is not a thing that can be written back.
|
||||
*/
|
||||
export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFile | null; onClose: () => void; caption?: ReactNode }) {
|
||||
export function FilePreviewDialog({
|
||||
file,
|
||||
onClose,
|
||||
caption,
|
||||
onSave,
|
||||
startInEdit,
|
||||
}: {
|
||||
file: PreviewFile | null;
|
||||
onClose: () => void;
|
||||
caption?: ReactNode;
|
||||
/** Write the text back. Rejecting with a message is how a conflict is reported. */
|
||||
onSave?: (text: string) => Promise<void>;
|
||||
/** Open straight into the editor -- what the row menu's Edit asks for. */
|
||||
startInEdit?: boolean;
|
||||
}) {
|
||||
const kind = file ? previewKind(file.type, file.name) : null;
|
||||
const tooBig = kind === "text" && typeof file?.size === "number" && file.size > TEXT_PREVIEW_MAX;
|
||||
const pdfRef = useRef<HTMLIFrameElement>(null);
|
||||
const markdown = Boolean(file) && kind === "text" && isMarkdown(file!.type, file!.name);
|
||||
/* Markdown opens as the document it is meant to be; the source is a click
|
||||
away for anyone who wants to see what it actually says. */
|
||||
const [rendered, setRendered] = useState(true);
|
||||
const pdfRef = useRef<HTMLIFrameElement>(null);
|
||||
|
||||
const loaded = useTextFile(kind === "text" && !tooBig ? file?.url ?? null : null);
|
||||
const [mode, setMode] = useState<Mode>("source");
|
||||
const [draft, setDraft] = useState("");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState<string | null>(null);
|
||||
const [pendingEdit, setPendingEdit] = useState(false);
|
||||
|
||||
/* A new file starts over: Markdown as the document it is, everything else as
|
||||
what it says, and never in the editor. */
|
||||
useEffect(() => {
|
||||
setMode(markdown ? "rendered" : "source");
|
||||
setDraft("");
|
||||
setSaveError(null);
|
||||
setPendingEdit(Boolean(startInEdit));
|
||||
}, [file?.url, markdown, startInEdit]);
|
||||
|
||||
/*
|
||||
* Three reasons not to offer editing, and each of them would lose data:
|
||||
*
|
||||
* - the file was truncated for display, so saving would write the tail away;
|
||||
* - it did not decode as UTF-8 (the replacement character is the giveaway),
|
||||
* so saving would write mojibake over whatever encoding it really is;
|
||||
* - the caller has no way to save it, or the reader has no right to.
|
||||
*/
|
||||
const lossy = loaded.text?.includes("�") ?? false;
|
||||
const editable = Boolean(onSave) && kind === "text" && !tooBig && !loaded.truncated && !lossy && loaded.text !== null && !loaded.failed;
|
||||
const editing = mode === "edit";
|
||||
const dirty = editing && draft !== (loaded.text ?? "");
|
||||
|
||||
/* Opening straight into the editor has to wait for the text to arrive, and
|
||||
may still land in the read-only view: whether a file can be edited is not
|
||||
known until it has been read (truncated? not UTF-8?), and the row menu
|
||||
could only guess from its name. The note under the pane says why. */
|
||||
useEffect(() => {
|
||||
if (!pendingEdit || loaded.text === null) return;
|
||||
setPendingEdit(false);
|
||||
if (!editable) return;
|
||||
setDraft(loaded.text);
|
||||
setMode("edit");
|
||||
}, [pendingEdit, editable, loaded.text]);
|
||||
|
||||
const startEditing = () => {
|
||||
setDraft(loaded.text ?? "");
|
||||
setSaveError(null);
|
||||
setMode("edit");
|
||||
};
|
||||
|
||||
const stopEditing = async () => {
|
||||
if (dirty && !(await confirmDialog({ title: t("Throw away your changes?"), confirmLabel: t("Discard"), danger: true }))) return;
|
||||
setMode(markdown ? "rendered" : "source");
|
||||
setSaveError(null);
|
||||
};
|
||||
|
||||
const save = useCallback(async () => {
|
||||
if (!onSave || saving) return;
|
||||
setSaving(true);
|
||||
setSaveError(null);
|
||||
try {
|
||||
await onSave(draft);
|
||||
loaded.replace(draft);
|
||||
setMode(markdown ? "rendered" : "source");
|
||||
} catch (err) {
|
||||
setSaveError((err as Error).message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [onSave, saving, draft, loaded, markdown]);
|
||||
|
||||
/* The shortcut everyone's hands already know. Only while the editor is open,
|
||||
so it does not shadow the browser's own Save anywhere else. */
|
||||
useEffect(() => {
|
||||
if (!editing) return;
|
||||
const onKey = (ev: KeyboardEvent) => {
|
||||
if ((ev.ctrlKey || ev.metaKey) && ev.key.toLowerCase() === "s") {
|
||||
ev.preventDefault();
|
||||
void save();
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [editing, save]);
|
||||
|
||||
/* Closing with unsaved work asks first -- Escape and the backdrop both come
|
||||
through here. */
|
||||
const requestClose = () => {
|
||||
if (!dirty) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
void confirmDialog({ title: t("Close without saving?"), confirmLabel: t("Discard"), danger: true }).then((yes) => yes && onClose());
|
||||
};
|
||||
|
||||
/*
|
||||
* Print what is on screen, not the mail or the file list behind it.
|
||||
@@ -76,27 +183,43 @@ export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFil
|
||||
clear();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={Boolean(file)}
|
||||
onClose={onClose}
|
||||
onClose={requestClose}
|
||||
title={file?.name ?? t("Preview")}
|
||||
size="xl"
|
||||
footer={file && (
|
||||
<>
|
||||
{markdown && !tooBig && (
|
||||
<div className="segmented left" role="group" aria-label={t("View as")}>
|
||||
<button className={rendered ? "active" : ""} aria-pressed={rendered} onClick={() => setRendered(true)}><Eye size={14} /> {t("Rendered")}</button>
|
||||
<button className={rendered ? "" : "active"} aria-pressed={!rendered} onClick={() => setRendered(false)}><Code2 size={14} /> {t("Source")}</button>
|
||||
</div>
|
||||
)}
|
||||
{kind && !tooBig && <button className="btn" onClick={print}><Printer size={16} /> {t("Print")}</button>}
|
||||
<a className="btn" href={file.url} download={file.name}><Download size={16} /> {t("Download")}</a>
|
||||
</>
|
||||
)}
|
||||
closeOnBackdrop={!editing}
|
||||
footer={
|
||||
file && (
|
||||
<>
|
||||
{editing ? (
|
||||
<>
|
||||
{dirty && <span className="hint left">{t("Unsaved changes")}</span>}
|
||||
<button className="btn" onClick={() => void stopEditing()} disabled={saving}><X size={16} /> {t("Cancel")}</button>
|
||||
<button className="btn btn-primary" onClick={() => void save()} disabled={saving || !dirty}><Save size={16} /> {saving ? t("Saving…") : t("Save")}</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{markdown && !tooBig && (
|
||||
<div className="segmented left" role="group" aria-label={t("View as")}>
|
||||
<button className={mode === "rendered" ? "active" : ""} aria-pressed={mode === "rendered"} onClick={() => setMode("rendered")}><Eye size={14} /> {t("Rendered")}</button>
|
||||
<button className={mode === "source" ? "active" : ""} aria-pressed={mode === "source"} onClick={() => setMode("source")}><Code2 size={14} /> {t("Source")}</button>
|
||||
</div>
|
||||
)}
|
||||
{editable && <button className="btn" onClick={startEditing}><Pencil size={16} /> {t("Edit")}</button>}
|
||||
{kind && !tooBig && <button className="btn" onClick={print}><Printer size={16} /> {t("Print")}</button>}
|
||||
<a className="btn" href={file.url} download={file.name}><Download size={16} /> {t("Download")}</a>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
>
|
||||
{file && (
|
||||
<>
|
||||
{saveError && <div className="error-box mb-8">{saveError}</div>}
|
||||
{tooBig ? (
|
||||
<p className="hint">{t("This file is too big to show here ({size}) — download it to read it.", { size: formatSize(file.size ?? 0) })}</p>
|
||||
) : kind === "image" ? (
|
||||
@@ -104,10 +227,14 @@ export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFil
|
||||
) : kind === "pdf" ? (
|
||||
<iframe ref={pdfRef} title={file.name} src={file.inlineUrl} style={{ width: "100%", height: "70vh", border: 0 }} />
|
||||
) : kind === "text" ? (
|
||||
/* `url`, not `inlineUrl`: fetch pays no attention to
|
||||
Content-Disposition, so this works for the text types the server
|
||||
will not serve inline -- Markdown among them. */
|
||||
<TextPreview url={file.url} markdown={markdown && rendered} />
|
||||
<TextPane
|
||||
loaded={loaded}
|
||||
mode={mode}
|
||||
markdown={markdown}
|
||||
draft={draft}
|
||||
onDraft={setDraft}
|
||||
note={editing ? null : readOnlyNote(loaded, lossy)}
|
||||
/>
|
||||
) : (
|
||||
<p className="hint">{t("There is no preview for this kind of file.")}</p>
|
||||
)}
|
||||
@@ -118,27 +245,45 @@ export function FilePreviewDialog({ file, onClose, caption }: { file: PreviewFil
|
||||
);
|
||||
}
|
||||
|
||||
function TextPreview({ url, markdown }: { url: string; markdown: boolean }) {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
setText(null);
|
||||
setTruncated(false);
|
||||
fetch(url, { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((body) => {
|
||||
if (!live) return;
|
||||
setTruncated(body.length > TEXT_PREVIEW_CHARS);
|
||||
setText(body.slice(0, TEXT_PREVIEW_CHARS));
|
||||
})
|
||||
.catch(() => live && setText(t("Could not load this file.")));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [url]);
|
||||
/** Why the file is being shown but not offered for editing, when there is a reason worth saying. */
|
||||
function readOnlyNote(loaded: LoadedText, lossy: boolean): string | null {
|
||||
if (loaded.text === null || loaded.failed) return null;
|
||||
if (loaded.truncated) return t("Only the beginning is shown — download the file for the rest.");
|
||||
if (lossy) return t("This file is not UTF-8 text, so editing it here would corrupt it — download it instead.");
|
||||
return null;
|
||||
}
|
||||
|
||||
function TextPane({
|
||||
loaded,
|
||||
mode,
|
||||
markdown,
|
||||
draft,
|
||||
onDraft,
|
||||
note,
|
||||
}: {
|
||||
loaded: LoadedText;
|
||||
mode: Mode;
|
||||
markdown: boolean;
|
||||
draft: string;
|
||||
onDraft: (v: string) => void;
|
||||
note: string | null;
|
||||
}) {
|
||||
/* Rendering is not free on a long file, and the toggle flips back and forth. */
|
||||
const html = useMemo(() => (markdown && text ? renderMarkdown(text) : null), [markdown, text]);
|
||||
const html = useMemo(() => (mode === "rendered" && markdown && loaded.text ? renderMarkdown(loaded.text) : null), [mode, markdown, loaded.text]);
|
||||
if (mode === "edit") {
|
||||
return (
|
||||
<textarea
|
||||
className="code notranslate"
|
||||
translate="no"
|
||||
autoFocus
|
||||
spellCheck={false}
|
||||
value={draft}
|
||||
onChange={(e) => onDraft(e.target.value)}
|
||||
style={{ height: "60vh", whiteSpace: "pre", display: "block" }}
|
||||
aria-label={t("File contents")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<>
|
||||
{/* Someone else's file: not ours to translate, and not ours to reflow. */}
|
||||
@@ -146,10 +291,56 @@ function TextPreview({ url, markdown }: { url: string; markdown: boolean }) {
|
||||
<div className="md-body notranslate" translate="no" dangerouslySetInnerHTML={{ __html: html }} />
|
||||
) : (
|
||||
<pre className="code notranslate" translate="no" style={{ maxHeight: "65vh", whiteSpace: "pre-wrap" }}>
|
||||
{text ?? t("Loading…")}
|
||||
{loaded.text ?? t("Loading…")}
|
||||
</pre>
|
||||
)}
|
||||
{truncated && <p className="hint">{t("Only the beginning is shown — download the file for the rest.")}</p>}
|
||||
{note && <p className="hint">{note}</p>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
interface LoadedText {
|
||||
text: string | null;
|
||||
truncated: boolean;
|
||||
failed: boolean;
|
||||
/** Adopt what was just saved as the new baseline, without re-fetching. */
|
||||
replace(next: string): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The file's text, held here rather than in the pane that shows it: the editor,
|
||||
* the source view and the rendered view are all looking at the same bytes, and
|
||||
* saving has to know what they were to tell whether anything changed.
|
||||
*/
|
||||
function useTextFile(url: string | null): LoadedText {
|
||||
const [text, setText] = useState<string | null>(null);
|
||||
const [truncated, setTruncated] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
useEffect(() => {
|
||||
setText(null);
|
||||
setTruncated(false);
|
||||
setFailed(false);
|
||||
if (!url) return;
|
||||
let live = true;
|
||||
fetch(url, { credentials: "same-origin" })
|
||||
.then((r) => (r.ok ? r.text() : Promise.reject(new Error(String(r.status)))))
|
||||
.then((body) => {
|
||||
if (!live) return;
|
||||
setTruncated(body.length > TEXT_PREVIEW_CHARS);
|
||||
setText(body.slice(0, TEXT_PREVIEW_CHARS));
|
||||
})
|
||||
.catch(() => {
|
||||
if (!live) return;
|
||||
setFailed(true);
|
||||
setText(t("Could not load this file."));
|
||||
});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [url]);
|
||||
const replace = useCallback((next: string) => {
|
||||
setText(next);
|
||||
setTruncated(false);
|
||||
}, []);
|
||||
return { text, truncated, failed, replace };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user