A .docx editor is a megabyte of vendored library. Bundling it would charge that to everyone who wanted a browser, including the people who will never open a Word document in it. So it leaves the build: out of bundled-addons/, out of extraResources, absent from a fresh profile. It arrives the way anyone else's extension does — Settings › Extensions › Community, from the catalogue the gateway builds, and listed on theseus.x/extensions alongside everything else published there. That also means it is signed by the owner of a BNS name rather than by the operator key, which is the right trust story for something that isn't part of the browser. `npm run pack` produces the tarball the publish page takes; the signature needs the publisher name's wallet, so it isn't something the repo can do. The end-to-end test now installs the extension into a throwaway profile the way the community installer would, and asserts up front that a fresh profile doesn't already have it — the bundling is what was being removed, so it is worth a test that would notice it coming back.
1015 lines
38 KiB
JavaScript
1015 lines
38 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 path:
|
|
// write.docToDocx() → bytes → <a download>. The original bytes are kept in
|
|
// memory for the whole session because the save grafts the document's
|
|
// headers, footers, notes, styles and page setup back out of them.
|
|
//
|
|
// The "blocked" state is worth understanding: a document with tracked
|
|
// changes or comments opens read-only, because mammoth silently renders
|
|
// insertions as ordinary text and drops deletions — saving would accept
|
|
// every pending revision without anyone choosing to. The user has to say so
|
|
// out loud, and then the editor unlocks.
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const V = () => window.DOCXV;
|
|
const DE = () => window.DocxEditor;
|
|
|
|
const AUTOSAVE_MS = 20_000;
|
|
const WORDS_PER_PAGE = 500;
|
|
|
|
const FONTS = [
|
|
"Calibri", "Cambria", "Georgia", "Times New Roman", "Arial", "Helvetica",
|
|
"Verdana", "Tahoma", "Trebuchet MS", "Garamond", "Book Antiqua",
|
|
"Courier New", "Consolas", "Segoe UI",
|
|
];
|
|
const TEXT_COLORS = [
|
|
"000000", "404040", "808080", "BFBFBF", "FFFFFF", "C00000", "FF0000", "FFC000",
|
|
"FFFF00", "92D050", "00B050", "00B0F0", "0070C0", "002060", "7030A0", "1F4E79",
|
|
];
|
|
|
|
let view = null; // ProseMirror EditorView
|
|
let schema = null;
|
|
let originalBytes = null; // the .docx this document was read from
|
|
let docMeta = {}; // docProps carried through a save
|
|
let docSetup = null; // page setup carried through a save
|
|
let docName = "Untitled document";
|
|
let scratchId = ""; // id in the add-on's recent ring
|
|
let report = { features: [] };
|
|
let locked = false; // tracked changes / comments, not yet accepted
|
|
let dirty = false;
|
|
let lastSavedAt = 0;
|
|
let autosaveTimer = null;
|
|
|
|
// ---------------------------------------------------------------- status ---
|
|
|
|
function status(text, kind) {
|
|
const el = $("msg");
|
|
el.textContent = text || "";
|
|
el.classList.toggle("err", kind === "err");
|
|
el.classList.toggle("ok", kind === "ok");
|
|
clearTimeout(status._t);
|
|
if (text) status._t = setTimeout(() => { el.textContent = ""; el.className = "msg"; }, 6000);
|
|
}
|
|
|
|
function setDirty(on) {
|
|
dirty = !!on;
|
|
const el = $("docname");
|
|
el.innerHTML = "";
|
|
el.append(document.createTextNode(docName));
|
|
if (dirty) {
|
|
const dot = document.createElement("span");
|
|
dot.className = "dirty";
|
|
dot.textContent = "•";
|
|
dot.title = "Unsaved changes";
|
|
el.append(dot);
|
|
}
|
|
document.title = (dirty ? "• " : "") + docName + " — Word editor";
|
|
}
|
|
|
|
function updateCounts() {
|
|
if (!view) return;
|
|
const text = view.state.doc.textBetween(0, view.state.doc.content.size, " ", " ");
|
|
const words = (text.match(/[^\s]+/g) || []).length;
|
|
const breaks = countPageBreaks(view.state.doc);
|
|
const pages = Math.max(1, Math.ceil(words / WORDS_PER_PAGE)) + breaks;
|
|
$("stat-words").textContent = `${words.toLocaleString()} word${words === 1 ? "" : "s"}`;
|
|
$("stat-pages").textContent = `${pages} page${pages === 1 ? "" : "s"}` + (breaks ? "" : " (estimated)");
|
|
}
|
|
|
|
function countPageBreaks(doc) {
|
|
let n = 0;
|
|
doc.descendants((node) => { if (node.type.name === "page_break") n++; });
|
|
return n;
|
|
}
|
|
|
|
// ---------------------------------------------------------------- banners ---
|
|
|
|
function renderBanners() {
|
|
const host = $("banners");
|
|
host.innerHTML = "";
|
|
|
|
const add = (cls, icon, html, actions) => {
|
|
const el = document.createElement("div");
|
|
el.className = "banner " + cls;
|
|
const i = document.createElement("span"); i.className = "ico"; i.textContent = icon;
|
|
const b = document.createElement("div"); b.className = "body"; b.innerHTML = html;
|
|
el.append(i, b);
|
|
if (actions && actions.length) {
|
|
const a = document.createElement("div"); a.className = "acts";
|
|
for (const act of actions) {
|
|
const btn = document.createElement("button");
|
|
btn.className = "btn wide" + (act.primary ? " primary" : "");
|
|
btn.innerHTML = `<span>${act.label}</span>`;
|
|
btn.addEventListener("click", act.onClick);
|
|
a.append(btn);
|
|
}
|
|
el.append(a);
|
|
}
|
|
host.append(el);
|
|
return el;
|
|
};
|
|
|
|
const blocked = report.features.filter((f) => f.level === "blocked");
|
|
const dropped = report.features.filter((f) => f.level === "dropped");
|
|
const lossy = report.features.filter((f) => f.level === "lossy");
|
|
|
|
if (locked && blocked.length) {
|
|
const names = blocked.map((f) => f.label.toLowerCase()).join(" and ");
|
|
add("block", "⚠",
|
|
`This document has <b>${names}</b>. This editor can't keep ${blocked.length > 1 ? "them" : "it"}: ` +
|
|
`saving would accept every pending revision and drop the comment threads, without Word ever asking. ` +
|
|
`The document is open for reading until you say that's what you want.`,
|
|
[{
|
|
label: "Accept all and edit", primary: true,
|
|
onClick: () => {
|
|
locked = false;
|
|
if (view) view.setProps({ editable: () => true });
|
|
renderBanners();
|
|
syncRibbon();
|
|
status("Revisions accepted in this copy. The original file on disk is untouched.", "ok");
|
|
},
|
|
}]);
|
|
}
|
|
|
|
if (dropped.length || lossy.length) {
|
|
const parts = [];
|
|
if (dropped.length) parts.push(`<b>${dropped.map((f) => f.label.toLowerCase()).join(", ")}</b> won't survive a save`);
|
|
if (lossy.length) parts.push(`<b>${lossy.map((f) => f.label.toLowerCase()).join(", ")}</b> come through only in part`);
|
|
add("warn", "○",
|
|
`In this document, ${parts.join("; ")}. Everything else — including headers, footers, ` +
|
|
`footnotes, page setup and the document's own styles — comes through untouched.`,
|
|
[{ label: "Details", onClick: () => openAbout() }]);
|
|
}
|
|
}
|
|
|
|
// ------------------------------------------------------------------ marks ---
|
|
|
|
function markActive(state, type) {
|
|
const { from, $from, to, empty } = state.selection;
|
|
if (empty) return !!type.isInSet(state.storedMarks || $from.marks());
|
|
return state.doc.rangeHasMark(from, to, type);
|
|
}
|
|
|
|
function markAttrs(state, type) {
|
|
const { $from, empty, from, to } = state.selection;
|
|
if (empty) {
|
|
const m = type.isInSet(state.storedMarks || $from.marks());
|
|
return m ? m.attrs : null;
|
|
}
|
|
let found = null;
|
|
state.doc.nodesBetween(from, to, (node) => {
|
|
if (found || !node.isText) return;
|
|
const m = type.isInSet(node.marks);
|
|
if (m) found = m.attrs;
|
|
});
|
|
return found;
|
|
}
|
|
|
|
function toggleMark(typeName, attrs) {
|
|
const { commands } = V().pm;
|
|
const type = schema.marks[typeName];
|
|
if (!type) return;
|
|
commands.toggleMark(type, attrs)(view.state, view.dispatch);
|
|
view.focus();
|
|
}
|
|
|
|
// Applying a value-carrying mark (font, size, colour, highlight) is not a
|
|
// toggle: picking Georgia over Arial has to replace, not stack.
|
|
function setValueMark(typeName, attrs) {
|
|
const type = schema.marks[typeName];
|
|
const { state, dispatch } = view;
|
|
const { from, to, empty } = state.selection;
|
|
const tr = state.tr;
|
|
if (empty) {
|
|
const marks = (state.storedMarks || state.selection.$from.marks()).filter((m) => m.type !== type);
|
|
dispatch(tr.setStoredMarks(attrs ? marks.concat(type.create(attrs)) : marks));
|
|
} else {
|
|
tr.removeMark(from, to, type);
|
|
if (attrs) tr.addMark(from, to, type.create(attrs));
|
|
dispatch(tr);
|
|
}
|
|
view.focus();
|
|
}
|
|
|
|
function clearFormatting() {
|
|
const { state, dispatch } = view;
|
|
const { from, to, empty } = state.selection;
|
|
if (empty) { dispatch(state.tr.setStoredMarks([])); view.focus(); return; }
|
|
const tr = state.tr;
|
|
for (const name of Object.keys(schema.marks)) {
|
|
if (name === "link") continue; // a link is content, not styling
|
|
tr.removeMark(from, to, schema.marks[name]);
|
|
}
|
|
dispatch(tr);
|
|
view.focus();
|
|
}
|
|
|
|
// ------------------------------------------------------------- paragraphs ---
|
|
|
|
function setBlockAttr(name, value) {
|
|
const { state, dispatch } = view;
|
|
const { from, to } = state.selection;
|
|
const tr = state.tr;
|
|
let touched = false;
|
|
state.doc.nodesBetween(from, to, (node, pos) => {
|
|
if (node.type !== schema.nodes.paragraph && node.type !== schema.nodes.heading) return;
|
|
tr.setNodeMarkup(pos, null, Object.assign({}, node.attrs, { [name]: value }));
|
|
touched = true;
|
|
});
|
|
if (touched) dispatch(tr);
|
|
view.focus();
|
|
}
|
|
|
|
function currentBlockAttrs() {
|
|
const { $from } = view.state.selection;
|
|
for (let d = $from.depth; d >= 0; d--) {
|
|
const node = $from.node(d);
|
|
if (node.type === schema.nodes.paragraph || node.type === schema.nodes.heading) return node;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function shiftIndent(delta) {
|
|
const node = currentBlockAttrs();
|
|
if (!node) return;
|
|
const next = Math.max(0, Math.min(8, (node.attrs.indent || 0) + delta));
|
|
setBlockAttr("indent", next);
|
|
}
|
|
|
|
function setStyle(value) {
|
|
const { commands } = V().pm;
|
|
const { setBlockType, lift, wrapIn } = commands;
|
|
const { state, dispatch } = view;
|
|
// Leave a quote before becoming something else, so switching Quote →
|
|
// Heading 2 doesn't leave the heading stranded inside a blockquote.
|
|
const inQuote = findParent(state.selection.$from, schema.nodes.blockquote);
|
|
if (inQuote && value !== "blockquote") lift(view.state, view.dispatch);
|
|
|
|
if (value === "paragraph") setBlockType(schema.nodes.paragraph)(view.state, view.dispatch);
|
|
else if (/^h([1-6])$/.test(value)) {
|
|
setBlockType(schema.nodes.heading, { level: parseInt(value.slice(1), 10) })(view.state, view.dispatch);
|
|
} else if (value === "code_block") setBlockType(schema.nodes.code_block)(view.state, view.dispatch);
|
|
else if (value === "blockquote") {
|
|
setBlockType(schema.nodes.paragraph)(view.state, view.dispatch);
|
|
if (!findParent(view.state.selection.$from, schema.nodes.blockquote)) {
|
|
wrapIn(schema.nodes.blockquote)(view.state, view.dispatch);
|
|
}
|
|
}
|
|
view.focus();
|
|
}
|
|
|
|
function findParent($pos, type) {
|
|
for (let d = $pos.depth; d > 0; d--) if ($pos.node(d).type === type) return { node: $pos.node(d), depth: d };
|
|
return null;
|
|
}
|
|
|
|
// ------------------------------------------------------------------ lists ---
|
|
|
|
function toggleList(kind) {
|
|
const { schemaList, commands } = V().pm;
|
|
const type = kind === "ordered" ? schema.nodes.ordered_list : schema.nodes.bullet_list;
|
|
const other = kind === "ordered" ? schema.nodes.bullet_list : schema.nodes.ordered_list;
|
|
const { state } = view;
|
|
const inThis = findParent(state.selection.$from, type);
|
|
const inOther = findParent(state.selection.$from, other);
|
|
|
|
if (inThis) {
|
|
schemaList.liftListItem(schema.nodes.list_item)(view.state, view.dispatch);
|
|
} else if (inOther) {
|
|
// Swap one kind of list for the other in place.
|
|
const tr = view.state.tr;
|
|
tr.setNodeMarkup(state.selection.$from.before(inOther.depth), type,
|
|
type === schema.nodes.ordered_list ? { order: 1, format: currentListFormat() } : {});
|
|
view.dispatch(tr);
|
|
} else {
|
|
const attrs = type === schema.nodes.ordered_list ? { order: 1, format: currentListFormat() } : {};
|
|
schemaList.wrapInList(type, attrs)(view.state, view.dispatch);
|
|
}
|
|
view.focus();
|
|
}
|
|
|
|
function currentListFormat() {
|
|
return $("list-format").value || "decimal";
|
|
}
|
|
|
|
function applyListFormat(format) {
|
|
const { state, dispatch } = view;
|
|
const found = findParent(state.selection.$from, schema.nodes.ordered_list);
|
|
if (!found) return;
|
|
const pos = state.selection.$from.before(found.depth);
|
|
dispatch(state.tr.setNodeMarkup(pos, null, Object.assign({}, found.node.attrs, { format })));
|
|
view.focus();
|
|
}
|
|
|
|
// ----------------------------------------------------------------- tables ---
|
|
|
|
function inTable() {
|
|
return !!findParent(view.state.selection.$from, schema.nodes.table);
|
|
}
|
|
|
|
function tableCmd(name) {
|
|
const t = V().pm.tables;
|
|
const fn = t[name];
|
|
if (typeof fn === "function") { fn(view.state, view.dispatch); view.focus(); }
|
|
}
|
|
|
|
function insertTable(rows, cols, withHeader) {
|
|
const { state, dispatch } = view;
|
|
const cell = (type) => schema.nodes[type].createAndFill();
|
|
const rowNodes = [];
|
|
for (let r = 0; r < rows; r++) {
|
|
const cells = [];
|
|
for (let c = 0; c < cols; c++) cells.push(cell(withHeader && r === 0 ? "table_header" : "table_cell"));
|
|
rowNodes.push(schema.nodes.table_row.create(null, cells));
|
|
}
|
|
const table = schema.nodes.table.create(null, rowNodes);
|
|
dispatch(state.tr.replaceSelectionWith(table).scrollIntoView());
|
|
view.focus();
|
|
}
|
|
|
|
function toggleHeaderRow() {
|
|
const t = V().pm.tables;
|
|
if (typeof t.toggleHeaderRow === "function") { t.toggleHeaderRow(view.state, view.dispatch); view.focus(); }
|
|
}
|
|
|
|
// ----------------------------------------------------------------- insert ---
|
|
|
|
function insertNode(node) {
|
|
const { state, dispatch } = view;
|
|
dispatch(state.tr.replaceSelectionWith(node).scrollIntoView());
|
|
view.focus();
|
|
}
|
|
|
|
async function insertImageFile(file) {
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const type = file.type || "image/png";
|
|
if (!/^image\/(png|jpeg|gif|bmp)$/.test(type)) {
|
|
status(`${type} pictures can't be saved into a .docx — use PNG, JPEG, GIF or BMP.`, "err");
|
|
return;
|
|
}
|
|
const size = DE().read.imageSizePt(bytes, null);
|
|
const src = `data:${type};base64,${DE().read.bytesToBase64(bytes)}`;
|
|
insertNode(schema.nodes.image.create({
|
|
src, alt: file.name || null, width: size.width, height: size.height,
|
|
}));
|
|
}
|
|
|
|
// ---------------------------------------------------------------- dialogs ---
|
|
|
|
function dialog(title, bodyHtml, buttons) {
|
|
const scrim = document.createElement("div");
|
|
scrim.className = "scrim";
|
|
const box = document.createElement("div");
|
|
box.className = "dialog";
|
|
box.innerHTML = `<h2>${title}</h2><div class="dbody">${bodyHtml}</div><div class="dfoot"></div>`;
|
|
const foot = box.querySelector(".dfoot");
|
|
const close = () => { scrim.remove(); document.removeEventListener("keydown", onKey); if (view) view.focus(); };
|
|
const onKey = (e) => {
|
|
if (e.key === "Escape") { e.preventDefault(); close(); }
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
const primary = buttons.find((b) => b.primary);
|
|
if (primary) { e.preventDefault(); if (primary.onClick(box, close) !== false) close(); }
|
|
}
|
|
};
|
|
for (const b of buttons) {
|
|
const btn = document.createElement("button");
|
|
btn.className = "btn wide" + (b.primary ? " primary" : "");
|
|
btn.innerHTML = `<span>${b.label}</span>`;
|
|
btn.addEventListener("click", () => { if (b.onClick(box, close) !== false) close(); });
|
|
foot.append(btn);
|
|
}
|
|
scrim.append(box);
|
|
scrim.addEventListener("mousedown", (e) => { if (e.target === scrim) close(); });
|
|
document.addEventListener("keydown", onKey);
|
|
document.body.append(scrim);
|
|
const first = box.querySelector("input");
|
|
if (first) first.focus();
|
|
return { box, close };
|
|
}
|
|
|
|
function openLinkDialog() {
|
|
const existing = markAttrs(view.state, schema.marks.link);
|
|
const { state } = view;
|
|
const selected = state.doc.textBetween(state.selection.from, state.selection.to, " ");
|
|
dialog("Link", `
|
|
<div class="field"><label for="lnk-href">Address</label>
|
|
<input id="lnk-href" type="text" placeholder="https://example.com" value="${(existing && existing.href || "").replace(/"/g, """)}"></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. Saving never overwrites the
|
|
file you opened — it downloads a new one.</p>
|
|
|
|
${found.length ? `<h3>In this document</h3><ul>${found.map(line).join("")}</ul>` : ""}
|
|
|
|
<h3>Not supported yet</h3>
|
|
<ul>${NOT_YET.map((t) => `<li>${t}</li>`).join("")}</ul>
|
|
|
|
<h3>Built with</h3>
|
|
<ul>
|
|
<li>mammoth — reads the .docx (BSD-2-Clause)</li>
|
|
<li>ProseMirror — the editor itself (MIT)</li>
|
|
<li>docx — writes the .docx (MIT)</li>
|
|
<li>JSZip — the package layer (MIT)</li>
|
|
</ul>
|
|
<p style="font-size:11.5px">Full licence texts ship in <code>vendor/LICENSES.txt</code>.</p>
|
|
`, [{ label: "Close", primary: true, onClick: () => {} }]);
|
|
}
|
|
|
|
// ------------------------------------------------------------- colour pops ---
|
|
|
|
function openColorPop(anchor, colors, onPick, onClear, clearLabel) {
|
|
document.querySelectorAll(".pop").forEach((p) => p.remove());
|
|
const pop = document.createElement("div");
|
|
pop.className = "pop";
|
|
const grid = document.createElement("div");
|
|
grid.className = "grid";
|
|
for (const c of colors) {
|
|
const chip = document.createElement("button");
|
|
chip.className = "chip";
|
|
chip.style.background = c.css;
|
|
chip.title = c.label;
|
|
chip.addEventListener("click", () => { onPick(c); pop.remove(); });
|
|
grid.append(chip);
|
|
}
|
|
const row = document.createElement("div");
|
|
row.className = "prow";
|
|
const clear = document.createElement("button");
|
|
clear.className = "btn wide";
|
|
clear.innerHTML = `<span>${clearLabel}</span>`;
|
|
clear.addEventListener("click", () => { onClear(); pop.remove(); });
|
|
row.append(clear);
|
|
pop.append(grid, row);
|
|
document.body.append(pop);
|
|
const r = anchor.getBoundingClientRect();
|
|
pop.style.left = Math.min(r.left, window.innerWidth - pop.offsetWidth - 8) + "px";
|
|
pop.style.top = (r.bottom + 4) + "px";
|
|
const away = (e) => {
|
|
if (!pop.contains(e.target) && e.target !== anchor) { pop.remove(); document.removeEventListener("mousedown", away); }
|
|
};
|
|
setTimeout(() => document.addEventListener("mousedown", away), 0);
|
|
}
|
|
|
|
// ------------------------------------------------------------------ ribbon ---
|
|
|
|
function syncRibbon() {
|
|
if (!view) return;
|
|
const st = view.state;
|
|
const on = (id, active) => $(id).classList.toggle("on", !!active);
|
|
|
|
on("m-strong", markActive(st, schema.marks.strong));
|
|
on("m-em", markActive(st, schema.marks.em));
|
|
on("m-underline", markActive(st, schema.marks.underline));
|
|
on("m-strike", markActive(st, schema.marks.strike));
|
|
on("m-sup", markActive(st, schema.marks.sup));
|
|
on("m-sub", markActive(st, schema.marks.sub));
|
|
|
|
const fontAttrs = markAttrs(st, schema.marks.font);
|
|
$("font-family").value = FONTS.includes(fontAttrs && fontAttrs.family) ? fontAttrs.family : "";
|
|
const sizeAttrs = markAttrs(st, schema.marks.fsize);
|
|
$("font-size").value = sizeAttrs ? sizeAttrs.pt : "";
|
|
const colorAttrs = markAttrs(st, schema.marks.color);
|
|
$("color-bar").style.background = colorAttrs ? "#" + colorAttrs.hex : "#c00000";
|
|
const hlAttrs = markAttrs(st, schema.marks.highlight);
|
|
$("hl-bar").style.background = hlAttrs
|
|
? (DE().schema.HIGHLIGHT_CSS[hlAttrs.name] || "#ffff00") : "#ffff00";
|
|
|
|
const block = currentBlockAttrs();
|
|
const $from = st.selection.$from;
|
|
const inQuote = findParent($from, schema.nodes.blockquote);
|
|
const inCode = findParent($from, schema.nodes.code_block);
|
|
let style = "paragraph";
|
|
if (inCode) style = "code_block";
|
|
else if (inQuote) style = "blockquote";
|
|
else if (block && block.type === schema.nodes.heading) style = "h" + block.attrs.level;
|
|
$("style-select").value = style;
|
|
|
|
const align = block ? block.attrs.align : null;
|
|
on("a-left", align === "left");
|
|
on("a-center", align === "center");
|
|
on("a-right", align === "right");
|
|
on("a-justify", align === "justify");
|
|
$("line-height").value = block && block.attrs.lineHeight ? String(block.attrs.lineHeight) : "";
|
|
|
|
const ol = findParent($from, schema.nodes.ordered_list);
|
|
const ul = findParent($from, schema.nodes.bullet_list);
|
|
on("l-bullet", !!ul);
|
|
on("l-ordered", !!ol);
|
|
$("list-format").disabled = !ol;
|
|
if (ol) $("list-format").value = ol.node.attrs.format || "decimal";
|
|
|
|
$("table-group").dataset.contextual = inTable() ? "on" : "off";
|
|
|
|
const { history } = V().pm;
|
|
$("undo").disabled = history.undoDepth(st) === 0;
|
|
$("redo").disabled = history.redoDepth(st) === 0;
|
|
|
|
// Everything that writes is off while the document is locked.
|
|
document.querySelectorAll("#ribbon .btn, #ribbon .rsel, #ribbon .rnum").forEach((el) => {
|
|
if (el.id === "undo" || el.id === "redo") return;
|
|
el.disabled = locked || (el.id === "list-format" && !ol);
|
|
});
|
|
$("file-save").disabled = locked;
|
|
}
|
|
|
|
function wireRibbon() {
|
|
const sel = $("font-family");
|
|
for (const f of FONTS) {
|
|
const opt = document.createElement("option");
|
|
opt.value = f; opt.textContent = f; opt.style.fontFamily = f;
|
|
sel.append(opt);
|
|
}
|
|
|
|
$("m-strong").onclick = () => toggleMark("strong");
|
|
$("m-em").onclick = () => toggleMark("em");
|
|
$("m-underline").onclick = () => toggleMark("underline");
|
|
$("m-strike").onclick = () => toggleMark("strike");
|
|
$("m-sup").onclick = () => toggleMark("sup");
|
|
$("m-sub").onclick = () => toggleMark("sub");
|
|
$("m-clear").onclick = clearFormatting;
|
|
|
|
sel.onchange = () => setValueMark("font", sel.value ? { family: sel.value } : null);
|
|
$("font-size").onchange = () => {
|
|
const pt = parseFloat($("font-size").value);
|
|
setValueMark("fsize", Number.isFinite(pt) && pt > 0 ? { pt } : null);
|
|
};
|
|
|
|
$("m-color").onclick = (e) => openColorPop(
|
|
e.currentTarget,
|
|
TEXT_COLORS.map((hex) => ({ css: "#" + hex, label: "#" + hex, hex })),
|
|
(c) => setValueMark("color", { hex: c.hex }),
|
|
() => setValueMark("color", null),
|
|
"Automatic");
|
|
|
|
$("m-highlight").onclick = (e) => openColorPop(
|
|
e.currentTarget,
|
|
DE().schema.HIGHLIGHTS.map((h) => ({ css: h.css, label: h.label, name: h.name })),
|
|
(c) => setValueMark("highlight", { name: c.name }),
|
|
() => setValueMark("highlight", null),
|
|
"No highlight");
|
|
|
|
$("style-select").onchange = (e) => setStyle(e.target.value);
|
|
|
|
for (const [id, value] of [["a-left", "left"], ["a-center", "center"], ["a-right", "right"], ["a-justify", "justify"]]) {
|
|
$(id).onclick = () => {
|
|
const block = currentBlockAttrs();
|
|
setBlockAttr("align", block && block.attrs.align === value ? null : value);
|
|
};
|
|
}
|
|
$("line-height").onchange = (e) => setBlockAttr("lineHeight", e.target.value ? parseFloat(e.target.value) : null);
|
|
$("indent-in").onclick = () => shiftIndent(1);
|
|
$("indent-out").onclick = () => shiftIndent(-1);
|
|
|
|
$("l-bullet").onclick = () => toggleList("bullet");
|
|
$("l-ordered").onclick = () => toggleList("ordered");
|
|
$("list-format").onchange = (e) => applyListFormat(e.target.value);
|
|
|
|
$("i-link").onclick = openLinkDialog;
|
|
$("i-table").onclick = openTableDialog;
|
|
$("i-image").onclick = () => $("image-input").click();
|
|
$("i-rule").onclick = () => insertNode(schema.nodes.horizontal_rule.create());
|
|
$("i-pagebreak").onclick = () => insertNode(schema.nodes.page_break.create());
|
|
|
|
$("t-row-after").onclick = () => tableCmd("addRowAfter");
|
|
$("t-row-del").onclick = () => tableCmd("deleteRow");
|
|
$("t-col-after").onclick = () => tableCmd("addColumnAfter");
|
|
$("t-col-del").onclick = () => tableCmd("deleteColumn");
|
|
$("t-merge").onclick = () => tableCmd("mergeCells");
|
|
$("t-split").onclick = () => tableCmd("splitCell");
|
|
$("t-header").onclick = toggleHeaderRow;
|
|
$("t-del").onclick = () => tableCmd("deleteTable");
|
|
|
|
$("undo").onclick = () => { V().pm.history.undo(view.state, view.dispatch); view.focus(); };
|
|
$("redo").onclick = () => { V().pm.history.redo(view.state, view.dispatch); view.focus(); };
|
|
|
|
$("file-new").onclick = () => newDocument();
|
|
$("file-open").onclick = () => $("file-input").click();
|
|
$("file-save").onclick = () => save();
|
|
$("file-print").onclick = () => window.print();
|
|
$("about").onclick = openAbout;
|
|
$("discard").onclick = () => closeTab();
|
|
$("open-folder").onclick = async () => {
|
|
try { await window.silentmode?.invoke("openFolder", { id: scratchId }); }
|
|
catch (e) { status("Couldn't open the folder: " + (e && e.message || e), "err"); }
|
|
};
|
|
|
|
$("file-input").onchange = async (e) => {
|
|
const file = e.target.files && e.target.files[0];
|
|
e.target.value = "";
|
|
if (file) await openFile(file);
|
|
};
|
|
$("image-input").onchange = async (e) => {
|
|
const file = e.target.files && e.target.files[0];
|
|
e.target.value = "";
|
|
if (file) await insertImageFile(file);
|
|
};
|
|
}
|
|
|
|
// ------------------------------------------------------------------ editor ---
|
|
|
|
function buildPlugins() {
|
|
const { keymap, history, commands, inputrules, dropcursor, gapcursor, tables, state: pmState } = V().pm;
|
|
const { baseKeymap, toggleMark: tm, chainCommands, exitCode } = commands;
|
|
const { wrapInList, splitListItem, liftListItem, sinkListItem } = V().pm.schemaList;
|
|
|
|
const keys = {
|
|
"Mod-z": history.undo,
|
|
"Shift-Mod-z": history.redo,
|
|
"Mod-y": history.redo,
|
|
"Mod-b": tm(schema.marks.strong),
|
|
"Mod-i": tm(schema.marks.em),
|
|
"Mod-u": tm(schema.marks.underline),
|
|
"Mod-k": () => { openLinkDialog(); return true; },
|
|
"Mod-s": () => { save(); return true; },
|
|
"Mod-o": () => { $("file-input").click(); return true; },
|
|
"Mod-p": () => { window.print(); return true; },
|
|
"Mod-Enter": (state, dispatch) => {
|
|
if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.page_break.create()).scrollIntoView());
|
|
return true;
|
|
},
|
|
"Shift-Enter": chainCommands(exitCode, (state, dispatch) => {
|
|
if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.hard_break.create()).scrollIntoView());
|
|
return true;
|
|
}),
|
|
"Enter": splitListItem(schema.nodes.list_item),
|
|
"Tab": (state, dispatch) => {
|
|
// Inside a table Tab moves between cells, the way Word does it;
|
|
// inside a list it nests; elsewhere it indents the paragraph.
|
|
if (tables.goToNextCell(1)(state, dispatch)) return true;
|
|
if (sinkListItem(schema.nodes.list_item)(state, dispatch)) return true;
|
|
shiftIndent(1);
|
|
return true;
|
|
},
|
|
"Shift-Tab": (state, dispatch) => {
|
|
if (tables.goToNextCell(-1)(state, dispatch)) return true;
|
|
if (liftListItem(schema.nodes.list_item)(state, dispatch)) return true;
|
|
shiftIndent(-1);
|
|
return true;
|
|
},
|
|
};
|
|
|
|
const { inputRules, wrappingInputRule, textblockTypeInputRule, smartQuotes,
|
|
ellipsis, emDash } = inputrules;
|
|
const rules = [
|
|
...smartQuotes, ellipsis, emDash,
|
|
wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bullet_list),
|
|
wrappingInputRule(/^(\d+)\.\s$/, schema.nodes.ordered_list,
|
|
(match) => ({ order: +match[1], format: "decimal" }),
|
|
(match, node) => node.childCount + node.attrs.order === +match[1]),
|
|
wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote),
|
|
textblockTypeInputRule(/^```$/, schema.nodes.code_block),
|
|
textblockTypeInputRule(/^(#{1,6})\s$/, schema.nodes.heading,
|
|
(match) => ({ level: match[1].length })),
|
|
];
|
|
|
|
return [
|
|
inputRules({ rules }),
|
|
keymap.keymap(keys),
|
|
keymap.keymap(baseKeymap),
|
|
dropcursor.dropCursor(),
|
|
gapcursor.gapCursor(),
|
|
history.history(),
|
|
tables.columnResizing(),
|
|
tables.tableEditing(),
|
|
];
|
|
}
|
|
|
|
function mountEditor(doc) {
|
|
const { state: pmState, view: pmView } = V().pm;
|
|
const sheet = $("sheet");
|
|
sheet.innerHTML = "";
|
|
|
|
const state = pmState.EditorState.create({ doc, plugins: buildPlugins() });
|
|
if (view) view.destroy();
|
|
view = new pmView.EditorView(sheet, {
|
|
state,
|
|
editable: () => !locked,
|
|
dispatchTransaction(tr) {
|
|
const next = view.state.apply(tr);
|
|
view.updateState(next);
|
|
if (tr.docChanged) { setDirty(true); updateCounts(); scheduleAutosave(); }
|
|
syncRibbon();
|
|
},
|
|
handlePaste(v, event) {
|
|
// A .docx dropped or pasted as a file goes through the document
|
|
// reader, not the HTML paste path.
|
|
const files = Array.from(event.clipboardData?.files || []);
|
|
const docxFile = files.find((f) => /\.docx$/i.test(f.name));
|
|
if (docxFile) { openFile(docxFile); return true; }
|
|
const image = files.find((f) => /^image\//.test(f.type));
|
|
if (image) { insertImageFile(image); return true; }
|
|
return false;
|
|
},
|
|
handleDrop(v, event) {
|
|
const files = Array.from(event.dataTransfer?.files || []);
|
|
const docxFile = files.find((f) => /\.docx$/i.test(f.name));
|
|
if (docxFile) { event.preventDefault(); openFile(docxFile); return true; }
|
|
const image = files.find((f) => /^image\//.test(f.type));
|
|
if (image) { event.preventDefault(); insertImageFile(image); return true; }
|
|
return false;
|
|
},
|
|
});
|
|
updateCounts();
|
|
syncRibbon();
|
|
view.focus();
|
|
}
|
|
|
|
// ------------------------------------------------------------------- files ---
|
|
|
|
function blankDoc() {
|
|
return V().pm.model.Node.fromJSON(schema, {
|
|
type: "doc", content: [{ type: "paragraph" }],
|
|
});
|
|
}
|
|
|
|
function newDocument() {
|
|
if (dirty && !confirm("Start a new document? Unsaved changes will be lost.")) return;
|
|
originalBytes = null;
|
|
docMeta = {};
|
|
docSetup = null;
|
|
scratchId = "";
|
|
docName = "Untitled document";
|
|
report = { features: [] };
|
|
locked = false;
|
|
renderBanners();
|
|
mountEditor(blankDoc());
|
|
setDirty(false);
|
|
status("New document.");
|
|
}
|
|
|
|
async function loadBytes(bytes, name, id) {
|
|
const t0 = performance.now();
|
|
status("Reading document…");
|
|
const res = await DE().read.docxToDoc(bytes, schema);
|
|
originalBytes = bytes;
|
|
docMeta = res.meta || {};
|
|
docSetup = res.setup || null;
|
|
report = res.report || { features: [] };
|
|
docName = name || "document.docx";
|
|
scratchId = id || "";
|
|
locked = report.blocked && report.blocked.length > 0;
|
|
|
|
renderBanners();
|
|
mountEditor(res.doc);
|
|
setDirty(false);
|
|
const ms = Math.round(performance.now() - t0);
|
|
status(`Opened ${docName} — ${res.doc.childCount} blocks in ${ms} ms`, "ok");
|
|
if (res.warnings && res.warnings.length) {
|
|
console.warn("[docx-editor] read warnings:", res.warnings, res.messages);
|
|
}
|
|
}
|
|
|
|
async function openFile(file) {
|
|
if (dirty && !confirm(`Open ${file.name}? Unsaved changes will be lost.`)) return;
|
|
try {
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
// Park a copy with the add-on so the document survives a tab reload and
|
|
// shows up in the sidebar's recent list.
|
|
let id = "";
|
|
try {
|
|
const stashed = await window.silentmode?.invoke("stash", {
|
|
name: file.name, base64: DE().read.bytesToBase64(bytes), kind: "opened",
|
|
});
|
|
id = stashed && stashed.id || "";
|
|
} catch (e) {
|
|
console.warn("[docx-editor] could not stash the document:", e);
|
|
}
|
|
await loadBytes(bytes, file.name, id);
|
|
} catch (e) {
|
|
console.error(e);
|
|
showError(`Couldn't open ${file.name}: ${e && e.message || e}`);
|
|
}
|
|
}
|
|
|
|
function showError(text) {
|
|
status(text, "err");
|
|
if (!view) {
|
|
$("sheet").innerHTML = `<div class="empty-state">${text}</div>`;
|
|
}
|
|
}
|
|
|
|
function downloadName() {
|
|
const base = docName.replace(/\.docx$/i, "") || "document";
|
|
return `${base}-edited.docx`;
|
|
}
|
|
|
|
async function save() {
|
|
if (!view || locked) return;
|
|
try {
|
|
status("Saving…");
|
|
const res = await DE().write.docToDocx(view.state.doc, {
|
|
originalBytes, setup: docSetup, meta: docMeta,
|
|
});
|
|
const blob = new Blob([res.bytes], {
|
|
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
});
|
|
const url = URL.createObjectURL(blob);
|
|
const a = $("download-link");
|
|
a.href = url;
|
|
a.download = downloadName();
|
|
a.click();
|
|
setTimeout(() => URL.revokeObjectURL(url), 8000);
|
|
|
|
setDirty(false);
|
|
lastSavedAt = Date.now();
|
|
await autosave(true);
|
|
|
|
const carried = res.carried && res.carried.length ? ` — carried over ${res.carried.join(", ")}` : "";
|
|
status(`Saved ${a.download} (${(res.bytes.length / 1024).toFixed(0)} KB)${carried}`, "ok");
|
|
if (res.warnings && res.warnings.length) {
|
|
console.warn("[docx-editor] save warnings:", res.warnings);
|
|
status(`Saved with warnings: ${res.warnings.join("; ")}`, "err");
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
status("Save failed: " + (e && e.message || e), "err");
|
|
}
|
|
}
|
|
|
|
// Autosave into the add-on's recent ring — not to the user's file. It exists
|
|
// so a closed tab or a crash doesn't cost the session's work: the sidebar
|
|
// lists the last few documents and can hand them back.
|
|
function scheduleAutosave() {
|
|
clearTimeout(autosaveTimer);
|
|
autosaveTimer = setTimeout(() => autosave(false), AUTOSAVE_MS);
|
|
}
|
|
|
|
async function autosave(force) {
|
|
if (!view || locked) return;
|
|
if (!force && !dirty) return;
|
|
try {
|
|
const res = await DE().write.docToDocx(view.state.doc, {
|
|
originalBytes, setup: docSetup, meta: docMeta,
|
|
});
|
|
const stashed = await window.silentmode?.invoke("autosave", {
|
|
name: docName,
|
|
base64: DE().read.bytesToBase64(res.bytes),
|
|
replaces: scratchId,
|
|
});
|
|
if (stashed && stashed.id) scratchId = stashed.id;
|
|
} catch (e) {
|
|
console.warn("[docx-editor] autosave failed:", e);
|
|
}
|
|
}
|
|
|
|
function closeTab() {
|
|
if (dirty && !confirm("Close the editor? Unsaved changes will be lost.")) return;
|
|
try { window.silentmode?.closeTab(); }
|
|
catch { window.close(); }
|
|
}
|
|
|
|
// ------------------------------------------------------------------- boot ---
|
|
|
|
function base64ToBytes(b64) {
|
|
const bin = atob(b64);
|
|
const out = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
return out;
|
|
}
|
|
|
|
async function boot() {
|
|
if (!window.DOCXV || !window.DocxEditor?.read) {
|
|
showError("The editor's libraries didn't load. Check vendor/docx-vendor.js.");
|
|
return;
|
|
}
|
|
schema = DE().schema.build();
|
|
wireRibbon();
|
|
|
|
// Whole-window drop target, so dropping a file anywhere works and not
|
|
// only over the page surface.
|
|
let dropDepth = 0;
|
|
const zone = document.createElement("div");
|
|
zone.className = "dropzone";
|
|
zone.textContent = "Drop a .docx to open it";
|
|
zone.hidden = true;
|
|
document.body.append(zone);
|
|
window.addEventListener("dragenter", (e) => {
|
|
if (!Array.from(e.dataTransfer?.types || []).includes("Files")) return;
|
|
dropDepth++; zone.hidden = false;
|
|
});
|
|
window.addEventListener("dragleave", () => { if (--dropDepth <= 0) { dropDepth = 0; zone.hidden = true; } });
|
|
window.addEventListener("dragover", (e) => e.preventDefault());
|
|
window.addEventListener("drop", async (e) => {
|
|
e.preventDefault();
|
|
dropDepth = 0; zone.hidden = true;
|
|
const file = Array.from(e.dataTransfer?.files || [])[0];
|
|
if (!file) return;
|
|
if (/\.docx$/i.test(file.name)) await openFile(file);
|
|
else if (/^image\//.test(file.type)) await insertImageFile(file);
|
|
else status(`${file.name} isn't a .docx.`, "err");
|
|
});
|
|
|
|
window.addEventListener("beforeunload", (e) => {
|
|
if (dirty) { e.preventDefault(); e.returnValue = ""; }
|
|
});
|
|
|
|
// Drain the hand-off: ?doc= wins (it survives a reload), then __pending.
|
|
let id = new URLSearchParams(location.search).get("doc") || "";
|
|
let name = "";
|
|
try {
|
|
const pending = await window.silentmode?.storage?.get("__pending", null);
|
|
if (pending && pending.id) {
|
|
if (!id) id = pending.id;
|
|
if (pending.id === id) name = pending.name || "";
|
|
await window.silentmode.storage.set("__pending", null);
|
|
}
|
|
} catch (e) {
|
|
console.warn("[docx-editor] no storage hand-off:", e);
|
|
}
|
|
|
|
if (!id) {
|
|
mountEditor(blankDoc());
|
|
setDirty(false);
|
|
status("Blank document — drop a .docx here, or use Open.");
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const res = await window.silentmode.invoke("getBytes", { id });
|
|
await loadBytes(base64ToBytes(res.base64), name || res.name, id);
|
|
} catch (e) {
|
|
console.error(e);
|
|
mountEditor(blankDoc());
|
|
showError(`Couldn't load that document: ${e && e.message || e}`);
|
|
}
|
|
}
|
|
|
|
boot();
|