theseus/extensions/docx-editor/panel.html
Local Dev 5e4bd22609 refactor(docx-editor): ship it as a community extension, not part of the browser
A .docx editor is a megabyte of vendored library. Bundling it would charge
that to everyone who wanted a browser, including the people who will never
open a Word document in it.

So it leaves the build: out of bundled-addons/, out of extraResources, absent
from a fresh profile. It arrives the way anyone else's extension does —
Settings › Extensions › Community, from the catalogue the gateway builds, and
listed on theseus.x/extensions alongside everything else published there.
That also means it is signed by the owner of a BNS name rather than by the
operator key, which is the right trust story for something that isn't part of
the browser.

`npm run pack` produces the tarball the publish page takes; the signature
needs the publisher name's wallet, so it isn't something the repo can do.

The end-to-end test now installs the extension into a throwaway profile the
way the community installer would, and asserts up front that a fresh profile
doesn't already have it — the bundling is what was being removed, so it is
worth a test that would notice it coming back.
2026-09-21 01:06:58 +02:00

221 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>