feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
// Word editor — the tab.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Load path:
|
|
|
|
|
|
// editor.html?doc=<scratch id>, or storage.__pending = {id, name}
|
|
|
|
|
|
// → silentmode.invoke("getBytes", {id}) → base64 of the original .docx
|
|
|
|
|
|
// → read.docxToDoc() → a ProseMirror document + a report of what's in
|
|
|
|
|
|
// the file that this editor can't render
|
|
|
|
|
|
// → the report becomes the banners at the top of the page
|
|
|
|
|
|
//
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
// Save paths:
|
|
|
|
|
|
// Save → write.docToDocx() → a copy in Downloads, or straight to the
|
|
|
|
|
|
// file the user chose earlier with Save as…
|
|
|
|
|
|
// Save as… → a native file dialog; the extension typed there decides
|
|
|
|
|
|
// whether they get a .docx or a .pdf
|
|
|
|
|
|
// PDF → the same PDF, one click, into Downloads
|
|
|
|
|
|
//
|
|
|
|
|
|
// The original bytes are kept in memory for the whole session because the
|
|
|
|
|
|
// save grafts the document's headers, footers, notes, styles and page
|
|
|
|
|
|
// setup back out of them. PDFs are rendered by Chromium's own print
|
|
|
|
|
|
// pipeline in a hidden window on the add-on side, from the very CSS the
|
|
|
|
|
|
// editor is displaying (lib/doc-css.js), so the export can't drift away
|
|
|
|
|
|
// from the preview.
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
//
|
|
|
|
|
|
// The "blocked" state is worth understanding: a document with tracked
|
|
|
|
|
|
// changes or comments opens read-only, because mammoth silently renders
|
|
|
|
|
|
// insertions as ordinary text and drops deletions — saving would accept
|
|
|
|
|
|
// every pending revision without anyone choosing to. The user has to say so
|
|
|
|
|
|
// out loud, and then the editor unlocks.
|
|
|
|
|
|
|
|
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
|
|
const V = () => window.DOCXV;
|
|
|
|
|
|
const DE = () => window.DocxEditor;
|
|
|
|
|
|
|
|
|
|
|
|
const AUTOSAVE_MS = 20_000;
|
|
|
|
|
|
const WORDS_PER_PAGE = 500;
|
|
|
|
|
|
|
feat(docx-editor): the page fills the window, and two fonts ship with it
The page sat marooned in the middle of a wide window with dark space either
side of it. It is now drawn at the size the document actually claims — A4
stays A4, margins come from its own sectPr — and CSS `zoom` scales that to
fit, defaulting to Fit width with a control in the footer and Ctrl +/-/0.
Scaling rather than widening is deliberate. A page stretched to the window
would break every line somewhere different from where the printed page
breaks it, and an editor whose whole claim is that it shows you the document
should not lie about where the lines end. `zoom` also beats a transform
here: it affects layout, so the board scrolls correctly and ProseMirror's
coordinate maths keeps working.
Ubuntu and Fraunces now ship in fonts/, because Windows has neither and a
font offered in the ribbon that the machine lacks is a font the user picks
and then cannot see. Fetched once by `npm run fonts` and committed, never at
runtime: an extension in a browser built around not phoning home should not
ask a font CDN what a document looks like every time one is opened.
Two things had to be worked around. On file:// Chromium registers @font-face
rules and then refuses to fetch the files — the family appears in
document.fonts and every glyph still renders in the fallback — so the add-on
reads the woff2 and hands the page a stylesheet with them inlined as data
URLs. The PDF export needed the same treatment for a different reason: its
print window runs from a temp folder, where a relative url() resolves to
nothing, which would have quietly undone the one-stylesheet-for-both promise
that lib/doc-css.js exists to keep. If either path fails, the ribbon labels
those families "(not available)" rather than implying otherwise.
About 700 KB, most of it Ubuntu's Cyrillic and Greek — kept because the
documents this is used on are not all English. Licences ship alongside.
2026-09-22 19:12:56 +02:00
|
|
|
|
// Ubuntu and Fraunces ship with the add-on (see fonts.css); the rest are
|
|
|
|
|
|
// whatever the machine already has. A font offered in the ribbon but absent
|
|
|
|
|
|
// from the machine is a font the user picks and then cannot see, which is
|
|
|
|
|
|
// why the two that Windows lacks are bundled rather than merely listed.
|
|
|
|
|
|
const BUNDLED_FONTS = ["Ubuntu", "Fraunces"];
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
const FONTS = [
|
|
|
|
|
|
"Calibri", "Cambria", "Georgia", "Times New Roman", "Arial", "Helvetica",
|
|
|
|
|
|
"Verdana", "Tahoma", "Trebuchet MS", "Garamond", "Book Antiqua",
|
feat(docx-editor): the page fills the window, and two fonts ship with it
The page sat marooned in the middle of a wide window with dark space either
side of it. It is now drawn at the size the document actually claims — A4
stays A4, margins come from its own sectPr — and CSS `zoom` scales that to
fit, defaulting to Fit width with a control in the footer and Ctrl +/-/0.
Scaling rather than widening is deliberate. A page stretched to the window
would break every line somewhere different from where the printed page
breaks it, and an editor whose whole claim is that it shows you the document
should not lie about where the lines end. `zoom` also beats a transform
here: it affects layout, so the board scrolls correctly and ProseMirror's
coordinate maths keeps working.
Ubuntu and Fraunces now ship in fonts/, because Windows has neither and a
font offered in the ribbon that the machine lacks is a font the user picks
and then cannot see. Fetched once by `npm run fonts` and committed, never at
runtime: an extension in a browser built around not phoning home should not
ask a font CDN what a document looks like every time one is opened.
Two things had to be worked around. On file:// Chromium registers @font-face
rules and then refuses to fetch the files — the family appears in
document.fonts and every glyph still renders in the fallback — so the add-on
reads the woff2 and hands the page a stylesheet with them inlined as data
URLs. The PDF export needed the same treatment for a different reason: its
print window runs from a temp folder, where a relative url() resolves to
nothing, which would have quietly undone the one-stylesheet-for-both promise
that lib/doc-css.js exists to keep. If either path fails, the ribbon labels
those families "(not available)" rather than implying otherwise.
About 700 KB, most of it Ubuntu's Cyrillic and Greek — kept because the
documents this is used on are not all English. Licences ship alongside.
2026-09-22 19:12:56 +02:00
|
|
|
|
"Courier New", "Consolas", "Segoe UI", "Ubuntu", "Fraunces",
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
];
|
|
|
|
|
|
const TEXT_COLORS = [
|
|
|
|
|
|
"000000", "404040", "808080", "BFBFBF", "FFFFFF", "C00000", "FF0000", "FFC000",
|
|
|
|
|
|
"FFFF00", "92D050", "00B050", "00B0F0", "0070C0", "002060", "7030A0", "1F4E79",
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
let view = null; // ProseMirror EditorView
|
|
|
|
|
|
let schema = null;
|
|
|
|
|
|
let originalBytes = null; // the .docx this document was read from
|
|
|
|
|
|
let docMeta = {}; // docProps carried through a save
|
|
|
|
|
|
let docSetup = null; // page setup carried through a save
|
|
|
|
|
|
let docName = "Untitled document";
|
|
|
|
|
|
let scratchId = ""; // id in the add-on's recent ring
|
|
|
|
|
|
let report = { features: [] };
|
|
|
|
|
|
let locked = false; // tracked changes / comments, not yet accepted
|
|
|
|
|
|
let dirty = false;
|
|
|
|
|
|
let lastSavedAt = 0;
|
|
|
|
|
|
let autosaveTimer = null;
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
// Where "Save" writes without asking, once the user has chosen a home for
|
|
|
|
|
|
// this document with Save as… Until then Save behaves like every other
|
|
|
|
|
|
// add-on here and drops the file in Downloads.
|
|
|
|
|
|
let savedTarget = null; // {name} — the path itself stays in the add-on
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
|
feat(docx-editor): the page fills the window, and two fonts ship with it
The page sat marooned in the middle of a wide window with dark space either
side of it. It is now drawn at the size the document actually claims — A4
stays A4, margins come from its own sectPr — and CSS `zoom` scales that to
fit, defaulting to Fit width with a control in the footer and Ctrl +/-/0.
Scaling rather than widening is deliberate. A page stretched to the window
would break every line somewhere different from where the printed page
breaks it, and an editor whose whole claim is that it shows you the document
should not lie about where the lines end. `zoom` also beats a transform
here: it affects layout, so the board scrolls correctly and ProseMirror's
coordinate maths keeps working.
Ubuntu and Fraunces now ship in fonts/, because Windows has neither and a
font offered in the ribbon that the machine lacks is a font the user picks
and then cannot see. Fetched once by `npm run fonts` and committed, never at
runtime: an extension in a browser built around not phoning home should not
ask a font CDN what a document looks like every time one is opened.
Two things had to be worked around. On file:// Chromium registers @font-face
rules and then refuses to fetch the files — the family appears in
document.fonts and every glyph still renders in the fallback — so the add-on
reads the woff2 and hands the page a stylesheet with them inlined as data
URLs. The PDF export needed the same treatment for a different reason: its
print window runs from a temp folder, where a relative url() resolves to
nothing, which would have quietly undone the one-stylesheet-for-both promise
that lib/doc-css.js exists to keep. If either path fails, the ribbon labels
those families "(not available)" rather than implying otherwise.
About 700 KB, most of it Ubuntu's Cyrillic and Greek — kept because the
documents this is used on are not all English. Licences ship alongside.
2026-09-22 19:12:56 +02:00
|
|
|
|
// How large the page is drawn. "fit" and "page" recompute as the window
|
|
|
|
|
|
// changes; a number is a fixed multiplier. Kept per editor rather than per
|
|
|
|
|
|
// document — it is a property of the screen you are looking at, not of the
|
|
|
|
|
|
// file.
|
|
|
|
|
|
let zoomMode = "fit";
|
|
|
|
|
|
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
// --- 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
// ---------------------------------------------------------------- status ---
|
|
|
|
|
|
|
|
|
|
|
|
function status(text, kind) {
|
|
|
|
|
|
const el = $("msg");
|
|
|
|
|
|
el.textContent = text || "";
|
|
|
|
|
|
el.classList.toggle("err", kind === "err");
|
|
|
|
|
|
el.classList.toggle("ok", kind === "ok");
|
|
|
|
|
|
clearTimeout(status._t);
|
|
|
|
|
|
if (text) status._t = setTimeout(() => { el.textContent = ""; el.className = "msg"; }, 6000);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setDirty(on) {
|
|
|
|
|
|
dirty = !!on;
|
|
|
|
|
|
const el = $("docname");
|
|
|
|
|
|
el.innerHTML = "";
|
|
|
|
|
|
el.append(document.createTextNode(docName));
|
|
|
|
|
|
if (dirty) {
|
|
|
|
|
|
const dot = document.createElement("span");
|
|
|
|
|
|
dot.className = "dirty";
|
|
|
|
|
|
dot.textContent = "•";
|
|
|
|
|
|
dot.title = "Unsaved changes";
|
|
|
|
|
|
el.append(dot);
|
|
|
|
|
|
}
|
|
|
|
|
|
document.title = (dirty ? "• " : "") + docName + " — Word editor";
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
const d = activeDoc();
|
|
|
|
|
|
if (d && (d.dirty !== dirty || d.docName !== docName)) {
|
|
|
|
|
|
d.dirty = dirty;
|
|
|
|
|
|
d.docName = docName;
|
|
|
|
|
|
renderDocTabs();
|
|
|
|
|
|
}
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function 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
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
margins, the document's style catalogue and its theme.</p>
|
|
|
|
|
|
|
|
|
|
|
|
<p><b>Save</b> puts a copy in your downloads until you have given the document a home
|
|
|
|
|
|
with <b>Save as…</b>, which is a real file dialog — type a <code>.docx</code> name for a
|
|
|
|
|
|
Word document or a <code>.pdf</code> one for a PDF. After that, Save writes there
|
|
|
|
|
|
directly. <b>PDF</b> is the one-click version: the page is rendered on the paper size the
|
|
|
|
|
|
document itself specifies, by the same engine behind Ctrl+P, so it matches what you were
|
|
|
|
|
|
looking at. Nothing here ever overwrites the file you opened.</p>
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
|
|
|
|
|
|
${found.length ? `<h3>In this document</h3><ul>${found.map(line).join("")}</ul>` : ""}
|
|
|
|
|
|
|
|
|
|
|
|
<h3>Not supported yet</h3>
|
|
|
|
|
|
<ul>${NOT_YET.map((t) => `<li>${t}</li>`).join("")}</ul>
|
|
|
|
|
|
|
|
|
|
|
|
<h3>Built with</h3>
|
|
|
|
|
|
<ul>
|
|
|
|
|
|
<li>mammoth — reads the .docx (BSD-2-Clause)</li>
|
|
|
|
|
|
<li>ProseMirror — the editor itself (MIT)</li>
|
|
|
|
|
|
<li>docx — writes the .docx (MIT)</li>
|
|
|
|
|
|
<li>JSZip — the package layer (MIT)</li>
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
<p style="font-size:11.5px">Full licence texts ship in <code>vendor/LICENSES.txt</code>.</p>
|
|
|
|
|
|
`, [{ label: "Close", primary: true, onClick: () => {} }]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------- colour pops ---
|
|
|
|
|
|
|
|
|
|
|
|
function openColorPop(anchor, colors, onPick, onClear, clearLabel) {
|
|
|
|
|
|
document.querySelectorAll(".pop").forEach((p) => p.remove());
|
|
|
|
|
|
const pop = document.createElement("div");
|
|
|
|
|
|
pop.className = "pop";
|
|
|
|
|
|
const grid = document.createElement("div");
|
|
|
|
|
|
grid.className = "grid";
|
|
|
|
|
|
for (const c of colors) {
|
|
|
|
|
|
const chip = document.createElement("button");
|
|
|
|
|
|
chip.className = "chip";
|
|
|
|
|
|
chip.style.background = c.css;
|
|
|
|
|
|
chip.title = c.label;
|
|
|
|
|
|
chip.addEventListener("click", () => { onPick(c); pop.remove(); });
|
|
|
|
|
|
grid.append(chip);
|
|
|
|
|
|
}
|
|
|
|
|
|
const row = document.createElement("div");
|
|
|
|
|
|
row.className = "prow";
|
|
|
|
|
|
const clear = document.createElement("button");
|
|
|
|
|
|
clear.className = "btn wide";
|
|
|
|
|
|
clear.innerHTML = `<span>${clearLabel}</span>`;
|
|
|
|
|
|
clear.addEventListener("click", () => { onClear(); pop.remove(); });
|
|
|
|
|
|
row.append(clear);
|
|
|
|
|
|
pop.append(grid, row);
|
|
|
|
|
|
document.body.append(pop);
|
|
|
|
|
|
const r = anchor.getBoundingClientRect();
|
|
|
|
|
|
pop.style.left = Math.min(r.left, window.innerWidth - pop.offsetWidth - 8) + "px";
|
|
|
|
|
|
pop.style.top = (r.bottom + 4) + "px";
|
|
|
|
|
|
const away = (e) => {
|
|
|
|
|
|
if (!pop.contains(e.target) && e.target !== anchor) { pop.remove(); document.removeEventListener("mousedown", away); }
|
|
|
|
|
|
};
|
|
|
|
|
|
setTimeout(() => document.addEventListener("mousedown", away), 0);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ ribbon ---
|
|
|
|
|
|
|
|
|
|
|
|
function syncRibbon() {
|
|
|
|
|
|
if (!view) return;
|
|
|
|
|
|
const st = view.state;
|
|
|
|
|
|
const on = (id, active) => $(id).classList.toggle("on", !!active);
|
|
|
|
|
|
|
|
|
|
|
|
on("m-strong", markActive(st, schema.marks.strong));
|
|
|
|
|
|
on("m-em", markActive(st, schema.marks.em));
|
|
|
|
|
|
on("m-underline", markActive(st, schema.marks.underline));
|
|
|
|
|
|
on("m-strike", markActive(st, schema.marks.strike));
|
|
|
|
|
|
on("m-sup", markActive(st, schema.marks.sup));
|
|
|
|
|
|
on("m-sub", markActive(st, schema.marks.sub));
|
|
|
|
|
|
|
|
|
|
|
|
const fontAttrs = markAttrs(st, schema.marks.font);
|
|
|
|
|
|
$("font-family").value = FONTS.includes(fontAttrs && fontAttrs.family) ? fontAttrs.family : "";
|
|
|
|
|
|
const sizeAttrs = markAttrs(st, schema.marks.fsize);
|
|
|
|
|
|
$("font-size").value = sizeAttrs ? sizeAttrs.pt : "";
|
|
|
|
|
|
const colorAttrs = markAttrs(st, schema.marks.color);
|
|
|
|
|
|
$("color-bar").style.background = colorAttrs ? "#" + colorAttrs.hex : "#c00000";
|
|
|
|
|
|
const hlAttrs = markAttrs(st, schema.marks.highlight);
|
|
|
|
|
|
$("hl-bar").style.background = hlAttrs
|
|
|
|
|
|
? (DE().schema.HIGHLIGHT_CSS[hlAttrs.name] || "#ffff00") : "#ffff00";
|
|
|
|
|
|
|
|
|
|
|
|
const block = currentBlockAttrs();
|
|
|
|
|
|
const $from = st.selection.$from;
|
|
|
|
|
|
const inQuote = findParent($from, schema.nodes.blockquote);
|
|
|
|
|
|
const inCode = findParent($from, schema.nodes.code_block);
|
|
|
|
|
|
let style = "paragraph";
|
|
|
|
|
|
if (inCode) style = "code_block";
|
|
|
|
|
|
else if (inQuote) style = "blockquote";
|
|
|
|
|
|
else if (block && block.type === schema.nodes.heading) style = "h" + block.attrs.level;
|
|
|
|
|
|
$("style-select").value = style;
|
|
|
|
|
|
|
|
|
|
|
|
const align = block ? block.attrs.align : null;
|
|
|
|
|
|
on("a-left", align === "left");
|
|
|
|
|
|
on("a-center", align === "center");
|
|
|
|
|
|
on("a-right", align === "right");
|
|
|
|
|
|
on("a-justify", align === "justify");
|
|
|
|
|
|
$("line-height").value = block && block.attrs.lineHeight ? String(block.attrs.lineHeight) : "";
|
|
|
|
|
|
|
|
|
|
|
|
const ol = findParent($from, schema.nodes.ordered_list);
|
|
|
|
|
|
const ul = findParent($from, schema.nodes.bullet_list);
|
|
|
|
|
|
on("l-bullet", !!ul);
|
|
|
|
|
|
on("l-ordered", !!ol);
|
|
|
|
|
|
$("list-format").disabled = !ol;
|
|
|
|
|
|
if (ol) $("list-format").value = ol.node.attrs.format || "decimal";
|
|
|
|
|
|
|
|
|
|
|
|
$("table-group").dataset.contextual = inTable() ? "on" : "off";
|
|
|
|
|
|
|
|
|
|
|
|
const { history } = V().pm;
|
|
|
|
|
|
$("undo").disabled = history.undoDepth(st) === 0;
|
|
|
|
|
|
$("redo").disabled = history.redoDepth(st) === 0;
|
|
|
|
|
|
|
|
|
|
|
|
// Everything that writes is off while the document is locked.
|
|
|
|
|
|
document.querySelectorAll("#ribbon .btn, #ribbon .rsel, #ribbon .rnum").forEach((el) => {
|
|
|
|
|
|
if (el.id === "undo" || el.id === "redo") return;
|
|
|
|
|
|
el.disabled = locked || (el.id === "list-format" && !ol);
|
|
|
|
|
|
});
|
|
|
|
|
|
$("file-save").disabled = locked;
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
$("file-save").title = savedTarget
|
|
|
|
|
|
? `Save to ${savedTarget.name} (Ctrl+S)`
|
|
|
|
|
|
: "Save a .docx to your downloads (Ctrl+S)";
|
|
|
|
|
|
$("file-saveas").disabled = locked;
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function wireRibbon() {
|
|
|
|
|
|
const sel = $("font-family");
|
|
|
|
|
|
for (const f of FONTS) {
|
|
|
|
|
|
const opt = document.createElement("option");
|
|
|
|
|
|
opt.value = f; opt.textContent = f; opt.style.fontFamily = f;
|
|
|
|
|
|
sel.append(opt);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$("m-strong").onclick = () => toggleMark("strong");
|
|
|
|
|
|
$("m-em").onclick = () => toggleMark("em");
|
|
|
|
|
|
$("m-underline").onclick = () => toggleMark("underline");
|
|
|
|
|
|
$("m-strike").onclick = () => toggleMark("strike");
|
|
|
|
|
|
$("m-sup").onclick = () => toggleMark("sup");
|
|
|
|
|
|
$("m-sub").onclick = () => toggleMark("sub");
|
|
|
|
|
|
$("m-clear").onclick = clearFormatting;
|
|
|
|
|
|
|
|
|
|
|
|
sel.onchange = () => setValueMark("font", sel.value ? { family: sel.value } : null);
|
|
|
|
|
|
$("font-size").onchange = () => {
|
|
|
|
|
|
const pt = parseFloat($("font-size").value);
|
|
|
|
|
|
setValueMark("fsize", Number.isFinite(pt) && pt > 0 ? { pt } : null);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
$("m-color").onclick = (e) => openColorPop(
|
|
|
|
|
|
e.currentTarget,
|
|
|
|
|
|
TEXT_COLORS.map((hex) => ({ css: "#" + hex, label: "#" + hex, hex })),
|
|
|
|
|
|
(c) => setValueMark("color", { hex: c.hex }),
|
|
|
|
|
|
() => setValueMark("color", null),
|
|
|
|
|
|
"Automatic");
|
|
|
|
|
|
|
|
|
|
|
|
$("m-highlight").onclick = (e) => openColorPop(
|
|
|
|
|
|
e.currentTarget,
|
|
|
|
|
|
DE().schema.HIGHLIGHTS.map((h) => ({ css: h.css, label: h.label, name: h.name })),
|
|
|
|
|
|
(c) => setValueMark("highlight", { name: c.name }),
|
|
|
|
|
|
() => setValueMark("highlight", null),
|
|
|
|
|
|
"No highlight");
|
|
|
|
|
|
|
|
|
|
|
|
$("style-select").onchange = (e) => setStyle(e.target.value);
|
|
|
|
|
|
|
|
|
|
|
|
for (const [id, value] of [["a-left", "left"], ["a-center", "center"], ["a-right", "right"], ["a-justify", "justify"]]) {
|
|
|
|
|
|
$(id).onclick = () => {
|
|
|
|
|
|
const block = currentBlockAttrs();
|
|
|
|
|
|
setBlockAttr("align", block && block.attrs.align === value ? null : value);
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
$("line-height").onchange = (e) => setBlockAttr("lineHeight", e.target.value ? parseFloat(e.target.value) : null);
|
|
|
|
|
|
$("indent-in").onclick = () => shiftIndent(1);
|
|
|
|
|
|
$("indent-out").onclick = () => shiftIndent(-1);
|
|
|
|
|
|
|
|
|
|
|
|
$("l-bullet").onclick = () => toggleList("bullet");
|
|
|
|
|
|
$("l-ordered").onclick = () => toggleList("ordered");
|
|
|
|
|
|
$("list-format").onchange = (e) => applyListFormat(e.target.value);
|
|
|
|
|
|
|
|
|
|
|
|
$("i-link").onclick = openLinkDialog;
|
|
|
|
|
|
$("i-table").onclick = openTableDialog;
|
|
|
|
|
|
$("i-image").onclick = () => $("image-input").click();
|
|
|
|
|
|
$("i-rule").onclick = () => insertNode(schema.nodes.horizontal_rule.create());
|
|
|
|
|
|
$("i-pagebreak").onclick = () => insertNode(schema.nodes.page_break.create());
|
|
|
|
|
|
|
|
|
|
|
|
$("t-row-after").onclick = () => tableCmd("addRowAfter");
|
|
|
|
|
|
$("t-row-del").onclick = () => tableCmd("deleteRow");
|
|
|
|
|
|
$("t-col-after").onclick = () => tableCmd("addColumnAfter");
|
|
|
|
|
|
$("t-col-del").onclick = () => tableCmd("deleteColumn");
|
|
|
|
|
|
$("t-merge").onclick = () => tableCmd("mergeCells");
|
|
|
|
|
|
$("t-split").onclick = () => tableCmd("splitCell");
|
|
|
|
|
|
$("t-header").onclick = toggleHeaderRow;
|
|
|
|
|
|
$("t-del").onclick = () => tableCmd("deleteTable");
|
|
|
|
|
|
|
|
|
|
|
|
$("undo").onclick = () => { V().pm.history.undo(view.state, view.dispatch); view.focus(); };
|
|
|
|
|
|
$("redo").onclick = () => { V().pm.history.redo(view.state, view.dispatch); view.focus(); };
|
|
|
|
|
|
|
|
|
|
|
|
$("file-new").onclick = () => newDocument();
|
|
|
|
|
|
$("file-open").onclick = () => $("file-input").click();
|
|
|
|
|
|
$("file-save").onclick = () => save();
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
$("file-saveas").onclick = () => saveAs();
|
|
|
|
|
|
$("file-pdf").onclick = () => exportPdf();
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
$("file-print").onclick = () => window.print();
|
feat(docx-editor): the page fills the window, and two fonts ship with it
The page sat marooned in the middle of a wide window with dark space either
side of it. It is now drawn at the size the document actually claims — A4
stays A4, margins come from its own sectPr — and CSS `zoom` scales that to
fit, defaulting to Fit width with a control in the footer and Ctrl +/-/0.
Scaling rather than widening is deliberate. A page stretched to the window
would break every line somewhere different from where the printed page
breaks it, and an editor whose whole claim is that it shows you the document
should not lie about where the lines end. `zoom` also beats a transform
here: it affects layout, so the board scrolls correctly and ProseMirror's
coordinate maths keeps working.
Ubuntu and Fraunces now ship in fonts/, because Windows has neither and a
font offered in the ribbon that the machine lacks is a font the user picks
and then cannot see. Fetched once by `npm run fonts` and committed, never at
runtime: an extension in a browser built around not phoning home should not
ask a font CDN what a document looks like every time one is opened.
Two things had to be worked around. On file:// Chromium registers @font-face
rules and then refuses to fetch the files — the family appears in
document.fonts and every glyph still renders in the fallback — so the add-on
reads the woff2 and hands the page a stylesheet with them inlined as data
URLs. The PDF export needed the same treatment for a different reason: its
print window runs from a temp folder, where a relative url() resolves to
nothing, which would have quietly undone the one-stylesheet-for-both promise
that lib/doc-css.js exists to keep. If either path fails, the ribbon labels
those families "(not available)" rather than implying otherwise.
About 700 KB, most of it Ubuntu's Cyrillic and Greek — kept because the
documents this is used on are not all English. Licences ship alongside.
2026-09-22 19:12:56 +02:00
|
|
|
|
$("zoom").onchange = (e) => setZoom(e.target.value);
|
|
|
|
|
|
$("zoom-in").onclick = () => stepZoom(1);
|
|
|
|
|
|
$("zoom-out").onclick = () => stepZoom(-1);
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
$("about").onclick = openAbout;
|
|
|
|
|
|
$("discard").onclick = () => closeTab();
|
|
|
|
|
|
$("open-folder").onclick = async () => {
|
|
|
|
|
|
try { await window.silentmode?.invoke("openFolder", { id: scratchId }); }
|
|
|
|
|
|
catch (e) { status("Couldn't open the folder: " + (e && e.message || e), "err"); }
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
$("file-input").onchange = async (e) => {
|
|
|
|
|
|
const file = e.target.files && e.target.files[0];
|
|
|
|
|
|
e.target.value = "";
|
|
|
|
|
|
if (file) await openFile(file);
|
|
|
|
|
|
};
|
|
|
|
|
|
$("image-input").onchange = async (e) => {
|
|
|
|
|
|
const file = e.target.files && e.target.files[0];
|
|
|
|
|
|
e.target.value = "";
|
|
|
|
|
|
if (file) await insertImageFile(file);
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
// ------------------------------------------------------------- document css ---
|
|
|
|
|
|
|
|
|
|
|
|
// The page's own typography is shared verbatim with the PDF export, so it
|
|
|
|
|
|
// lives in lib/doc-css.js rather than editor.css. Injecting it here (rather
|
|
|
|
|
|
// than linking a second stylesheet) keeps the export and the screen reading
|
|
|
|
|
|
// from one string.
|
feat(docx-editor): the page fills the window, and two fonts ship with it
The page sat marooned in the middle of a wide window with dark space either
side of it. It is now drawn at the size the document actually claims — A4
stays A4, margins come from its own sectPr — and CSS `zoom` scales that to
fit, defaulting to Fit width with a control in the footer and Ctrl +/-/0.
Scaling rather than widening is deliberate. A page stretched to the window
would break every line somewhere different from where the printed page
breaks it, and an editor whose whole claim is that it shows you the document
should not lie about where the lines end. `zoom` also beats a transform
here: it affects layout, so the board scrolls correctly and ProseMirror's
coordinate maths keeps working.
Ubuntu and Fraunces now ship in fonts/, because Windows has neither and a
font offered in the ribbon that the machine lacks is a font the user picks
and then cannot see. Fetched once by `npm run fonts` and committed, never at
runtime: an extension in a browser built around not phoning home should not
ask a font CDN what a document looks like every time one is opened.
Two things had to be worked around. On file:// Chromium registers @font-face
rules and then refuses to fetch the files — the family appears in
document.fonts and every glyph still renders in the fallback — so the add-on
reads the woff2 and hands the page a stylesheet with them inlined as data
URLs. The PDF export needed the same treatment for a different reason: its
print window runs from a temp folder, where a relative url() resolves to
nothing, which would have quietly undone the one-stylesheet-for-both promise
that lib/doc-css.js exists to keep. If either path fails, the ribbon labels
those families "(not available)" rather than implying otherwise.
About 700 KB, most of it Ubuntu's Cyrillic and Greek — kept because the
documents this is used on are not all English. Licences ship alongside.
2026-09-22 19:12:56 +02:00
|
|
|
|
// The bundled webfonts, fetched from the add-on with the woff2 already
|
|
|
|
|
|
// inlined as data URLs.
|
|
|
|
|
|
//
|
|
|
|
|
|
// A plain <link> to fonts.css does not work here: the page is on file://,
|
|
|
|
|
|
// where Chromium registers the @font-face rules but refuses to fetch the
|
|
|
|
|
|
// font files themselves — the family appears in document.fonts and every
|
|
|
|
|
|
// glyph still comes out in the fallback face. Data URLs sidestep the fetch
|
|
|
|
|
|
// entirely, and the add-on is the only side that can read the files.
|
|
|
|
|
|
async function installFontCss() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await window.silentmode?.invoke("fontCss", {});
|
|
|
|
|
|
if (res && res.css) {
|
|
|
|
|
|
const el = document.createElement("style");
|
|
|
|
|
|
el.id = "bundled-fonts";
|
|
|
|
|
|
el.textContent = res.css;
|
|
|
|
|
|
document.head.append(el);
|
|
|
|
|
|
await document.fonts.ready;
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn("[docx-editor] bundled fonts unavailable:", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
// Without them, a document that asks for Ubuntu or Fraunces still saves
|
|
|
|
|
|
// correctly — Word will use its own copy — it just isn't drawn faithfully
|
|
|
|
|
|
// here, so say so rather than letting the ribbon imply otherwise.
|
|
|
|
|
|
for (const name of BUNDLED_FONTS) {
|
|
|
|
|
|
const opt = [...$("font-family").options].find((o) => o.value === name);
|
|
|
|
|
|
if (opt) opt.textContent = name + " (not available)";
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
function installDocCss() {
|
|
|
|
|
|
const el = document.createElement("style");
|
|
|
|
|
|
el.id = "doc-css";
|
|
|
|
|
|
el.textContent = DE().docCss.DOC_CSS;
|
|
|
|
|
|
document.head.append(el);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Ctrl+P should use the document's paper size too, not the browser default.
|
|
|
|
|
|
function syncPageRule() {
|
|
|
|
|
|
let el = document.getElementById("page-rule");
|
|
|
|
|
|
if (!el) {
|
|
|
|
|
|
el = document.createElement("style");
|
|
|
|
|
|
el.id = "page-rule";
|
|
|
|
|
|
document.head.append(el);
|
|
|
|
|
|
}
|
|
|
|
|
|
el.textContent = DE().docCss.pageRule(docSetup);
|
feat(docx-editor): the page fills the window, and two fonts ship with it
The page sat marooned in the middle of a wide window with dark space either
side of it. It is now drawn at the size the document actually claims — A4
stays A4, margins come from its own sectPr — and CSS `zoom` scales that to
fit, defaulting to Fit width with a control in the footer and Ctrl +/-/0.
Scaling rather than widening is deliberate. A page stretched to the window
would break every line somewhere different from where the printed page
breaks it, and an editor whose whole claim is that it shows you the document
should not lie about where the lines end. `zoom` also beats a transform
here: it affects layout, so the board scrolls correctly and ProseMirror's
coordinate maths keeps working.
Ubuntu and Fraunces now ship in fonts/, because Windows has neither and a
font offered in the ribbon that the machine lacks is a font the user picks
and then cannot see. Fetched once by `npm run fonts` and committed, never at
runtime: an extension in a browser built around not phoning home should not
ask a font CDN what a document looks like every time one is opened.
Two things had to be worked around. On file:// Chromium registers @font-face
rules and then refuses to fetch the files — the family appears in
document.fonts and every glyph still renders in the fallback — so the add-on
reads the woff2 and hands the page a stylesheet with them inlined as data
URLs. The PDF export needed the same treatment for a different reason: its
print window runs from a temp folder, where a relative url() resolves to
nothing, which would have quietly undone the one-stylesheet-for-both promise
that lib/doc-css.js exists to keep. If either path fails, the ribbon labels
those families "(not available)" rather than implying otherwise.
About 700 KB, most of it Ubuntu's Cyrillic and Greek — kept because the
documents this is used on are not all English. Licences ship alongside.
2026-09-22 19:12:56 +02:00
|
|
|
|
syncPageGeometry();
|
|
|
|
|
|
applyZoom();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The sheet is drawn at the size the document claims, margins included, so
|
|
|
|
|
|
// what is on screen is the page rather than a generic rectangle.
|
|
|
|
|
|
function pageInches() {
|
|
|
|
|
|
const page = (docSetup && docSetup.page) || {};
|
|
|
|
|
|
const size = page.size || {};
|
|
|
|
|
|
const m = page.margin || {};
|
|
|
|
|
|
const inch = (twips, fallback) => {
|
|
|
|
|
|
const n = Number(twips);
|
|
|
|
|
|
return Number.isFinite(n) && n > 0 ? n / 1440 : fallback;
|
|
|
|
|
|
};
|
|
|
|
|
|
return {
|
|
|
|
|
|
w: inch(size.width, 8.27), h: inch(size.height, 11.69),
|
|
|
|
|
|
top: inch(m.top, 1), right: inch(m.right, 1),
|
|
|
|
|
|
bottom: inch(m.bottom, 1), left: inch(m.left, 1),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function syncPageGeometry() {
|
|
|
|
|
|
const p = pageInches();
|
|
|
|
|
|
const root = document.documentElement.style;
|
|
|
|
|
|
root.setProperty("--page-w", p.w + "in");
|
|
|
|
|
|
root.setProperty("--page-h", p.h + "in");
|
|
|
|
|
|
root.setProperty("--page-mt", p.top + "in");
|
|
|
|
|
|
root.setProperty("--page-mr", p.right + "in");
|
|
|
|
|
|
root.setProperty("--page-mb", p.bottom + "in");
|
|
|
|
|
|
root.setProperty("--page-ml", p.left + "in");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const ZOOM_MIN = 0.4, ZOOM_MAX = 4;
|
|
|
|
|
|
|
|
|
|
|
|
function applyZoom() {
|
|
|
|
|
|
const board = $("board");
|
|
|
|
|
|
if (!board) return;
|
|
|
|
|
|
const p = pageInches();
|
|
|
|
|
|
const style = getComputedStyle(board);
|
|
|
|
|
|
const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight);
|
|
|
|
|
|
const availW = board.clientWidth - padding - 2;
|
|
|
|
|
|
const availH = board.clientHeight - parseFloat(style.paddingTop) - 24;
|
|
|
|
|
|
|
|
|
|
|
|
let z;
|
|
|
|
|
|
if (zoomMode === "fit") z = availW / (p.w * 96);
|
|
|
|
|
|
else if (zoomMode === "page") z = Math.min(availW / (p.w * 96), availH / (p.h * 96));
|
|
|
|
|
|
else z = parseFloat(zoomMode) || 1;
|
|
|
|
|
|
|
|
|
|
|
|
z = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, z));
|
|
|
|
|
|
document.documentElement.style.setProperty("--zoom", String(z));
|
|
|
|
|
|
const sel = $("zoom");
|
|
|
|
|
|
if (sel && sel.value !== zoomMode) sel.value = zoomMode;
|
|
|
|
|
|
return z;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setZoom(mode) {
|
|
|
|
|
|
zoomMode = mode;
|
|
|
|
|
|
applyZoom();
|
|
|
|
|
|
try { window.silentmode?.storage?.set("zoom", mode); } catch {}
|
|
|
|
|
|
if (view) view.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Stepping from a fit mode starts at whatever that fit worked out to, so the
|
|
|
|
|
|
// first click doesn't jump somewhere unrelated to what is on screen.
|
|
|
|
|
|
function stepZoom(dir) {
|
|
|
|
|
|
const steps = [0.5, 0.75, 1, 1.25, 1.5, 2, 3];
|
|
|
|
|
|
const now = parseFloat(getComputedStyle(document.documentElement).getPropertyValue("--zoom")) || 1;
|
|
|
|
|
|
const next = dir > 0
|
|
|
|
|
|
? steps.find((v) => v > now + 0.01)
|
|
|
|
|
|
: [...steps].reverse().find((v) => v < now - 0.01);
|
|
|
|
|
|
if (next) setZoom(String(next));
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// The document as a standalone HTML page: ProseMirror's own DOM serialisation
|
|
|
|
|
|
// (so what you see is what gets rendered), with images already inline as data
|
|
|
|
|
|
// URLs and the editor's chrome left behind.
|
|
|
|
|
|
function printableHtml() {
|
|
|
|
|
|
const pm = document.querySelector(".ProseMirror");
|
|
|
|
|
|
if (!pm) throw new Error("nothing to export");
|
|
|
|
|
|
const clone = pm.cloneNode(true);
|
|
|
|
|
|
// Editing scaffolding that has no business in a finished document.
|
|
|
|
|
|
clone.querySelectorAll(".ProseMirror-separator, .ProseMirror-trailingBreak, .column-resize-handle")
|
|
|
|
|
|
.forEach((n) => n.remove());
|
|
|
|
|
|
clone.querySelectorAll("[contenteditable]").forEach((n) => n.removeAttribute("contenteditable"));
|
|
|
|
|
|
return DE().docCss.printableHtml({
|
|
|
|
|
|
title: docName.replace(/\.docx$/i, ""),
|
|
|
|
|
|
bodyHtml: clone.innerHTML,
|
|
|
|
|
|
setup: docSetup,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
// ------------------------------------------------------------------ editor ---
|
|
|
|
|
|
|
|
|
|
|
|
function buildPlugins() {
|
|
|
|
|
|
const { keymap, history, commands, inputrules, dropcursor, gapcursor, tables, state: pmState } = V().pm;
|
|
|
|
|
|
const { baseKeymap, toggleMark: tm, chainCommands, exitCode } = commands;
|
|
|
|
|
|
const { wrapInList, splitListItem, liftListItem, sinkListItem } = V().pm.schemaList;
|
|
|
|
|
|
|
|
|
|
|
|
const keys = {
|
|
|
|
|
|
"Mod-z": history.undo,
|
|
|
|
|
|
"Shift-Mod-z": history.redo,
|
|
|
|
|
|
"Mod-y": history.redo,
|
|
|
|
|
|
"Mod-b": tm(schema.marks.strong),
|
|
|
|
|
|
"Mod-i": tm(schema.marks.em),
|
|
|
|
|
|
"Mod-u": tm(schema.marks.underline),
|
|
|
|
|
|
"Mod-k": () => { openLinkDialog(); return true; },
|
|
|
|
|
|
"Mod-s": () => { save(); return true; },
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
"Shift-Mod-s": () => { saveAs(); return true; },
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
"Mod-o": () => { $("file-input").click(); return true; },
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
// 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; },
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
"Mod-p": () => { window.print(); return true; },
|
feat(docx-editor): the page fills the window, and two fonts ship with it
The page sat marooned in the middle of a wide window with dark space either
side of it. It is now drawn at the size the document actually claims — A4
stays A4, margins come from its own sectPr — and CSS `zoom` scales that to
fit, defaulting to Fit width with a control in the footer and Ctrl +/-/0.
Scaling rather than widening is deliberate. A page stretched to the window
would break every line somewhere different from where the printed page
breaks it, and an editor whose whole claim is that it shows you the document
should not lie about where the lines end. `zoom` also beats a transform
here: it affects layout, so the board scrolls correctly and ProseMirror's
coordinate maths keeps working.
Ubuntu and Fraunces now ship in fonts/, because Windows has neither and a
font offered in the ribbon that the machine lacks is a font the user picks
and then cannot see. Fetched once by `npm run fonts` and committed, never at
runtime: an extension in a browser built around not phoning home should not
ask a font CDN what a document looks like every time one is opened.
Two things had to be worked around. On file:// Chromium registers @font-face
rules and then refuses to fetch the files — the family appears in
document.fonts and every glyph still renders in the fallback — so the add-on
reads the woff2 and hands the page a stylesheet with them inlined as data
URLs. The PDF export needed the same treatment for a different reason: its
print window runs from a temp folder, where a relative url() resolves to
nothing, which would have quietly undone the one-stylesheet-for-both promise
that lib/doc-css.js exists to keep. If either path fails, the ribbon labels
those families "(not available)" rather than implying otherwise.
About 700 KB, most of it Ubuntu's Cyrillic and Greek — kept because the
documents this is used on are not all English. Licences ship alongside.
2026-09-22 19:12:56 +02:00
|
|
|
|
"Mod-=": () => { stepZoom(1); return true; },
|
|
|
|
|
|
"Mod-+": () => { stepZoom(1); return true; },
|
|
|
|
|
|
"Mod--": () => { stepZoom(-1); return true; },
|
|
|
|
|
|
"Mod-0": () => { setZoom("fit"); return true; },
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
"Mod-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(),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
const sheet = $("sheet");
|
|
|
|
|
|
sheet.innerHTML = "";
|
|
|
|
|
|
view = new pmView.EditorView(sheet, {
|
|
|
|
|
|
state,
|
|
|
|
|
|
editable: () => !locked,
|
|
|
|
|
|
dispatchTransaction(tr) {
|
|
|
|
|
|
const next = view.state.apply(tr);
|
|
|
|
|
|
view.updateState(next);
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
// --------------------------------------------------------- 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"); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
// ------------------------------------------------------------------- files ---
|
|
|
|
|
|
|
|
|
|
|
|
function blankDoc() {
|
|
|
|
|
|
return V().pm.model.Node.fromJSON(schema, {
|
|
|
|
|
|
type: "doc", content: [{ type: "paragraph" }],
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function newDocument() {
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
addDoc({ pmDoc: blankDoc(), name: "Untitled document" });
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
status("New document.");
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function loadBytes(bytes, name, id) {
|
|
|
|
|
|
const t0 = performance.now();
|
|
|
|
|
|
status("Reading document…");
|
|
|
|
|
|
const res = await DE().read.docxToDoc(bytes, schema);
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
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 || "",
|
|
|
|
|
|
});
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
// 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 {}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
async function openFile(file) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
|
|
|
|
// Park a copy with the add-on so the document survives a tab reload and
|
|
|
|
|
|
// shows up in the sidebar's recent list.
|
|
|
|
|
|
let id = "";
|
|
|
|
|
|
try {
|
|
|
|
|
|
const stashed = await window.silentmode?.invoke("stash", {
|
|
|
|
|
|
name: file.name, base64: DE().read.bytesToBase64(bytes), kind: "opened",
|
|
|
|
|
|
});
|
|
|
|
|
|
id = stashed && stashed.id || "";
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn("[docx-editor] could not stash the document:", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
await loadBytes(bytes, file.name, id);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
showError(`Couldn't open ${file.name}: ${e && e.message || e}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function showError(text) {
|
|
|
|
|
|
status(text, "err");
|
|
|
|
|
|
if (!view) {
|
|
|
|
|
|
$("sheet").innerHTML = `<div class="empty-state">${text}</div>`;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function downloadName() {
|
|
|
|
|
|
const base = docName.replace(/\.docx$/i, "") || "document";
|
|
|
|
|
|
return `${base}-edited.docx`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
// Build the .docx bytes for whatever is on screen.
|
|
|
|
|
|
async function buildDocx() {
|
|
|
|
|
|
const res = await DE().write.docToDocx(view.state.doc, {
|
|
|
|
|
|
originalBytes, setup: docSetup, meta: docMeta,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (res.warnings && res.warnings.length) console.warn("[docx-editor] save warnings:", res.warnings);
|
|
|
|
|
|
return res;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Render the document to PDF. Chromium's own print pipeline does the work,
|
|
|
|
|
|
// in a hidden window on the add-on side — the same engine as Ctrl+P, so the
|
|
|
|
|
|
// PDF matches the preview instead of being a second opinion about it.
|
|
|
|
|
|
async function buildPdf() {
|
|
|
|
|
|
if (!window.silentmode?.invoke) throw new Error("PDF export needs the add-on host");
|
|
|
|
|
|
const res = await window.silentmode.invoke("renderPdf", {
|
|
|
|
|
|
html: printableHtml(),
|
|
|
|
|
|
name: docName,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (!res || !res.base64) throw new Error("the renderer returned nothing");
|
|
|
|
|
|
return base64ToBytes(res.base64);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function downloadBytes(bytes, filename, mime) {
|
|
|
|
|
|
const blob = new Blob([bytes], { type: mime });
|
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
|
|
const a = $("download-link");
|
|
|
|
|
|
a.href = url;
|
|
|
|
|
|
a.download = filename;
|
|
|
|
|
|
a.click();
|
|
|
|
|
|
setTimeout(() => URL.revokeObjectURL(url), 8000);
|
|
|
|
|
|
return filename;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
|
|
|
|
|
|
|
|
|
|
// Save. Once the document has a home — set by Save as… — this writes there
|
|
|
|
|
|
// and says so. Before that it does what the other add-ons do and puts a copy
|
|
|
|
|
|
// in Downloads, because the file picker never told us where the original
|
|
|
|
|
|
// came from.
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
async function save() {
|
|
|
|
|
|
if (!view || locked) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
status("Saving…");
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
const res = await buildDocx();
|
|
|
|
|
|
const kb = (res.bytes.length / 1024).toFixed(0);
|
|
|
|
|
|
const carried = res.carried && res.carried.length ? ` — carried over ${res.carried.join(", ")}` : "";
|
|
|
|
|
|
|
|
|
|
|
|
if (savedTarget) {
|
|
|
|
|
|
const written = await window.silentmode.invoke("writeChosen", {
|
|
|
|
|
|
token: savedTarget.token,
|
|
|
|
|
|
base64: DE().read.bytesToBase64(res.bytes),
|
|
|
|
|
|
});
|
|
|
|
|
|
// A token is good for one write, so take a fresh one for the next save
|
|
|
|
|
|
// to the same place.
|
|
|
|
|
|
savedTarget = await reserveSameTarget(written.path, written.name);
|
|
|
|
|
|
docName = written.name;
|
|
|
|
|
|
setDirty(false);
|
|
|
|
|
|
lastSavedAt = Date.now();
|
|
|
|
|
|
await autosave(true);
|
|
|
|
|
|
status(`Saved ${written.name} (${kb} KB)${carried}`, "ok");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
const name = downloadBytes(res.bytes, downloadName(), DOCX_MIME);
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
setDirty(false);
|
|
|
|
|
|
lastSavedAt = Date.now();
|
|
|
|
|
|
await autosave(true);
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
status(`Saved ${name} (${kb} KB)${carried}`, "ok");
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
if (res.warnings && res.warnings.length) {
|
|
|
|
|
|
status(`Saved with warnings: ${res.warnings.join("; ")}`, "err");
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
status("Save failed: " + (e && e.message || e), "err");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
// Ask the add-on to re-reserve a path the user already chose, so the next
|
|
|
|
|
|
// plain Save can write to it without another dialog.
|
|
|
|
|
|
async function reserveSameTarget(path, name) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const r = await window.silentmode.invoke("reserveSavePath", { path });
|
|
|
|
|
|
return r && r.token ? { token: r.token, name: name || r.name } : null;
|
|
|
|
|
|
} catch { return null; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Save as… — a real file picker, and the extension the user types decides
|
|
|
|
|
|
// whether they get a Word document or a PDF.
|
|
|
|
|
|
async function saveAs() {
|
|
|
|
|
|
if (!view || locked) return;
|
|
|
|
|
|
if (!window.silentmode?.invoke) { status("Save as… needs the add-on host", "err"); return; }
|
|
|
|
|
|
try {
|
|
|
|
|
|
const pick = await window.silentmode.invoke("pickSavePath", { name: downloadName() });
|
|
|
|
|
|
if (!pick || pick.cancelled) return;
|
|
|
|
|
|
|
|
|
|
|
|
status(pick.format === "pdf" ? "Rendering PDF…" : "Saving…");
|
|
|
|
|
|
let bytes, carried = "";
|
|
|
|
|
|
if (pick.format === "pdf") {
|
|
|
|
|
|
bytes = await buildPdf();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const res = await buildDocx();
|
|
|
|
|
|
bytes = res.bytes;
|
|
|
|
|
|
if (res.carried && res.carried.length) carried = ` — carried over ${res.carried.join(", ")}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const written = await window.silentmode.invoke("writeChosen", {
|
|
|
|
|
|
token: pick.token,
|
|
|
|
|
|
base64: DE().read.bytesToBase64(bytes),
|
|
|
|
|
|
});
|
|
|
|
|
|
const kb = (written.bytes / 1024).toFixed(0);
|
|
|
|
|
|
|
|
|
|
|
|
if (pick.format === "pdf") {
|
|
|
|
|
|
// A PDF is an export, not the document's home: the editor still edits
|
|
|
|
|
|
// the .docx, so Save keeps pointing wherever it pointed before.
|
|
|
|
|
|
status(`Exported ${written.name} (${kb} KB)`, "ok");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
savedTarget = await reserveSameTarget(written.path, written.name);
|
|
|
|
|
|
docName = written.name;
|
|
|
|
|
|
setDirty(false);
|
|
|
|
|
|
lastSavedAt = Date.now();
|
|
|
|
|
|
await autosave(true);
|
|
|
|
|
|
status(`Saved ${written.name} (${kb} KB)${carried}`, "ok");
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
status("Save as failed: " + (e && e.message || e), "err");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// PDF straight to Downloads — the one-click version of Save as… › .pdf.
|
|
|
|
|
|
async function exportPdf() {
|
|
|
|
|
|
if (!view) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
status("Rendering PDF…");
|
|
|
|
|
|
const bytes = await buildPdf();
|
|
|
|
|
|
const name = downloadName().replace(/\.docx$/i, "") + ".pdf";
|
|
|
|
|
|
downloadBytes(bytes, name, "application/pdf");
|
|
|
|
|
|
status(`Saved ${name} (${(bytes.length / 1024).toFixed(0)} KB)`, "ok");
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.error(e);
|
|
|
|
|
|
status("PDF export failed: " + (e && e.message || e), "err");
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
// Autosave into the add-on's recent ring — not to the user's file. It exists
|
|
|
|
|
|
// so a closed tab or a crash doesn't cost the session's work: the sidebar
|
|
|
|
|
|
// lists the last few documents and can hand them back.
|
|
|
|
|
|
function scheduleAutosave() {
|
|
|
|
|
|
clearTimeout(autosaveTimer);
|
|
|
|
|
|
autosaveTimer = setTimeout(() => autosave(false), AUTOSAVE_MS);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function autosave(force) {
|
|
|
|
|
|
if (!view || locked) return;
|
|
|
|
|
|
if (!force && !dirty) return;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const res = await DE().write.docToDocx(view.state.doc, {
|
|
|
|
|
|
originalBytes, setup: docSetup, meta: docMeta,
|
|
|
|
|
|
});
|
|
|
|
|
|
const stashed = await window.silentmode?.invoke("autosave", {
|
|
|
|
|
|
name: docName,
|
|
|
|
|
|
base64: DE().read.bytesToBase64(res.bytes),
|
|
|
|
|
|
replaces: scratchId,
|
|
|
|
|
|
});
|
|
|
|
|
|
if (stashed && stashed.id) scratchId = stashed.id;
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn("[docx-editor] autosave failed:", e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function closeTab() {
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
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;
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
try { window.silentmode?.closeTab(); }
|
|
|
|
|
|
catch { window.close(); }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------- boot ---
|
|
|
|
|
|
|
|
|
|
|
|
function base64ToBytes(b64) {
|
|
|
|
|
|
const bin = atob(b64);
|
|
|
|
|
|
const out = new Uint8Array(bin.length);
|
|
|
|
|
|
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
|
|
|
|
return out;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function boot() {
|
|
|
|
|
|
if (!window.DOCXV || !window.DocxEditor?.read) {
|
|
|
|
|
|
showError("The editor's libraries didn't load. Check vendor/docx-vendor.js.");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
schema = DE().schema.build();
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
installDocCss();
|
feat(docx-editor): the page fills the window, and two fonts ship with it
The page sat marooned in the middle of a wide window with dark space either
side of it. It is now drawn at the size the document actually claims — A4
stays A4, margins come from its own sectPr — and CSS `zoom` scales that to
fit, defaulting to Fit width with a control in the footer and Ctrl +/-/0.
Scaling rather than widening is deliberate. A page stretched to the window
would break every line somewhere different from where the printed page
breaks it, and an editor whose whole claim is that it shows you the document
should not lie about where the lines end. `zoom` also beats a transform
here: it affects layout, so the board scrolls correctly and ProseMirror's
coordinate maths keeps working.
Ubuntu and Fraunces now ship in fonts/, because Windows has neither and a
font offered in the ribbon that the machine lacks is a font the user picks
and then cannot see. Fetched once by `npm run fonts` and committed, never at
runtime: an extension in a browser built around not phoning home should not
ask a font CDN what a document looks like every time one is opened.
Two things had to be worked around. On file:// Chromium registers @font-face
rules and then refuses to fetch the files — the family appears in
document.fonts and every glyph still renders in the fallback — so the add-on
reads the woff2 and hands the page a stylesheet with them inlined as data
URLs. The PDF export needed the same treatment for a different reason: its
print window runs from a temp folder, where a relative url() resolves to
nothing, which would have quietly undone the one-stylesheet-for-both promise
that lib/doc-css.js exists to keep. If either path fails, the ribbon labels
those families "(not available)" rather than implying otherwise.
About 700 KB, most of it Ubuntu's Cyrillic and Greek — kept because the
documents this is used on are not all English. Licences ship alongside.
2026-09-22 19:12:56 +02:00
|
|
|
|
try {
|
|
|
|
|
|
const saved = await window.silentmode?.storage?.get("zoom", "fit");
|
|
|
|
|
|
if (saved) zoomMode = String(saved);
|
|
|
|
|
|
} catch {}
|
feat(docx-editor): Save as…, PDF export, and a mark of our own
Three gaps, one theme: the editor could produce a file but not decide where
it went, what format it was in, or look like anything in the dock.
**Save as…** opens a real file dialog, and the extension typed there picks
the format. Save then writes to that file instead of dropping another copy
in Downloads every time. The renderer never names a path: the dialog returns
an opaque token, and the add-on will only write to a path a dialog actually
returned. An extension page is the least trusted thing in the add-on, and
"write these bytes anywhere" is not a capability it needs.
**PDF** goes through Chromium's own print pipeline in a hidden window — the
same engine as Ctrl+P — on the paper size read out of the document's own
sectPr. For that to match what the user was looking at, the page's
typography had to stop living in editor.css, which the export window can't
reach: it moves to lib/doc-css.js and both surfaces read the one string. The
result embeds subsetted fonts, keeps images, and turns hyperlinks into real
PDF link annotations.
**The icon** is ours. Microsoft's Word mark is a trademark and borrowing it
to look official is not something a browser that talks about sovereignty
should do. icon.svg says "text document" in its own words — a turned corner,
a heading rule, body lines, a pilcrow badge in Silent Mode green — and
`npm run icons` derives the PNGs and addon.json's copy from it, so there is
one drawing rather than several that drift.
Also: the scratch folder follows the profile rename to extensions-data/ via
the api.dataDir the host now provides, instead of creating a stale
addons-data/ beside it.
2026-09-21 03:35:55 +02:00
|
|
|
|
syncPageRule();
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
wireRibbon();
|
feat(docx-editor): the page fills the window, and two fonts ship with it
The page sat marooned in the middle of a wide window with dark space either
side of it. It is now drawn at the size the document actually claims — A4
stays A4, margins come from its own sectPr — and CSS `zoom` scales that to
fit, defaulting to Fit width with a control in the footer and Ctrl +/-/0.
Scaling rather than widening is deliberate. A page stretched to the window
would break every line somewhere different from where the printed page
breaks it, and an editor whose whole claim is that it shows you the document
should not lie about where the lines end. `zoom` also beats a transform
here: it affects layout, so the board scrolls correctly and ProseMirror's
coordinate maths keeps working.
Ubuntu and Fraunces now ship in fonts/, because Windows has neither and a
font offered in the ribbon that the machine lacks is a font the user picks
and then cannot see. Fetched once by `npm run fonts` and committed, never at
runtime: an extension in a browser built around not phoning home should not
ask a font CDN what a document looks like every time one is opened.
Two things had to be worked around. On file:// Chromium registers @font-face
rules and then refuses to fetch the files — the family appears in
document.fonts and every glyph still renders in the fallback — so the add-on
reads the woff2 and hands the page a stylesheet with them inlined as data
URLs. The PDF export needed the same treatment for a different reason: its
print window runs from a temp folder, where a relative url() resolves to
nothing, which would have quietly undone the one-stylesheet-for-both promise
that lib/doc-css.js exists to keep. If either path fails, the ribbon labels
those families "(not available)" rather than implying otherwise.
About 700 KB, most of it Ubuntu's Cyrillic and Greek — kept because the
documents this is used on are not all English. Licences ship alongside.
2026-09-22 19:12:56 +02:00
|
|
|
|
// After wireRibbon, which is what fills the font menu this may have to
|
|
|
|
|
|
// annotate.
|
|
|
|
|
|
await installFontCss();
|
|
|
|
|
|
|
|
|
|
|
|
// A fit mode is a statement about the window, so it has to be recomputed
|
|
|
|
|
|
// when the window changes.
|
|
|
|
|
|
let resizeTimer = null;
|
|
|
|
|
|
window.addEventListener("resize", () => {
|
|
|
|
|
|
if (zoomMode !== "fit" && zoomMode !== "page") return;
|
|
|
|
|
|
clearTimeout(resizeTimer);
|
|
|
|
|
|
resizeTimer = setTimeout(applyZoom, 60);
|
|
|
|
|
|
});
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
|
|
|
|
|
|
// 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) => {
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
captureActive();
|
|
|
|
|
|
if (openDocs.some((d) => d.dirty)) { e.preventDefault(); e.returnValue = ""; }
|
|
|
|
|
|
try { window.silentmode?.invoke("editorBye", {}); } catch {}
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
});
|
|
|
|
|
|
|
feat(docx-editor): 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.
2026-09-22 08:35:06 +02:00
|
|
|
|
// 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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
// 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) {
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
addDoc({ pmDoc: blankDoc(), name: "Untitled document" });
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
status("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);
|
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.
2026-09-22 08:35:06 +02:00
|
|
|
|
addDoc({ pmDoc: blankDoc(), name: "Untitled document" });
|
feat(docx-editor): edit Word documents without quietly eating what Word put in them
A .docx editor is easy to write badly: read the file into HTML, let someone
edit it, write a fresh document back, and hand them a file that lost its
headers, its page size and half its formatting without ever saying so.
Three things keep this one honest.
The reader doesn't use mammoth's HTML. mammoth's converter is deliberately
semantic, and HTML has nowhere to put a run's colour or a paragraph's line
spacing, so it drops them — and those are controls this editor puts in the
ribbon. Taking its parsed document model instead means what the ribbon offers
is what the file can actually carry. Six properties mammoth's model didn't
keep are added by build-time patches, each asserting its anchor so an upgrade
that moves the code fails the build rather than shipping a lossy reader.
The writer rebuilds the body but carries the rest of the package across:
headers, footers, footnotes, endnotes, the document's own style catalogue,
its theme and its page setup, with relationship ids and content types
re-wired. Word features the editor can't model are still lost, so they are
detected when the file opens and named in a banner before anyone edits.
Tracked changes get their own gate. mammoth renders insertions as ordinary
text and drops deletions, so saving would accept every pending revision
without Word ever asking. Such a document opens read-only until the user
says that is what they want.
Verified over 66 real documents: 65 round-trip with an identical model and a
structurally valid package, the one exception being a 7 MB WMF picture, which
no browser can display and the writer cannot emit. Also driven end to end
through a real Theseus over CDP — sidebar, ribbon, typing, save, reopen.
2026-09-20 20:46:29 +02:00
|
|
|
|
showError(`Couldn't load that document: ${e && e.message || e}`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
boot();
|