// 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; 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 // ---------------------------------------------------------------- status --- function status(text, kind) { const el = $("msg"); el.textContent = text || ""; el.classList.toggle("err", kind === "err"); el.classList.toggle("ok", kind === "ok"); clearTimeout(status._t); if (text) status._t = setTimeout(() => { el.textContent = ""; el.className = "msg"; }, 6000); } function setDirty(on) { dirty = !!on; const el = $("docname"); el.innerHTML = ""; el.append(document.createTextNode(docName)); if (dirty) { const dot = document.createElement("span"); dot.className = "dirty"; dot.textContent = "•"; dot.title = "Unsaved changes"; el.append(dot); } document.title = (dirty ? "• " : "") + docName + " — Word editor"; } function updateCounts() { if (!view) return; const text = view.state.doc.textBetween(0, view.state.doc.content.size, " ", " "); const words = (text.match(/[^\s]+/g) || []).length; const breaks = countPageBreaks(view.state.doc); const pages = Math.max(1, Math.ceil(words / WORDS_PER_PAGE)) + breaks; $("stat-words").textContent = `${words.toLocaleString()} word${words === 1 ? "" : "s"}`; $("stat-pages").textContent = `${pages} page${pages === 1 ? "" : "s"}` + (breaks ? "" : " (estimated)"); } function countPageBreaks(doc) { let n = 0; doc.descendants((node) => { if (node.type.name === "page_break") n++; }); return n; } // ---------------------------------------------------------------- banners --- function renderBanners() { const host = $("banners"); host.innerHTML = ""; const add = (cls, icon, html, actions) => { const el = document.createElement("div"); el.className = "banner " + cls; const i = document.createElement("span"); i.className = "ico"; i.textContent = icon; const b = document.createElement("div"); b.className = "body"; b.innerHTML = html; el.append(i, b); if (actions && actions.length) { const a = document.createElement("div"); a.className = "acts"; for (const act of actions) { const btn = document.createElement("button"); btn.className = "btn wide" + (act.primary ? " primary" : ""); btn.innerHTML = `${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(); $("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; }, "Mod-p": () => { window.print(); return true; }, "Mod-Enter": (state, dispatch) => { if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.page_break.create()).scrollIntoView()); return true; }, "Shift-Enter": chainCommands(exitCode, (state, dispatch) => { if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView()); return true; }), "Enter": splitListItem(schema.nodes.list_item), "Tab": (state, dispatch) => { // Inside a table Tab moves between cells, the way Word does it; // inside a list it nests; elsewhere it indents the paragraph. if (tables.goToNextCell(1)(state, dispatch)) return true; if (sinkListItem(schema.nodes.list_item)(state, dispatch)) return true; shiftIndent(1); return true; }, "Shift-Tab": (state, dispatch) => { if (tables.goToNextCell(-1)(state, dispatch)) return true; if (liftListItem(schema.nodes.list_item)(state, dispatch)) return true; shiftIndent(-1); return true; }, }; const { inputRules, wrappingInputRule, textblockTypeInputRule, smartQuotes, ellipsis, emDash } = inputrules; const rules = [ ...smartQuotes, ellipsis, emDash, wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list), wrappingInputRule(/^(\d+)\.\s$/, schema.nodes.ordered_list, (match) => ({ order: +match[1], format: "decimal" }), (match, node) => node.childCount + node.attrs.order === +match[1]), wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote), textblockTypeInputRule(/^```$/, schema.nodes.code_block), textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes.heading, (match) => ({ level: match[1].length })), ]; return [ inputRules({ rules }), keymap.keymap(keys), keymap.keymap(baseKeymap), dropcursor.dropCursor(), gapcursor.gapCursor(), history.history(), tables.columnResizing(), tables.tableEditing(), ]; } function mountEditor(doc) { const { state: pmState, view: pmView } = V().pm; const sheet = $("sheet"); sheet.innerHTML = ""; const state = pmState.EditorState.create({ doc, plugins: buildPlugins() }); if (view) view.destroy(); view = new pmView.EditorView(sheet, { state, editable: () => !locked, dispatchTransaction(tr) { const next = view.state.apply(tr); view.updateState(next); if (tr.docChanged) { setDirty(true); updateCounts(); scheduleAutosave(); } syncRibbon(); }, handlePaste(v, event) { // A .docx dropped or pasted as a file goes through the document // reader, not the HTML paste path. const files = Array.from(event.clipboardData?.files || []); const docxFile = files.find((f) => /\.docx$/i.test(f.name)); if (docxFile) { openFile(docxFile); return true; } const image = files.find((f) => /^image\//.test(f.type)); if (image) { insertImageFile(image); return true; } return false; }, handleDrop(v, event) { const files = Array.from(event.dataTransfer?.files || []); const docxFile = files.find((f) => /\.docx$/i.test(f.name)); if (docxFile) { event.preventDefault(); openFile(docxFile); return true; } const image = files.find((f) => /^image\//.test(f.type)); if (image) { event.preventDefault(); insertImageFile(image); return true; } return false; }, }); updateCounts(); syncRibbon(); view.focus(); } // ------------------------------------------------------------------- files --- function blankDoc() { return V().pm.model.Node.fromJSON(schema, { type: "doc", content: [{ type: "paragraph" }], }); } function newDocument() { if (dirty && !confirm("Start a new document? Unsaved changes will be lost.")) return; originalBytes = null; docMeta = {}; docSetup = null; scratchId = ""; savedTarget = null; docName = "Untitled document"; report = { features: [] }; locked = false; renderBanners(); mountEditor(blankDoc()); setDirty(false); status("New document."); } async function loadBytes(bytes, name, id) { const t0 = performance.now(); status("Reading document…"); const res = await DE().read.docxToDoc(bytes, schema); originalBytes = bytes; savedTarget = null; docMeta = res.meta || {}; docSetup = res.setup || null; report = res.report || { features: [] }; syncPageRule(); docName = name || "document.docx"; scratchId = id || ""; locked = report.blocked && report.blocked.length > 0; renderBanners(); mountEditor(res.doc); setDirty(false); const ms = Math.round(performance.now() - t0); status(`Opened ${docName} — ${res.doc.childCount} blocks in ${ms} ms`, "ok"); if (res.warnings && res.warnings.length) { console.warn("[docx-editor] read warnings:", res.warnings, res.messages); } } async function openFile(file) { if (dirty && !confirm(`Open ${file.name}? Unsaved changes will be lost.`)) return; try { const bytes = new Uint8Array(await file.arrayBuffer()); // Park a copy with the add-on so the document survives a tab reload and // shows up in the sidebar's recent list. let id = ""; try { const stashed = await window.silentmode?.invoke("stash", { name: file.name, base64: DE().read.bytesToBase64(bytes), kind: "opened", }); id = stashed && stashed.id || ""; } catch (e) { console.warn("[docx-editor] could not stash the document:", e); } await loadBytes(bytes, file.name, id); } catch (e) { console.error(e); showError(`Couldn't open ${file.name}: ${e && e.message || e}`); } } function showError(text) { status(text, "err"); if (!view) { $("sheet").innerHTML = `
    ${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() { if (dirty && !confirm("Close the editor? Unsaved changes will be lost.")) return; try { window.silentmode?.closeTab(); } catch { window.close(); } } // ------------------------------------------------------------------- boot --- function base64ToBytes(b64) { const bin = atob(b64); const out = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i); return out; } async function boot() { if (!window.DOCXV || !window.DocxEditor?.read) { showError("The editor's libraries didn't load. Check vendor/docx-vendor.js."); return; } schema = DE().schema.build(); 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) => { if (dirty) { e.preventDefault(); e.returnValue = ""; } }); // Drain the hand-off: ?doc= wins (it survives a reload), then __pending. let id = new URLSearchParams(location.search).get("doc") || ""; let name = ""; try { const pending = await window.silentmode?.storage?.get("__pending", null); if (pending && pending.id) { if (!id) id = pending.id; if (pending.id === id) name = pending.name || ""; await window.silentmode.storage.set("__pending", null); } } catch (e) { console.warn("[docx-editor] no storage hand-off:", e); } if (!id) { mountEditor(blankDoc()); setDirty(false); status("Blank document — drop a .docx here, or use Open."); return; } try { const res = await window.silentmode.invoke("getBytes", { id }); await loadBytes(base64ToBytes(res.base64), name || res.name, id); } catch (e) { console.error(e); mountEditor(blankDoc()); showError(`Couldn't load that document: ${e && e.message || e}`); } } boot();