// Word editor — the tab. // // Load path: // editor.html?doc=, 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; // Ubuntu and Fraunces ship with the add-on (see fonts.css); the rest are // whatever the machine already has. A font offered in the ribbon but absent // from the machine is a font the user picks and then cannot see, which is // why the two that Windows lacks are bundled rather than merely listed. const BUNDLED_FONTS = ["Ubuntu", "Fraunces"]; const FONTS = [ "Calibri", "Cambria", "Georgia", "Times New Roman", "Arial", "Helvetica", "Verdana", "Tahoma", "Trebuchet MS", "Garamond", "Book Antiqua", "Courier New", "Consolas", "Segoe UI", "Ubuntu", "Fraunces", ]; 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 // How large the page is drawn. "fit" and "page" recompute as the window // changes; a number is a fixed multiplier. Kept per editor rather than per // document — it is a property of the screen you are looking at, not of the // file. let zoomMode = "fit"; // --- 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 = `${act.label}`; 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 ${names}. 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(`${dropped.map((f) => f.label.toLowerCase()).join(", ")} won't survive a save`); if (lossy.length) parts.push(`${lossy.map((f) => f.label.toLowerCase()).join(", ")} 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 = `

${title}

${bodyHtml}
`; 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 = `${b.label}`; 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", `
`, [ ...(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", `
`, [ { 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 `
  • ${word}${f.label}${f.note ? ` — ${f.note}` : ""}
  • `; }; dialog("Word editor", `

    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.

    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.

    Save puts a copy in your downloads until you have given the document a home with Save as…, which is a real file dialog — type a .docx name for a Word document or a .pdf one for a PDF. After that, Save writes there directly. PDF 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.

    ${found.length ? `

    In this document

    ` : ""}

    Not supported yet

    Built with

    Full licence texts ship in vendor/LICENSES.txt.

    `, [{ 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 = `${clearLabel}`; 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(); $("zoom").onchange = (e) => setZoom(e.target.value); $("zoom-in").onclick = () => stepZoom(1); $("zoom-out").onclick = () => stepZoom(-1); $("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. // The bundled webfonts, fetched from the add-on with the woff2 already // inlined as data URLs. // // A plain to fonts.css does not work here: the page is on file://, // where Chromium registers the @font-face rules but refuses to fetch the // font files themselves — the family appears in document.fonts and every // glyph still comes out in the fallback face. Data URLs sidestep the fetch // entirely, and the add-on is the only side that can read the files. async function installFontCss() { try { const res = await window.silentmode?.invoke("fontCss", {}); if (res && res.css) { const el = document.createElement("style"); el.id = "bundled-fonts"; el.textContent = res.css; document.head.append(el); await document.fonts.ready; return; } } catch (e) { console.warn("[docx-editor] bundled fonts unavailable:", e); } // Without them, a document that asks for Ubuntu or Fraunces still saves // correctly — Word will use its own copy — it just isn't drawn faithfully // here, so say so rather than letting the ribbon imply otherwise. for (const name of BUNDLED_FONTS) { const opt = [...$("font-family").options].find((o) => o.value === name); if (opt) opt.textContent = name + " (not available)"; } } 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); syncPageGeometry(); applyZoom(); } // The sheet is drawn at the size the document claims, margins included, so // what is on screen is the page rather than a generic rectangle. function pageInches() { const page = (docSetup && docSetup.page) || {}; const size = page.size || {}; const m = page.margin || {}; const inch = (twips, fallback) => { const n = Number(twips); return Number.isFinite(n) && n > 0 ? n / 1440 : fallback; }; return { w: inch(size.width, 8.27), h: inch(size.height, 11.69), top: inch(m.top, 1), right: inch(m.right, 1), bottom: inch(m.bottom, 1), left: inch(m.left, 1), }; } function syncPageGeometry() { const p = pageInches(); const root = document.documentElement.style; root.setProperty("--page-w", p.w + "in"); root.setProperty("--page-h", p.h + "in"); root.setProperty("--page-mt", p.top + "in"); root.setProperty("--page-mr", p.right + "in"); root.setProperty("--page-mb", p.bottom + "in"); root.setProperty("--page-ml", p.left + "in"); } const ZOOM_MIN = 0.4, ZOOM_MAX = 4; function applyZoom() { const board = $("board"); if (!board) return; const p = pageInches(); const style = getComputedStyle(board); const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight); const availW = board.clientWidth - padding - 2; const availH = board.clientHeight - parseFloat(style.paddingTop) - 24; let z; if (zoomMode === "fit") z = availW / (p.w * 96); else if (zoomMode === "page") z = Math.min(availW / (p.w * 96), availH / (p.h * 96)); else z = parseFloat(zoomMode) || 1; z = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z)); document.documentElement.style.setProperty("--zoom", String(z)); const sel = $("zoom"); if (sel && sel.value !== zoomMode) sel.value = zoomMode; return z; } function setZoom(mode) { zoomMode = mode; applyZoom(); try { window.silentmode?.storage?.set("zoom", mode); } catch {} if (view) view.focus(); } // Stepping from a fit mode starts at whatever that fit worked out to, so the // first click doesn't jump somewhere unrelated to what is on screen. function stepZoom(dir) { const steps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3]; const now = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--zoom")) || 1; const next = dir > 0 ? steps.find((v) => v > now + 0.01) : [...steps].reverse().find((v) => v < now - 0.01); if (next) setZoom(String(next)); } // 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-=": () => { stepZoom(1); return true; }, "Mod-+": () => { stepZoom(1); return true; }, "Mod--": () => { stepZoom(-1); return true; }, "Mod-0": () => { setZoom("fit"); 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 = `
    ${text}
    `; } } 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(); try { const saved = await window.silentmode?.storage?.get("zoom", "fit"); if (saved) zoomMode = String(saved); } catch {} syncPageRule(); wireRibbon(); // After wireRibbon, which is what fills the font menu this may have to // annotate. await installFontCss(); // A fit mode is a statement about the window, so it has to be recomputed // when the window changes. let resizeTimer = null; window.addEventListener("resize", () => { if (zoomMode !== "fit" && zoomMode !== "page") return; clearTimeout(resizeTimer); resizeTimer = setTimeout(applyZoom, 60); }); // 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();