theseus/bundled-addons/docx-editor/editor.js
Local Dev f189b48102 feat(docx-editor): documents are tabs, each with its own close button and menu
Opening a second .docx used to mean a second browser tab: a whole ribbon,
banner and footer repeated, with one ✕ at the far end of a row that also
held the file's name. The name looked like a tab and nothing about it
behaved like one.

Now the editor holds documents the way the browser holds pages. A strip
under the toolbar carries one tab per open document — icon, name, unsaved
dot, its own ✕ — plus a + to open another. Middle-click closes, Ctrl+W
closes, Ctrl+Tab cycles, and right-click (or the caret on the tab under the
pointer) drops a menu: Duplicate, Open in the default app, Show in folder,
Close others, Close. The gestures are the browser's because that is the tab
strip every user of this editor already knows.

Under it, one ProseMirror view is handed a different state per document
rather than one view per tab, and the module-level "current document"
variables are marshalled in and out on a switch. That keeps the change out
of every function that touches the current document, at the price of one
list — DOC_FIELDS in captureActive/adoptDoc — that has to stay complete. A
variable missed there leaks one document's state into another, which would
look like the editor corrupting a file, so it is called out in a comment.

Closing the last document closes the editor tab, the way closing a
browser's last tab closes the window; an empty ribbon staring at the user
is not a state worth having.

The add-on hands a newly opened document to the editor that is already up
and fronts it, falling back to opening a tab if no editor acknowledges
within 900ms — so a crashed or closed editor degrades to exactly the old
behaviour rather than swallowing the document.
2026-09-22 08:35:06 +02:00

1527 lines
57 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 paths:
// Save → write.docToDocx() → a copy in Downloads, or straight to the
// file the user chose earlier with Save as…
// Save as… → a native file dialog; the extension typed there decides
// whether they get a .docx or a .pdf
// PDF → the same PDF, one click, into Downloads
//
// 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. PDFs are rendered by Chromium's own print
// pipeline in a hidden window on the add-on side, from the very CSS the
// editor is displaying (lib/doc-css.js), so the export can't drift away
// from the preview.
//
// 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;
// Where "Save" writes without asking, once the user has chosen a home for
// this document with Save as… Until then Save behaves like every other
// add-on here and drops the file in Downloads.
let savedTarget = null; // {name} — the path itself stays in the add-on
// --- open documents -------------------------------------------------------
//
// The editor holds several documents at once, the way the browser holds
// several pages. Rather than thread a document object through every function
// that touches the current one, the variables above stay as "the document on
// screen" and switching tabs marshals them in and out of this list. One
// EditorView is reused throughout — ProseMirror is happy to be handed a
// different state, and a view per document would multiply the DOM for no
// gain.
//
// Everything a document owns lives in DOC_FIELDS. If you add a per-document
// variable above, add it here too, or it will leak from one tab into the
// next — which looks like the editor corrupting a file.
const openDocs = [];
let activeKey = null;
let keySeq = 0;
function byKey(key) { return openDocs.find((d) => d.key === key); }
function activeDoc() { return byKey(activeKey); }
function captureActive() {
const d = activeDoc();
if (!d) return;
d.state = view ? view.state : d.state;
d.originalBytes = originalBytes;
d.docMeta = docMeta;
d.docSetup = docSetup;
d.docName = docName;
d.scratchId = scratchId;
d.report = report;
d.locked = locked;
d.dirty = dirty;
d.savedTarget = savedTarget;
d.lastSavedAt = lastSavedAt;
}
function adoptDoc(d) {
originalBytes = d.originalBytes;
docMeta = d.docMeta;
docSetup = d.docSetup;
docName = d.docName;
scratchId = d.scratchId;
report = d.report;
locked = d.locked;
dirty = d.dirty;
savedTarget = d.savedTarget;
lastSavedAt = d.lastSavedAt || 0;
}
// ---------------------------------------------------------------- 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";
const d = activeDoc();
if (d && (d.dirty !== dirty || d.docName !== docName)) {
d.dirty = dirty;
d.docName = docName;
renderDocTabs();
}
}
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.</p>
<p><b>Save</b> puts a copy in your downloads until you have given the document a home
with <b>Save as…</b>, which is a real file dialog — type a <code>.docx</code> name for a
Word document or a <code>.pdf</code> one for a PDF. After that, Save writes there
directly. <b>PDF</b> is the one-click version: the page is rendered on the paper size the
document itself specifies, by the same engine behind Ctrl+P, so it matches what you were
looking at. Nothing here ever overwrites the file you opened.</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;
$("file-save").title = savedTarget
? `Save to ${savedTarget.name} (Ctrl+S)`
: "Save a .docx to your downloads (Ctrl+S)";
$("file-saveas").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-saveas").onclick = () => saveAs();
$("file-pdf").onclick = () => exportPdf();
$("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);
};
}
// ------------------------------------------------------------- document css ---
// The page's own typography is shared verbatim with the PDF export, so it
// lives in lib/doc-css.js rather than editor.css. Injecting it here (rather
// than linking a second stylesheet) keeps the export and the screen reading
// from one string.
function installDocCss() {
const el = document.createElement("style");
el.id = "doc-css";
el.textContent = DE().docCss.DOC_CSS;
document.head.append(el);
}
// Ctrl+P should use the document's paper size too, not the browser default.
function syncPageRule() {
let el = document.getElementById("page-rule");
if (!el) {
el = document.createElement("style");
el.id = "page-rule";
document.head.append(el);
}
el.textContent = DE().docCss.pageRule(docSetup);
}
// The document as a standalone HTML page: ProseMirror's own DOM serialisation
// (so what you see is what gets rendered), with images already inline as data
// URLs and the editor's chrome left behind.
function printableHtml() {
const pm = document.querySelector(".ProseMirror");
if (!pm) throw new Error("nothing to export");
const clone = pm.cloneNode(true);
// Editing scaffolding that has no business in a finished document.
clone.querySelectorAll(".ProseMirror-separator, .ProseMirror-trailingBreak, .column-resize-handle")
.forEach((n) => n.remove());
clone.querySelectorAll("[contenteditable]").forEach((n) => n.removeAttribute("contenteditable"));
return DE().docCss.printableHtml({
title: docName.replace(/\.docx$/i, ""),
bodyHtml: clone.innerHTML,
setup: docSetup,
});
}
// ------------------------------------------------------------------ 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; },
"Shift-Mod-s": () => { saveAs(); return true; },
"Mod-o": () => { $("file-input").click(); return true; },
// The browser's own shortcuts, because this row of tabs is the browser's
// row of tabs as far as the user is concerned.
"Mod-w": () => { if (activeKey) closeDoc(activeKey); return true; },
"Mod-Tab": () => { cycleDoc(1); return true; },
"Shift-Mod-Tab": () => { cycleDoc(-1); 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(),
];
}
// One view for the whole editor; switching documents hands it a different
// state. Building a view per document would multiply the DOM and throw away
// the scroll position on every switch, and ProseMirror has no need of it.
function mountEditor(state) {
const { view: pmView } = V().pm;
if (view) {
view.updateState(state);
updateCounts();
syncRibbon();
view.focus();
return;
}
const sheet = $("sheet");
sheet.innerHTML = "";
view = new pmView.EditorView(sheet, {
state,
editable: () => !locked,
dispatchTransaction(tr) {
const next = view.state.apply(tr);
view.updateState(next);
if (tr.docChanged) {
const wasClean = !dirty;
setDirty(true);
// The tab strip carries the same unsaved marker as the title, so it
// has to hear about the first edit to a clean document.
if (wasClean) { const d = activeDoc(); if (d) { d.dirty = true; renderDocTabs(); } }
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();
}
// --------------------------------------------------------- document tabs ---
function renderDocTabs() {
const host = $("doctabs");
host.innerHTML = "";
if (!openDocs.length) return;
for (const d of openDocs) {
const el = document.createElement("div");
el.className = "dtab" + (d.key === activeKey ? " active" : "");
el.setAttribute("role", "tab");
el.setAttribute("aria-selected", d.key === activeKey ? "true" : "false");
el.title = d.docName + (d.dirty ? " \u2014 unsaved changes" : "");
const ico = document.createElement("img");
ico.className = "ico"; ico.src = "icon.svg"; ico.alt = "";
const nm = document.createElement("span");
nm.className = "nm";
nm.textContent = d.docName.replace(/\.docx$/i, "");
el.append(ico, nm);
if (d.dirty) {
const dot = document.createElement("span");
dot.className = "dot"; dot.textContent = "\u2022"; dot.title = "Unsaved changes";
el.append(dot);
}
const caret = document.createElement("button");
caret.className = "caret"; caret.textContent = "\u25be";
caret.title = "Document options";
caret.addEventListener("click", (e) => {
e.stopPropagation();
const r = e.currentTarget.getBoundingClientRect();
openDocMenu(d.key, r.left, r.bottom + 2);
});
const x = document.createElement("button");
x.className = "x"; x.textContent = "\u2715";
x.title = "Close this document";
x.addEventListener("click", (e) => { e.stopPropagation(); closeDoc(d.key); });
el.append(caret, x);
el.addEventListener("click", () => switchDoc(d.key));
el.addEventListener("auxclick", (e) => { if (e.button === 1) { e.preventDefault(); closeDoc(d.key); } });
el.addEventListener("contextmenu", (e) => { e.preventDefault(); openDocMenu(d.key, e.clientX, e.clientY); });
host.append(el);
}
const add = document.createElement("button");
add.className = "dtab-add";
add.textContent = "+";
add.title = "Open another document (Ctrl+O)";
add.addEventListener("click", () => $("file-input").click());
host.append(add);
const activeEl = host.querySelector(".dtab.active");
if (activeEl) activeEl.scrollIntoView({ block: "nearest", inline: "nearest" });
}
function closeDocMenu() {
document.querySelectorAll(".dmenu").forEach((m) => m.remove());
}
function openDocMenu(key, x, y) {
closeDocMenu();
const d = byKey(key);
if (!d) return;
const menu = document.createElement("div");
menu.className = "dmenu";
const add = (label, fn, cls) => {
const b = document.createElement("button");
b.textContent = label;
if (cls) b.className = cls;
if (!fn) b.disabled = true;
else b.addEventListener("click", () => { closeDocMenu(); fn(); });
menu.append(b);
return b;
};
const sep = () => { const el = document.createElement("div"); el.className = "sep"; menu.append(el); };
add("Duplicate", () => duplicateDoc(key));
add("Open in the default app", d.scratchId ? () => openExternally(key) : null);
add("Show in folder", d.scratchId ? () => showInFolder(key) : null);
sep();
add("Close others", openDocs.length > 1 ? () => closeOtherDocs(key) : null);
add("Close", () => closeDoc(key), "danger");
document.body.append(menu);
const w = menu.offsetWidth, h = menu.offsetHeight;
menu.style.left = Math.min(x, window.innerWidth - w - 8) + "px";
menu.style.top = Math.min(y, window.innerHeight - h - 8) + "px";
const away = (e) => {
if (!menu.contains(e.target)) { closeDocMenu(); document.removeEventListener("mousedown", away); }
};
setTimeout(() => document.addEventListener("mousedown", away), 0);
document.addEventListener("keydown", function esc(e) {
if (e.key === "Escape") { closeDocMenu(); document.removeEventListener("keydown", esc); }
});
}
// Add a document and show it. `pmDoc` is a ProseMirror document node.
function addDoc({ pmDoc, name, bytes, meta, setup, rep, isLocked, id }) {
captureActive();
const { state: pmState } = V().pm;
const d = {
key: "d" + (++keySeq),
state: pmState.EditorState.create({ doc: pmDoc, plugins: buildPlugins() }),
originalBytes: bytes || null,
docMeta: meta || {},
docSetup: setup || null,
docName: name || "Untitled document",
scratchId: id || "",
report: rep || { features: [] },
locked: !!isLocked,
dirty: false,
savedTarget: null,
lastSavedAt: 0,
};
openDocs.push(d);
activateDoc(d);
return d;
}
// Put a document on screen. Everything that reads the module-level variables
// keeps working, because they are pointed at this document first.
function activateDoc(d) {
activeKey = d.key;
adoptDoc(d);
clearTimeout(autosaveTimer);
mountEditor(d.state);
syncPageRule();
renderBanners();
renderDocTabs();
setDirty(d.dirty);
}
// Next/previous document, wrapping, like Ctrl+Tab in the browser.
function cycleDoc(step) {
if (openDocs.length < 2) return;
const i = openDocs.findIndex((d) => d.key === activeKey);
const next = openDocs[(i + step + openDocs.length) % openDocs.length];
if (next) switchDoc(next.key);
}
function switchDoc(key) {
if (key === activeKey) { if (view) view.focus(); return; }
const d = byKey(key);
if (!d) return;
captureActive();
activateDoc(d);
}
function closeDoc(key) {
const d = byKey(key);
if (!d) return;
const live = d.key === activeKey ? dirty : d.dirty;
if (live && !confirm(`Close ${d.docName}? Unsaved changes will be lost.`)) return;
const i = openDocs.indexOf(d);
openDocs.splice(i, 1);
// Closing the last document closes the editor, the way closing a browser's
// last tab closes the window. Anything else leaves an empty ribbon staring
// at the user.
if (!openDocs.length) {
activeKey = null;
try { window.silentmode.closeTab(); } catch { window.close(); }
return;
}
if (key === activeKey) activateDoc(openDocs[Math.min(i, openDocs.length - 1)]);
else renderDocTabs();
}
function closeOtherDocs(key) {
const others = openDocs.filter((d) => d.key !== key);
const unsaved = others.filter((d) => (d.key === activeKey ? dirty : d.dirty));
if (unsaved.length && !confirm(
`Close ${others.length} other document${others.length === 1 ? "" : "s"}? ` +
`${unsaved.length} ${unsaved.length === 1 ? "has" : "have"} unsaved changes.`)) return;
for (const d of others) openDocs.splice(openDocs.indexOf(d), 1);
const keep = byKey(key);
if (keep) { captureActive(); activateDoc(keep); }
}
// Duplicate: a second copy of what is on screen, sharing the original bytes
// so a save still grafts the source document's headers and styles. It has no
// home of its own, so Save on a duplicate asks where to put it.
function duplicateDoc(key) {
const d = byKey(key);
if (!d) return;
if (d.key === activeKey) captureActive();
const base = d.docName.replace(/\.docx$/i, "");
addDoc({
pmDoc: d.state.doc,
name: `${base} (copy).docx`,
bytes: d.originalBytes,
meta: Object.assign({}, d.docMeta),
setup: d.docSetup,
rep: d.report,
isLocked: d.locked,
id: "",
});
status(`Duplicated ${d.docName}.`);
}
async function openExternally(key) {
const d = byKey(key);
if (!d || !d.scratchId) return;
try {
const r = await window.silentmode.invoke("openExternally", { id: d.scratchId });
status(`Opened ${r.name} in the default app.`);
} catch (e) {
status("Couldn't open it: " + (e && e.message || e), "err");
}
}
async function showInFolder(key) {
const d = byKey(key);
if (!d) return;
try { await window.silentmode.invoke("openFolder", { id: d.scratchId || "" }); }
catch (e) { status("Couldn't open the folder: " + (e && e.message || e), "err"); }
}
// ------------------------------------------------------------------- files ---
function blankDoc() {
return V().pm.model.Node.fromJSON(schema, {
type: "doc", content: [{ type: "paragraph" }],
});
}
function newDocument() {
addDoc({ pmDoc: blankDoc(), name: "Untitled document" });
status("New document.");
}
async function loadBytes(bytes, name, id) {
const t0 = performance.now();
status("Reading document…");
const res = await DE().read.docxToDoc(bytes, schema);
const rep = res.report || { features: [] };
addDoc({
pmDoc: res.doc,
name: name || "document.docx",
bytes,
meta: res.meta || {},
setup: res.setup || null,
rep,
isLocked: !!(rep.blocked && rep.blocked.length),
id: id || "",
});
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);
}
}
// A document already in the recent ring, handed over by the add-on. Bringing
// the editor to the front matters: the click that asked for it happened in
// the sidebar, so without this the document lands in a tab nobody is looking
// at.
async function openStashed(id, name) {
const already = openDocs.find((d) => d.scratchId && d.scratchId === id);
if (already) {
switchDoc(already.key);
try { await window.silentmode.focusTab(); } catch {}
return;
}
const res = await window.silentmode.invoke("getBytes", { id });
await loadBytes(base64ToBytes(res.base64), name || res.name, id);
try { await window.silentmode.focusTab(); } catch {}
}
async function openFile(file) {
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`;
}
// Build the .docx bytes for whatever is on screen.
async function buildDocx() {
const res = await DE().write.docToDocx(view.state.doc, {
originalBytes, setup: docSetup, meta: docMeta,
});
if (res.warnings && res.warnings.length) console.warn("[docx-editor] save warnings:", res.warnings);
return res;
}
// Render the document to PDF. Chromium's own print pipeline does the work,
// in a hidden window on the add-on side — the same engine as Ctrl+P, so the
// PDF matches the preview instead of being a second opinion about it.
async function buildPdf() {
if (!window.silentmode?.invoke) throw new Error("PDF export needs the add-on host");
const res = await window.silentmode.invoke("renderPdf", {
html: printableHtml(),
name: docName,
});
if (!res || !res.base64) throw new Error("the renderer returned nothing");
return base64ToBytes(res.base64);
}
function downloadBytes(bytes, filename, mime) {
const blob = new Blob([bytes], { type: mime });
const url = URL.createObjectURL(blob);
const a = $("download-link");
a.href = url;
a.download = filename;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 8000);
return filename;
}
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
// Save. Once the document has a home — set by Save as… — this writes there
// and says so. Before that it does what the other add-ons do and puts a copy
// in Downloads, because the file picker never told us where the original
// came from.
async function save() {
if (!view || locked) return;
try {
status("Saving…");
const res = await buildDocx();
const kb = (res.bytes.length / 1024).toFixed(0);
const carried = res.carried && res.carried.length ? ` — carried over ${res.carried.join(", ")}` : "";
if (savedTarget) {
const written = await window.silentmode.invoke("writeChosen", {
token: savedTarget.token,
base64: DE().read.bytesToBase64(res.bytes),
});
// A token is good for one write, so take a fresh one for the next save
// to the same place.
savedTarget = await reserveSameTarget(written.path, written.name);
docName = written.name;
setDirty(false);
lastSavedAt = Date.now();
await autosave(true);
status(`Saved ${written.name} (${kb} KB)${carried}`, "ok");
return;
}
const name = downloadBytes(res.bytes, downloadName(), DOCX_MIME);
setDirty(false);
lastSavedAt = Date.now();
await autosave(true);
status(`Saved ${name} (${kb} KB)${carried}`, "ok");
if (res.warnings && res.warnings.length) {
status(`Saved with warnings: ${res.warnings.join("; ")}`, "err");
}
} catch (e) {
console.error(e);
status("Save failed: " + (e && e.message || e), "err");
}
}
// Ask the add-on to re-reserve a path the user already chose, so the next
// plain Save can write to it without another dialog.
async function reserveSameTarget(path, name) {
try {
const r = await window.silentmode.invoke("reserveSavePath", { path });
return r && r.token ? { token: r.token, name: name || r.name } : null;
} catch { return null; }
}
// Save as… — a real file picker, and the extension the user types decides
// whether they get a Word document or a PDF.
async function saveAs() {
if (!view || locked) return;
if (!window.silentmode?.invoke) { status("Save as… needs the add-on host", "err"); return; }
try {
const pick = await window.silentmode.invoke("pickSavePath", { name: downloadName() });
if (!pick || pick.cancelled) return;
status(pick.format === "pdf" ? "Rendering PDF…" : "Saving…");
let bytes, carried = "";
if (pick.format === "pdf") {
bytes = await buildPdf();
} else {
const res = await buildDocx();
bytes = res.bytes;
if (res.carried && res.carried.length) carried = ` — carried over ${res.carried.join(", ")}`;
}
const written = await window.silentmode.invoke("writeChosen", {
token: pick.token,
base64: DE().read.bytesToBase64(bytes),
});
const kb = (written.bytes / 1024).toFixed(0);
if (pick.format === "pdf") {
// A PDF is an export, not the document's home: the editor still edits
// the .docx, so Save keeps pointing wherever it pointed before.
status(`Exported ${written.name} (${kb} KB)`, "ok");
return;
}
savedTarget = await reserveSameTarget(written.path, written.name);
docName = written.name;
setDirty(false);
lastSavedAt = Date.now();
await autosave(true);
status(`Saved ${written.name} (${kb} KB)${carried}`, "ok");
} catch (e) {
console.error(e);
status("Save as failed: " + (e && e.message || e), "err");
}
}
// PDF straight to Downloads — the one-click version of Save as… .pdf.
async function exportPdf() {
if (!view) return;
try {
status("Rendering PDF…");
const bytes = await buildPdf();
const name = downloadName().replace(/\.docx$/i, "") + ".pdf";
downloadBytes(bytes, name, "application/pdf");
status(`Saved ${name} (${(bytes.length / 1024).toFixed(0)} KB)`, "ok");
} catch (e) {
console.error(e);
status("PDF export 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() {
captureActive();
const unsaved = openDocs.filter((d) => d.dirty).length;
if (unsaved && !confirm(
`Close the editor? ${unsaved} document${unsaved === 1 ? "" : "s"} ` +
`${unsaved === 1 ? "has" : "have"} unsaved changes.`)) 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();
installDocCss();
syncPageRule();
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) => {
captureActive();
if (openDocs.some((d) => d.dirty)) { e.preventDefault(); e.returnValue = ""; }
try { window.silentmode?.invoke("editorBye", {}); } catch {}
});
// Tell the add-on there is an editor here, so it hands the next document
// to this page instead of opening a second editor tab. Anything that goes
// wrong on this path simply gets the old behaviour back.
try { await window.silentmode?.invoke("editorHello", {}); } catch {}
try {
window.silentmode?.on("open-doc", async (payload) => {
const id = String(payload && payload.id || "");
if (!id) return;
try {
await openStashed(id, payload.name || "");
// The acknowledgement is what stops the add-on opening a tab as well.
await window.silentmode.invoke("docOpened", { id });
} catch (e) {
console.error("[docx-editor] handed a document we couldn't open:", e);
status("Couldn't open that document: " + (e && e.message || e), "err");
}
});
} catch (e) {
console.warn("[docx-editor] no event channel; documents will open in their own tabs:", e);
}
// 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) {
addDoc({ pmDoc: blankDoc(), name: "Untitled document" });
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);
addDoc({ pmDoc: blankDoc(), name: "Untitled document" });
showError(`Couldn't load that document: ${e && e.message || e}`);
}
}
boot();