222 lines
8.7 KiB
HTML
222 lines
8.7 KiB
HTML
|
|
<!doctype html>
|
||
|
|
<html lang="en">
|
||
|
|
<head>
|
||
|
|
<meta charset="utf-8">
|
||
|
|
<title>Word editor</title>
|
||
|
|
<style>
|
||
|
|
:root { color-scheme: light dark;
|
||
|
|
--bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09);
|
||
|
|
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d; --danger:#ff5b5b; }
|
||
|
|
@media (prefers-color-scheme: light) {
|
||
|
|
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10);
|
||
|
|
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid:#0AC18E; }
|
||
|
|
}
|
||
|
|
* { box-sizing: border-box; }
|
||
|
|
html, body { margin: 0; height: 100%; }
|
||
|
|
body { background: var(--bg); color: var(--ink);
|
||
|
|
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||
|
|
display: flex; flex-direction: column; }
|
||
|
|
h1 { font-size: 13px; font-weight: 600; margin: 0; }
|
||
|
|
.head { display: flex; align-items: center; gap: 8px; padding: 10px 12px;
|
||
|
|
border-bottom: 1px solid var(--line); background: var(--panel); }
|
||
|
|
.head .ico { font-size: 15px; }
|
||
|
|
.body { flex: 1; overflow: auto; padding: 12px; }
|
||
|
|
.lede { color: var(--mut); font-size: 12px; margin: 0 0 12px; }
|
||
|
|
.btn { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
||
|
|
border-radius: 7px; cursor: pointer; padding: 8px 10px; font: inherit;
|
||
|
|
display: flex; align-items: center; gap: 8px; width: 100%; text-align: left; }
|
||
|
|
.btn:hover { border-color: rgb(from var(--acid) r g b / .55); }
|
||
|
|
.btn.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
|
||
|
|
.btn.primary:hover { filter: brightness(1.06); }
|
||
|
|
.btn + .btn { margin-top: 8px; }
|
||
|
|
.btn .sub { display: block; font-size: 11px; color: var(--dim); font-weight: 400; }
|
||
|
|
.btn.primary .sub { color: rgba(16,20,24,.7); }
|
||
|
|
.drop { border: 1.5px dashed var(--line); border-radius: 8px; padding: 18px 12px;
|
||
|
|
text-align: center; color: var(--dim); font-size: 12px; margin: 12px 0; }
|
||
|
|
.drop.over { border-color: var(--acid); color: var(--ink); }
|
||
|
|
h2 { font-size: 11px; text-transform: uppercase; letter-spacing: .05em;
|
||
|
|
color: var(--dim); margin: 18px 0 8px; font-weight: 600; }
|
||
|
|
.doc { display: flex; align-items: center; gap: 8px; padding: 7px 8px;
|
||
|
|
border: 1px solid var(--line); border-radius: 7px; background: var(--panel);
|
||
|
|
margin-bottom: 6px; cursor: pointer; }
|
||
|
|
.doc:hover { border-color: rgb(from var(--acid) r g b / .5); }
|
||
|
|
.doc .meta { flex: 1; min-width: 0; }
|
||
|
|
.doc .name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
|
|
.doc .when { font-size: 10.5px; color: var(--dim); }
|
||
|
|
.doc .x { border: 0; background: transparent; color: var(--dim); cursor: pointer;
|
||
|
|
font: inherit; padding: 2px 5px; border-radius: 5px; }
|
||
|
|
.doc .x:hover { color: var(--danger); background: var(--panel2); }
|
||
|
|
.tag { font-size: 9.5px; padding: 1px 5px; border-radius: 99px; border: 1px solid var(--line); color: var(--dim); }
|
||
|
|
.empty { color: var(--dim); font-size: 12px; padding: 6px 0; }
|
||
|
|
.foot { border-top: 1px solid var(--line); padding: 8px 12px; background: var(--panel);
|
||
|
|
font-size: 11px; color: var(--dim); display: flex; gap: 8px; align-items: center; }
|
||
|
|
.foot button { border: 1px solid var(--line); background: var(--panel2); color: var(--ink);
|
||
|
|
border-radius: 5px; cursor: pointer; padding: 3px 7px; font: inherit; font-size: 11px; }
|
||
|
|
.foot button:hover { border-color: rgb(from var(--acid) r g b / .55); }
|
||
|
|
.msg { min-height: 16px; font-size: 11.5px; color: var(--mut); margin-top: 8px; }
|
||
|
|
.msg.err { color: var(--danger); }
|
||
|
|
</style>
|
||
|
|
</head>
|
||
|
|
<body>
|
||
|
|
|
||
|
|
<div class="head"><span class="ico">📝</span><h1>Word editor</h1></div>
|
||
|
|
|
||
|
|
<div class="body">
|
||
|
|
<p class="lede">Open a .docx in a full tab. Headers, footers, footnotes, page setup and
|
||
|
|
the document's own styles survive a save; a few Word features don't, and the editor says
|
||
|
|
which before you start.</p>
|
||
|
|
|
||
|
|
<button class="btn primary" id="new">
|
||
|
|
<span>📄</span><span>New document<span class="sub">A blank page</span></span>
|
||
|
|
</button>
|
||
|
|
<button class="btn" id="open">
|
||
|
|
<span>📂</span><span>Open a .docx…<span class="sub">From this computer</span></span>
|
||
|
|
</button>
|
||
|
|
|
||
|
|
<div class="drop" id="drop">or drop a .docx here</div>
|
||
|
|
|
||
|
|
<h2>Recent</h2>
|
||
|
|
<div id="recent"><div class="empty">Nothing yet.</div></div>
|
||
|
|
|
||
|
|
<div class="msg" id="msg"></div>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<div class="foot">
|
||
|
|
<span style="flex:1">Documents are kept in a scratch folder.</span>
|
||
|
|
<button id="folder">Folder</button>
|
||
|
|
<button id="clear">Clear</button>
|
||
|
|
</div>
|
||
|
|
|
||
|
|
<input type="file" id="file" accept=".docx,application/vnd.openxmlformats-officedocument.wordprocessingml.document" hidden>
|
||
|
|
|
||
|
|
<script>
|
||
|
|
const $ = (id) => document.getElementById(id);
|
||
|
|
const SM = () => window.silentmode;
|
||
|
|
|
||
|
|
function say(text, err) {
|
||
|
|
const el = $("msg");
|
||
|
|
el.textContent = text || "";
|
||
|
|
el.classList.toggle("err", !!err);
|
||
|
|
clearTimeout(say._t);
|
||
|
|
if (text) say._t = setTimeout(() => { el.textContent = ""; el.classList.remove("err"); }, 6000);
|
||
|
|
}
|
||
|
|
|
||
|
|
function ago(ts) {
|
||
|
|
const s = Math.max(0, Math.round((Date.now() - ts) / 1000));
|
||
|
|
if (s < 60) return "just now";
|
||
|
|
if (s < 3600) return `${Math.round(s / 60)} min ago`;
|
||
|
|
if (s < 86400) return `${Math.round(s / 3600)} h ago`;
|
||
|
|
return new Date(ts).toLocaleDateString();
|
||
|
|
}
|
||
|
|
|
||
|
|
function bytesLabel(n) {
|
||
|
|
return n > 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`;
|
||
|
|
}
|
||
|
|
|
||
|
|
async function toBase64(file) {
|
||
|
|
const buf = new Uint8Array(await file.arrayBuffer());
|
||
|
|
let s = "";
|
||
|
|
for (let i = 0; i < buf.length; i += 0x8000) {
|
||
|
|
s += String.fromCharCode.apply(null, buf.subarray(i, i + 0x8000));
|
||
|
|
}
|
||
|
|
return btoa(s);
|
||
|
|
}
|
||
|
|
|
||
|
|
async function refresh() {
|
||
|
|
let list = [];
|
||
|
|
try { list = await SM().invoke("listRecent", {}); }
|
||
|
|
catch (e) { say("Couldn't read the recent list.", true); return; }
|
||
|
|
const host = $("recent");
|
||
|
|
host.innerHTML = "";
|
||
|
|
if (!list.length) {
|
||
|
|
host.innerHTML = '<div class="empty">Nothing yet.</div>';
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
for (const d of list) {
|
||
|
|
const row = document.createElement("div");
|
||
|
|
row.className = "doc";
|
||
|
|
row.title = "Open in the editor";
|
||
|
|
const meta = document.createElement("div");
|
||
|
|
meta.className = "meta";
|
||
|
|
const name = document.createElement("div");
|
||
|
|
name.className = "name";
|
||
|
|
name.textContent = d.name;
|
||
|
|
const when = document.createElement("div");
|
||
|
|
when.className = "when";
|
||
|
|
when.textContent = `${ago(d.at)} · ${bytesLabel(d.bytes)}`;
|
||
|
|
if (d.kind === "saved") {
|
||
|
|
const tag = document.createElement("span");
|
||
|
|
tag.className = "tag";
|
||
|
|
tag.textContent = "autosaved";
|
||
|
|
when.append(" ", tag);
|
||
|
|
}
|
||
|
|
meta.append(name, when);
|
||
|
|
const x = document.createElement("button");
|
||
|
|
x.className = "x";
|
||
|
|
x.textContent = "✕";
|
||
|
|
x.title = "Forget this document";
|
||
|
|
x.addEventListener("click", async (e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
try { await SM().invoke("clearRecent", { id: d.id }); await refresh(); }
|
||
|
|
catch (err) { say("Couldn't remove it.", true); }
|
||
|
|
});
|
||
|
|
row.append(document.createTextNode("📄"), meta, x);
|
||
|
|
row.addEventListener("click", () => openEditor(d.id));
|
||
|
|
host.append(row);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function openEditor(id) {
|
||
|
|
try { await SM().invoke("openEditor", id ? { id } : {}); }
|
||
|
|
catch (e) { say("Couldn't open the editor: " + (e && e.message || e), true); }
|
||
|
|
}
|
||
|
|
|
||
|
|
async function handleFile(file) {
|
||
|
|
if (!/\.docx$/i.test(file.name)) { say("That isn't a .docx file.", true); return; }
|
||
|
|
say("Opening…");
|
||
|
|
try {
|
||
|
|
const res = await SM().invoke("stash", { name: file.name, base64: await toBase64(file), kind: "opened" });
|
||
|
|
await openEditor(res.id);
|
||
|
|
await refresh();
|
||
|
|
say("");
|
||
|
|
} catch (e) {
|
||
|
|
say((e && e.message || e).toString(), true);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
$("new").addEventListener("click", () => openEditor(""));
|
||
|
|
$("open").addEventListener("click", () => $("file").click());
|
||
|
|
$("file").addEventListener("change", async (e) => {
|
||
|
|
const f = e.target.files && e.target.files[0];
|
||
|
|
e.target.value = "";
|
||
|
|
if (f) await handleFile(f);
|
||
|
|
});
|
||
|
|
$("folder").addEventListener("click", async () => {
|
||
|
|
try { await SM().invoke("openFolder", {}); } catch (e) { say("Couldn't open the folder.", true); }
|
||
|
|
});
|
||
|
|
$("clear").addEventListener("click", async () => {
|
||
|
|
try { await SM().invoke("clearRecent", {}); await refresh(); say("Cleared."); }
|
||
|
|
catch (e) { say("Couldn't clear the list.", true); }
|
||
|
|
});
|
||
|
|
|
||
|
|
const drop = $("drop");
|
||
|
|
for (const type of ["dragenter", "dragover"]) {
|
||
|
|
drop.addEventListener(type, (e) => { e.preventDefault(); drop.classList.add("over"); });
|
||
|
|
}
|
||
|
|
for (const type of ["dragleave", "drop"]) {
|
||
|
|
drop.addEventListener(type, () => drop.classList.remove("over"));
|
||
|
|
}
|
||
|
|
drop.addEventListener("drop", async (e) => {
|
||
|
|
e.preventDefault();
|
||
|
|
const f = Array.from(e.dataTransfer?.files || [])[0];
|
||
|
|
if (f) await handleFile(f);
|
||
|
|
});
|
||
|
|
window.addEventListener("dragover", (e) => e.preventDefault());
|
||
|
|
window.addEventListener("drop", (e) => e.preventDefault());
|
||
|
|
|
||
|
|
document.addEventListener("visibilitychange", () => { if (!document.hidden) refresh(); });
|
||
|
|
refresh();
|
||
|
|
</script>
|
||
|
|
</body>
|
||
|
|
</html>
|