Moving it out of the build left it with no way in. Settings can only install from the community catalogue, so a first-party extension that isn't bundled has a working update channel and no first copy for anyone to update — the mechanism was all there and the front door was missing. So it goes back beside screenshot, aegis and pdf-editor: seeded into every profile by the build, listed under "Built into Theseus", and kept current between releases by the operator-signed channel at theseus.x/extensions/docx-editor/. That is the arrangement docs/ADDON-UPDATES.md describes, and the one the signing script was written for. About 400 KB compressed in the installer, most of it the vendored editor libraries — next to the ~4 MB of pdf.js that pdf-editor already ships, the weight argument for keeping it out didn't survive contact with the numbers. The end-to-end driver goes back to checking that a fresh profile seeds it, which is the property that actually matters now.
1199 lines
45 KiB
JavaScript
1199 lines
45 KiB
JavaScript
// Word editor — the tab.
|
||
//
|
||
// Load path:
|
||
// editor.html?doc=<scratch id>, or storage.__pending = {id, name}
|
||
// → silentmode.invoke("getBytes", {id}) → base64 of the original .docx
|
||
// → read.docxToDoc() → a ProseMirror document + a report of what's in
|
||
// the file that this editor can't render
|
||
// → the report becomes the banners at the top of the page
|
||
//
|
||
// Save paths:
|
||
// Save → write.docToDocx() → a copy in Downloads, or straight to the
|
||
// file the user chose earlier with Save as…
|
||
// Save as… → a native file dialog; the extension typed there decides
|
||
// whether they get a .docx or a .pdf
|
||
// PDF → the same PDF, one click, into Downloads
|
||
//
|
||
// The original bytes are kept in memory for the whole session because the
|
||
// save grafts the document's headers, footers, notes, styles and page
|
||
// setup back out of them. PDFs are rendered by Chromium's own print
|
||
// pipeline in a hidden window on the add-on side, from the very CSS the
|
||
// editor is displaying (lib/doc-css.js), so the export can't drift away
|
||
// from the preview.
|
||
//
|
||
// The "blocked" state is worth understanding: a document with tracked
|
||
// changes or comments opens read-only, because mammoth silently renders
|
||
// insertions as ordinary text and drops deletions — saving would accept
|
||
// every pending revision without anyone choosing to. The user has to say so
|
||
// out loud, and then the editor unlocks.
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const V = () => window.DOCXV;
|
||
const DE = () => window.DocxEditor;
|
||
|
||
const AUTOSAVE_MS = 20_000;
|
||
const WORDS_PER_PAGE = 500;
|
||
|
||
const FONTS = [
|
||
"Calibri", "Cambria", "Georgia", "Times New Roman", "Arial", "Helvetica",
|
||
"Verdana", "Tahoma", "Trebuchet MS", "Garamond", "Book Antiqua",
|
||
"Courier New", "Consolas", "Segoe UI",
|
||
];
|
||
const TEXT_COLORS = [
|
||
"000000", "404040", "808080", "BFBFBF", "FFFFFF", "C00000", "FF0000", "FFC000",
|
||
"FFFF00", "92D050", "00B050", "00B0F0", "0070C0", "002060", "7030A0", "1F4E79",
|
||
];
|
||
|
||
let view = null; // ProseMirror EditorView
|
||
let schema = null;
|
||
let originalBytes = null; // the .docx this document was read from
|
||
let docMeta = {}; // docProps carried through a save
|
||
let docSetup = null; // page setup carried through a save
|
||
let docName = "Untitled document";
|
||
let scratchId = ""; // id in the add-on's recent ring
|
||
let report = { features: [] };
|
||
let locked = false; // tracked changes / comments, not yet accepted
|
||
let dirty = false;
|
||
let lastSavedAt = 0;
|
||
let autosaveTimer = null;
|
||
// Where "Save" writes without asking, once the user has chosen a home for
|
||
// this document with Save as… Until then Save behaves like every other
|
||
// add-on here and drops the file in Downloads.
|
||
let savedTarget = null; // {name} — the path itself stays in the add-on
|
||
|
||
// ---------------------------------------------------------------- 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, """)}"></div>
|
||
<div class="field"><label for="lnk-text">Text</label>
|
||
<input id="lnk-text" type="text" value="${(selected || "").replace(/"/g, """)}"
|
||
${selected ? "" : 'placeholder="the words to link"'}></div>
|
||
`, [
|
||
...(existing ? [{ label: "Remove link", onClick: () => {
|
||
const { from, to } = view.state.selection;
|
||
view.dispatch(view.state.tr.removeMark(from, to, schema.marks.link));
|
||
view.focus();
|
||
} }] : []),
|
||
{ label: "Cancel", onClick: () => {} },
|
||
{ label: existing ? "Update" : "Add link", primary: true, onClick: (box) => {
|
||
const href = box.querySelector("#lnk-href").value.trim();
|
||
const text = box.querySelector("#lnk-text").value;
|
||
if (!href) return false;
|
||
const { state, dispatch } = view;
|
||
const mark = schema.marks.link.create({ href, title: null, anchor: null });
|
||
if (state.selection.empty || text !== selected) {
|
||
const node = schema.text(text || href, [mark]);
|
||
dispatch(state.tr.replaceSelectionWith(node, false).scrollIntoView());
|
||
} else {
|
||
dispatch(state.tr.addMark(state.selection.from, state.selection.to, mark));
|
||
}
|
||
view.focus();
|
||
} },
|
||
]);
|
||
}
|
||
|
||
function openTableDialog() {
|
||
dialog("Insert table", `
|
||
<div class="row">
|
||
<div class="field"><label for="tbl-rows">Rows</label><input id="tbl-rows" type="number" min="1" max="60" value="3"></div>
|
||
<div class="field"><label for="tbl-cols">Columns</label><input id="tbl-cols" type="number" min="1" max="20" value="3"></div>
|
||
</div>
|
||
<div class="field"><label><input id="tbl-head" type="checkbox" checked style="height:auto;width:auto;margin-right:6px"> First row is a header</label></div>
|
||
`, [
|
||
{ label: "Cancel", onClick: () => {} },
|
||
{ label: "Insert", primary: true, onClick: (box) => {
|
||
const rows = Math.max(1, Math.min(60, parseInt(box.querySelector("#tbl-rows").value, 10) || 3));
|
||
const cols = Math.max(1, Math.min(20, parseInt(box.querySelector("#tbl-cols").value, 10) || 3));
|
||
insertTable(rows, cols, box.querySelector("#tbl-head").checked);
|
||
} },
|
||
]);
|
||
}
|
||
|
||
const NOT_YET = [
|
||
"Tracked changes — a document that has them opens read-only until you accept them",
|
||
"Comments",
|
||
"Equations (OMML)",
|
||
"Shapes, text boxes and WordArt",
|
||
"Content controls",
|
||
"Fields: page numbers, tables of contents, cross-references",
|
||
"Bookmarks and internal cross-references",
|
||
"Section breaks and multi-column layout",
|
||
"Headers and footers can't be edited here — they are carried through unchanged",
|
||
"Footnote and endnote text can't be edited here — the notes and their markers are carried through unchanged",
|
||
"A styles panel: styles are applied by the Style box, not edited",
|
||
"Paragraph borders and shading, other than a horizontal rule",
|
||
"Metafile pictures (WMF/EMF) — they can't be written back and are dropped",
|
||
];
|
||
|
||
function openAbout() {
|
||
const found = report.features || [];
|
||
const line = (f) => {
|
||
const cls = f.level === "preserved" ? "keep" : f.level === "blocked" ? "stop" : "lose";
|
||
const word = f.level === "preserved" ? "kept" : f.level === "lossy" ? "partly" : f.level === "blocked" ? "blocked" : "dropped";
|
||
return `<li><span class="pill ${cls}">${word}</span>${f.label}${f.note ? ` — ${f.note}` : ""}</li>`;
|
||
};
|
||
dialog("Word editor", `
|
||
<p>A basic, honest .docx editor. It opens most Word documents, lets you edit the
|
||
things below, and writes a file Word will open without complaint.</p>
|
||
|
||
<p>Saving rebuilds the document body from what you see, and carries the rest of the
|
||
original file across untouched: headers, footers, footnotes, endnotes, page size and
|
||
margins, the document's style catalogue and its theme.</p>
|
||
|
||
<p><b>Save</b> puts a copy in your downloads until you have given the document a home
|
||
with <b>Save as…</b>, which is a real file dialog — type a <code>.docx</code> name for a
|
||
Word document or a <code>.pdf</code> one for a PDF. After that, Save writes there
|
||
directly. <b>PDF</b> is the one-click version: the page is rendered on the paper size the
|
||
document itself specifies, by the same engine behind Ctrl+P, so it matches what you were
|
||
looking at. Nothing here ever overwrites the file you opened.</p>
|
||
|
||
${found.length ? `<h3>In this document</h3><ul>${found.map(line).join("")}</ul>` : ""}
|
||
|
||
<h3>Not supported yet</h3>
|
||
<ul>${NOT_YET.map((t) => `<li>${t}</li>`).join("")}</ul>
|
||
|
||
<h3>Built with</h3>
|
||
<ul>
|
||
<li>mammoth — reads the .docx (BSD-2-Clause)</li>
|
||
<li>ProseMirror — the editor itself (MIT)</li>
|
||
<li>docx — writes the .docx (MIT)</li>
|
||
<li>JSZip — the package layer (MIT)</li>
|
||
</ul>
|
||
<p style="font-size:11.5px">Full licence texts ship in <code>vendor/LICENSES.txt</code>.</p>
|
||
`, [{ label: "Close", primary: true, onClick: () => {} }]);
|
||
}
|
||
|
||
// ------------------------------------------------------------- colour pops ---
|
||
|
||
function openColorPop(anchor, colors, onPick, onClear, clearLabel) {
|
||
document.querySelectorAll(".pop").forEach((p) => p.remove());
|
||
const pop = document.createElement("div");
|
||
pop.className = "pop";
|
||
const grid = document.createElement("div");
|
||
grid.className = "grid";
|
||
for (const c of colors) {
|
||
const chip = document.createElement("button");
|
||
chip.className = "chip";
|
||
chip.style.background = c.css;
|
||
chip.title = c.label;
|
||
chip.addEventListener("click", () => { onPick(c); pop.remove(); });
|
||
grid.append(chip);
|
||
}
|
||
const row = document.createElement("div");
|
||
row.className = "prow";
|
||
const clear = document.createElement("button");
|
||
clear.className = "btn wide";
|
||
clear.innerHTML = `<span>${clearLabel}</span>`;
|
||
clear.addEventListener("click", () => { onClear(); pop.remove(); });
|
||
row.append(clear);
|
||
pop.append(grid, row);
|
||
document.body.append(pop);
|
||
const r = anchor.getBoundingClientRect();
|
||
pop.style.left = Math.min(r.left, window.innerWidth - pop.offsetWidth - 8) + "px";
|
||
pop.style.top = (r.bottom + 4) + "px";
|
||
const away = (e) => {
|
||
if (!pop.contains(e.target) && e.target !== anchor) { pop.remove(); document.removeEventListener("mousedown", away); }
|
||
};
|
||
setTimeout(() => document.addEventListener("mousedown", away), 0);
|
||
}
|
||
|
||
// ------------------------------------------------------------------ ribbon ---
|
||
|
||
function syncRibbon() {
|
||
if (!view) return;
|
||
const st = view.state;
|
||
const on = (id, active) => $(id).classList.toggle("on", !!active);
|
||
|
||
on("m-strong", markActive(st, schema.marks.strong));
|
||
on("m-em", markActive(st, schema.marks.em));
|
||
on("m-underline", markActive(st, schema.marks.underline));
|
||
on("m-strike", markActive(st, schema.marks.strike));
|
||
on("m-sup", markActive(st, schema.marks.sup));
|
||
on("m-sub", markActive(st, schema.marks.sub));
|
||
|
||
const fontAttrs = markAttrs(st, schema.marks.font);
|
||
$("font-family").value = FONTS.includes(fontAttrs && fontAttrs.family) ? fontAttrs.family : "";
|
||
const sizeAttrs = markAttrs(st, schema.marks.fsize);
|
||
$("font-size").value = sizeAttrs ? sizeAttrs.pt : "";
|
||
const colorAttrs = markAttrs(st, schema.marks.color);
|
||
$("color-bar").style.background = colorAttrs ? "#" + colorAttrs.hex : "#c00000";
|
||
const hlAttrs = markAttrs(st, schema.marks.highlight);
|
||
$("hl-bar").style.background = hlAttrs
|
||
? (DE().schema.HIGHLIGHT_CSS[hlAttrs.name] || "#ffff00") : "#ffff00";
|
||
|
||
const block = currentBlockAttrs();
|
||
const $from = st.selection.$from;
|
||
const inQuote = findParent($from, schema.nodes.blockquote);
|
||
const inCode = findParent($from, schema.nodes.code_block);
|
||
let style = "paragraph";
|
||
if (inCode) style = "code_block";
|
||
else if (inQuote) style = "blockquote";
|
||
else if (block && block.type === schema.nodes.heading) style = "h" + block.attrs.level;
|
||
$("style-select").value = style;
|
||
|
||
const align = block ? block.attrs.align : null;
|
||
on("a-left", align === "left");
|
||
on("a-center", align === "center");
|
||
on("a-right", align === "right");
|
||
on("a-justify", align === "justify");
|
||
$("line-height").value = block && block.attrs.lineHeight ? String(block.attrs.lineHeight) : "";
|
||
|
||
const ol = findParent($from, schema.nodes.ordered_list);
|
||
const ul = findParent($from, schema.nodes.bullet_list);
|
||
on("l-bullet", !!ul);
|
||
on("l-ordered", !!ol);
|
||
$("list-format").disabled = !ol;
|
||
if (ol) $("list-format").value = ol.node.attrs.format || "decimal";
|
||
|
||
$("table-group").dataset.contextual = inTable() ? "on" : "off";
|
||
|
||
const { history } = V().pm;
|
||
$("undo").disabled = history.undoDepth(st) === 0;
|
||
$("redo").disabled = history.redoDepth(st) === 0;
|
||
|
||
// Everything that writes is off while the document is locked.
|
||
document.querySelectorAll("#ribbon .btn, #ribbon .rsel, #ribbon .rnum").forEach((el) => {
|
||
if (el.id === "undo" || el.id === "redo") return;
|
||
el.disabled = locked || (el.id === "list-format" && !ol);
|
||
});
|
||
$("file-save").disabled = locked;
|
||
$("file-save").title = savedTarget
|
||
? `Save to ${savedTarget.name} (Ctrl+S)`
|
||
: "Save a .docx to your downloads (Ctrl+S)";
|
||
$("file-saveas").disabled = locked;
|
||
}
|
||
|
||
function wireRibbon() {
|
||
const sel = $("font-family");
|
||
for (const f of FONTS) {
|
||
const opt = document.createElement("option");
|
||
opt.value = f; opt.textContent = f; opt.style.fontFamily = f;
|
||
sel.append(opt);
|
||
}
|
||
|
||
$("m-strong").onclick = () => toggleMark("strong");
|
||
$("m-em").onclick = () => toggleMark("em");
|
||
$("m-underline").onclick = () => toggleMark("underline");
|
||
$("m-strike").onclick = () => toggleMark("strike");
|
||
$("m-sup").onclick = () => toggleMark("sup");
|
||
$("m-sub").onclick = () => toggleMark("sub");
|
||
$("m-clear").onclick = clearFormatting;
|
||
|
||
sel.onchange = () => setValueMark("font", sel.value ? { family: sel.value } : null);
|
||
$("font-size").onchange = () => {
|
||
const pt = parseFloat($("font-size").value);
|
||
setValueMark("fsize", Number.isFinite(pt) && pt > 0 ? { pt } : null);
|
||
};
|
||
|
||
$("m-color").onclick = (e) => openColorPop(
|
||
e.currentTarget,
|
||
TEXT_COLORS.map((hex) => ({ css: "#" + hex, label: "#" + hex, hex })),
|
||
(c) => setValueMark("color", { hex: c.hex }),
|
||
() => setValueMark("color", null),
|
||
"Automatic");
|
||
|
||
$("m-highlight").onclick = (e) => openColorPop(
|
||
e.currentTarget,
|
||
DE().schema.HIGHLIGHTS.map((h) => ({ css: h.css, label: h.label, name: h.name })),
|
||
(c) => setValueMark("highlight", { name: c.name }),
|
||
() => setValueMark("highlight", null),
|
||
"No highlight");
|
||
|
||
$("style-select").onchange = (e) => setStyle(e.target.value);
|
||
|
||
for (const [id, value] of [["a-left", "left"], ["a-center", "center"], ["a-right", "right"], ["a-justify", "justify"]]) {
|
||
$(id).onclick = () => {
|
||
const block = currentBlockAttrs();
|
||
setBlockAttr("align", block && block.attrs.align === value ? null : value);
|
||
};
|
||
}
|
||
$("line-height").onchange = (e) => setBlockAttr("lineHeight", e.target.value ? parseFloat(e.target.value) : null);
|
||
$("indent-in").onclick = () => shiftIndent(1);
|
||
$("indent-out").onclick = () => shiftIndent(-1);
|
||
|
||
$("l-bullet").onclick = () => toggleList("bullet");
|
||
$("l-ordered").onclick = () => toggleList("ordered");
|
||
$("list-format").onchange = (e) => applyListFormat(e.target.value);
|
||
|
||
$("i-link").onclick = openLinkDialog;
|
||
$("i-table").onclick = openTableDialog;
|
||
$("i-image").onclick = () => $("image-input").click();
|
||
$("i-rule").onclick = () => insertNode(schema.nodes.horizontal_rule.create());
|
||
$("i-pagebreak").onclick = () => insertNode(schema.nodes.page_break.create());
|
||
|
||
$("t-row-after").onclick = () => tableCmd("addRowAfter");
|
||
$("t-row-del").onclick = () => tableCmd("deleteRow");
|
||
$("t-col-after").onclick = () => tableCmd("addColumnAfter");
|
||
$("t-col-del").onclick = () => tableCmd("deleteColumn");
|
||
$("t-merge").onclick = () => tableCmd("mergeCells");
|
||
$("t-split").onclick = () => tableCmd("splitCell");
|
||
$("t-header").onclick = toggleHeaderRow;
|
||
$("t-del").onclick = () => tableCmd("deleteTable");
|
||
|
||
$("undo").onclick = () => { V().pm.history.undo(view.state, view.dispatch); view.focus(); };
|
||
$("redo").onclick = () => { V().pm.history.redo(view.state, view.dispatch); view.focus(); };
|
||
|
||
$("file-new").onclick = () => newDocument();
|
||
$("file-open").onclick = () => $("file-input").click();
|
||
$("file-save").onclick = () => save();
|
||
$("file-saveas").onclick = () => saveAs();
|
||
$("file-pdf").onclick = () => exportPdf();
|
||
$("file-print").onclick = () => window.print();
|
||
$("about").onclick = openAbout;
|
||
$("discard").onclick = () => closeTab();
|
||
$("open-folder").onclick = async () => {
|
||
try { await window.silentmode?.invoke("openFolder", { id: scratchId }); }
|
||
catch (e) { status("Couldn't open the folder: " + (e && e.message || e), "err"); }
|
||
};
|
||
|
||
$("file-input").onchange = async (e) => {
|
||
const file = e.target.files && e.target.files[0];
|
||
e.target.value = "";
|
||
if (file) await openFile(file);
|
||
};
|
||
$("image-input").onchange = async (e) => {
|
||
const file = e.target.files && e.target.files[0];
|
||
e.target.value = "";
|
||
if (file) await insertImageFile(file);
|
||
};
|
||
}
|
||
|
||
// ------------------------------------------------------------- document css ---
|
||
|
||
// The page's own typography is shared verbatim with the PDF export, so it
|
||
// lives in lib/doc-css.js rather than editor.css. Injecting it here (rather
|
||
// than linking a second stylesheet) keeps the export and the screen reading
|
||
// from one string.
|
||
function installDocCss() {
|
||
const el = document.createElement("style");
|
||
el.id = "doc-css";
|
||
el.textContent = DE().docCss.DOC_CSS;
|
||
document.head.append(el);
|
||
}
|
||
|
||
// Ctrl+P should use the document's paper size too, not the browser default.
|
||
function syncPageRule() {
|
||
let el = document.getElementById("page-rule");
|
||
if (!el) {
|
||
el = document.createElement("style");
|
||
el.id = "page-rule";
|
||
document.head.append(el);
|
||
}
|
||
el.textContent = DE().docCss.pageRule(docSetup);
|
||
}
|
||
|
||
// The document as a standalone HTML page: ProseMirror's own DOM serialisation
|
||
// (so what you see is what gets rendered), with images already inline as data
|
||
// URLs and the editor's chrome left behind.
|
||
function printableHtml() {
|
||
const pm = document.querySelector(".ProseMirror");
|
||
if (!pm) throw new Error("nothing to export");
|
||
const clone = pm.cloneNode(true);
|
||
// Editing scaffolding that has no business in a finished document.
|
||
clone.querySelectorAll(".ProseMirror-separator, .ProseMirror-trailingBreak, .column-resize-handle")
|
||
.forEach((n) => n.remove());
|
||
clone.querySelectorAll("[contenteditable]").forEach((n) => n.removeAttribute("contenteditable"));
|
||
return DE().docCss.printableHtml({
|
||
title: docName.replace(/\.docx$/i, ""),
|
||
bodyHtml: clone.innerHTML,
|
||
setup: docSetup,
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------ editor ---
|
||
|
||
function buildPlugins() {
|
||
const { keymap, history, commands, inputrules, dropcursor, gapcursor, tables, state: pmState } = V().pm;
|
||
const { baseKeymap, toggleMark: tm, chainCommands, exitCode } = commands;
|
||
const { wrapInList, splitListItem, liftListItem, sinkListItem } = V().pm.schemaList;
|
||
|
||
const keys = {
|
||
"Mod-z": history.undo,
|
||
"Shift-Mod-z": history.redo,
|
||
"Mod-y": history.redo,
|
||
"Mod-b": tm(schema.marks.strong),
|
||
"Mod-i": tm(schema.marks.em),
|
||
"Mod-u": tm(schema.marks.underline),
|
||
"Mod-k": () => { openLinkDialog(); return true; },
|
||
"Mod-s": () => { save(); return true; },
|
||
"Shift-Mod-s": () => { saveAs(); return true; },
|
||
"Mod-o": () => { $("file-input").click(); return true; },
|
||
"Mod-p": () => { window.print(); return true; },
|
||
"Mod-Enter": (state, dispatch) => {
|
||
if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.page_break.create()).scrollIntoView());
|
||
return true;
|
||
},
|
||
"Shift-Enter": chainCommands(exitCode, (state, dispatch) => {
|
||
if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView());
|
||
return true;
|
||
}),
|
||
"Enter": splitListItem(schema.nodes.list_item),
|
||
"Tab": (state, dispatch) => {
|
||
// Inside a table Tab moves between cells, the way Word does it;
|
||
// inside a list it nests; elsewhere it indents the paragraph.
|
||
if (tables.goToNextCell(1)(state, dispatch)) return true;
|
||
if (sinkListItem(schema.nodes.list_item)(state, dispatch)) return true;
|
||
shiftIndent(1);
|
||
return true;
|
||
},
|
||
"Shift-Tab": (state, dispatch) => {
|
||
if (tables.goToNextCell(-1)(state, dispatch)) return true;
|
||
if (liftListItem(schema.nodes.list_item)(state, dispatch)) return true;
|
||
shiftIndent(-1);
|
||
return true;
|
||
},
|
||
};
|
||
|
||
const { inputRules, wrappingInputRule, textblockTypeInputRule, smartQuotes,
|
||
ellipsis, emDash } = inputrules;
|
||
const rules = [
|
||
...smartQuotes, ellipsis, emDash,
|
||
wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list),
|
||
wrappingInputRule(/^(\d+)\.\s$/, schema.nodes.ordered_list,
|
||
(match) => ({ order: +match[1], format: "decimal" }),
|
||
(match, node) => node.childCount + node.attrs.order === +match[1]),
|
||
wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote),
|
||
textblockTypeInputRule(/^```$/, schema.nodes.code_block),
|
||
textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes.heading,
|
||
(match) => ({ level: match[1].length })),
|
||
];
|
||
|
||
return [
|
||
inputRules({ rules }),
|
||
keymap.keymap(keys),
|
||
keymap.keymap(baseKeymap),
|
||
dropcursor.dropCursor(),
|
||
gapcursor.gapCursor(),
|
||
history.history(),
|
||
tables.columnResizing(),
|
||
tables.tableEditing(),
|
||
];
|
||
}
|
||
|
||
function mountEditor(doc) {
|
||
const { state: pmState, view: pmView } = V().pm;
|
||
const sheet = $("sheet");
|
||
sheet.innerHTML = "";
|
||
|
||
const state = pmState.EditorState.create({ doc, plugins: buildPlugins() });
|
||
if (view) view.destroy();
|
||
view = new pmView.EditorView(sheet, {
|
||
state,
|
||
editable: () => !locked,
|
||
dispatchTransaction(tr) {
|
||
const next = view.state.apply(tr);
|
||
view.updateState(next);
|
||
if (tr.docChanged) { setDirty(true); updateCounts(); scheduleAutosave(); }
|
||
syncRibbon();
|
||
},
|
||
handlePaste(v, event) {
|
||
// A .docx dropped or pasted as a file goes through the document
|
||
// reader, not the HTML paste path.
|
||
const files = Array.from(event.clipboardData?.files || []);
|
||
const docxFile = files.find((f) => /\.docx$/i.test(f.name));
|
||
if (docxFile) { openFile(docxFile); return true; }
|
||
const image = files.find((f) => /^image\//.test(f.type));
|
||
if (image) { insertImageFile(image); return true; }
|
||
return false;
|
||
},
|
||
handleDrop(v, event) {
|
||
const files = Array.from(event.dataTransfer?.files || []);
|
||
const docxFile = files.find((f) => /\.docx$/i.test(f.name));
|
||
if (docxFile) { event.preventDefault(); openFile(docxFile); return true; }
|
||
const image = files.find((f) => /^image\//.test(f.type));
|
||
if (image) { event.preventDefault(); insertImageFile(image); return true; }
|
||
return false;
|
||
},
|
||
});
|
||
updateCounts();
|
||
syncRibbon();
|
||
view.focus();
|
||
}
|
||
|
||
// ------------------------------------------------------------------- files ---
|
||
|
||
function blankDoc() {
|
||
return V().pm.model.Node.fromJSON(schema, {
|
||
type: "doc", content: [{ type: "paragraph" }],
|
||
});
|
||
}
|
||
|
||
function newDocument() {
|
||
if (dirty && !confirm("Start a new document? Unsaved changes will be lost.")) return;
|
||
originalBytes = null;
|
||
docMeta = {};
|
||
docSetup = null;
|
||
scratchId = "";
|
||
savedTarget = null;
|
||
docName = "Untitled document";
|
||
report = { features: [] };
|
||
locked = false;
|
||
renderBanners();
|
||
mountEditor(blankDoc());
|
||
setDirty(false);
|
||
status("New document.");
|
||
}
|
||
|
||
async function loadBytes(bytes, name, id) {
|
||
const t0 = performance.now();
|
||
status("Reading document…");
|
||
const res = await DE().read.docxToDoc(bytes, schema);
|
||
originalBytes = bytes;
|
||
savedTarget = null;
|
||
docMeta = res.meta || {};
|
||
docSetup = res.setup || null;
|
||
report = res.report || { features: [] };
|
||
syncPageRule();
|
||
docName = name || "document.docx";
|
||
scratchId = id || "";
|
||
locked = report.blocked && report.blocked.length > 0;
|
||
|
||
renderBanners();
|
||
mountEditor(res.doc);
|
||
setDirty(false);
|
||
const ms = Math.round(performance.now() - t0);
|
||
status(`Opened ${docName} — ${res.doc.childCount} blocks in ${ms} ms`, "ok");
|
||
if (res.warnings && res.warnings.length) {
|
||
console.warn("[docx-editor] read warnings:", res.warnings, res.messages);
|
||
}
|
||
}
|
||
|
||
async function openFile(file) {
|
||
if (dirty && !confirm(`Open ${file.name}? Unsaved changes will be lost.`)) return;
|
||
try {
|
||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||
// Park a copy with the add-on so the document survives a tab reload and
|
||
// shows up in the sidebar's recent list.
|
||
let id = "";
|
||
try {
|
||
const stashed = await window.silentmode?.invoke("stash", {
|
||
name: file.name, base64: DE().read.bytesToBase64(bytes), kind: "opened",
|
||
});
|
||
id = stashed && stashed.id || "";
|
||
} catch (e) {
|
||
console.warn("[docx-editor] could not stash the document:", e);
|
||
}
|
||
await loadBytes(bytes, file.name, id);
|
||
} catch (e) {
|
||
console.error(e);
|
||
showError(`Couldn't open ${file.name}: ${e && e.message || e}`);
|
||
}
|
||
}
|
||
|
||
function showError(text) {
|
||
status(text, "err");
|
||
if (!view) {
|
||
$("sheet").innerHTML = `<div class="empty-state">${text}</div>`;
|
||
}
|
||
}
|
||
|
||
function downloadName() {
|
||
const base = docName.replace(/\.docx$/i, "") || "document";
|
||
return `${base}-edited.docx`;
|
||
}
|
||
|
||
// Build the .docx bytes for whatever is on screen.
|
||
async function buildDocx() {
|
||
const res = await DE().write.docToDocx(view.state.doc, {
|
||
originalBytes, setup: docSetup, meta: docMeta,
|
||
});
|
||
if (res.warnings && res.warnings.length) console.warn("[docx-editor] save warnings:", res.warnings);
|
||
return res;
|
||
}
|
||
|
||
// Render the document to PDF. Chromium's own print pipeline does the work,
|
||
// in a hidden window on the add-on side — the same engine as Ctrl+P, so the
|
||
// PDF matches the preview instead of being a second opinion about it.
|
||
async function buildPdf() {
|
||
if (!window.silentmode?.invoke) throw new Error("PDF export needs the add-on host");
|
||
const res = await window.silentmode.invoke("renderPdf", {
|
||
html: printableHtml(),
|
||
name: docName,
|
||
});
|
||
if (!res || !res.base64) throw new Error("the renderer returned nothing");
|
||
return base64ToBytes(res.base64);
|
||
}
|
||
|
||
function downloadBytes(bytes, filename, mime) {
|
||
const blob = new Blob([bytes], { type: mime });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = $("download-link");
|
||
a.href = url;
|
||
a.download = filename;
|
||
a.click();
|
||
setTimeout(() => URL.revokeObjectURL(url), 8000);
|
||
return filename;
|
||
}
|
||
|
||
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||
|
||
// Save. Once the document has a home — set by Save as… — this writes there
|
||
// and says so. Before that it does what the other add-ons do and puts a copy
|
||
// in Downloads, because the file picker never told us where the original
|
||
// came from.
|
||
async function save() {
|
||
if (!view || locked) return;
|
||
try {
|
||
status("Saving…");
|
||
const res = await buildDocx();
|
||
const kb = (res.bytes.length / 1024).toFixed(0);
|
||
const carried = res.carried && res.carried.length ? ` — carried over ${res.carried.join(", ")}` : "";
|
||
|
||
if (savedTarget) {
|
||
const written = await window.silentmode.invoke("writeChosen", {
|
||
token: savedTarget.token,
|
||
base64: DE().read.bytesToBase64(res.bytes),
|
||
});
|
||
// A token is good for one write, so take a fresh one for the next save
|
||
// to the same place.
|
||
savedTarget = await reserveSameTarget(written.path, written.name);
|
||
docName = written.name;
|
||
setDirty(false);
|
||
lastSavedAt = Date.now();
|
||
await autosave(true);
|
||
status(`Saved ${written.name} (${kb} KB)${carried}`, "ok");
|
||
return;
|
||
}
|
||
|
||
const name = downloadBytes(res.bytes, downloadName(), DOCX_MIME);
|
||
setDirty(false);
|
||
lastSavedAt = Date.now();
|
||
await autosave(true);
|
||
status(`Saved ${name} (${kb} KB)${carried}`, "ok");
|
||
if (res.warnings && res.warnings.length) {
|
||
status(`Saved with warnings: ${res.warnings.join("; ")}`, "err");
|
||
}
|
||
} catch (e) {
|
||
console.error(e);
|
||
status("Save failed: " + (e && e.message || e), "err");
|
||
}
|
||
}
|
||
|
||
// Ask the add-on to re-reserve a path the user already chose, so the next
|
||
// plain Save can write to it without another dialog.
|
||
async function reserveSameTarget(path, name) {
|
||
try {
|
||
const r = await window.silentmode.invoke("reserveSavePath", { path });
|
||
return r && r.token ? { token: r.token, name: name || r.name } : null;
|
||
} catch { return null; }
|
||
}
|
||
|
||
// Save as… — a real file picker, and the extension the user types decides
|
||
// whether they get a Word document or a PDF.
|
||
async function saveAs() {
|
||
if (!view || locked) return;
|
||
if (!window.silentmode?.invoke) { status("Save as… needs the add-on host", "err"); return; }
|
||
try {
|
||
const pick = await window.silentmode.invoke("pickSavePath", { name: downloadName() });
|
||
if (!pick || pick.cancelled) return;
|
||
|
||
status(pick.format === "pdf" ? "Rendering PDF…" : "Saving…");
|
||
let bytes, carried = "";
|
||
if (pick.format === "pdf") {
|
||
bytes = await buildPdf();
|
||
} else {
|
||
const res = await buildDocx();
|
||
bytes = res.bytes;
|
||
if (res.carried && res.carried.length) carried = ` — carried over ${res.carried.join(", ")}`;
|
||
}
|
||
|
||
const written = await window.silentmode.invoke("writeChosen", {
|
||
token: pick.token,
|
||
base64: DE().read.bytesToBase64(bytes),
|
||
});
|
||
const kb = (written.bytes / 1024).toFixed(0);
|
||
|
||
if (pick.format === "pdf") {
|
||
// A PDF is an export, not the document's home: the editor still edits
|
||
// the .docx, so Save keeps pointing wherever it pointed before.
|
||
status(`Exported ${written.name} (${kb} KB)`, "ok");
|
||
return;
|
||
}
|
||
savedTarget = await reserveSameTarget(written.path, written.name);
|
||
docName = written.name;
|
||
setDirty(false);
|
||
lastSavedAt = Date.now();
|
||
await autosave(true);
|
||
status(`Saved ${written.name} (${kb} KB)${carried}`, "ok");
|
||
} catch (e) {
|
||
console.error(e);
|
||
status("Save as failed: " + (e && e.message || e), "err");
|
||
}
|
||
}
|
||
|
||
// PDF straight to Downloads — the one-click version of Save as… › .pdf.
|
||
async function exportPdf() {
|
||
if (!view) return;
|
||
try {
|
||
status("Rendering PDF…");
|
||
const bytes = await buildPdf();
|
||
const name = downloadName().replace(/\.docx$/i, "") + ".pdf";
|
||
downloadBytes(bytes, name, "application/pdf");
|
||
status(`Saved ${name} (${(bytes.length / 1024).toFixed(0)} KB)`, "ok");
|
||
} catch (e) {
|
||
console.error(e);
|
||
status("PDF export failed: " + (e && e.message || e), "err");
|
||
}
|
||
}
|
||
|
||
// Autosave into the add-on's recent ring — not to the user's file. It exists
|
||
// so a closed tab or a crash doesn't cost the session's work: the sidebar
|
||
// lists the last few documents and can hand them back.
|
||
function scheduleAutosave() {
|
||
clearTimeout(autosaveTimer);
|
||
autosaveTimer = setTimeout(() => autosave(false), AUTOSAVE_MS);
|
||
}
|
||
|
||
async function autosave(force) {
|
||
if (!view || locked) return;
|
||
if (!force && !dirty) return;
|
||
try {
|
||
const res = await DE().write.docToDocx(view.state.doc, {
|
||
originalBytes, setup: docSetup, meta: docMeta,
|
||
});
|
||
const stashed = await window.silentmode?.invoke("autosave", {
|
||
name: docName,
|
||
base64: DE().read.bytesToBase64(res.bytes),
|
||
replaces: scratchId,
|
||
});
|
||
if (stashed && stashed.id) scratchId = stashed.id;
|
||
} catch (e) {
|
||
console.warn("[docx-editor] autosave failed:", e);
|
||
}
|
||
}
|
||
|
||
function closeTab() {
|
||
if (dirty && !confirm("Close the editor? Unsaved changes will be lost.")) return;
|
||
try { window.silentmode?.closeTab(); }
|
||
catch { window.close(); }
|
||
}
|
||
|
||
// ------------------------------------------------------------------- boot ---
|
||
|
||
function base64ToBytes(b64) {
|
||
const bin = atob(b64);
|
||
const out = new Uint8Array(bin.length);
|
||
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
||
return out;
|
||
}
|
||
|
||
async function boot() {
|
||
if (!window.DOCXV || !window.DocxEditor?.read) {
|
||
showError("The editor's libraries didn't load. Check vendor/docx-vendor.js.");
|
||
return;
|
||
}
|
||
schema = DE().schema.build();
|
||
installDocCss();
|
||
syncPageRule();
|
||
wireRibbon();
|
||
|
||
// Whole-window drop target, so dropping a file anywhere works and not
|
||
// only over the page surface.
|
||
let dropDepth = 0;
|
||
const zone = document.createElement("div");
|
||
zone.className = "dropzone";
|
||
zone.textContent = "Drop a .docx to open it";
|
||
zone.hidden = true;
|
||
document.body.append(zone);
|
||
window.addEventListener("dragenter", (e) => {
|
||
if (!Array.from(e.dataTransfer?.types || []).includes("Files")) return;
|
||
dropDepth++; zone.hidden = false;
|
||
});
|
||
window.addEventListener("dragleave", () => { if (--dropDepth <= 0) { dropDepth = 0; zone.hidden = true; } });
|
||
window.addEventListener("dragover", (e) => e.preventDefault());
|
||
window.addEventListener("drop", async (e) => {
|
||
e.preventDefault();
|
||
dropDepth = 0; zone.hidden = true;
|
||
const file = Array.from(e.dataTransfer?.files || [])[0];
|
||
if (!file) return;
|
||
if (/\.docx$/i.test(file.name)) await openFile(file);
|
||
else if (/^image\//.test(file.type)) await insertImageFile(file);
|
||
else status(`${file.name} isn't a .docx.`, "err");
|
||
});
|
||
|
||
window.addEventListener("beforeunload", (e) => {
|
||
if (dirty) { e.preventDefault(); e.returnValue = ""; }
|
||
});
|
||
|
||
// Drain the hand-off: ?doc= wins (it survives a reload), then __pending.
|
||
let id = new URLSearchParams(location.search).get("doc") || "";
|
||
let name = "";
|
||
try {
|
||
const pending = await window.silentmode?.storage?.get("__pending", null);
|
||
if (pending && pending.id) {
|
||
if (!id) id = pending.id;
|
||
if (pending.id === id) name = pending.name || "";
|
||
await window.silentmode.storage.set("__pending", null);
|
||
}
|
||
} catch (e) {
|
||
console.warn("[docx-editor] no storage hand-off:", e);
|
||
}
|
||
|
||
if (!id) {
|
||
mountEditor(blankDoc());
|
||
setDirty(false);
|
||
status("Blank document — drop a .docx here, or use Open.");
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const res = await window.silentmode.invoke("getBytes", { id });
|
||
await loadBytes(base64ToBytes(res.base64), name || res.name, id);
|
||
} catch (e) {
|
||
console.error(e);
|
||
mountEditor(blankDoc());
|
||
showError(`Couldn't load that document: ${e && e.message || e}`);
|
||
}
|
||
}
|
||
|
||
boot();
|