theseus/extensions/docx-editor/editor.js
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

1015 lines
38 KiB
JavaScript

// Word editor — the tab.
//
// Load path:
// editor.html?doc=<scratch id>, or storage.__pending = {id, name}
// → silentmode.invoke("getBytes", {id}) → base64 of the original .docx
// → read.docxToDoc() → a ProseMirror document + a report of what's in
// the file that this editor can't render
// → the report becomes the banners at the top of the page
//
// Save path:
// write.docToDocx() → bytes → <a download>. The original bytes are kept in
// memory for the whole session because the save grafts the document's
// headers, footers, notes, styles and page setup back out of them.
//
// The "blocked" state is worth understanding: a document with tracked
// changes or comments opens read-only, because mammoth silently renders
// insertions as ordinary text and drops deletions — saving would accept
// every pending revision without anyone choosing to. The user has to say so
// out loud, and then the editor unlocks.
const $ = (id) => document.getElementById(id);
const V = () => window.DOCXV;
const DE = () => window.DocxEditor;
const AUTOSAVE_MS = 20_000;
const WORDS_PER_PAGE = 500;
const FONTS = [
"Calibri", "Cambria", "Georgia", "Times New Roman", "Arial", "Helvetica",
"Verdana", "Tahoma", "Trebuchet MS", "Garamond", "Book Antiqua",
"Courier New", "Consolas", "Segoe UI",
];
const TEXT_COLORS = [
"000000", "404040", "808080", "BFBFBF", "FFFFFF", "C00000", "FF0000", "FFC000",
"FFFF00", "92D050", "00B050", "00B0F0", "0070C0", "002060", "7030A0", "1F4E79",
];
let view = null; // ProseMirror EditorView
let schema = null;
let originalBytes = null; // the .docx this document was read from
let docMeta = {}; // docProps carried through a save
let docSetup = null; // page setup carried through a save
let docName = "Untitled document";
let scratchId = ""; // id in the add-on's recent ring
let report = { features: [] };
let locked = false; // tracked changes / comments, not yet accepted
let dirty = false;
let lastSavedAt = 0;
let autosaveTimer = null;
// ---------------------------------------------------------------- status ---
function status(text, kind) {
const el = $("msg");
el.textContent = text || "";
el.classList.toggle("err", kind === "err");
el.classList.toggle("ok", kind === "ok");
clearTimeout(status._t);
if (text) status._t = setTimeout(() => { el.textContent = ""; el.className = "msg"; }, 6000);
}
function setDirty(on) {
dirty = !!on;
const el = $("docname");
el.innerHTML = "";
el.append(document.createTextNode(docName));
if (dirty) {
const dot = document.createElement("span");
dot.className = "dirty";
dot.textContent = "•";
dot.title = "Unsaved changes";
el.append(dot);
}
document.title = (dirty ? "• " : "") + docName + " — Word editor";
}
function updateCounts() {
if (!view) return;
const text = view.state.doc.textBetween(0, view.state.doc.content.size, " ", " ");
const words = (text.match(/[^\s]+/g) || []).length;
const breaks = countPageBreaks(view.state.doc);
const pages = Math.max(1, Math.ceil(words / WORDS_PER_PAGE)) + breaks;
$("stat-words").textContent = `${words.toLocaleString()} word${words === 1 ? "" : "s"}`;
$("stat-pages").textContent = `${pages} page${pages === 1 ? "" : "s"}` + (breaks ? "" : " (estimated)");
}
function countPageBreaks(doc) {
let n = 0;
doc.descendants((node) => { if (node.type.name === "page_break") n++; });
return n;
}
// ---------------------------------------------------------------- banners ---
function renderBanners() {
const host = $("banners");
host.innerHTML = "";
const add = (cls, icon, html, actions) => {
const el = document.createElement("div");
el.className = "banner " + cls;
const i = document.createElement("span"); i.className = "ico"; i.textContent = icon;
const b = document.createElement("div"); b.className = "body"; b.innerHTML = html;
el.append(i, b);
if (actions && actions.length) {
const a = document.createElement("div"); a.className = "acts";
for (const act of actions) {
const btn = document.createElement("button");
btn.className = "btn wide" + (act.primary ? " primary" : "");
btn.innerHTML = `<span>${act.label}</span>`;
btn.addEventListener("click", act.onClick);
a.append(btn);
}
el.append(a);
}
host.append(el);
return el;
};
const blocked = report.features.filter((f) => f.level === "blocked");
const dropped = report.features.filter((f) => f.level === "dropped");
const lossy = report.features.filter((f) => f.level === "lossy");
if (locked && blocked.length) {
const names = blocked.map((f) => f.label.toLowerCase()).join(" and ");
add("block", "⚠",
`This document has <b>${names}</b>. This editor can't keep ${blocked.length > 1 ? "them" : "it"}: ` +
`saving would accept every pending revision and drop the comment threads, without Word ever asking. ` +
`The document is open for reading until you say that's what you want.`,
[{
label: "Accept all and edit", primary: true,
onClick: () => {
locked = false;
if (view) view.setProps({ editable: () => true });
renderBanners();
syncRibbon();
status("Revisions accepted in this copy. The original file on disk is untouched.", "ok");
},
}]);
}
if (dropped.length || lossy.length) {
const parts = [];
if (dropped.length) parts.push(`<b>${dropped.map((f) => f.label.toLowerCase()).join(", ")}</b> won't survive a save`);
if (lossy.length) parts.push(`<b>${lossy.map((f) => f.label.toLowerCase()).join(", ")}</b> come through only in part`);
add("warn", "○",
`In this document, ${parts.join("; ")}. Everything else — including headers, footers, ` +
`footnotes, page setup and the document's own styles — comes through untouched.`,
[{ label: "Details", onClick: () => openAbout() }]);
}
}
// ------------------------------------------------------------------ marks ---
function markActive(state, type) {
const { from, $from, to, empty } = state.selection;
if (empty) return !!type.isInSet(state.storedMarks || $from.marks());
return state.doc.rangeHasMark(from, to, type);
}
function markAttrs(state, type) {
const { $from, empty, from, to } = state.selection;
if (empty) {
const m = type.isInSet(state.storedMarks || $from.marks());
return m ? m.attrs : null;
}
let found = null;
state.doc.nodesBetween(from, to, (node) => {
if (found || !node.isText) return;
const m = type.isInSet(node.marks);
if (m) found = m.attrs;
});
return found;
}
function toggleMark(typeName, attrs) {
const { commands } = V().pm;
const type = schema.marks[typeName];
if (!type) return;
commands.toggleMark(type, attrs)(view.state, view.dispatch);
view.focus();
}
// Applying a value-carrying mark (font, size, colour, highlight) is not a
// toggle: picking Georgia over Arial has to replace, not stack.
function setValueMark(typeName, attrs) {
const type = schema.marks[typeName];
const { state, dispatch } = view;
const { from, to, empty } = state.selection;
const tr = state.tr;
if (empty) {
const marks = (state.storedMarks || state.selection.$from.marks()).filter((m) => m.type !== type);
dispatch(tr.setStoredMarks(attrs ? marks.concat(type.create(attrs)) : marks));
} else {
tr.removeMark(from, to, type);
if (attrs) tr.addMark(from, to, type.create(attrs));
dispatch(tr);
}
view.focus();
}
function clearFormatting() {
const { state, dispatch } = view;
const { from, to, empty } = state.selection;
if (empty) { dispatch(state.tr.setStoredMarks([])); view.focus(); return; }
const tr = state.tr;
for (const name of Object.keys(schema.marks)) {
if (name === "link") continue; // a link is content, not styling
tr.removeMark(from, to, schema.marks[name]);
}
dispatch(tr);
view.focus();
}
// ------------------------------------------------------------- paragraphs ---
function setBlockAttr(name, value) {
const { state, dispatch } = view;
const { from, to } = state.selection;
const tr = state.tr;
let touched = false;
state.doc.nodesBetween(from, to, (node, pos) => {
if (node.type !== schema.nodes.paragraph && node.type !== schema.nodes.heading) return;
tr.setNodeMarkup(pos, null, Object.assign({}, node.attrs, { [name]: value }));
touched = true;
});
if (touched) dispatch(tr);
view.focus();
}
function currentBlockAttrs() {
const { $from } = view.state.selection;
for (let d = $from.depth; d >= 0; d--) {
const node = $from.node(d);
if (node.type === schema.nodes.paragraph || node.type === schema.nodes.heading) return node;
}
return null;
}
function shiftIndent(delta) {
const node = currentBlockAttrs();
if (!node) return;
const next = Math.max(0, Math.min(8, (node.attrs.indent || 0) + delta));
setBlockAttr("indent", next);
}
function setStyle(value) {
const { commands } = V().pm;
const { setBlockType, lift, wrapIn } = commands;
const { state, dispatch } = view;
// Leave a quote before becoming something else, so switching Quote →
// Heading 2 doesn't leave the heading stranded inside a blockquote.
const inQuote = findParent(state.selection.$from, schema.nodes.blockquote);
if (inQuote && value !== "blockquote") lift(view.state, view.dispatch);
if (value === "paragraph") setBlockType(schema.nodes.paragraph)(view.state, view.dispatch);
else if (/^h([1-6])$/.test(value)) {
setBlockType(schema.nodes.heading, { level: parseInt(value.slice(1), 10) })(view.state, view.dispatch);
} else if (value === "code_block") setBlockType(schema.nodes.code_block)(view.state, view.dispatch);
else if (value === "blockquote") {
setBlockType(schema.nodes.paragraph)(view.state, view.dispatch);
if (!findParent(view.state.selection.$from, schema.nodes.blockquote)) {
wrapIn(schema.nodes.blockquote)(view.state, view.dispatch);
}
}
view.focus();
}
function findParent($pos, type) {
for (let d = $pos.depth; d > 0; d--) if ($pos.node(d).type === type) return { node: $pos.node(d), depth: d };
return null;
}
// ------------------------------------------------------------------ lists ---
function toggleList(kind) {
const { schemaList, commands } = V().pm;
const type = kind === "ordered" ? schema.nodes.ordered_list : schema.nodes.bullet_list;
const other = kind === "ordered" ? schema.nodes.bullet_list : schema.nodes.ordered_list;
const { state } = view;
const inThis = findParent(state.selection.$from, type);
const inOther = findParent(state.selection.$from, other);
if (inThis) {
schemaList.liftListItem(schema.nodes.list_item)(view.state, view.dispatch);
} else if (inOther) {
// Swap one kind of list for the other in place.
const tr = view.state.tr;
tr.setNodeMarkup(state.selection.$from.before(inOther.depth), type,
type === schema.nodes.ordered_list ? { order: 1, format: currentListFormat() } : {});
view.dispatch(tr);
} else {
const attrs = type === schema.nodes.ordered_list ? { order: 1, format: currentListFormat() } : {};
schemaList.wrapInList(type, attrs)(view.state, view.dispatch);
}
view.focus();
}
function currentListFormat() {
return $("list-format").value || "decimal";
}
function applyListFormat(format) {
const { state, dispatch } = view;
const found = findParent(state.selection.$from, schema.nodes.ordered_list);
if (!found) return;
const pos = state.selection.$from.before(found.depth);
dispatch(state.tr.setNodeMarkup(pos, null, Object.assign({}, found.node.attrs, { format })));
view.focus();
}
// ----------------------------------------------------------------- tables ---
function inTable() {
return !!findParent(view.state.selection.$from, schema.nodes.table);
}
function tableCmd(name) {
const t = V().pm.tables;
const fn = t[name];
if (typeof fn === "function") { fn(view.state, view.dispatch); view.focus(); }
}
function insertTable(rows, cols, withHeader) {
const { state, dispatch } = view;
const cell = (type) => schema.nodes[type].createAndFill();
const rowNodes = [];
for (let r = 0; r < rows; r++) {
const cells = [];
for (let c = 0; c < cols; c++) cells.push(cell(withHeader && r === 0 ? "table_header" : "table_cell"));
rowNodes.push(schema.nodes.table_row.create(null, cells));
}
const table = schema.nodes.table.create(null, rowNodes);
dispatch(state.tr.replaceSelectionWith(table).scrollIntoView());
view.focus();
}
function toggleHeaderRow() {
const t = V().pm.tables;
if (typeof t.toggleHeaderRow === "function") { t.toggleHeaderRow(view.state, view.dispatch); view.focus(); }
}
// ----------------------------------------------------------------- insert ---
function insertNode(node) {
const { state, dispatch } = view;
dispatch(state.tr.replaceSelectionWith(node).scrollIntoView());
view.focus();
}
async function insertImageFile(file) {
const bytes = new Uint8Array(await file.arrayBuffer());
const type = file.type || "image/png";
if (!/^image\/(png|jpeg|gif|bmp)$/.test(type)) {
status(`${type} pictures can't be saved into a .docx — use PNG, JPEG, GIF or BMP.`, "err");
return;
}
const size = DE().read.imageSizePt(bytes, null);
const src = `data:${type};base64,${DE().read.bytesToBase64(bytes)}`;
insertNode(schema.nodes.image.create({
src, alt: file.name || null, width: size.width, height: size.height,
}));
}
// ---------------------------------------------------------------- dialogs ---
function dialog(title, bodyHtml, buttons) {
const scrim = document.createElement("div");
scrim.className = "scrim";
const box = document.createElement("div");
box.className = "dialog";
box.innerHTML = `<h2>${title}</h2><div class="dbody">${bodyHtml}</div><div class="dfoot"></div>`;
const foot = box.querySelector(".dfoot");
const close = () => { scrim.remove(); document.removeEventListener("keydown", onKey); if (view) view.focus(); };
const onKey = (e) => {
if (e.key === "Escape") { e.preventDefault(); close(); }
if (e.key === "Enter" && !e.shiftKey) {
const primary = buttons.find((b) => b.primary);
if (primary) { e.preventDefault(); if (primary.onClick(box, close) !== false) close(); }
}
};
for (const b of buttons) {
const btn = document.createElement("button");
btn.className = "btn wide" + (b.primary ? " primary" : "");
btn.innerHTML = `<span>${b.label}</span>`;
btn.addEventListener("click", () => { if (b.onClick(box, close) !== false) close(); });
foot.append(btn);
}
scrim.append(box);
scrim.addEventListener("mousedown", (e) => { if (e.target === scrim) close(); });
document.addEventListener("keydown", onKey);
document.body.append(scrim);
const first = box.querySelector("input");
if (first) first.focus();
return { box, close };
}
function openLinkDialog() {
const existing = markAttrs(view.state, schema.marks.link);
const { state } = view;
const selected = state.doc.textBetween(state.selection.from, state.selection.to, " ");
dialog("Link", `
<div class="field"><label for="lnk-href">Address</label>
<input id="lnk-href" type="text" placeholder="https://example.com" value="${(existing && existing.href || "").replace(/"/g, "&quot;")}"></div>
<div class="field"><label for="lnk-text">Text</label>
<input id="lnk-text" type="text" value="${(selected || "").replace(/"/g, "&quot;")}"
${selected ? "" : 'placeholder="the words to link"'}></div>
`, [
...(existing ? [{ label: "Remove link", onClick: () => {
const { from, to } = view.state.selection;
view.dispatch(view.state.tr.removeMark(from, to, schema.marks.link));
view.focus();
} }] : []),
{ label: "Cancel", onClick: () => {} },
{ label: existing ? "Update" : "Add link", primary: true, onClick: (box) => {
const href = box.querySelector("#lnk-href").value.trim();
const text = box.querySelector("#lnk-text").value;
if (!href) return false;
const { state, dispatch } = view;
const mark = schema.marks.link.create({ href, title: null, anchor: null });
if (state.selection.empty || text !== selected) {
const node = schema.text(text || href, [mark]);
dispatch(state.tr.replaceSelectionWith(node, false).scrollIntoView());
} else {
dispatch(state.tr.addMark(state.selection.from, state.selection.to, mark));
}
view.focus();
} },
]);
}
function openTableDialog() {
dialog("Insert table", `
<div class="row">
<div class="field"><label for="tbl-rows">Rows</label><input id="tbl-rows" type="number" min="1" max="60" value="3"></div>
<div class="field"><label for="tbl-cols">Columns</label><input id="tbl-cols" type="number" min="1" max="20" value="3"></div>
</div>
<div class="field"><label><input id="tbl-head" type="checkbox" checked style="height:auto;width:auto;margin-right:6px"> First row is a header</label></div>
`, [
{ label: "Cancel", onClick: () => {} },
{ label: "Insert", primary: true, onClick: (box) => {
const rows = Math.max(1, Math.min(60, parseInt(box.querySelector("#tbl-rows").value, 10) || 3));
const cols = Math.max(1, Math.min(20, parseInt(box.querySelector("#tbl-cols").value, 10) || 3));
insertTable(rows, cols, box.querySelector("#tbl-head").checked);
} },
]);
}
const NOT_YET = [
"Tracked changes — a document that has them opens read-only until you accept them",
"Comments",
"Equations (OMML)",
"Shapes, text boxes and WordArt",
"Content controls",
"Fields: page numbers, tables of contents, cross-references",
"Bookmarks and internal cross-references",
"Section breaks and multi-column layout",
"Headers and footers can't be edited here — they are carried through unchanged",
"Footnote and endnote text can't be edited here — the notes and their markers are carried through unchanged",
"A styles panel: styles are applied by the Style box, not edited",
"Paragraph borders and shading, other than a horizontal rule",
"Metafile pictures (WMF/EMF) — they can't be written back and are dropped",
];
function openAbout() {
const found = report.features || [];
const line = (f) => {
const cls = f.level === "preserved" ? "keep" : f.level === "blocked" ? "stop" : "lose";
const word = f.level === "preserved" ? "kept" : f.level === "lossy" ? "partly" : f.level === "blocked" ? "blocked" : "dropped";
return `<li><span class="pill ${cls}">${word}</span>${f.label}${f.note ? `${f.note}` : ""}</li>`;
};
dialog("Word editor", `
<p>A basic, honest .docx editor. It opens most Word documents, lets you edit the
things below, and writes a file Word will open without complaint.</p>
<p>Saving rebuilds the document body from what you see, and carries the rest of the
original file across untouched: headers, footers, footnotes, endnotes, page size and
margins, the document's style catalogue and its theme. Saving never overwrites the
file you opened — it downloads a new one.</p>
${found.length ? `<h3>In this document</h3><ul>${found.map(line).join("")}</ul>` : ""}
<h3>Not supported yet</h3>
<ul>${NOT_YET.map((t) => `<li>${t}</li>`).join("")}</ul>
<h3>Built with</h3>
<ul>
<li>mammoth — reads the .docx (BSD-2-Clause)</li>
<li>ProseMirror — the editor itself (MIT)</li>
<li>docx — writes the .docx (MIT)</li>
<li>JSZip — the package layer (MIT)</li>
</ul>
<p style="font-size:11.5px">Full licence texts ship in <code>vendor/LICENSES.txt</code>.</p>
`, [{ label: "Close", primary: true, onClick: () => {} }]);
}
// ------------------------------------------------------------- colour pops ---
function openColorPop(anchor, colors, onPick, onClear, clearLabel) {
document.querySelectorAll(".pop").forEach((p) => p.remove());
const pop = document.createElement("div");
pop.className = "pop";
const grid = document.createElement("div");
grid.className = "grid";
for (const c of colors) {
const chip = document.createElement("button");
chip.className = "chip";
chip.style.background = c.css;
chip.title = c.label;
chip.addEventListener("click", () => { onPick(c); pop.remove(); });
grid.append(chip);
}
const row = document.createElement("div");
row.className = "prow";
const clear = document.createElement("button");
clear.className = "btn wide";
clear.innerHTML = `<span>${clearLabel}</span>`;
clear.addEventListener("click", () => { onClear(); pop.remove(); });
row.append(clear);
pop.append(grid, row);
document.body.append(pop);
const r = anchor.getBoundingClientRect();
pop.style.left = Math.min(r.left, window.innerWidth - pop.offsetWidth - 8) + "px";
pop.style.top = (r.bottom + 4) + "px";
const away = (e) => {
if (!pop.contains(e.target) && e.target !== anchor) { pop.remove(); document.removeEventListener("mousedown", away); }
};
setTimeout(() => document.addEventListener("mousedown", away), 0);
}
// ------------------------------------------------------------------ ribbon ---
function syncRibbon() {
if (!view) return;
const st = view.state;
const on = (id, active) => $(id).classList.toggle("on", !!active);
on("m-strong", markActive(st, schema.marks.strong));
on("m-em", markActive(st, schema.marks.em));
on("m-underline", markActive(st, schema.marks.underline));
on("m-strike", markActive(st, schema.marks.strike));
on("m-sup", markActive(st, schema.marks.sup));
on("m-sub", markActive(st, schema.marks.sub));
const fontAttrs = markAttrs(st, schema.marks.font);
$("font-family").value = FONTS.includes(fontAttrs && fontAttrs.family) ? fontAttrs.family : "";
const sizeAttrs = markAttrs(st, schema.marks.fsize);
$("font-size").value = sizeAttrs ? sizeAttrs.pt : "";
const colorAttrs = markAttrs(st, schema.marks.color);
$("color-bar").style.background = colorAttrs ? "#" + colorAttrs.hex : "#c00000";
const hlAttrs = markAttrs(st, schema.marks.highlight);
$("hl-bar").style.background = hlAttrs
? (DE().schema.HIGHLIGHT_CSS[hlAttrs.name] || "#ffff00") : "#ffff00";
const block = currentBlockAttrs();
const $from = st.selection.$from;
const inQuote = findParent($from, schema.nodes.blockquote);
const inCode = findParent($from, schema.nodes.code_block);
let style = "paragraph";
if (inCode) style = "code_block";
else if (inQuote) style = "blockquote";
else if (block && block.type === schema.nodes.heading) style = "h" + block.attrs.level;
$("style-select").value = style;
const align = block ? block.attrs.align : null;
on("a-left", align === "left");
on("a-center", align === "center");
on("a-right", align === "right");
on("a-justify", align === "justify");
$("line-height").value = block && block.attrs.lineHeight ? String(block.attrs.lineHeight) : "";
const ol = findParent($from, schema.nodes.ordered_list);
const ul = findParent($from, schema.nodes.bullet_list);
on("l-bullet", !!ul);
on("l-ordered", !!ol);
$("list-format").disabled = !ol;
if (ol) $("list-format").value = ol.node.attrs.format || "decimal";
$("table-group").dataset.contextual = inTable() ? "on" : "off";
const { history } = V().pm;
$("undo").disabled = history.undoDepth(st) === 0;
$("redo").disabled = history.redoDepth(st) === 0;
// Everything that writes is off while the document is locked.
document.querySelectorAll("#ribbon .btn, #ribbon .rsel, #ribbon .rnum").forEach((el) => {
if (el.id === "undo" || el.id === "redo") return;
el.disabled = locked || (el.id === "list-format" && !ol);
});
$("file-save").disabled = locked;
}
function wireRibbon() {
const sel = $("font-family");
for (const f of FONTS) {
const opt = document.createElement("option");
opt.value = f; opt.textContent = f; opt.style.fontFamily = f;
sel.append(opt);
}
$("m-strong").onclick = () => toggleMark("strong");
$("m-em").onclick = () => toggleMark("em");
$("m-underline").onclick = () => toggleMark("underline");
$("m-strike").onclick = () => toggleMark("strike");
$("m-sup").onclick = () => toggleMark("sup");
$("m-sub").onclick = () => toggleMark("sub");
$("m-clear").onclick = clearFormatting;
sel.onchange = () => setValueMark("font", sel.value ? { family: sel.value } : null);
$("font-size").onchange = () => {
const pt = parseFloat($("font-size").value);
setValueMark("fsize", Number.isFinite(pt) && pt > 0 ? { pt } : null);
};
$("m-color").onclick = (e) => openColorPop(
e.currentTarget,
TEXT_COLORS.map((hex) => ({ css: "#" + hex, label: "#" + hex, hex })),
(c) => setValueMark("color", { hex: c.hex }),
() => setValueMark("color", null),
"Automatic");
$("m-highlight").onclick = (e) => openColorPop(
e.currentTarget,
DE().schema.HIGHLIGHTS.map((h) => ({ css: h.css, label: h.label, name: h.name })),
(c) => setValueMark("highlight", { name: c.name }),
() => setValueMark("highlight", null),
"No highlight");
$("style-select").onchange = (e) => setStyle(e.target.value);
for (const [id, value] of [["a-left", "left"], ["a-center", "center"], ["a-right", "right"], ["a-justify", "justify"]]) {
$(id).onclick = () => {
const block = currentBlockAttrs();
setBlockAttr("align", block && block.attrs.align === value ? null : value);
};
}
$("line-height").onchange = (e) => setBlockAttr("lineHeight", e.target.value ? parseFloat(e.target.value) : null);
$("indent-in").onclick = () => shiftIndent(1);
$("indent-out").onclick = () => shiftIndent(-1);
$("l-bullet").onclick = () => toggleList("bullet");
$("l-ordered").onclick = () => toggleList("ordered");
$("list-format").onchange = (e) => applyListFormat(e.target.value);
$("i-link").onclick = openLinkDialog;
$("i-table").onclick = openTableDialog;
$("i-image").onclick = () => $("image-input").click();
$("i-rule").onclick = () => insertNode(schema.nodes.horizontal_rule.create());
$("i-pagebreak").onclick = () => insertNode(schema.nodes.page_break.create());
$("t-row-after").onclick = () => tableCmd("addRowAfter");
$("t-row-del").onclick = () => tableCmd("deleteRow");
$("t-col-after").onclick = () => tableCmd("addColumnAfter");
$("t-col-del").onclick = () => tableCmd("deleteColumn");
$("t-merge").onclick = () => tableCmd("mergeCells");
$("t-split").onclick = () => tableCmd("splitCell");
$("t-header").onclick = toggleHeaderRow;
$("t-del").onclick = () => tableCmd("deleteTable");
$("undo").onclick = () => { V().pm.history.undo(view.state, view.dispatch); view.focus(); };
$("redo").onclick = () => { V().pm.history.redo(view.state, view.dispatch); view.focus(); };
$("file-new").onclick = () => newDocument();
$("file-open").onclick = () => $("file-input").click();
$("file-save").onclick = () => save();
$("file-print").onclick = () => window.print();
$("about").onclick = openAbout;
$("discard").onclick = () => closeTab();
$("open-folder").onclick = async () => {
try { await window.silentmode?.invoke("openFolder", { id: scratchId }); }
catch (e) { status("Couldn't open the folder: " + (e && e.message || e), "err"); }
};
$("file-input").onchange = async (e) => {
const file = e.target.files && e.target.files[0];
e.target.value = "";
if (file) await openFile(file);
};
$("image-input").onchange = async (e) => {
const file = e.target.files && e.target.files[0];
e.target.value = "";
if (file) await insertImageFile(file);
};
}
// ------------------------------------------------------------------ editor ---
function buildPlugins() {
const { keymap, history, commands, inputrules, dropcursor, gapcursor, tables, state: pmState } = V().pm;
const { baseKeymap, toggleMark: tm, chainCommands, exitCode } = commands;
const { wrapInList, splitListItem, liftListItem, sinkListItem } = V().pm.schemaList;
const keys = {
"Mod-z": history.undo,
"Shift-Mod-z": history.redo,
"Mod-y": history.redo,
"Mod-b": tm(schema.marks.strong),
"Mod-i": tm(schema.marks.em),
"Mod-u": tm(schema.marks.underline),
"Mod-k": () => { openLinkDialog(); return true; },
"Mod-s": () => { save(); return true; },
"Mod-o": () => { $("file-input").click(); return true; },
"Mod-p": () => { window.print(); return true; },
"Mod-Enter": (state, dispatch) => {
if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.page_break.create()).scrollIntoView());
return true;
},
"Shift-Enter": chainCommands(exitCode, (state, dispatch) => {
if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView());
return true;
}),
"Enter": splitListItem(schema.nodes.list_item),
"Tab": (state, dispatch) => {
// Inside a table Tab moves between cells, the way Word does it;
// inside a list it nests; elsewhere it indents the paragraph.
if (tables.goToNextCell(1)(state, dispatch)) return true;
if (sinkListItem(schema.nodes.list_item)(state, dispatch)) return true;
shiftIndent(1);
return true;
},
"Shift-Tab": (state, dispatch) => {
if (tables.goToNextCell(-1)(state, dispatch)) return true;
if (liftListItem(schema.nodes.list_item)(state, dispatch)) return true;
shiftIndent(-1);
return true;
},
};
const { inputRules, wrappingInputRule, textblockTypeInputRule, smartQuotes,
ellipsis, emDash } = inputrules;
const rules = [
...smartQuotes, ellipsis, emDash,
wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list),
wrappingInputRule(/^(\d+)\.\s$/, schema.nodes.ordered_list,
(match) => ({ order: +match[1], format: "decimal" }),
(match, node) => node.childCount + node.attrs.order === +match[1]),
wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote),
textblockTypeInputRule(/^```$/, schema.nodes.code_block),
textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes.heading,
(match) => ({ level: match[1].length })),
];
return [
inputRules({ rules }),
keymap.keymap(keys),
keymap.keymap(baseKeymap),
dropcursor.dropCursor(),
gapcursor.gapCursor(),
history.history(),
tables.columnResizing(),
tables.tableEditing(),
];
}
function mountEditor(doc) {
const { state: pmState, view: pmView } = V().pm;
const sheet = $("sheet");
sheet.innerHTML = "";
const state = pmState.EditorState.create({ doc, plugins: buildPlugins() });
if (view) view.destroy();
view = new pmView.EditorView(sheet, {
state,
editable: () => !locked,
dispatchTransaction(tr) {
const next = view.state.apply(tr);
view.updateState(next);
if (tr.docChanged) { setDirty(true); updateCounts(); scheduleAutosave(); }
syncRibbon();
},
handlePaste(v, event) {
// A .docx dropped or pasted as a file goes through the document
// reader, not the HTML paste path.
const files = Array.from(event.clipboardData?.files || []);
const docxFile = files.find((f) => /\.docx$/i.test(f.name));
if (docxFile) { openFile(docxFile); return true; }
const image = files.find((f) => /^image\//.test(f.type));
if (image) { insertImageFile(image); return true; }
return false;
},
handleDrop(v, event) {
const files = Array.from(event.dataTransfer?.files || []);
const docxFile = files.find((f) => /\.docx$/i.test(f.name));
if (docxFile) { event.preventDefault(); openFile(docxFile); return true; }
const image = files.find((f) => /^image\//.test(f.type));
if (image) { event.preventDefault(); insertImageFile(image); return true; }
return false;
},
});
updateCounts();
syncRibbon();
view.focus();
}
// ------------------------------------------------------------------- files ---
function blankDoc() {
return V().pm.model.Node.fromJSON(schema, {
type: "doc", content: [{ type: "paragraph" }],
});
}
function newDocument() {
if (dirty && !confirm("Start a new document? Unsaved changes will be lost.")) return;
originalBytes = null;
docMeta = {};
docSetup = null;
scratchId = "";
docName = "Untitled document";
report = { features: [] };
locked = false;
renderBanners();
mountEditor(blankDoc());
setDirty(false);
status("New document.");
}
async function loadBytes(bytes, name, id) {
const t0 = performance.now();
status("Reading document…");
const res = await DE().read.docxToDoc(bytes, schema);
originalBytes = bytes;
docMeta = res.meta || {};
docSetup = res.setup || null;
report = res.report || { features: [] };
docName = name || "document.docx";
scratchId = id || "";
locked = report.blocked && report.blocked.length > 0;
renderBanners();
mountEditor(res.doc);
setDirty(false);
const ms = Math.round(performance.now() - t0);
status(`Opened ${docName}${res.doc.childCount} blocks in ${ms} ms`, "ok");
if (res.warnings && res.warnings.length) {
console.warn("[docx-editor] read warnings:", res.warnings, res.messages);
}
}
async function openFile(file) {
if (dirty && !confirm(`Open ${file.name}? Unsaved changes will be lost.`)) return;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
// Park a copy with the add-on so the document survives a tab reload and
// shows up in the sidebar's recent list.
let id = "";
try {
const stashed = await window.silentmode?.invoke("stash", {
name: file.name, base64: DE().read.bytesToBase64(bytes), kind: "opened",
});
id = stashed && stashed.id || "";
} catch (e) {
console.warn("[docx-editor] could not stash the document:", e);
}
await loadBytes(bytes, file.name, id);
} catch (e) {
console.error(e);
showError(`Couldn't open ${file.name}: ${e && e.message || e}`);
}
}
function showError(text) {
status(text, "err");
if (!view) {
$("sheet").innerHTML = `<div class="empty-state">${text}</div>`;
}
}
function downloadName() {
const base = docName.replace(/\.docx$/i, "") || "document";
return `${base}-edited.docx`;
}
async function save() {
if (!view || locked) return;
try {
status("Saving…");
const res = await DE().write.docToDocx(view.state.doc, {
originalBytes, setup: docSetup, meta: docMeta,
});
const blob = new Blob([res.bytes], {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
});
const url = URL.createObjectURL(blob);
const a = $("download-link");
a.href = url;
a.download = downloadName();
a.click();
setTimeout(() => URL.revokeObjectURL(url), 8000);
setDirty(false);
lastSavedAt = Date.now();
await autosave(true);
const carried = res.carried && res.carried.length ? ` — carried over ${res.carried.join(", ")}` : "";
status(`Saved ${a.download} (${(res.bytes.length / 1024).toFixed(0)} KB)${carried}`, "ok");
if (res.warnings && res.warnings.length) {
console.warn("[docx-editor] save warnings:", res.warnings);
status(`Saved with warnings: ${res.warnings.join("; ")}`, "err");
}
} catch (e) {
console.error(e);
status("Save failed: " + (e && e.message || e), "err");
}
}
// Autosave into the add-on's recent ring — not to the user's file. It exists
// so a closed tab or a crash doesn't cost the session's work: the sidebar
// lists the last few documents and can hand them back.
function scheduleAutosave() {
clearTimeout(autosaveTimer);
autosaveTimer = setTimeout(() => autosave(false), AUTOSAVE_MS);
}
async function autosave(force) {
if (!view || locked) return;
if (!force && !dirty) return;
try {
const res = await DE().write.docToDocx(view.state.doc, {
originalBytes, setup: docSetup, meta: docMeta,
});
const stashed = await window.silentmode?.invoke("autosave", {
name: docName,
base64: DE().read.bytesToBase64(res.bytes),
replaces: scratchId,
});
if (stashed && stashed.id) scratchId = stashed.id;
} catch (e) {
console.warn("[docx-editor] autosave failed:", e);
}
}
function closeTab() {
if (dirty && !confirm("Close the editor? Unsaved changes will be lost.")) return;
try { window.silentmode?.closeTab(); }
catch { window.close(); }
}
// ------------------------------------------------------------------- boot ---
function base64ToBytes(b64) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function boot() {
if (!window.DOCXV || !window.DocxEditor?.read) {
showError("The editor's libraries didn't load. Check vendor/docx-vendor.js.");
return;
}
schema = DE().schema.build();
wireRibbon();
// Whole-window drop target, so dropping a file anywhere works and not
// only over the page surface.
let dropDepth = 0;
const zone = document.createElement("div");
zone.className = "dropzone";
zone.textContent = "Drop a .docx to open it";
zone.hidden = true;
document.body.append(zone);
window.addEventListener("dragenter", (e) => {
if (!Array.from(e.dataTransfer?.types || []).includes("Files")) return;
dropDepth++; zone.hidden = false;
});
window.addEventListener("dragleave", () => { if (--dropDepth <= 0) { dropDepth = 0; zone.hidden = true; } });
window.addEventListener("dragover", (e) => e.preventDefault());
window.addEventListener("drop", async (e) => {
e.preventDefault();
dropDepth = 0; zone.hidden = true;
const file = Array.from(e.dataTransfer?.files || [])[0];
if (!file) return;
if (/\.docx$/i.test(file.name)) await openFile(file);
else if (/^image\//.test(file.type)) await insertImageFile(file);
else status(`${file.name} isn't a .docx.`, "err");
});
window.addEventListener("beforeunload", (e) => {
if (dirty) { e.preventDefault(); e.returnValue = ""; }
});
// Drain the hand-off: ?doc= wins (it survives a reload), then __pending.
let id = new URLSearchParams(location.search).get("doc") || "";
let name = "";
try {
const pending = await window.silentmode?.storage?.get("__pending", null);
if (pending && pending.id) {
if (!id) id = pending.id;
if (pending.id === id) name = pending.name || "";
await window.silentmode.storage.set("__pending", null);
}
} catch (e) {
console.warn("[docx-editor] no storage hand-off:", e);
}
if (!id) {
mountEditor(blankDoc());
setDirty(false);
status("Blank document — drop a .docx here, or use Open.");
return;
}
try {
const res = await window.silentmode.invoke("getBytes", { id });
await loadBytes(base64ToBytes(res.base64), name || res.name, id);
} catch (e) {
console.error(e);
mountEditor(blankDoc());
showError(`Couldn't load that document: ${e && e.message || e}`);
}
}
boot();