feat(docx-editor): documents are tabs, each with its own close button and menu
Opening a second .docx used to mean a second browser tab: a whole ribbon, banner and footer repeated, with one ✕ at the far end of a row that also held the file's name. The name looked like a tab and nothing about it behaved like one. Now the editor holds documents the way the browser holds pages. A strip under the toolbar carries one tab per open document — icon, name, unsaved dot, its own ✕ — plus a + to open another. Middle-click closes, Ctrl+W closes, Ctrl+Tab cycles, and right-click (or the caret on the tab under the pointer) drops a menu: Duplicate, Open in the default app, Show in folder, Close others, Close. The gestures are the browser's because that is the tab strip every user of this editor already knows. Under it, one ProseMirror view is handed a different state per document rather than one view per tab, and the module-level "current document" variables are marshalled in and out on a switch. That keeps the change out of every function that touches the current document, at the price of one list — DOC_FIELDS in captureActive/adoptDoc — that has to stay complete. A variable missed there leaks one document's state into another, which would look like the editor corrupting a file, so it is called out in a comment. Closing the last document closes the editor tab, the way closing a browser's last tab closes the window; an empty ribbon staring at the user is not a state worth having. The add-on hands a newly opened document to the editor that is already up and fronts it, falling back to opening a tab if no editor acknowledges within 900ms — so a crashed or closed editor degrades to exactly the old behaviour rather than swallowing the document.
This commit is contained in:
parent
eb94cb061e
commit
6d6d93d549
4 changed files with 474 additions and 48 deletions
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -44,6 +44,12 @@
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Open documents. Modelled on the browser's own tab strip, because that
|
||||
is the thing every user of this editor already knows how to operate:
|
||||
click to switch, ✕ to close, right-click (or the caret on the active
|
||||
one) for the rest. -->
|
||||
<div class="doctabs" id="doctabs" role="tablist" aria-label="Open documents"></div>
|
||||
|
||||
<!-- Ribbon. Grouped button sets with a caption under each group — the
|
||||
Office 2007 shape without the tab strip, which would hide half the
|
||||
controls behind a click for no gain at this feature count. -->
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue