theseus/bundled-addons/docx-editor/editor.js

1016 lines
38 KiB
JavaScript
Raw Normal View History

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