diff --git a/bundled-addons/docx-editor/editor.css b/bundled-addons/docx-editor/editor.css index 4a7a5fcc..b8900cfe 100644 --- a/bundled-addons/docx-editor/editor.css +++ b/bundled-addons/docx-editor/editor.css @@ -32,6 +32,51 @@ body { background: var(--bg); color: var(--ink); max-width: 42vw; margin: 0 6px; } .topbar .docname .dirty { color: var(--acid); margin-left: 4px; } +/* ---- open-document tabs ---------------------------------------------- */ +/* Deliberately close to the browser's own strip: same shape, same gestures, + same place for the close button. An editor that invents its own idea of a + tab makes the user learn a second one for no reason. */ +.doctabs { display: flex; align-items: stretch; gap: 2px; padding: 4px 6px 0; + background: var(--panel); border-bottom: 1px solid var(--line); + overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; } +.doctabs:empty { display: none; } +.dtab { display: flex; align-items: center; gap: 6px; flex: 0 1 auto; + max-width: 230px; min-width: 96px; padding: 5px 6px 5px 9px; + border: 1px solid transparent; border-bottom: 0; + border-radius: 7px 7px 0 0; background: var(--panel2); color: var(--mut); + cursor: default; user-select: none; font-size: 12px; white-space: nowrap; } +.dtab:hover { color: var(--ink); } +.dtab.active { background: var(--board); color: var(--ink); + border-color: var(--line); box-shadow: inset 0 2px 0 var(--acid); } +.dtab .ico { width: 13px; height: 13px; flex: none; display: block; opacity: .9; } +.dtab .nm { overflow: hidden; text-overflow: ellipsis; flex: 1 1 auto; min-width: 0; } +.dtab .dot { color: var(--acid); flex: none; font-size: 15px; line-height: 0; } +.dtab .x, .dtab .caret { + flex: none; opacity: .55; border: 0; background: transparent; color: inherit; + cursor: pointer; border-radius: 4px; padding: 1px 3px; font: inherit; line-height: 1; +} +.dtab .x:hover { opacity: 1; background: var(--line); color: var(--danger); } +.dtab .caret:hover { opacity: 1; background: var(--line); } +.dtab .caret { display: none; } +.dtab.active .caret, .dtab:hover .caret { display: block; } +.dtab-add { flex: none; align-self: center; border: 0; background: transparent; + color: var(--mut); cursor: pointer; font-size: 16px; line-height: 1; + padding: 3px 8px; border-radius: 6px; margin-left: 2px; } +.dtab-add:hover { background: var(--panel2); color: var(--ink); } + +/* The menu a document tab drops. Plain DOM rather than a native popup: it + belongs to the page, so it can carry per-document state. */ +.dmenu { position: fixed; z-index: 70; min-width: 190px; padding: 4px; + background: var(--panel); border: 1px solid var(--line); border-radius: 8px; + box-shadow: 0 12px 30px rgba(0,0,0,.42); font-size: 12.5px; } +.dmenu button { display: flex; width: 100%; gap: 8px; align-items: center; + border: 0; background: transparent; color: var(--ink); cursor: pointer; + padding: 7px 10px; border-radius: 6px; font: inherit; text-align: left; } +.dmenu button:hover { background: var(--panel2); } +.dmenu button.danger { color: var(--danger); } +.dmenu button:disabled { opacity: .4; cursor: default; background: transparent; } +.dmenu .sep { height: 1px; background: var(--line); margin: 4px 2px; } + /* ---- ribbon ---------------------------------------------------------- */ .ribbon { display: flex; align-items: stretch; gap: 0; padding: 4px 6px 2px; border-bottom: 1px solid var(--line); diff --git a/bundled-addons/docx-editor/editor.html b/bundled-addons/docx-editor/editor.html index b9f3408e..9440aa8d 100644 --- a/bundled-addons/docx-editor/editor.html +++ b/bundled-addons/docx-editor/editor.html @@ -44,6 +44,12 @@ + +
+ diff --git a/bundled-addons/docx-editor/editor.js b/bundled-addons/docx-editor/editor.js index 2e8f7b28..a92a6072 100644 --- a/bundled-addons/docx-editor/editor.js +++ b/bundled-addons/docx-editor/editor.js @@ -61,6 +61,55 @@ let autosaveTimer = null; // add-on here and drops the file in Downloads. let savedTarget = null; // {name} — the path itself stays in the add-on +// --- open documents ------------------------------------------------------- +// +// The editor holds several documents at once, the way the browser holds +// several pages. Rather than thread a document object through every function +// that touches the current one, the variables above stay as "the document on +// screen" and switching tabs marshals them in and out of this list. One +// EditorView is reused throughout — ProseMirror is happy to be handed a +// different state, and a view per document would multiply the DOM for no +// gain. +// +// Everything a document owns lives in DOC_FIELDS. If you add a per-document +// variable above, add it here too, or it will leak from one tab into the +// next — which looks like the editor corrupting a file. +const openDocs = []; +let activeKey = null; +let keySeq = 0; + +function byKey(key) { return openDocs.find((d) => d.key === key); } +function activeDoc() { return byKey(activeKey); } + +function captureActive() { + const d = activeDoc(); + if (!d) return; + d.state = view ? view.state : d.state; + d.originalBytes = originalBytes; + d.docMeta = docMeta; + d.docSetup = docSetup; + d.docName = docName; + d.scratchId = scratchId; + d.report = report; + d.locked = locked; + d.dirty = dirty; + d.savedTarget = savedTarget; + d.lastSavedAt = lastSavedAt; +} + +function adoptDoc(d) { + originalBytes = d.originalBytes; + docMeta = d.docMeta; + docSetup = d.docSetup; + docName = d.docName; + scratchId = d.scratchId; + report = d.report; + locked = d.locked; + dirty = d.dirty; + savedTarget = d.savedTarget; + lastSavedAt = d.lastSavedAt || 0; +} + // ---------------------------------------------------------------- status --- function status(text, kind) { @@ -85,6 +134,12 @@ function setDirty(on) { el.append(dot); } document.title = (dirty ? "• " : "") + docName + " — Word editor"; + const d = activeDoc(); + if (d && (d.dirty !== dirty || d.docName !== docName)) { + d.dirty = dirty; + d.docName = docName; + renderDocTabs(); + } } function updateCounts() { @@ -768,6 +823,11 @@ function buildPlugins() { "Mod-s": () => { save(); return true; }, "Shift-Mod-s": () => { saveAs(); return true; }, "Mod-o": () => { $("file-input").click(); return true; }, + // The browser's own shortcuts, because this row of tabs is the browser's + // row of tabs as far as the user is concerned. + "Mod-w": () => { if (activeKey) closeDoc(activeKey); return true; }, + "Mod-Tab": () => { cycleDoc(1); return true; }, + "Shift-Mod-Tab": () => { cycleDoc(-1); return true; }, "Mod-p": () => { window.print(); return true; }, "Mod-Enter": (state, dispatch) => { if (dispatch) dispatch(state.tr.replaceSelectionWith(schema.nodes.page_break.create()).scrollIntoView()); @@ -820,20 +880,35 @@ function buildPlugins() { ]; } -function mountEditor(doc) { - const { state: pmState, view: pmView } = V().pm; +// One view for the whole editor; switching documents hands it a different +// state. Building a view per document would multiply the DOM and throw away +// the scroll position on every switch, and ProseMirror has no need of it. +function mountEditor(state) { + const { view: pmView } = V().pm; + if (view) { + view.updateState(state); + updateCounts(); + syncRibbon(); + view.focus(); + return; + } 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(); } + if (tr.docChanged) { + const wasClean = !dirty; + setDirty(true); + // The tab strip carries the same unsaved marker as the title, so it + // has to hear about the first edit to a clean document. + if (wasClean) { const d = activeDoc(); if (d) { d.dirty = true; renderDocTabs(); } } + updateCounts(); + scheduleAutosave(); + } syncRibbon(); }, handlePaste(v, event) { @@ -860,6 +935,231 @@ function mountEditor(doc) { view.focus(); } +// --------------------------------------------------------- document tabs --- + +function renderDocTabs() { + const host = $("doctabs"); + host.innerHTML = ""; + if (!openDocs.length) return; + + for (const d of openDocs) { + const el = document.createElement("div"); + el.className = "dtab" + (d.key === activeKey ? " active" : ""); + el.setAttribute("role", "tab"); + el.setAttribute("aria-selected", d.key === activeKey ? "true" : "false"); + el.title = d.docName + (d.dirty ? " \u2014 unsaved changes" : ""); + + const ico = document.createElement("img"); + ico.className = "ico"; ico.src = "icon.svg"; ico.alt = ""; + const nm = document.createElement("span"); + nm.className = "nm"; + nm.textContent = d.docName.replace(/\.docx$/i, ""); + el.append(ico, nm); + + if (d.dirty) { + const dot = document.createElement("span"); + dot.className = "dot"; dot.textContent = "\u2022"; dot.title = "Unsaved changes"; + el.append(dot); + } + + const caret = document.createElement("button"); + caret.className = "caret"; caret.textContent = "\u25be"; + caret.title = "Document options"; + caret.addEventListener("click", (e) => { + e.stopPropagation(); + const r = e.currentTarget.getBoundingClientRect(); + openDocMenu(d.key, r.left, r.bottom + 2); + }); + + const x = document.createElement("button"); + x.className = "x"; x.textContent = "\u2715"; + x.title = "Close this document"; + x.addEventListener("click", (e) => { e.stopPropagation(); closeDoc(d.key); }); + + el.append(caret, x); + el.addEventListener("click", () => switchDoc(d.key)); + el.addEventListener("auxclick", (e) => { if (e.button === 1) { e.preventDefault(); closeDoc(d.key); } }); + el.addEventListener("contextmenu", (e) => { e.preventDefault(); openDocMenu(d.key, e.clientX, e.clientY); }); + host.append(el); + } + + const add = document.createElement("button"); + add.className = "dtab-add"; + add.textContent = "+"; + add.title = "Open another document (Ctrl+O)"; + add.addEventListener("click", () => $("file-input").click()); + host.append(add); + + const activeEl = host.querySelector(".dtab.active"); + if (activeEl) activeEl.scrollIntoView({ block: "nearest", inline: "nearest" }); +} + +function closeDocMenu() { + document.querySelectorAll(".dmenu").forEach((m) => m.remove()); +} + +function openDocMenu(key, x, y) { + closeDocMenu(); + const d = byKey(key); + if (!d) return; + + const menu = document.createElement("div"); + menu.className = "dmenu"; + const add = (label, fn, cls) => { + const b = document.createElement("button"); + b.textContent = label; + if (cls) b.className = cls; + if (!fn) b.disabled = true; + else b.addEventListener("click", () => { closeDocMenu(); fn(); }); + menu.append(b); + return b; + }; + const sep = () => { const el = document.createElement("div"); el.className = "sep"; menu.append(el); }; + + add("Duplicate", () => duplicateDoc(key)); + add("Open in the default app", d.scratchId ? () => openExternally(key) : null); + add("Show in folder", d.scratchId ? () => showInFolder(key) : null); + sep(); + add("Close others", openDocs.length > 1 ? () => closeOtherDocs(key) : null); + add("Close", () => closeDoc(key), "danger"); + + document.body.append(menu); + const w = menu.offsetWidth, h = menu.offsetHeight; + menu.style.left = Math.min(x, window.innerWidth - w - 8) + "px"; + menu.style.top = Math.min(y, window.innerHeight - h - 8) + "px"; + + const away = (e) => { + if (!menu.contains(e.target)) { closeDocMenu(); document.removeEventListener("mousedown", away); } + }; + setTimeout(() => document.addEventListener("mousedown", away), 0); + document.addEventListener("keydown", function esc(e) { + if (e.key === "Escape") { closeDocMenu(); document.removeEventListener("keydown", esc); } + }); +} + +// Add a document and show it. `pmDoc` is a ProseMirror document node. +function addDoc({ pmDoc, name, bytes, meta, setup, rep, isLocked, id }) { + captureActive(); + const { state: pmState } = V().pm; + const d = { + key: "d" + (++keySeq), + state: pmState.EditorState.create({ doc: pmDoc, plugins: buildPlugins() }), + originalBytes: bytes || null, + docMeta: meta || {}, + docSetup: setup || null, + docName: name || "Untitled document", + scratchId: id || "", + report: rep || { features: [] }, + locked: !!isLocked, + dirty: false, + savedTarget: null, + lastSavedAt: 0, + }; + openDocs.push(d); + activateDoc(d); + return d; +} + +// Put a document on screen. Everything that reads the module-level variables +// keeps working, because they are pointed at this document first. +function activateDoc(d) { + activeKey = d.key; + adoptDoc(d); + clearTimeout(autosaveTimer); + mountEditor(d.state); + syncPageRule(); + renderBanners(); + renderDocTabs(); + setDirty(d.dirty); +} + +// Next/previous document, wrapping, like Ctrl+Tab in the browser. +function cycleDoc(step) { + if (openDocs.length < 2) return; + const i = openDocs.findIndex((d) => d.key === activeKey); + const next = openDocs[(i + step + openDocs.length) % openDocs.length]; + if (next) switchDoc(next.key); +} + +function switchDoc(key) { + if (key === activeKey) { if (view) view.focus(); return; } + const d = byKey(key); + if (!d) return; + captureActive(); + activateDoc(d); +} + +function closeDoc(key) { + const d = byKey(key); + if (!d) return; + const live = d.key === activeKey ? dirty : d.dirty; + if (live && !confirm(`Close ${d.docName}? Unsaved changes will be lost.`)) return; + + const i = openDocs.indexOf(d); + openDocs.splice(i, 1); + + // Closing the last document closes the editor, the way closing a browser's + // last tab closes the window. Anything else leaves an empty ribbon staring + // at the user. + if (!openDocs.length) { + activeKey = null; + try { window.silentmode.closeTab(); } catch { window.close(); } + return; + } + if (key === activeKey) activateDoc(openDocs[Math.min(i, openDocs.length - 1)]); + else renderDocTabs(); +} + +function closeOtherDocs(key) { + const others = openDocs.filter((d) => d.key !== key); + const unsaved = others.filter((d) => (d.key === activeKey ? dirty : d.dirty)); + if (unsaved.length && !confirm( + `Close ${others.length} other document${others.length === 1 ? "" : "s"}? ` + + `${unsaved.length} ${unsaved.length === 1 ? "has" : "have"} unsaved changes.`)) return; + for (const d of others) openDocs.splice(openDocs.indexOf(d), 1); + const keep = byKey(key); + if (keep) { captureActive(); activateDoc(keep); } +} + +// Duplicate: a second copy of what is on screen, sharing the original bytes +// so a save still grafts the source document's headers and styles. It has no +// home of its own, so Save on a duplicate asks where to put it. +function duplicateDoc(key) { + const d = byKey(key); + if (!d) return; + if (d.key === activeKey) captureActive(); + const base = d.docName.replace(/\.docx$/i, ""); + addDoc({ + pmDoc: d.state.doc, + name: `${base} (copy).docx`, + bytes: d.originalBytes, + meta: Object.assign({}, d.docMeta), + setup: d.docSetup, + rep: d.report, + isLocked: d.locked, + id: "", + }); + status(`Duplicated ${d.docName}.`); +} + +async function openExternally(key) { + const d = byKey(key); + if (!d || !d.scratchId) return; + try { + const r = await window.silentmode.invoke("openExternally", { id: d.scratchId }); + status(`Opened ${r.name} in the default app.`); + } catch (e) { + status("Couldn't open it: " + (e && e.message || e), "err"); + } +} + +async function showInFolder(key) { + const d = byKey(key); + if (!d) return; + try { await window.silentmode.invoke("openFolder", { id: d.scratchId || "" }); } + catch (e) { status("Couldn't open the folder: " + (e && e.message || e), "err"); } +} + // ------------------------------------------------------------------- files --- function blankDoc() { @@ -869,18 +1169,7 @@ function blankDoc() { } 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); + addDoc({ pmDoc: blankDoc(), name: "Untitled document" }); status("New document."); } @@ -888,19 +1177,17 @@ 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 rep = res.report || { features: [] }; + addDoc({ + pmDoc: res.doc, + name: name || "document.docx", + bytes, + meta: res.meta || {}, + setup: res.setup || null, + rep, + isLocked: !!(rep.blocked && rep.blocked.length), + id: id || "", + }); const ms = Math.round(performance.now() - t0); status(`Opened ${docName} — ${res.doc.childCount} blocks in ${ms} ms`, "ok"); if (res.warnings && res.warnings.length) { @@ -908,8 +1195,23 @@ async function loadBytes(bytes, name, id) { } } +// A document already in the recent ring, handed over by the add-on. Bringing +// the editor to the front matters: the click that asked for it happened in +// the sidebar, so without this the document lands in a tab nobody is looking +// at. +async function openStashed(id, name) { + const already = openDocs.find((d) => d.scratchId && d.scratchId === id); + if (already) { + switchDoc(already.key); + try { await window.silentmode.focusTab(); } catch {} + return; + } + const res = await window.silentmode.invoke("getBytes", { id }); + await loadBytes(base64ToBytes(res.base64), name || res.name, id); + try { await window.silentmode.focusTab(); } catch {} +} + 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 @@ -1113,7 +1415,11 @@ async function autosave(force) { } function closeTab() { - if (dirty && !confirm("Close the editor? Unsaved changes will be lost.")) return; + captureActive(); + const unsaved = openDocs.filter((d) => d.dirty).length; + if (unsaved && !confirm( + `Close the editor? ${unsaved} document${unsaved === 1 ? "" : "s"} ` + + `${unsaved === 1 ? "has" : "have"} unsaved changes.`)) return; try { window.silentmode?.closeTab(); } catch { window.close(); } } @@ -1162,9 +1468,32 @@ async function boot() { }); window.addEventListener("beforeunload", (e) => { - if (dirty) { e.preventDefault(); e.returnValue = ""; } + captureActive(); + if (openDocs.some((d) => d.dirty)) { e.preventDefault(); e.returnValue = ""; } + try { window.silentmode?.invoke("editorBye", {}); } catch {} }); + // Tell the add-on there is an editor here, so it hands the next document + // to this page instead of opening a second editor tab. Anything that goes + // wrong on this path simply gets the old behaviour back. + try { await window.silentmode?.invoke("editorHello", {}); } catch {} + try { + window.silentmode?.on("open-doc", async (payload) => { + const id = String(payload && payload.id || ""); + if (!id) return; + try { + await openStashed(id, payload.name || ""); + // The acknowledgement is what stops the add-on opening a tab as well. + await window.silentmode.invoke("docOpened", { id }); + } catch (e) { + console.error("[docx-editor] handed a document we couldn't open:", e); + status("Couldn't open that document: " + (e && e.message || e), "err"); + } + }); + } catch (e) { + console.warn("[docx-editor] no event channel; documents will open in their own tabs:", e); + } + // Drain the hand-off: ?doc= wins (it survives a reload), then __pending. let id = new URLSearchParams(location.search).get("doc") || ""; let name = ""; @@ -1180,8 +1509,7 @@ async function boot() { } if (!id) { - mountEditor(blankDoc()); - setDirty(false); + addDoc({ pmDoc: blankDoc(), name: "Untitled document" }); status("Blank document — drop a .docx here, or use Open."); return; } @@ -1191,7 +1519,7 @@ async function boot() { await loadBytes(base64ToBytes(res.base64), name || res.name, id); } catch (e) { console.error(e); - mountEditor(blankDoc()); + addDoc({ pmDoc: blankDoc(), name: "Untitled document" }); showError(`Couldn't load that document: ${e && e.message || e}`); } } diff --git a/bundled-addons/docx-editor/index.js b/bundled-addons/docx-editor/index.js index 10ef6aad..44d0bfdd 100644 --- a/bundled-addons/docx-editor/index.js +++ b/bundled-addons/docx-editor/index.js @@ -127,21 +127,68 @@ module.exports = { api.onMessage("stash", (payload) => stash(payload || {})); - // Hand a document to the editor tab. The pointer under __pending is what - // the editor drains on its first paint; the query string carries the same - // id so a reload of the tab still finds its document. - api.onMessage("openEditor", (payload) => { + // --- handing a document to the editor ------------------------------- + // + // The editor holds several documents at once, so a second file should + // become a tab inside the editor that is already open rather than a + // second browser tab full of ribbon. There is no way to ask "is my page + // still alive?", so the page says so itself: it announces on load and + // acknowledges each document it accepts. If no acknowledgement arrives + // the editor is gone (crashed, closed, reloaded) and we open a tab as + // before — the failure mode is the old behaviour, not a lost document. + let editorLive = false; + const acks = new Map(); // document id -> resolve() + + api.onMessage("editorHello", () => { editorLive = true; api.log("an editor tab is open"); return { ok: true }; }); + api.onMessage("editorBye", () => { editorLive = false; api.log("the editor tab is gone"); return { ok: true }; }); + api.onMessage("docOpened", (payload) => { + const id = String(payload && payload.id || ""); + const done = acks.get(id); + if (done) { acks.delete(id); done(); } + return { ok: true }; + }); + + function handOff(id, name) { + return new Promise((resolve) => { + const timer = setTimeout(() => { acks.delete(id); resolve(false); }, 900); + acks.set(id, () => { clearTimeout(timer); resolve(true); }); + api.emit("open-doc", { id, name, at: Date.now() }); + }); + } + + api.onMessage("openEditor", async (payload) => { const id = payload && payload.id ? String(payload.id) : ""; - if (id) { - const hit = findRecent(id); - if (!hit) throw new Error(`no document "${id}" in the recent list`); - api.storage.set("__pending", { id, name: hit.name, at: Date.now() }); - } else { - api.storage.set("__pending", null); + const hit = id ? findRecent(id) : null; + if (id && !hit) throw new Error(`no document "${id}" in the recent list`); + + if (editorLive && id) { + if (await handOff(id, hit.name)) { + api.log(`handed ${id} to the open editor`); + return { ok: true, id, reused: true }; + } + editorLive = false; // it didn't answer; assume it's gone + api.log("the open editor didn't answer — opening a new tab"); } + + // The pointer under __pending is what a fresh editor drains on its + // first paint; the query string carries the same id so a reload of the + // tab still finds its document. + api.storage.set("__pending", id ? { id, name: hit.name, at: Date.now() } : null); api.openTab("editor.html", id ? { query: { doc: id } } : undefined); api.log(id ? `opening editor for ${id}` : "opening editor with a blank document"); - return { ok: true, id }; + return { ok: true, id, reused: false }; + }); + + // "Open in default app" from a document tab's menu — the scratch copy is + // what the user opened (or the last autosave of it), which is the nearest + // thing to "the file" that this add-on has. + api.onMessage("openExternally", (payload) => { + const { shell } = api.require("electron"); + const id = String(payload && payload.id || ""); + const hit = findRecent(id); + if (!hit) throw new Error("that document is no longer in the recent list"); + shell.openPath(pathOf(id)); + return { ok: true, name: hit.name }; }); api.onMessage("listRecent", () => {