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.
243 lines
14 KiB
CSS
243 lines
14 KiB
CSS
/* Word editor — full-tab. Shares the screenshot editor's shell (same
|
|
variables, same topbar + toolbar + footer skeleton) with a ribbon-ish
|
|
toolbar over a continuous document surface.
|
|
|
|
"Ribbon-ish" means grouped button sets with labelled groups, not a real
|
|
ribbon widget: no tabs, no gallery, no contextual tab strip. It reads as
|
|
Office without pretending to be it. */
|
|
:root { color-scheme: light dark;
|
|
--bg:#0e131c; --panel:#141a24; --panel2:#191f2b; --line:rgba(255,255,255,.09);
|
|
--ink:#e7eaf1; --mut:#8b98a9; --dim:#5e6678; --acid:#d6ff3d;
|
|
--danger:#ff5b5b; --warn:#ffb648; --board:#0a0d13;
|
|
--paper:#ffffff; --paper-ink:#14161a; --paper-edge:rgba(0,0,0,.4); }
|
|
@media (prefers-color-scheme: light) {
|
|
:root { --bg:#f8faff; --panel:#ffffff; --panel2:#eff3fb; --line:rgba(0,0,0,.10);
|
|
--ink:#1a1f2b; --mut:#5c6577; --dim:#8a93a5; --acid:#0AC18E;
|
|
--board:#dde3ee; --paper-edge:rgba(0,0,0,.18); }
|
|
}
|
|
* { box-sizing: border-box; }
|
|
html, body { margin: 0; height: 100%; }
|
|
body { background: var(--bg); color: var(--ink);
|
|
font: 13px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
|
display: flex; flex-direction: column; overflow: hidden; }
|
|
[hidden] { display: none !important; }
|
|
|
|
/* ---- top bar --------------------------------------------------------- */
|
|
.topbar { display: flex; align-items: center; gap: 4px; padding: 6px 8px;
|
|
border-bottom: 1px solid var(--line); background: var(--panel);
|
|
user-select: none; flex-wrap: nowrap; }
|
|
.topbar .spacer { flex: 1; }
|
|
.topbar .docname { color: var(--ink); font-size: 12.5px; font-weight: 600;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
max-width: 42vw; margin: 0 6px; }
|
|
.topbar .docname .dirty { color: var(--acid); margin-left: 4px; }
|
|
|
|
/* ---- open-document tabs ---------------------------------------------- */
|
|
/* Deliberately close to the browser's own strip: same shape, same gestures,
|
|
same place for the close button. An editor that invents its own idea of a
|
|
tab makes the user learn a second one for no reason. */
|
|
.doctabs { display: flex; align-items: stretch; gap: 2px; padding: 4px 6px 0;
|
|
background: var(--panel); border-bottom: 1px solid var(--line);
|
|
overflow-x: auto; overflow-y: hidden; scrollbar-width: thin; }
|
|
.doctabs:empty { display: none; }
|
|
.dtab { display: flex; align-items: center; gap: 6px; flex: 0 1 auto;
|
|
max-width: 230px; min-width: 96px; padding: 5px 6px 5px 9px;
|
|
border: 1px solid transparent; border-bottom: 0;
|
|
border-radius: 7px 7px 0 0; background: var(--panel2); color: var(--mut);
|
|
cursor: default; user-select: none; font-size: 12px; white-space: nowrap; }
|
|
.dtab:hover { color: var(--ink); }
|
|
.dtab.active { background: var(--board); color: var(--ink);
|
|
border-color: var(--line); box-shadow: inset 0 2px 0 var(--acid); }
|
|
.dtab .ico { width: 13px; height: 13px; flex: none; display: block; opacity: .9; }
|
|
.dtab .nm { overflow: hidden; text-overflow: ellipsis; flex: 1 1 auto; min-width: 0; }
|
|
.dtab .dot { color: var(--acid); flex: none; font-size: 15px; line-height: 0; }
|
|
.dtab .x, .dtab .caret {
|
|
flex: none; opacity: .55; border: 0; background: transparent; color: inherit;
|
|
cursor: pointer; border-radius: 4px; padding: 1px 3px; font: inherit; line-height: 1;
|
|
}
|
|
.dtab .x:hover { opacity: 1; background: var(--line); color: var(--danger); }
|
|
.dtab .caret:hover { opacity: 1; background: var(--line); }
|
|
.dtab .caret { display: none; }
|
|
.dtab.active .caret, .dtab:hover .caret { display: block; }
|
|
.dtab-add { flex: none; align-self: center; border: 0; background: transparent;
|
|
color: var(--mut); cursor: pointer; font-size: 16px; line-height: 1;
|
|
padding: 3px 8px; border-radius: 6px; margin-left: 2px; }
|
|
.dtab-add:hover { background: var(--panel2); color: var(--ink); }
|
|
|
|
/* The menu a document tab drops. Plain DOM rather than a native popup: it
|
|
belongs to the page, so it can carry per-document state. */
|
|
.dmenu { position: fixed; z-index: 70; min-width: 190px; padding: 4px;
|
|
background: var(--panel); border: 1px solid var(--line); border-radius: 8px;
|
|
box-shadow: 0 12px 30px rgba(0,0,0,.42); font-size: 12.5px; }
|
|
.dmenu button { display: flex; width: 100%; gap: 8px; align-items: center;
|
|
border: 0; background: transparent; color: var(--ink); cursor: pointer;
|
|
padding: 7px 10px; border-radius: 6px; font: inherit; text-align: left; }
|
|
.dmenu button:hover { background: var(--panel2); }
|
|
.dmenu button.danger { color: var(--danger); }
|
|
.dmenu button:disabled { opacity: .4; cursor: default; background: transparent; }
|
|
.dmenu .sep { height: 1px; background: var(--line); margin: 4px 2px; }
|
|
|
|
/* ---- ribbon ---------------------------------------------------------- */
|
|
.ribbon { display: flex; align-items: stretch; gap: 0;
|
|
padding: 4px 6px 2px; border-bottom: 1px solid var(--line);
|
|
background: linear-gradient(180deg, var(--panel), var(--panel2));
|
|
user-select: none; overflow-x: auto; overflow-y: hidden; }
|
|
.rgroup { display: flex; flex-direction: column; align-items: center;
|
|
gap: 3px; padding: 0 8px; flex: 0 0 auto;
|
|
border-right: 1px solid var(--line); }
|
|
.rgroup:last-child { border-right: 0; }
|
|
.rgroup .rrow { display: flex; align-items: center; gap: 3px; flex-wrap: nowrap; }
|
|
.rgroup .rlabel { font-size: 10px; color: var(--dim); letter-spacing: .02em;
|
|
text-transform: lowercase; }
|
|
.rgroup[data-contextual] { opacity: .45; pointer-events: none; }
|
|
.rgroup[data-contextual="on"] { opacity: 1; pointer-events: auto; }
|
|
|
|
.btn {
|
|
border: 1px solid transparent; background: transparent; color: var(--ink);
|
|
min-width: 26px; height: 26px; border-radius: 5px; cursor: pointer;
|
|
display: inline-flex; align-items: center; justify-content: center;
|
|
padding: 0 4px; font: inherit; line-height: 0;
|
|
transition: background 90ms, border-color 90ms;
|
|
}
|
|
.btn:hover:not(:disabled) { background: var(--panel2); border-color: var(--line); }
|
|
.btn.on { background: rgb(from var(--acid) r g b / .16);
|
|
border-color: rgb(from var(--acid) r g b / .55); color: var(--acid); }
|
|
.btn:disabled { opacity: .35; cursor: default; }
|
|
.btn svg { width: 15px; height: 15px; display: block; }
|
|
.btn.wide { min-width: auto; padding: 0 8px; gap: 5px; line-height: 1; }
|
|
.btn.wide span { font-size: 12px; }
|
|
.btn.primary { background: var(--acid); color: #101418; border-color: transparent; font-weight: 600; }
|
|
.btn.primary:hover:not(:disabled) { filter: brightness(1.06); background: var(--acid); }
|
|
.btn.danger:hover:not(:disabled) { border-color: rgba(255,91,91,.55); color: var(--danger); }
|
|
.btn .caret { width: 8px; height: 8px; opacity: .6; }
|
|
|
|
select.rsel, input.rnum {
|
|
height: 26px; border-radius: 5px; border: 1px solid var(--line);
|
|
background: var(--panel2); color: var(--ink); font: inherit; font-size: 12px;
|
|
padding: 0 4px; cursor: pointer; max-width: 150px;
|
|
}
|
|
select.rsel:focus, input.rnum:focus { outline: 1px solid rgb(from var(--acid) r g b / .5); }
|
|
input.rnum { width: 52px; cursor: text; text-align: center; }
|
|
.swatch-btn { position: relative; }
|
|
.swatch-btn .bar { position: absolute; left: 4px; right: 4px; bottom: 3px;
|
|
height: 3px; border-radius: 1px; background: #c00; }
|
|
|
|
/* colour / highlight popovers */
|
|
.pop { position: absolute; z-index: 60; background: var(--panel);
|
|
border: 1px solid var(--line); border-radius: 8px; padding: 8px;
|
|
box-shadow: 0 10px 28px rgba(0,0,0,.35); }
|
|
.pop .grid { display: grid; grid-template-columns: repeat(8, 20px); gap: 4px; }
|
|
.pop .chip { width: 20px; height: 20px; border-radius: 4px; cursor: pointer;
|
|
border: 1px solid var(--line); padding: 0; }
|
|
.pop .chip:hover { outline: 2px solid rgb(from var(--acid) r g b / .6); }
|
|
.pop .prow { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
|
|
.pop .prow .btn { height: 24px; }
|
|
|
|
/* ---- banners --------------------------------------------------------- */
|
|
.banners { background: var(--board); }
|
|
.banner { display: flex; align-items: flex-start; gap: 10px;
|
|
padding: 9px 14px; font-size: 12.5px; line-height: 1.45;
|
|
border-bottom: 1px solid var(--line); background: var(--panel); }
|
|
.banner .ico { flex: 0 0 auto; font-size: 14px; line-height: 1.3; }
|
|
.banner .body { flex: 1 1 auto; min-width: 0; }
|
|
.banner .body b { font-weight: 600; }
|
|
.banner .acts { flex: 0 0 auto; display: flex; gap: 6px; }
|
|
.banner.warn { border-left: 3px solid var(--warn); }
|
|
.banner.block { border-left: 3px solid var(--danger); }
|
|
.banner.info { border-left: 3px solid var(--acid); }
|
|
.banner .btn { border-color: var(--line); background: var(--panel2); }
|
|
|
|
/* ---- document surface ------------------------------------------------ */
|
|
.board { flex: 1; overflow: auto; background: var(--board); padding: 20px 16px 60px; }
|
|
.sheet { max-width: 8.27in; margin: 0 auto; background: var(--paper);
|
|
color: var(--paper-ink); box-shadow: 0 2px 18px var(--paper-edge);
|
|
padding: 0.9in 1in; min-height: 60vh; }
|
|
.sheet:focus { outline: none; }
|
|
.sheet .ProseMirror { outline: none; min-height: 50vh; }
|
|
|
|
/* The document's own typography lives in lib/doc-css.js, injected at boot.
|
|
It is shared verbatim with the PDF export, which renders in a throwaway
|
|
window that can't reach this stylesheet — one copy means the PDF can't
|
|
drift away from what the editor showed. Only the "paper on a desk"
|
|
framing stays here. */
|
|
|
|
|
|
/* prosemirror-tables' own furniture */
|
|
.sheet .selectedCell:after {
|
|
content: ""; position: absolute; inset: 0; background: rgba(100,150,255,.25);
|
|
pointer-events: none; z-index: 2;
|
|
}
|
|
.sheet .column-resize-handle {
|
|
position: absolute; right: -2px; top: 0; bottom: 0; width: 4px;
|
|
background: #6ba0ff; pointer-events: none; z-index: 3;
|
|
}
|
|
.resize-cursor { cursor: col-resize; }
|
|
.ProseMirror-gapcursor { display: none; pointer-events: none; position: absolute; }
|
|
.ProseMirror-gapcursor:after {
|
|
content: ""; display: block; position: absolute; top: -2px;
|
|
width: 20px; border-top: 1px solid var(--paper-ink); animation: pm-blink 1.1s steps(2, start) infinite;
|
|
}
|
|
.ProseMirror-focused .ProseMirror-gapcursor { display: block; }
|
|
@keyframes pm-blink { to { visibility: hidden; } }
|
|
.ProseMirror-selectednode { outline: 2px solid #6ba0ff; }
|
|
|
|
.empty-state { color: var(--dim); text-align: center; padding: 60px 20px; }
|
|
|
|
/* ---- footer ---------------------------------------------------------- */
|
|
.footer { display: flex; align-items: center; gap: 14px;
|
|
padding: 5px 12px; border-top: 1px solid var(--line);
|
|
background: var(--panel); font-size: 11.5px; color: var(--dim); }
|
|
.footer .stat { flex: 0 0 auto; white-space: nowrap; }
|
|
.footer .msg { flex: 1 1 auto; min-width: 0; overflow: hidden;
|
|
text-overflow: ellipsis; white-space: nowrap; text-align: right; }
|
|
.footer .msg.err { color: var(--danger); }
|
|
.footer .msg.ok { color: var(--acid); }
|
|
.footer .fbtn { border: 1px solid var(--line); background: var(--panel2);
|
|
color: var(--ink); border-radius: 5px; cursor: pointer;
|
|
padding: 3px 7px; font: inherit; font-size: 11px; flex: 0 0 auto; }
|
|
.footer .fbtn:hover { border-color: rgb(from var(--acid) r g b / .55); }
|
|
|
|
/* ---- dialogs --------------------------------------------------------- */
|
|
.scrim { position: fixed; inset: 0; background: rgba(4,7,12,.6); z-index: 80;
|
|
display: flex; align-items: center; justify-content: center; padding: 24px; }
|
|
.dialog { background: var(--panel); border: 1px solid var(--line); border-radius: 10px;
|
|
box-shadow: 0 18px 50px rgba(0,0,0,.45); width: min(560px, 100%);
|
|
max-height: 80vh; display: flex; flex-direction: column; }
|
|
.dialog h2 { margin: 0; padding: 14px 18px 10px; font-size: 14.5px; font-weight: 600; }
|
|
.dialog .dbody { padding: 0 18px 4px; overflow: auto; font-size: 12.5px; line-height: 1.55; color: var(--mut); }
|
|
.dialog .dbody h3 { font-size: 12px; text-transform: uppercase; letter-spacing: .04em;
|
|
color: var(--dim); margin: 14px 0 6px; font-weight: 600; }
|
|
.dialog .dbody ul { margin: 0 0 8px; padding-left: 18px; }
|
|
.dialog .dbody li { margin: 2px 0; }
|
|
.dialog .dbody code { font-family: Consolas, monospace; font-size: 11.5px; color: var(--ink); }
|
|
.dialog .dfoot { display: flex; gap: 8px; justify-content: flex-end;
|
|
padding: 12px 18px 14px; border-top: 1px solid var(--line); margin-top: 10px; }
|
|
.dialog .field { display: flex; flex-direction: column; gap: 4px; margin: 10px 0; }
|
|
.dialog .field label { font-size: 11.5px; color: var(--dim); }
|
|
.dialog .field input {
|
|
height: 30px; border-radius: 6px; border: 1px solid var(--line);
|
|
background: var(--panel2); color: var(--ink); font: inherit; padding: 0 8px;
|
|
}
|
|
.dialog .field input:focus { outline: 1px solid rgb(from var(--acid) r g b / .5); }
|
|
.dialog .row { display: flex; gap: 12px; }
|
|
.dialog .row .field { flex: 1; }
|
|
.pill { display: inline-block; font-size: 10.5px; padding: 1px 6px; border-radius: 99px;
|
|
border: 1px solid var(--line); margin-right: 6px; }
|
|
.pill.keep { color: var(--acid); border-color: rgb(from var(--acid) r g b / .45); }
|
|
.pill.lose { color: var(--warn); border-color: rgba(255,182,72,.45); }
|
|
.pill.stop { color: var(--danger); border-color: rgba(255,91,91,.45); }
|
|
|
|
/* ---- drop target ----------------------------------------------------- */
|
|
.dropzone { position: fixed; inset: 0; z-index: 90; background: rgba(10,14,20,.82);
|
|
display: flex; align-items: center; justify-content: center;
|
|
border: 3px dashed var(--acid); font-size: 16px; color: var(--ink); }
|
|
|
|
/* ---- print ----------------------------------------------------------- */
|
|
@media print {
|
|
.topbar, .ribbon, .footer, .banners, .pop, .scrim, .dropzone { display: none !important; }
|
|
html, body { height: auto; overflow: visible; background: #fff; }
|
|
.board { overflow: visible; padding: 0; background: #fff; }
|
|
.sheet { box-shadow: none; max-width: none; margin: 0; padding: 0; background: #fff; color: #000; }
|
|
/* Page breaks and widow control come from lib/doc-css.js, which carries
|
|
its own @media print block so Ctrl+P and the PDF export agree. */
|
|
}
|