feat: each tab edits its own document

Every document-scoped key now goes through docKey(), which hangs it off the
tab's document id; the first document keeps the empty id, so existing browser
copies read back unchanged. A fresh tab takes up the most recently used
document no live tab holds, else starts a new one — one signed timestamp per
document (edentext-live@id) says which, written only by the tab holding it.
Snapshots and embedded fonts get a database per document. The picture store
stays shared and content-addressed, so its sweep now drops only keys no stored
document names any more, instead of the open document's own.
This commit is contained in:
Steffen Becker committed 2026-09-07 23:50:11 +02:00
1 parent dc843db78f
commit 554f0f6674
31 files changed
+332 -42

No files matched your search

+2 -1
View File
@@ -19,6 +19,7 @@ npm run test:smoke # boots the dist/ build headless (tests/smoke/run.mjs); BRO
npm run test:dom # pagination + editing in the real browser (tests/dom/run.mjs); same BROWSER
npm run test:layout # page counts vs LibreOffice, layout invariants, page starts vs baseline.json (tests/layout/)
npm run test:monkey # random editing, body + header/footer, under invariants: schema, undo/redo, the saved file reads back
npm run test:tabs # two tabs, two documents: each keeps its own across reloads (tests/tabs/)
npm run test:coverage # vitest + v8 coverage over src/ → coverage/index.html
npm run test:parity # render parity vs LibreOffice (tests/render-parity/README.md)
node scripts/make-thesaurus.mjs; node scripts/collect-licenses.mjs # re-vendor public/thesaurus/ (MyThes); regenerate public/licenses.txt
@@ -31,7 +32,7 @@ Tests live in `tests/` (outside `src/`, so `svelte-check` ignores them), jsdom v
`corpus.test.ts` round-trips the committed `tests/corpus/` documents (by `make-fixtures.mjs` + Word re-saves in `corpus/word/`, never our exporter);
`fuzz-roundtrip.test.ts` round-trips seeded random documents under random export options (`fuzzDoc.ts`, `fuzzOptions.ts`) through both formats and validates every export against the schemas (`schemaValidate.ts`; `FUZZ_SEEDS=500` widens it);
`schema-validation.test.ts` validates the `kitchenSink.ts` exports against the vendored schemas in `tests/schemas/` (self-skips without `xmllint`); `package-lint.test.ts` checks the semantic invariants schemas can't express (unique ids, dangling references); `tests/unit/` holds fast helper tests;
the four browser runs (`tests/smoke/`, `tests/dom/`, `tests/layout/`, `tests/monkey/`) share `tests/browser.mjs`. All test tooling stays a `devDependency`. No linter/formatter. CI (`.github/workflows/ci.yml`) runs `check` + `test`.
the five browser runs (`tests/smoke/`, `tests/dom/`, `tests/layout/`, `tests/monkey/`, `tests/tabs/`) share `tests/browser.mjs`. All test tooling stays a `devDependency`. No linter/formatter. CI (`.github/workflows/ci.yml`) runs `check` + `test`.
## Rules
+1
View File
@@ -22,6 +22,7 @@
"test:dom": "node tests/dom/run.mjs",
"test:layout": "node tests/layout/run.mjs",
"test:monkey": "node tests/monkey/run.mjs",
"test:tabs": "node tests/tabs/run.mjs",
"test:coverage": "vitest run --coverage",
"test:parity": "node tests/render-parity/run.mjs",
"test:parity:fixtures": "node tests/render-parity/make-fixtures.mjs"
+16 -2
View File
@@ -38,8 +38,22 @@ Both margins and orientation are passed into the ODT export so the exported docu
All keys share the `edentext-` prefix — a namespace, not a description: on GitHub Pages every repo of an account shares one origin, so a generic prefix would collide.
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`); hiding the page (`visibilitychange`/`pagehide`) writes what is pending at once, and writes queue behind each other so a slow picture round trip never stores an older document over a newer one. Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor, or a reload cut its start short: the user is asked whether to try again; declined, it moves to `edentext-doc-broken` and the kept versions are offered before the app starts empty, so a freeze is escaped instead of repeated. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:<key>` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, and each save sweeps the keys the document no longer names. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
- **Earlier versions:** the `edentext-snapshots` IndexedDB store (`snapshots.ts`) — up to three earlier autosaves, one taken at most every five minutes, keyed by their timestamp. They hold the document **whole**, pictures inline: the image store is swept against the open document, so an older version could not point into it. **Nothing in the UI points at them**: they are offered only where the browser copy has failed the reader — `loadDocument`'s recovery question, newest first, one confirm each (the newest may be the one that broke). A version taken up starts under the boot flag like any other document, so one that freezes the editor again brings the question back.
**Document scope (`docScope.ts`).** Every key below that belongs to the *document* is
written through `docKey()`, which appends `@<id>` — that is how two tabs edit two
documents. The **first document keeps the empty id**, so its keys read exactly as this
list spells them and no earlier version needs migrating. The id comes out of
`sessionStorage` (per tab, survives its reloads) or, for a fresh tab, from the most
recently used document that no live tab holds; `edentext-live@<id>` is that marker —
epoch ms while held, negated on `pagehide`, refreshed every 60 s and abandoned after 10.
Only the holding tab ever writes its own marker, so no two tabs race over one key.
`pruneOldDocuments()` (from `main.ts`) drops all but the newest ten, never one a tab
still holds and never the first. Duplicating a tab copies its `sessionStorage`, so the
copy lands on the same document — the one case still shared. The app's own keys (theme,
zoom, chrome, AutoCorrect, AutoText, word completion, recent files/fonts/chars, markup
view, user dictionary, number recognition) stay unscoped.
- **Document:** `edentext-doc` — TipTap JSON, debounced 1 s on every `onUpdate` (`autosave.ts`); hiding the page (`visibilitychange`/`pagehide`) writes what is pending at once, and writes queue behind each other so a slow picture round trip never stores an older document over a newer one. Loading it raises `edentext-doc-loading`, which only a completed startup clears (`markDocumentLoaded`, two frames after `Editor.svelte`'s mount). Still set at the next load = that document hung or crashed the editor, or a reload cut its start short: the user is asked whether to try again; declined, it moves to `edentext-doc-broken` and the kept versions are offered before the app starts empty, so a freeze is escaped instead of repeated. **Pictures do not live in it:** `imageStore.ts` swaps every data-URI past 4 KB for an `idb:<key>` before the write and keeps the bytes in the `edentext-images` IndexedDB store (a handful of photos is the whole localStorage quota). The key is a content hash of the data-URI's head, tail and length — sampled, because this runs on every autosave — so a picture used twice is stored once, across documents too — which is why the store is the one thing **not** scoped per document. Each save sweeps the keys no stored document names any more, found by scanning localStorage for `idb:` references rather than by a per-document list, which would go stale for every document no tab has opened since. Where IndexedDB is unavailable the data-URIs stay inline, exactly as before; a key the store has lost keeps its `idb:` src and is reported, rather than quietly dropping the reader's content. `loadDocument` is therefore **async**, and `Editor.svelte` awaits it before building the editor.
- **Earlier versions:** the `edentext-snapshots` IndexedDB database (`snapshots.ts`, one per document via `docKey`, as `edentext-fonts` is) — up to three earlier autosaves, one taken at most every five minutes, keyed by their timestamp. They hold the document **whole**, pictures inline: the image store is swept against the stored documents, and a snapshot is none of them. **Nothing in the UI points at them**: they are offered only where the browser copy has failed the reader — `loadDocument`'s recovery question, newest first, one confirm each (the newest may be the one that broke). A version taken up starts under the boot flag like any other document, so one that freezes the editor again brings the question back.
- **Recent files:** `edentext-recent-files` — `{id, name, at}` per entry, newest first, eight kept (`recentFiles.ts`). Only the label lives here: the `FileSystemFileHandle` that reopens one is structured-cloneable but not JSON, so it sits in the `edentext-recent` IndexedDB store under the same id, and each write sweeps the handles no entry names. Two handles compare only through `isSameEntry`, which is async — hence the walk when recording a file already in the list. A file without a handle is never recorded (nothing could reopen it — no File System Access API, or a template), so the list simply stays empty in Firefox/Safari/Brave; a startup prune drops entries whose handle is gone. Reopening asks for `read` permission again (the API drops it on reload) — a declined prompt keeps the entry for the next try, only a moved or deleted file drops it (with a message). Save re-prompts for write on its own (Save-As fallback in `App.svelte`).
- **Document protected:** `edentext-doc-protected` — `'1'` while the open document is password-protected; the password itself is never stored, so after a reload the first save asks for it again or lets the protection go (`documentName.ts`).
- **Document format:** `edentext-doc-format` — `'docx'` while the open document round-trips as .docx; absent = .odt (`documentName.ts`). Set by the format a file was opened in, and by the extension picked in Save As.
+4 -3
View File
@@ -1,15 +1,16 @@
import { t, locale } from '../i18n/i18n.svelte';
import { stashImages, putImages, restoreImages } from './imageStore';
import { keepSnapshot, listSnapshots, readSnapshot } from './snapshots';
import { docKey } from './docScope';
const STORAGE_KEY = 'edentext-doc';
const STORAGE_KEY = docKey('edentext-doc');
// Set while a stored document is being handed to the editor, cleared once the editor
// has actually come up (markDocumentLoaded). Finding it still set at load time means
// the last attempt hung or threw — reloading would only freeze again.
const BOOT_KEY = 'edentext-doc-loading';
const BOOT_KEY = docKey('edentext-doc-loading');
// Where such a document is parked instead of being loaded, so nothing is lost and
// it can still be pulled out of localStorage.
const BROKEN_KEY = 'edentext-doc-broken';
const BROKEN_KEY = docKey('edentext-doc-broken');
const DEBOUNCE_MS = 1000;
let timeout: ReturnType<typeof setTimeout> | null = null;
+3 -1
View File
@@ -1,3 +1,5 @@
import { docKey } from './docScope';
// The document's descriptive metadata — LibreOffice's File ▸ Properties, Word's
// File ▸ Info. Written to ODF meta.xml and DOCX docProps/core.xml, read back on import.
@@ -12,7 +14,7 @@ export type DocProperties = {
description: string;
};
const KEY = 'edentext-doc-properties';
const KEY = docKey('edentext-doc-properties');
export const EMPTY_DOC_PROPERTIES: DocProperties = {
title: '', subject: '', author: '', keywords: '', description: '',
+109
View File
@@ -0,0 +1,109 @@
// Which document this tab edits. Every document-scoped key hangs off the id, so two
// tabs never write over each other; the first document keeps the empty id, and with it
// the bare key names every earlier version wrote.
// Duplicating a tab copies its sessionStorage, so the copy lands on the same document.
// ponytail: telling a duplicate from a reload needs the navigation type — worth
// measuring per browser before anything relies on it.
const TAB_KEY = 'edentext-tab-doc';
// One marker per document: epoch ms while a tab holds it, the same stamp negated once
// the tab lets go. Only ever written by the tab that holds it, so no two tabs race over
// one key, and the stamp doubles as "when was this document last used".
const LIVE = 'edentext-live@';
// A hidden tab's timers are throttled to about a minute, so a marker is only abandoned
// well past that.
const BEAT_MS = 60_000;
const STALE_MS = 10 * 60_000;
const MAX_DOCS = 10;
function markers(): Record<string, number> {
const out: Record<string, number> = {};
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (!key?.startsWith(LIVE)) continue;
const at = Number(localStorage.getItem(key));
if (at) out[key.slice(LIVE.length)] = at;
}
return out;
}
/**
* The document a fresh tab opens: the one used last that no live tab holds, else a new
* one. The empty id is the first document — a browser that has never run this editor
* has no marker at all, and so does one that ran every earlier version.
*/
export function pickSlot(marks: Record<string, number>, now: number): string {
const free = Object.entries(marks)
.filter(([, at]) => at < 0 || now - at > STALE_MS)
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))[0];
if (free) return free[0];
if (!Object.keys(marks).length) return '';
return `d${now.toString(36)}${Math.floor(Math.random() * 1e4).toString(36)}`;
}
function mark(id: string, at: number): void {
try {
localStorage.setItem(LIVE + id, String(at));
} catch { /* a full localStorage costs the marker, not the document */ }
}
function resolve(): string {
try {
// The tab's own slot, unconditionally: after a reload — or a crash — it has to get
// its document back, and nothing but this tab knows which one that was.
const mine = sessionStorage.getItem(TAB_KEY);
const id = mine ?? pickSlot(markers(), Date.now());
if (mine === null) sessionStorage.setItem(TAB_KEY, id);
// Claimed here rather than from main.ts: the storage modules read their keys while
// the import graph is walked, long before the first statement of main.ts runs.
mark(id, Date.now());
return id;
} catch {
return '';
}
}
export const docId = resolve();
/** The key this tab's document keeps `name` under. */
export function docKey(name: string): string {
return docId === '' ? name : `${name}@${docId}`;
}
/** Keep this tab's marker fresh, and sign it off when the tab goes away. */
export function startTabPresence(): void {
setInterval(() => mark(docId, Date.now()), BEAT_MS);
addEventListener('pagehide', () => mark(docId, -Date.now()));
// A page revived from the back/forward cache is holding its document again.
addEventListener('pageshow', () => mark(docId, Date.now()));
}
function dropDocument(id: string): void {
const suffix = `@${id}`;
const keys: string[] = [];
for (let i = 0; i < localStorage.length; i++) {
const key = localStorage.key(i);
if (key?.endsWith(suffix)) keys.push(key);
}
for (const key of keys) localStorage.removeItem(key);
// Its pictures are swept the next time any document saves — the store is shared.
if (typeof indexedDB === 'undefined') return;
indexedDB.deleteDatabase(`edentext-snapshots${suffix}`);
indexedDB.deleteDatabase(`edentext-fonts${suffix}`);
}
/**
* Keep the newest MAX_DOCS documents. A document a tab still holds is never dropped,
* however old it is, and neither is the first one: its keys carry no id to sweep by.
*/
export function pruneOldDocuments(): void {
const now = Date.now();
const old = Object.entries(markers())
.sort((a, b) => Math.abs(b[1]) - Math.abs(a[1]))
.slice(MAX_DOCS);
for (const [id, at] of old) {
if (id === '' || (at > 0 && now - at <= STALE_MS)) continue;
dropDocument(id);
localStorage.removeItem(LIVE + id);
}
}
+2 -1
View File
@@ -3,6 +3,7 @@
// the default paragraph style). 'none' disables checking.
import { resolveBrowserLocale } from '../i18n/config';
import { docKey } from './docScope';
export const NO_LANGUAGE = 'none';
@@ -22,7 +23,7 @@ export const LANGUAGES: LanguageDef[] = [
{ code: 'de', label: 'Deutsch', odf: { language: 'de', country: 'DE' } },
];
const KEY = 'edentext-doc-language';
const KEY = docKey('edentext-doc-language');
export function findLanguage(code: DocumentLanguage): LanguageDef | undefined {
return LANGUAGES.find((l) => l.code === code);
+5 -3
View File
@@ -1,7 +1,9 @@
import { docKey } from './docScope';
// The user-visible document name (without the .odt extension). Drives the
// suggested filename on save; empty falls back to the heading-derived name.
const KEY = 'edentext-doc-name';
const KEY = docKey('edentext-doc-name');
export function loadDocName(): string {
return localStorage.getItem(KEY) ?? '';
@@ -39,7 +41,7 @@ export type DocumentFormat = 'odt' | 'docx';
// The format the open document round-trips in. Absent at the .odt default, so only a
// document that came in as .docx writes anything.
const FORMAT_KEY = 'edentext-doc-format';
const FORMAT_KEY = docKey('edentext-doc-format');
export function loadDocFormat(): DocumentFormat {
return localStorage.getItem(FORMAT_KEY) === 'docx' ? 'docx' : 'odt';
@@ -52,7 +54,7 @@ export function saveDocFormat(format: DocumentFormat): void {
// Whether the open document is password-protected. The password itself is never
// stored, so after a reload this is what makes the first save ask for it again.
const PROTECTED_KEY = 'edentext-doc-protected';
const PROTECTED_KEY = docKey('edentext-doc-protected');
export function loadDocProtected(): boolean {
return localStorage.getItem(PROTECTED_KEY) === '1';
+2 -1
View File
@@ -1,10 +1,11 @@
import type { EmbeddedFont } from '../fonts/embeddedFonts';
import { openDb, idbRequest } from './idb';
import { docKey } from './docScope';
// The current document's embedded fonts, persisted in IndexedDB (not localStorage, whose
// ~5 MB quota is already tight for images). One record, replaced on each open, so storage
// stays bounded to the open document; on app start it is re-registered via FontFace.
const DB_NAME = 'edentext-fonts';
const DB_NAME = docKey('edentext-fonts');
const STORE = 'fonts';
const KEY = 'current';
+3 -1
View File
@@ -1,8 +1,10 @@
import { docKey } from './docScope';
// Fold and punch marks in the left margin (DIN 5008 letter sheets). Off by default —
// neither word processor prints any — so a fresh document writes nothing. They ride the
// master page's header on export, like the watermark, as named <draw:line>/VML lines.
const KEY = 'edentext-fold-marks';
const KEY = docKey('edentext-fold-marks');
/** Distances from the top paper edge, in mm (DIN 5008: fold marks and punch mark). */
export const FOLD_MARK_MM = [87, 192];
+7 -6
View File
@@ -6,6 +6,7 @@ import type { PageMargins } from './pageMargins';
import type { PageFormat } from './pageFormat';
import type { Orientation } from './pageOrientation';
import type { NoteNumFormat } from './noteSettings';
import { docKey } from './docScope';
export type HfZone = 'header' | 'footer';
export type HfVariant = 'default' | 'first' | 'even';
@@ -73,7 +74,7 @@ export function hfSetIsEmpty(s: HfSet): boolean {
// Sections past the first, in order. Persisted whole rather than per zone like
// section 1's, whose six docs each have their own key.
const EXTRA_KEY = 'edentext-hf-sections';
const EXTRA_KEY = docKey('edentext-hf-sections');
export function loadExtraHfSections(): HfSet[] {
const raw = localStorage.getItem(EXTRA_KEY);
@@ -92,14 +93,14 @@ export function saveExtraHfSections(sections: HfSet[]): void {
}
const KEYS: Record<HfZone, Record<HfVariant, string>> = {
header: { default: 'edentext-header', first: 'edentext-header-first', even: 'edentext-header-even' },
footer: { default: 'edentext-footer', first: 'edentext-footer-first', even: 'edentext-footer-even' },
header: { default: docKey('edentext-header'), first: docKey('edentext-header-first'), even: docKey('edentext-header-even') },
footer: { default: docKey('edentext-footer'), first: docKey('edentext-footer-first'), even: docKey('edentext-footer-even') },
};
// Whether page 1 uses its own header/footer (Word w:titlePg / ODF header-first).
const DIFFERENT_FIRST_KEY = 'edentext-hf-different-first';
const DIFFERENT_FIRST_KEY = docKey('edentext-hf-different-first');
// Whether even pages use their own header/footer (Word w:evenAndOddHeaders / ODF header-left).
const DIFFERENT_ODD_EVEN_KEY = 'edentext-hf-odd-even';
const DIFFERENT_ODD_EVEN_KEY = docKey('edentext-hf-odd-even');
export function loadDifferentFirstPage(): boolean {
return localStorage.getItem(DIFFERENT_FIRST_KEY) === 'true';
@@ -130,7 +131,7 @@ export type HfDistances = { header: number; footer: number };
export const DEFAULT_HF_DISTANCES: HfDistances = { header: HF_DISTANCE_CM, footer: HF_DISTANCE_CM };
const DIST_KEY = 'edentext-hf-distances';
const DIST_KEY = docKey('edentext-hf-distances');
const DIST_MIN = 0;
const DIST_MAX = 10;
+3 -1
View File
@@ -1,9 +1,11 @@
import { docKey } from './docScope';
// Automatic hyphenation for the whole document: LibreOffice's Format ▸ Paragraph ▸
// Text Flow on the default paragraph style, Word's Layout ▸ Hyphenation. Off in both,
// so off here. ODF carries it as `fo:hyphenate` on that style, Word as
// `w:autoHyphenation` in settings.xml.
const KEY = 'edentext-hyphenation';
const KEY = docKey('edentext-hyphenation');
export const DEFAULT_HYPHENATION = false;
+16 -2
View File
@@ -56,7 +56,19 @@ export function stashImages(json: object): { json: object; blobs: Map<string, st
// picture was just deleted would skip the sweep below and leave its bytes behind.
let inUse = false;
/** Write the pictures and drop every key the document no longer references. */
// The store is shared across this browser's documents, so a key may only go when no
// stored document names it any more. A list per document would go stale for every one
// no tab has opened since; ponytail: a regex over localStorage on each debounced save.
function referenced(): Set<string> {
const out = new Set<string>();
for (let i = 0; i < localStorage.length; i++) {
const raw = localStorage.getItem(localStorage.key(i)!) ?? '';
for (const [, key] of raw.matchAll(/idb:([a-z0-9]+)/g)) out.add(key);
}
return out;
}
/** Write the pictures and drop every key no document references any more. */
export async function putImages(blobs: Map<string, string>): Promise<boolean> {
if (!blobs.size && !inUse) return true;
if (typeof indexedDB === 'undefined') return false;
@@ -67,8 +79,10 @@ export async function putImages(blobs: Map<string, string>): Promise<boolean> {
for (const [key, src] of blobs) {
if (!known.includes(key)) await idbRequest(db, STORE, 'readwrite', (s) => s.put(src, key));
}
// Not from the stored copy yet, so the sweeper's own pictures come from `blobs`.
const live = referenced();
for (const key of known) {
if (!blobs.has(String(key))) await idbRequest(db, STORE, 'readwrite', (s) => s.delete(key));
if (!blobs.has(String(key)) && !live.has(String(key))) await idbRequest(db, STORE, 'readwrite', (s) => s.delete(key));
}
db.close();
return true;
+3 -1
View File
@@ -1,9 +1,11 @@
import { docKey } from './docScope';
// LibreOffice's Tools ▸ Line Numbering / Word's Layout ▸ Line Numbers. Off by default in
// both, so a fresh document writes nothing and neither importer stamps it on a file that
// declares none. ODF keeps it as one <text:linenumbering-configuration> in office:styles,
// Word as <w:lnNumType> in every w:sectPr (probed).
const KEY = 'edentext-line-numbering';
const KEY = docKey('edentext-line-numbering');
export type LineNumbering = {
on: boolean;
+2 -1
View File
@@ -1,6 +1,7 @@
import type { OrderedTypeDef } from '../utils/orderedListTypes';
import { docKey } from './docScope';
const KEY = 'edentext-notes';
const KEY = docKey('edentext-notes');
// How footnotes and endnotes are numbered and where they sit. Every default here was
// read out of a document LibreOffice saved (docs/architecture/notes.md) — an
+3 -1
View File
@@ -1,10 +1,12 @@
import { docKey } from './docScope';
// The page's own decoration: LibreOffice's Format ▸ Page Style ▸ Area and Borders, and
// its Format ▸ Watermark. All three are page-level and all three ride the page layout
// (the watermark rides the master page's header, where both word processors keep it),
// so they share one key. Every field is absent by default — a fresh document decorates
// nothing, and neither importer stamps a decoration on a file that has none.
const KEY = 'edentext-page-decor';
const KEY = docKey('edentext-page-decor');
// A page border is one rule around the whole page, as both word processors draw it: one
// width and colour for all four sides, offset from the text by `paddingCm`.
+2 -1
View File
@@ -1,5 +1,6 @@
import { PX_PER_CM } from './pageMargins';
import type { Orientation } from './pageOrientation';
import { docKey } from './docScope';
export type PageFormat =
| 'A3' | 'A4' | 'A5' | 'A6'
@@ -7,7 +8,7 @@ export type PageFormat =
| 'jisB4' | 'jisB5'
| 'letter' | 'legal' | 'tabloid' | 'executive' | 'folio' | 'statement';
const KEY = 'edentext-page-format';
const KEY = docKey('edentext-page-format');
// Portrait page dimensions in cm per format (width < height). Landscape swaps them.
// Grouped A / ISO-B / JIS-B / US, matching the Word/LibreOffice paper menus.
+3 -1
View File
@@ -1,3 +1,5 @@
import { docKey } from './docScope';
// cm. `mirrored` = ODF style:page-usage="mirrored" / Word's mirror margins: left and
// right are the inner/outer pair, and an even (left-hand) page swaps them.
export type PageMargins = {
@@ -19,7 +21,7 @@ export function marginAxisLabel(axis: MarginAxis, m: PageMargins): MarginAxis |
return axis === 'left' ? 'inner' : 'outer';
}
const KEY = 'edentext-page-margins';
const KEY = docKey('edentext-page-margins');
// LibreOffice Writer's default page margins (Word uses 2.54cm all round). Only a new
// document gets these — an imported one always adopts its own page geometry.
+2 -1
View File
@@ -1,4 +1,5 @@
import type { NoteNumFormat } from './noteSettings';
import { docKey } from './docScope';
// How the page-number field counts: Word's Insert ▸ Page Number ▸ Format Page Numbers,
// LibreOffice's page style ▸ Layout Settings. ODF keeps the format on the page layout
@@ -7,7 +8,7 @@ import type { NoteNumFormat } from './noteSettings';
export type PageNumbering = { format: NoteNumFormat; start: number };
const KEY = 'edentext-page-numbering';
const KEY = docKey('edentext-page-numbering');
export const DEFAULT_PAGE_NUMBERING: PageNumbering = { format: '1', start: 1 };
+3 -1
View File
@@ -1,6 +1,8 @@
import { docKey } from './docScope';
export type Orientation = 'portrait' | 'landscape';
const KEY = 'edentext-page-orientation';
const KEY = docKey('edentext-page-orientation');
export function loadOrientation(): Orientation {
return localStorage.getItem(KEY) === 'landscape' ? 'landscape' : 'portrait';
+2 -1
View File
@@ -1,10 +1,11 @@
import { openDb, idbRequest } from './idb';
import { docKey } from './docScope';
// Earlier versions of the autosaved document, in IndexedDB. The browser copy is one
// document deep: a bad edit, a failed load or a swept picture would otherwise take it
// with no way back. A snapshot holds the document's text — margins, styles and the
// header/footer belong to the open document and are left where they are.
const DB_NAME = 'edentext-snapshots';
const DB_NAME = docKey('edentext-snapshots');
const STORE = 'snapshots';
// Three is what a session is worth; each holds the pictures inline, unlike the
// localStorage copy, so the store pays for them.
+4 -2
View File
@@ -1,9 +1,11 @@
import { docKey } from './docScope';
// How the space between two blocks is measured. LibreOffice adds the upper block's
// space-below to the lower one's space-above ('add', its native ODF behaviour) but takes
// only the larger of the two for a Word document ('max') — probed, and per document.
export type SpacingModel = 'add' | 'max';
const KEY = 'edentext-spacing-model';
const KEY = docKey('edentext-spacing-model');
export function loadSpacingModel(): SpacingModel {
return localStorage.getItem(KEY) === 'max' ? 'max' : 'add';
@@ -18,7 +20,7 @@ export function saveSpacingModel(m: SpacingModel): void {
// other way round). Off, the block that opens a page loses its space above however the
// page broke — probed: the same document renders its heading at 25.4mm with the option
// on and at 20.0mm with it off. On is the default both products write.
const AT_START_KEY = 'edentext-spacing-at-page-start';
const AT_START_KEY = docKey('edentext-spacing-at-page-start');
export function loadSpacingAtPageStart(): boolean {
return localStorage.getItem(AT_START_KEY) !== 'false';
+3 -1
View File
@@ -1,4 +1,6 @@
const KEY = 'edentext-tab-interval';
import { docKey } from './docScope';
const KEY = docKey('edentext-tab-interval');
// The grid every tab past the last custom stop falls on (Word w:defaultTabStop, ODF
// style:tab-stop-distance). LibreOffice's default for a new document.
+3 -1
View File
@@ -1,9 +1,11 @@
import { docKey } from './docScope';
// Whether the editor records revisions, as a reactive singleton (same shape as
// autoCorrect.svelte.ts): the extension reads recordChanges() on every transaction, the
// Review tab flips it. Off by default, as in both word processors — and a document that
// records nothing writes no revision registry.
const KEY = 'edentext-record-changes';
const KEY = docKey('edentext-record-changes');
let current = $state(localStorage.getItem(KEY) === 'true');
+3 -1
View File
@@ -1,8 +1,10 @@
import { docKey } from './docScope';
// The page's text direction: ODF style:writing-mode="rl-tb" / Word's w:bidi. A
// right-to-left page fills its columns from the right and sets the body's base
// direction, so bidi resolves a Hebrew or Arabic line the way the file means it.
const KEY = 'edentext-page-rtl';
const KEY = docKey('edentext-page-rtl');
export function loadPageRtl(): boolean {
return localStorage.getItem(KEY) === 'true';
+2 -1
View File
@@ -5,8 +5,9 @@
import { builtinStyleSheet, DEFAULT_STYLE, mergeStoredSheet, STYLE_SHEET_VERSION, type Style, type StyleFamily, type StyleSheet } from './styleSheet';
import type { TableStyle } from './tableStyles';
import type { ListStyle } from './listStyles';
import { docKey } from '../storage/docScope';
const STORAGE_KEY = 'edentext-styles';
const STORAGE_KEY = docKey('edentext-styles');
function load(): StyleSheet {
const raw = localStorage.getItem(STORAGE_KEY);
+6
View File
@@ -3,6 +3,12 @@ import App from './App.svelte';
import './styles/global.css';
import { loadTheme, applyTheme } from './lib/storage/theme';
import { locale } from './lib/i18n/i18n.svelte';
import { startTabPresence, pruneOldDocuments } from './lib/storage/docScope';
// This tab holds its document while it lives; the ones no tab has held for a while
// and that fell out of the newest few are dropped here.
startTabPresence();
pruneOldDocuments();
// Apply saved theme before mount to prevent flash of wrong theme
applyTheme(loadTheme());
+4 -4
View File
@@ -1,16 +1,16 @@
// A two-call IndexedDB in memory — what openDb/idbRequest (storage/idb.ts) ask for.
export function fakeIndexedDb() {
const data = new Map<number, unknown>();
const data = new Map<IDBValidKey, unknown>();
const req = (result: unknown) => {
const r: Record<string, unknown> = { result };
queueMicrotask(() => (r.onsuccess as (() => void) | undefined)?.());
return r;
};
const store = {
put: (v: unknown, k: number) => { data.set(k, v); return req(undefined); },
get: (k: number) => req(data.get(k)),
put: (v: unknown, k: IDBValidKey) => { data.set(k, v); return req(undefined); },
get: (k: IDBValidKey) => req(data.get(k)),
getAllKeys: () => req([...data.keys()]),
delete: (k: number) => { data.delete(k); return req(undefined); },
delete: (k: IDBValidKey) => { data.delete(k); return req(undefined); },
};
const db = { transaction: () => ({ objectStore: () => store }), createObjectStore: () => {}, close: () => {} };
return {
+71
View File
@@ -0,0 +1,71 @@
// Several tabs, several documents: the one thing jsdom cannot show, since it has no
// second tab. Two pages in one browser context share localStorage and have their own
// sessionStorage each — exactly what two tabs of the same browser are.
import { checker, previewServer, openApp } from '../browser.mjs';
const PORT = +(process.env.TABS_PORT ?? 4183);
const URL = `http://localhost:${PORT}/`;
const { check, failures } = checker();
const server = await previewServer(PORT);
const { browser, page, pageErrors } = await openApp(PORT);
// openApp's page has a context of its own; the tabs need to share one.
await page.close();
const ctx = await browser.newContext({ viewport: { width: 1400, height: 1000 }, locale: 'en-US' });
const settle = (p) => p.waitForTimeout(1500);
const text = (p) => p.textContent('.tiptap');
async function openTab() {
const p = await ctx.newPage();
p.on('dialog', (d) => d.accept());
p.on('pageerror', (err) => pageErrors.push(String(err)));
await p.goto(URL, { waitUntil: 'load' });
await p.waitForSelector('.tiptap', { timeout: 15_000 });
return p;
}
async function type(p, s) {
await p.click('.tiptap p');
await p.keyboard.type(s);
await settle(p);
}
try {
const a = await openTab();
await a.evaluate(() => localStorage.clear());
await a.reload({ waitUntil: 'load' });
await a.waitForSelector('.tiptap');
await type(a, 'Alpha');
const b = await openTab();
check(!(await text(b)).includes('Alpha'), 'a second tab starts on its own empty document');
await type(b, 'Beta');
check((await text(a)).includes('Alpha') && !(await text(a)).includes('Beta'), 'the first tab keeps its own text');
const keys = await a.evaluate(() => Object.fromEntries(Object.keys(localStorage)
.filter((k) => k.startsWith('edentext-doc'))
.map((k) => [k, localStorage.getItem(k) ?? ''])));
const scoped = Object.keys(keys).find((k) => k.startsWith('edentext-doc@'));
check(keys['edentext-doc']?.includes('Alpha'), 'the first document keeps the unsuffixed key');
check(!!scoped && keys[scoped].includes('Beta'), `the second document has its own key (${scoped})`);
for (const p of [a, b]) {
await p.reload({ waitUntil: 'load' });
await p.waitForSelector('.tiptap');
await settle(p);
}
check((await text(a)).includes('Alpha') && (await text(b)).includes('Beta'), 'both tabs keep their document across a reload');
// A closed tab signs its document off, so the next fresh tab takes it up again.
await b.close();
const c = await openTab();
await settle(c);
check((await text(c)).includes('Beta'), 'a new tab takes up the document the closed tab held');
} catch (err) {
check(false, `tabs run threw: ${err.message ?? err}`);
} finally {
check(pageErrors.length === 0, pageErrors.length ? `no uncaught page errors — got: ${pageErrors.join(' | ')}` : 'no uncaught page errors');
await browser.close();
if (server) process.kill(-server.pid);
}
process.exit(failures.length ? 1 : 0);
+26
View File
@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { pickSlot } from '../../src/lib/storage/docScope';
const NOW = 1_700_000_000_000;
const MIN = 60_000;
describe('pickSlot', () => {
it('gives the first tab of any browser the unsuffixed slot', () => {
expect(pickSlot({}, NOW)).toBe('');
});
it('mints a new document while every one is held', () => {
const id = pickSlot({ '': NOW - 1000 }, NOW);
expect(id).not.toBe('');
expect(pickSlot({ '': NOW - 1000, [id]: NOW }, NOW)).not.toBe(id);
});
it('takes up the most recent document no tab holds', () => {
// Negative = a tab signed off; the stamp still says how recently it was used.
expect(pickSlot({ '': -(NOW - 9 * MIN), d2: -(NOW - 2 * MIN) }, NOW)).toBe('d2');
});
it('counts a marker nobody has refreshed in ten minutes as abandoned', () => {
expect(pickSlot({ '': NOW - 11 * MIN, d2: NOW - 1 * MIN }, NOW)).toBe('');
});
});
+17 -2
View File
@@ -1,5 +1,6 @@
import { describe, it, expect } from 'vitest';
import { stashImages, IDB_SRC } from '../../src/lib/storage/imageStore';
import { describe, it, expect, vi } from 'vitest';
import { stashImages, putImages, IDB_SRC } from '../../src/lib/storage/imageStore';
import { fakeIndexedDb } from '../fakeIdb';
const big = (fill: string) => `data:image/png;base64,${fill.repeat(5000)}`;
@@ -45,3 +46,17 @@ describe('stashImages', () => {
expect(blobs.size).toBe(2);
});
});
describe('putImages', () => {
it('sweeps what no document names any more, and nothing else', async () => {
const idb = fakeIndexedDb();
vi.stubGlobal('indexedDB', { open: idb.open });
localStorage.clear();
// A second document, stored by another tab and not open here.
localStorage.setItem('edentext-doc@d2', JSON.stringify({ attrs: { src: `${IDB_SRC}theirs` } }));
idb.data.set('theirs', big('B'));
idb.data.set('orphan', big('C'));
await putImages(new Map([['mine', big('A')]]));
expect([...idb.data.keys()].sort()).toEqual(['mine', 'theirs']);
});
});