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

1200 lines
45 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
//
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
// 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.
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
//
// 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;
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
// 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
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
// ---------------------------------------------------------------- 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
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
margins, the document's style catalogue and its theme.</p>
<p><b>Save</b> puts a copy in your downloads until you have given the document a home
with <b>Save as</b>, which is a real file dialog type a <code>.docx</code> name for a
Word document or a <code>.pdf</code> one for a PDF. After that, Save writes there
directly. <b>PDF</b> is the one-click version: the page is rendered on the paper size the
document itself specifies, by the same engine behind Ctrl+P, so it matches what you were
looking at. Nothing here ever overwrites the file you opened.</p>
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
${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;
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
$("file-save").title = savedTarget
? `Save to ${savedTarget.name} (Ctrl+S)`
: "Save a .docx to your downloads (Ctrl+S)";
$("file-saveas").disabled = locked;
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
}
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();
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
$("file-saveas").onclick = () => saveAs();
$("file-pdf").onclick = () => exportPdf();
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
$("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);
};
}
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
// ------------------------------------------------------------- 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,
});
}
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
// ------------------------------------------------------------------ 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; },
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
"Shift-Mod-s": () => { saveAs(); return true; },
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
"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 = "";
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
savedTarget = null;
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
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;
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
savedTarget = null;
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
docMeta = res.meta || {};
docSetup = res.setup || null;
report = res.report || { features: [] };
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
syncPageRule();
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
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`;
}
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
// 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.
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
async function save() {
if (!view || locked) return;
try {
status("Saving…");
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
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;
}
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
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
const name = downloadBytes(res.bytes, downloadName(), DOCX_MIME);
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
setDirty(false);
lastSavedAt = Date.now();
await autosave(true);
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
status(`Saved ${name} (${kb} KB)${carried}`, "ok");
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
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");
}
}
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
// 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");
}
}
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
// 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();
feat(docx-editor): Save as…, PDF export, and a mark of our own Three gaps, one theme: the editor could produce a file but not decide where it went, what format it was in, or look like anything in the dock. **Save as…** opens a real file dialog, and the extension typed there picks the format. Save then writes to that file instead of dropping another copy in Downloads every time. The renderer never names a path: the dialog returns an opaque token, and the add-on will only write to a path a dialog actually returned. An extension page is the least trusted thing in the add-on, and "write these bytes anywhere" is not a capability it needs. **PDF** goes through Chromium's own print pipeline in a hidden window — the same engine as Ctrl+P — on the paper size read out of the document's own sectPr. For that to match what the user was looking at, the page's typography had to stop living in editor.css, which the export window can't reach: it moves to lib/doc-css.js and both surfaces read the one string. The result embeds subsetted fonts, keeps images, and turns hyperlinks into real PDF link annotations. **The icon** is ours. Microsoft's Word mark is a trademark and borrowing it to look official is not something a browser that talks about sovereignty should do. icon.svg says "text document" in its own words — a turned corner, a heading rule, body lines, a pilcrow badge in Silent Mode green — and `npm run icons` derives the PNGs and addon.json's copy from it, so there is one drawing rather than several that drift. Also: the scratch folder follows the profile rename to extensions-data/ via the api.dataDir the host now provides, instead of creating a stale addons-data/ beside it.
2026-09-21 03:35:55 +02:00
installDocCss();
syncPageRule();
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
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();