@operato/shell 10.29.1 → 10.29.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,15 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ### [10.29.2](https://github.com/hatiolab/operato/compare/v10.29.1...v10.29.2) (2026-09-27)
7
+
8
+
9
+ ### :rocket: New Features
10
+
11
+ * **shell:** keep unsaved edits as drafts, offered back when the screen opens ([c4d3eb2](https://github.com/hatiolab/operato/commit/c4d3eb2e124c788ce37d154a1051626aa41bc3c0))
12
+
13
+
14
+
6
15
  ### [10.29.1](https://github.com/hatiolab/operato/compare/v11.0.0...v10.29.1) (2026-09-26)
7
16
 
8
17
 
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Unsaved edits kept as drafts in this browser, so a reload, a closed tab or a crash does not lose them
3
+ * (design/ui/unsaved-drafts.md).
4
+ *
5
+ * The browser lets a page ask only its own "Reload site?" box while it is being torn down, in its own words
6
+ * and look. So nothing asks at that moment: the edit is already written here, and the screen offers it back
7
+ * with the app's own dialog when it opens again.
8
+ *
9
+ * A database of its own. Adding a store to things-factory-database would need a version upgrade of a
10
+ * database the service worker also opens.
11
+ */
12
+ export interface DraftAddress {
13
+ /** Who edited - a draft is never offered to someone else on the same browser. */
14
+ user: string;
15
+ /** The site (domain) it was edited on. */
16
+ domain: string;
17
+ /** The screen - 'figure-modeller', 'board-modeller', … */
18
+ screen: string;
19
+ /** What was being edited - the figure's or board's id, or a name for something not saved yet. */
20
+ target: string;
21
+ }
22
+ export interface Draft<T = unknown> {
23
+ key: string;
24
+ /** The value the screen hands back to `restore`. */
25
+ value: T;
26
+ /** When the draft was last written (ms). */
27
+ at: number;
28
+ /**
29
+ * The saved copy's revision the editing began from, or null for something never saved. It is handed
30
+ * back with the value, and the save after restoring goes out against it - so if someone saved over it
31
+ * in between, the save's own conflict check refuses, and the draft never overwrites that silently.
32
+ */
33
+ base: string | number | null;
34
+ }
35
+ /** A draft older than this is not offered; it is dropped when found. */
36
+ export declare const DRAFT_LIFETIME_MS: number;
37
+ export declare function draftKey({ user, domain, screen, target }: DraftAddress): string;
38
+ /** The key prefix of every draft one user has - for clearing them at sign-out. */
39
+ export declare function draftKeyPrefixOf(user: string): string;
40
+ /**
41
+ * What to do with a draft found when a screen opens.
42
+ *
43
+ * - `drop`: too old to trust - remove it without asking.
44
+ * - `offer`: offer it back.
45
+ * - `offer-behind`: offer it back, and say the saved copy has been saved again since this draft began -
46
+ * saving will report that conflict (the save goes out against the draft's base).
47
+ *
48
+ * `savedRevision` is the saved copy's revision now (null when there is none).
49
+ */
50
+ export declare function draftDecision(draft: Pick<Draft, 'at' | 'base'>, savedRevision: string | number | null, now?: number): 'drop' | 'offer' | 'offer-behind';
51
+ /** Whether this browser keeps drafts at all - false in a private window or with storage blocked. */
52
+ export declare function draftsKept(): Promise<boolean>;
53
+ export declare function writeDraft<T>(draft: Draft<T>): Promise<unknown>;
54
+ export declare function readDraft<T>(key: string): Promise<Draft<T> | undefined>;
55
+ export declare function removeDraft(key: string): Promise<unknown>;
56
+ /** Remove every draft of one user - at sign-out, so a shared computer keeps none of their edits. */
57
+ export declare function removeDraftsOf(user: string): Promise<unknown>;
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Unsaved edits kept as drafts in this browser, so a reload, a closed tab or a crash does not lose them
3
+ * (design/ui/unsaved-drafts.md).
4
+ *
5
+ * The browser lets a page ask only its own "Reload site?" box while it is being torn down, in its own words
6
+ * and look. So nothing asks at that moment: the edit is already written here, and the screen offers it back
7
+ * with the app's own dialog when it opens again.
8
+ *
9
+ * A database of its own. Adding a store to things-factory-database would need a version upgrade of a
10
+ * database the service worker also opens.
11
+ */
12
+ /** A draft older than this is not offered; it is dropped when found. */
13
+ export const DRAFT_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000;
14
+ export function draftKey({ user, domain, screen, target }) {
15
+ return [user, domain, screen, target].map(part => encodeURIComponent(part)).join('/');
16
+ }
17
+ /** The key prefix of every draft one user has - for clearing them at sign-out. */
18
+ export function draftKeyPrefixOf(user) {
19
+ return `${encodeURIComponent(user)}/`;
20
+ }
21
+ /**
22
+ * What to do with a draft found when a screen opens.
23
+ *
24
+ * - `drop`: too old to trust - remove it without asking.
25
+ * - `offer`: offer it back.
26
+ * - `offer-behind`: offer it back, and say the saved copy has been saved again since this draft began -
27
+ * saving will report that conflict (the save goes out against the draft's base).
28
+ *
29
+ * `savedRevision` is the saved copy's revision now (null when there is none).
30
+ */
31
+ export function draftDecision(draft, savedRevision, now = Date.now()) {
32
+ if (now - draft.at > DRAFT_LIFETIME_MS)
33
+ return 'drop';
34
+ if (savedRevision != null && savedRevision !== draft.base)
35
+ return 'offer-behind';
36
+ return 'offer';
37
+ }
38
+ /* ── storage ───────────────────────────────────────────────────────────── */
39
+ const DB_NAME = 'operato-drafts';
40
+ const STORE = 'drafts';
41
+ let opened;
42
+ function database() {
43
+ if (!opened) {
44
+ opened = new Promise((resolve, reject) => {
45
+ if (typeof indexedDB === 'undefined')
46
+ return reject(new Error('this browser keeps no drafts'));
47
+ const request = indexedDB.open(DB_NAME, 1);
48
+ request.onupgradeneeded = () => request.result.createObjectStore(STORE, { keyPath: 'key' });
49
+ request.onsuccess = () => resolve(request.result);
50
+ request.onerror = () => reject(request.error);
51
+ });
52
+ /* A failed open is not remembered - the next call tries again. */
53
+ opened.catch(() => (opened = undefined));
54
+ }
55
+ return opened;
56
+ }
57
+ async function run(mode, work) {
58
+ const db = await database();
59
+ return new Promise((resolve, reject) => {
60
+ const request = work(db.transaction(STORE, mode).objectStore(STORE));
61
+ request.onsuccess = () => resolve(request.result);
62
+ request.onerror = () => reject(request.error);
63
+ });
64
+ }
65
+ /** Whether this browser keeps drafts at all - false in a private window or with storage blocked. */
66
+ export async function draftsKept() {
67
+ try {
68
+ await database();
69
+ return true;
70
+ }
71
+ catch {
72
+ return false;
73
+ }
74
+ }
75
+ export function writeDraft(draft) {
76
+ return run('readwrite', store => store.put(draft));
77
+ }
78
+ export function readDraft(key) {
79
+ return run('readonly', store => store.get(key));
80
+ }
81
+ export function removeDraft(key) {
82
+ return run('readwrite', store => store.delete(key));
83
+ }
84
+ /** Remove every draft of one user - at sign-out, so a shared computer keeps none of their edits. */
85
+ export function removeDraftsOf(user) {
86
+ const prefix = draftKeyPrefixOf(user);
87
+ return run('readwrite', store => store.delete(IDBKeyRange.bound(prefix, prefix + '￿')));
88
+ }
89
+ //# sourceMappingURL=draft-store.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"draft-store.js","sourceRoot":"","sources":["../../../src/drafts/draft-store.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Unsaved edits kept as drafts in this browser, so a reload, a closed tab or a crash does not lose them\n * (design/ui/unsaved-drafts.md).\n *\n * The browser lets a page ask only its own \"Reload site?\" box while it is being torn down, in its own words\n * and look. So nothing asks at that moment: the edit is already written here, and the screen offers it back\n * with the app's own dialog when it opens again.\n *\n * A database of its own. Adding a store to things-factory-database would need a version upgrade of a\n * database the service worker also opens.\n */\n\nexport interface DraftAddress {\n /** Who edited - a draft is never offered to someone else on the same browser. */\n user: string\n /** The site (domain) it was edited on. */\n domain: string\n /** The screen - 'figure-modeller', 'board-modeller', … */\n screen: string\n /** What was being edited - the figure's or board's id, or a name for something not saved yet. */\n target: string\n}\n\nexport interface Draft<T = unknown> {\n key: string\n /** The value the screen hands back to `restore`. */\n value: T\n /** When the draft was last written (ms). */\n at: number\n /**\n * The saved copy's revision the editing began from, or null for something never saved. It is handed\n * back with the value, and the save after restoring goes out against it - so if someone saved over it\n * in between, the save's own conflict check refuses, and the draft never overwrites that silently.\n */\n base: string | number | null\n}\n\n/** A draft older than this is not offered; it is dropped when found. */\nexport const DRAFT_LIFETIME_MS = 7 * 24 * 60 * 60 * 1000\n\nexport function draftKey({ user, domain, screen, target }: DraftAddress): string {\n return [user, domain, screen, target].map(part => encodeURIComponent(part)).join('/')\n}\n\n/** The key prefix of every draft one user has - for clearing them at sign-out. */\nexport function draftKeyPrefixOf(user: string): string {\n return `${encodeURIComponent(user)}/`\n}\n\n/**\n * What to do with a draft found when a screen opens.\n *\n * - `drop`: too old to trust - remove it without asking.\n * - `offer`: offer it back.\n * - `offer-behind`: offer it back, and say the saved copy has been saved again since this draft began -\n * saving will report that conflict (the save goes out against the draft's base).\n *\n * `savedRevision` is the saved copy's revision now (null when there is none).\n */\nexport function draftDecision(\n draft: Pick<Draft, 'at' | 'base'>,\n savedRevision: string | number | null,\n now: number = Date.now()\n): 'drop' | 'offer' | 'offer-behind' {\n if (now - draft.at > DRAFT_LIFETIME_MS) return 'drop'\n if (savedRevision != null && savedRevision !== draft.base) return 'offer-behind'\n return 'offer'\n}\n\n/* ── storage ───────────────────────────────────────────────────────────── */\n\nconst DB_NAME = 'operato-drafts'\nconst STORE = 'drafts'\n\nlet opened: Promise<IDBDatabase> | undefined\n\nfunction database(): Promise<IDBDatabase> {\n if (!opened) {\n opened = new Promise((resolve, reject) => {\n if (typeof indexedDB === 'undefined') return reject(new Error('this browser keeps no drafts'))\n const request = indexedDB.open(DB_NAME, 1)\n request.onupgradeneeded = () => request.result.createObjectStore(STORE, { keyPath: 'key' })\n request.onsuccess = () => resolve(request.result)\n request.onerror = () => reject(request.error)\n })\n /* A failed open is not remembered - the next call tries again. */\n opened.catch(() => (opened = undefined))\n }\n return opened\n}\n\nasync function run<R>(mode: IDBTransactionMode, work: (store: IDBObjectStore) => IDBRequest<R>): Promise<R> {\n const db = await database()\n return new Promise((resolve, reject) => {\n const request = work(db.transaction(STORE, mode).objectStore(STORE))\n request.onsuccess = () => resolve(request.result)\n request.onerror = () => reject(request.error)\n })\n}\n\n/** Whether this browser keeps drafts at all - false in a private window or with storage blocked. */\nexport async function draftsKept(): Promise<boolean> {\n try {\n await database()\n return true\n } catch {\n return false\n }\n}\n\nexport function writeDraft<T>(draft: Draft<T>): Promise<unknown> {\n return run('readwrite', store => store.put(draft))\n}\n\nexport function readDraft<T>(key: string): Promise<Draft<T> | undefined> {\n return run('readonly', store => store.get(key) as IDBRequest<Draft<T> | undefined>)\n}\n\nexport function removeDraft(key: string): Promise<unknown> {\n return run('readwrite', store => store.delete(key))\n}\n\n/** Remove every draft of one user - at sign-out, so a shared computer keeps none of their edits. */\nexport function removeDraftsOf(user: string): Promise<unknown> {\n const prefix = draftKeyPrefixOf(user)\n return run('readwrite', store => store.delete(IDBKeyRange.bound(prefix, prefix + '￿')))\n}\n"]}
@@ -0,0 +1,50 @@
1
+ import type { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ import { type DraftAddress } from './draft-store.js';
3
+ export interface UnsavedDraftSource<T> {
4
+ /** Where the draft lives - who, which site, which screen, what is edited. Undefined until it is known. */
5
+ address(): DraftAddress | undefined;
6
+ /** The unsaved model and the saved revision it began from; undefined when nothing is unsaved. */
7
+ snapshot(): {
8
+ value: T;
9
+ base: string | number | null;
10
+ } | undefined;
11
+ /** The saved copy's revision now, or null when it was never saved. */
12
+ savedRevision(): string | number | null;
13
+ /** Put a draft back on the canvas. The next save goes out against `base`, so a conflict is reported. */
14
+ restore(value: T, base: string | number | null): void;
15
+ }
16
+ /**
17
+ * Keeps a screen's unsaved edit as a draft, and offers it back when the screen opens (design/ui/unsaved-drafts.md).
18
+ *
19
+ * The screen calls `changed()` when its edit changes, `settled()` when it saved or discarded, and `offer()` once
20
+ * the saved copy is loaded. Nothing asks at reload - the edit is already written.
21
+ *
22
+ * Where a draft cannot be written (a private window, blocked storage), this screen falls back to the browser's
23
+ * own leave question while something is unsaved: without either, the edit would be lost without a word.
24
+ */
25
+ export declare class UnsavedDraftController<T> implements ReactiveController {
26
+ private host;
27
+ private source;
28
+ /** How long an edit rests before it is written. */
29
+ static WAIT_MS: number;
30
+ private timer?;
31
+ private cannotKeep;
32
+ constructor(host: ReactiveControllerHost, source: UnsavedDraftSource<T>);
33
+ hostConnected(): void;
34
+ hostDisconnected(): void;
35
+ /** Whether this browser keeps drafts for this screen (false after a write failed). */
36
+ get keeps(): boolean;
37
+ /** The edit changed. It is written after it rests for a moment. */
38
+ changed(): void;
39
+ /** Saved, or discarded on purpose: there is nothing to keep. */
40
+ settled(): Promise<void>;
41
+ /** Write the current edit now. */
42
+ flush(): Promise<void>;
43
+ /**
44
+ * Offer a kept draft back - call once the saved copy is loaded. Resolves true when the draft was restored.
45
+ * A draft too old to trust is dropped without asking.
46
+ */
47
+ offer(): Promise<boolean>;
48
+ /** Only where drafts cannot be kept: the browser's own leave question, while something is unsaved. */
49
+ private holdIfNotKept;
50
+ }
@@ -0,0 +1,129 @@
1
+ import { i18next } from '@operato/i18n';
2
+ import { OxPrompt } from '@operato/popup';
3
+ import { draftDecision, draftKey, draftsKept, readDraft, removeDraft, writeDraft } from './draft-store.js';
4
+ /**
5
+ * Keeps a screen's unsaved edit as a draft, and offers it back when the screen opens (design/ui/unsaved-drafts.md).
6
+ *
7
+ * The screen calls `changed()` when its edit changes, `settled()` when it saved or discarded, and `offer()` once
8
+ * the saved copy is loaded. Nothing asks at reload - the edit is already written.
9
+ *
10
+ * Where a draft cannot be written (a private window, blocked storage), this screen falls back to the browser's
11
+ * own leave question while something is unsaved: without either, the edit would be lost without a word.
12
+ */
13
+ export class UnsavedDraftController {
14
+ /** How long an edit rests before it is written. */
15
+ static { this.WAIT_MS = 1000; }
16
+ constructor(host, source) {
17
+ this.host = host;
18
+ this.source = source;
19
+ this.cannotKeep = false;
20
+ /** Only where drafts cannot be kept: the browser's own leave question, while something is unsaved. */
21
+ this.holdIfNotKept = (event) => {
22
+ if (!this.cannotKeep || !this.source.snapshot())
23
+ return;
24
+ event.preventDefault();
25
+ event.returnValue = '';
26
+ };
27
+ host.addController(this);
28
+ }
29
+ hostConnected() {
30
+ window.addEventListener('beforeunload', this.holdIfNotKept);
31
+ /* Known before the first edit, so a reload in that first second is not the one that loses it. */
32
+ draftsKept().then(kept => (this.cannotKeep = !kept));
33
+ }
34
+ hostDisconnected() {
35
+ window.removeEventListener('beforeunload', this.holdIfNotKept);
36
+ /* Leaving the screen with a write still waiting: write it now, not never. */
37
+ if (this.timer)
38
+ this.flush();
39
+ }
40
+ /** Whether this browser keeps drafts for this screen (false after a write failed). */
41
+ get keeps() {
42
+ return !this.cannotKeep;
43
+ }
44
+ /** The edit changed. It is written after it rests for a moment. */
45
+ changed() {
46
+ clearTimeout(this.timer);
47
+ this.timer = setTimeout(() => this.flush(), UnsavedDraftController.WAIT_MS);
48
+ }
49
+ /** Saved, or discarded on purpose: there is nothing to keep. */
50
+ async settled() {
51
+ clearTimeout(this.timer);
52
+ this.timer = undefined;
53
+ const address = this.source.address();
54
+ if (!address)
55
+ return;
56
+ try {
57
+ await removeDraft(draftKey(address));
58
+ }
59
+ catch {
60
+ /* Nothing was kept here, so there is nothing to take away. */
61
+ }
62
+ }
63
+ /** Write the current edit now. */
64
+ async flush() {
65
+ clearTimeout(this.timer);
66
+ this.timer = undefined;
67
+ const address = this.source.address();
68
+ if (!address)
69
+ return;
70
+ const snapshot = this.source.snapshot();
71
+ try {
72
+ if (!snapshot)
73
+ await removeDraft(draftKey(address));
74
+ else
75
+ await writeDraft({ key: draftKey(address), value: snapshot.value, base: snapshot.base, at: Date.now() });
76
+ this.cannotKeep = false;
77
+ }
78
+ catch {
79
+ this.cannotKeep = true;
80
+ }
81
+ }
82
+ /**
83
+ * Offer a kept draft back - call once the saved copy is loaded. Resolves true when the draft was restored.
84
+ * A draft too old to trust is dropped without asking.
85
+ */
86
+ async offer() {
87
+ const address = this.source.address();
88
+ if (!address)
89
+ return false;
90
+ const key = draftKey(address);
91
+ let draft;
92
+ try {
93
+ draft = await readDraft(key);
94
+ }
95
+ catch {
96
+ this.cannotKeep = true;
97
+ return false;
98
+ }
99
+ if (!draft)
100
+ return false;
101
+ const decision = draftDecision(draft, this.source.savedRevision());
102
+ if (decision === 'drop') {
103
+ await removeDraft(key).catch(() => { });
104
+ return false;
105
+ }
106
+ const t = (name, fallback, params = {}) => String(i18next.t(name, { defaultValue: fallback, ...params }));
107
+ const minutes = Math.max(1, Math.round((Date.now() - draft.at) / 60000));
108
+ const lines = [
109
+ t('text.unsaved-draft-kept', 'what you changed {minutes} minutes ago is still kept in this browser.', { minutes })
110
+ ];
111
+ if (decision === 'offer-behind') {
112
+ lines.push(t('text.unsaved-draft-behind', 'it has been saved again since then. saving will tell you about the conflict.'));
113
+ }
114
+ const resume = await OxPrompt.open({
115
+ type: 'info',
116
+ title: t('title.unsaved-draft', 'there is an unsaved edit'),
117
+ text: lines.join(' '),
118
+ confirmButton: { text: t('button.resume-editing', 'resume editing') },
119
+ cancelButton: { text: t('button.discard', 'discard') }
120
+ });
121
+ if (resume) {
122
+ this.source.restore(draft.value, draft.base);
123
+ return true;
124
+ }
125
+ await removeDraft(key).catch(() => { });
126
+ return false;
127
+ }
128
+ }
129
+ //# sourceMappingURL=unsaved-draft-controller.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"unsaved-draft-controller.js","sourceRoot":"","sources":["../../../src/drafts/unsaved-draft-controller.ts"],"names":[],"mappings":"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","sourcesContent":["import type { ReactiveController, ReactiveControllerHost } from 'lit'\nimport { i18next } from '@operato/i18n'\nimport { OxPrompt } from '@operato/popup'\n\nimport { draftDecision, draftKey, draftsKept, readDraft, removeDraft, writeDraft, type DraftAddress } from './draft-store.js'\n\nexport interface UnsavedDraftSource<T> {\n /** Where the draft lives - who, which site, which screen, what is edited. Undefined until it is known. */\n address(): DraftAddress | undefined\n /** The unsaved model and the saved revision it began from; undefined when nothing is unsaved. */\n snapshot(): { value: T; base: string | number | null } | undefined\n /** The saved copy's revision now, or null when it was never saved. */\n savedRevision(): string | number | null\n /** Put a draft back on the canvas. The next save goes out against `base`, so a conflict is reported. */\n restore(value: T, base: string | number | null): void\n}\n\n/**\n * Keeps a screen's unsaved edit as a draft, and offers it back when the screen opens (design/ui/unsaved-drafts.md).\n *\n * The screen calls `changed()` when its edit changes, `settled()` when it saved or discarded, and `offer()` once\n * the saved copy is loaded. Nothing asks at reload - the edit is already written.\n *\n * Where a draft cannot be written (a private window, blocked storage), this screen falls back to the browser's\n * own leave question while something is unsaved: without either, the edit would be lost without a word.\n */\nexport class UnsavedDraftController<T> implements ReactiveController {\n /** How long an edit rests before it is written. */\n static WAIT_MS = 1000\n\n private timer?: ReturnType<typeof setTimeout>\n private cannotKeep = false\n\n constructor(\n private host: ReactiveControllerHost,\n private source: UnsavedDraftSource<T>\n ) {\n host.addController(this)\n }\n\n hostConnected() {\n window.addEventListener('beforeunload', this.holdIfNotKept)\n /* Known before the first edit, so a reload in that first second is not the one that loses it. */\n draftsKept().then(kept => (this.cannotKeep = !kept))\n }\n\n hostDisconnected() {\n window.removeEventListener('beforeunload', this.holdIfNotKept)\n /* Leaving the screen with a write still waiting: write it now, not never. */\n if (this.timer) this.flush()\n }\n\n /** Whether this browser keeps drafts for this screen (false after a write failed). */\n get keeps() {\n return !this.cannotKeep\n }\n\n /** The edit changed. It is written after it rests for a moment. */\n changed() {\n clearTimeout(this.timer)\n this.timer = setTimeout(() => this.flush(), UnsavedDraftController.WAIT_MS)\n }\n\n /** Saved, or discarded on purpose: there is nothing to keep. */\n async settled() {\n clearTimeout(this.timer)\n this.timer = undefined\n const address = this.source.address()\n if (!address) return\n try {\n await removeDraft(draftKey(address))\n } catch {\n /* Nothing was kept here, so there is nothing to take away. */\n }\n }\n\n /** Write the current edit now. */\n async flush() {\n clearTimeout(this.timer)\n this.timer = undefined\n const address = this.source.address()\n if (!address) return\n\n const snapshot = this.source.snapshot()\n try {\n if (!snapshot) await removeDraft(draftKey(address))\n else await writeDraft({ key: draftKey(address), value: snapshot.value, base: snapshot.base, at: Date.now() })\n this.cannotKeep = false\n } catch {\n this.cannotKeep = true\n }\n }\n\n /**\n * Offer a kept draft back - call once the saved copy is loaded. Resolves true when the draft was restored.\n * A draft too old to trust is dropped without asking.\n */\n async offer(): Promise<boolean> {\n const address = this.source.address()\n if (!address) return false\n const key = draftKey(address)\n\n let draft\n try {\n draft = await readDraft<T>(key)\n } catch {\n this.cannotKeep = true\n return false\n }\n if (!draft) return false\n\n const decision = draftDecision(draft, this.source.savedRevision())\n if (decision === 'drop') {\n await removeDraft(key).catch(() => {})\n return false\n }\n\n const t = (name: string, fallback: string, params: Record<string, unknown> = {}) =>\n String(i18next.t(name, { defaultValue: fallback, ...params }))\n const minutes = Math.max(1, Math.round((Date.now() - draft.at) / 60000))\n const lines = [\n t('text.unsaved-draft-kept', 'what you changed {minutes} minutes ago is still kept in this browser.', { minutes })\n ]\n if (decision === 'offer-behind') {\n lines.push(\n t('text.unsaved-draft-behind', 'it has been saved again since then. saving will tell you about the conflict.')\n )\n }\n\n const resume = await OxPrompt.open({\n type: 'info',\n title: t('title.unsaved-draft', 'there is an unsaved edit'),\n text: lines.join(' '),\n confirmButton: { text: t('button.resume-editing', 'resume editing') },\n cancelButton: { text: t('button.discard', 'discard') }\n })\n\n if (resume) {\n this.source.restore(draft.value, draft.base)\n return true\n }\n await removeDraft(key).catch(() => {})\n return false\n }\n\n /** Only where drafts cannot be kept: the browser's own leave question, while something is unsaved. */\n private holdIfNotKept = (event: BeforeUnloadEvent) => {\n if (!this.cannotKeep || !this.source.snapshot()) return\n event.preventDefault()\n event.returnValue = ''\n }\n}\n"]}
@@ -7,3 +7,5 @@ export * from './custom-alert.js';
7
7
  export { connect } from './connect-mixin.js';
8
8
  export * from './controllers/index.js';
9
9
  export * from './require-custom-element.js';
10
+ export * from './drafts/draft-store.js';
11
+ export * from './drafts/unsaved-draft-controller.js';
package/dist/src/index.js CHANGED
@@ -7,4 +7,6 @@ export * from './custom-alert.js';
7
7
  export { connect } from './connect-mixin.js';
8
8
  export * from './controllers/index.js';
9
9
  export * from './require-custom-element.js';
10
+ export * from './drafts/draft-store.js';
11
+ export * from './drafts/unsaved-draft-controller.js';
10
12
  //# sourceMappingURL=index.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAA;AAChC,cAAc,YAAY,CAAA;AAC1B,cAAc,oBAAoB,CAAA;AAClC,cAAc,0BAA0B,CAAA;AACxC,cAAc,mBAAmB,CAAA;AACjC,cAAc,mBAAmB,CAAA;AACjC,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAA;AAC5C,cAAc,wBAAwB,CAAA;AACtC,cAAc,6BAA6B,CAAA","sourcesContent":["export * from './types/index.js'\nexport * from './store.js'\nexport * from './actions/index.js'\nexport * from './app/pages/page-view.js'\nexport * from './object-store.js'\nexport * from './custom-alert.js'\nexport { connect } from './connect-mixin.js'\nexport * from './controllers/index.js'\nexport * from './require-custom-element.js'\n"]}
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/index.ts"],"names":[],"mappings":"AAAA,cAAc,kBAAkB,CAAA;AAChC,cAAc,YAAY,CAAA;AAC1B,cAAc,oBAAoB,CAAA;AAClC,cAAc,0BAA0B,CAAA;AACxC,cAAc,mBAAmB,CAAA;AACjC,cAAc,mBAAmB,CAAA;AACjC,OAAO,EAAE,OAAO,EAAE,MAAM,oBAAoB,CAAA;AAC5C,cAAc,wBAAwB,CAAA;AACtC,cAAc,6BAA6B,CAAA;AAC3C,cAAc,yBAAyB,CAAA;AACvC,cAAc,sCAAsC,CAAA","sourcesContent":["export * from './types/index.js'\nexport * from './store.js'\nexport * from './actions/index.js'\nexport * from './app/pages/page-view.js'\nexport * from './object-store.js'\nexport * from './custom-alert.js'\nexport { connect } from './connect-mixin.js'\nexport * from './controllers/index.js'\nexport * from './require-custom-element.js'\nexport * from './drafts/draft-store.js'\nexport * from './drafts/unsaved-draft-controller.js'\n"]}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,123 @@
1
+ import { expect } from '@open-wc/testing';
2
+ import { OxPrompt } from '@operato/popup';
3
+ import { draftDecision, draftKey, readDraft, removeDraftsOf, writeDraft, DRAFT_LIFETIME_MS, UnsavedDraftController } from '../src/index.js';
4
+ /**
5
+ * Unsaved edits kept as drafts (design/ui/unsaved-drafts.md, architect ruling 2026-09-27): the edit is
6
+ * written as it happens and offered back when the screen opens, so nothing has to ask at reload.
7
+ */
8
+ const at = (target, user = 'u1') => ({ user, domain: 'd', screen: 'test-modeller', target });
9
+ class Host {
10
+ constructor() {
11
+ this.controllers = [];
12
+ this.updateComplete = Promise.resolve(true);
13
+ }
14
+ addController(c) {
15
+ this.controllers.push(c);
16
+ }
17
+ removeController() { }
18
+ requestUpdate() { }
19
+ connect() {
20
+ this.controllers.forEach(c => c.hostConnected?.());
21
+ }
22
+ disconnect() {
23
+ this.controllers.forEach(c => c.hostDisconnected?.());
24
+ }
25
+ }
26
+ describe('draft decision', () => {
27
+ const now = 1_000_000_000_000;
28
+ it('★ a draft whose base is the saved copy now is offered', () => {
29
+ expect(draftDecision({ at: now - 60_000, base: 7 }, 7, now)).to.equal('offer');
30
+ expect(draftDecision({ at: now - 60_000, base: null }, null, now)).to.equal('offer');
31
+ });
32
+ it('★ saved again since the draft began: offered, and the save will report the conflict', () => {
33
+ expect(draftDecision({ at: now - 60_000, base: 7 }, 8, now)).to.equal('offer-behind');
34
+ expect(draftDecision({ at: now - 60_000, base: null }, 1, now)).to.equal('offer-behind');
35
+ });
36
+ it('★ older than seven days is dropped without asking', () => {
37
+ expect(draftDecision({ at: now - DRAFT_LIFETIME_MS - 1, base: 7 }, 7, now)).to.equal('drop');
38
+ });
39
+ it('a key never lets one user read another\'s draft, and survives odd names', () => {
40
+ expect(draftKey(at('x', 'u1'))).to.not.equal(draftKey(at('x', 'u2')));
41
+ expect(draftKey({ user: 'a/b', domain: 'd', screen: 's', target: 't' })).to.not.equal(draftKey({ user: 'a', domain: 'b/d', screen: 's', target: 't' }));
42
+ });
43
+ });
44
+ describe('draft store', () => {
45
+ it('★ what is written is read back', async () => {
46
+ const key = draftKey(at('round-trip'));
47
+ await writeDraft({ key, value: { parts: 3 }, base: 7, at: 123 });
48
+ const read = await readDraft(key);
49
+ expect(read?.value.parts).to.equal(3);
50
+ expect(read?.base).to.equal(7);
51
+ });
52
+ it('★ sign-out takes one user\'s drafts, and only theirs', async () => {
53
+ await writeDraft({ key: draftKey(at('a', 'leaving')), value: 1, base: null, at: 1 });
54
+ await writeDraft({ key: draftKey(at('b', 'leaving')), value: 2, base: null, at: 1 });
55
+ await writeDraft({ key: draftKey(at('a', 'staying')), value: 3, base: null, at: 1 });
56
+ await removeDraftsOf('leaving');
57
+ expect(await readDraft(draftKey(at('a', 'leaving')))).to.equal(undefined);
58
+ expect(await readDraft(draftKey(at('b', 'leaving')))).to.equal(undefined);
59
+ expect((await readDraft(draftKey(at('a', 'staying'))))?.value).to.equal(3);
60
+ });
61
+ });
62
+ describe('UnsavedDraftController', () => {
63
+ const originalOpen = OxPrompt.open;
64
+ afterEach(() => (OxPrompt.open = originalOpen));
65
+ function screen(target, state) {
66
+ const host = new Host();
67
+ const restored = [];
68
+ const controller = new UnsavedDraftController(host, {
69
+ address: () => at(target),
70
+ snapshot: () => (state.value == null ? undefined : { value: state.value, base: state.base }),
71
+ savedRevision: () => state.saved,
72
+ restore: (value, base) => restored.push({ value, base })
73
+ });
74
+ host.connect();
75
+ return { host, controller, restored };
76
+ }
77
+ it('★ an edit is written; reopening offers it and puts it back with its base', async () => {
78
+ const first = screen('reopen', { value: 42, base: 5, saved: 5 });
79
+ first.controller.changed();
80
+ await first.controller.flush();
81
+ first.host.disconnect();
82
+ let asked = '';
83
+ OxPrompt.open = async (options) => ((asked = options.text), true);
84
+ const again = screen('reopen', { base: 5, saved: 5 });
85
+ expect(await again.controller.offer()).to.equal(true);
86
+ expect(again.restored).to.deep.equal([{ value: 42, base: 5 }]);
87
+ expect(asked).to.not.contain('conflict');
88
+ });
89
+ it('★ saved again elsewhere: the question says the save will report the conflict', async () => {
90
+ const first = screen('behind', { value: 1, base: 5, saved: 5 });
91
+ await first.controller.flush();
92
+ let asked = '';
93
+ OxPrompt.open = async (options) => ((asked = options.text), true);
94
+ const again = screen('behind', { base: 6, saved: 6 });
95
+ await again.controller.offer();
96
+ expect(asked).to.contain('conflict');
97
+ expect(again.restored[0].base).to.equal(5);
98
+ });
99
+ it('★ discard removes it; saving removes it', async () => {
100
+ ;
101
+ OxPrompt.open = async () => false;
102
+ const a = screen('discard', { value: 1, base: null, saved: null });
103
+ await a.controller.flush();
104
+ expect(await a.controller.offer()).to.equal(false);
105
+ expect(await readDraft(draftKey(at('discard')))).to.equal(undefined);
106
+ const b = screen('saved', { value: 1, base: null, saved: null });
107
+ await b.controller.flush();
108
+ await b.controller.settled();
109
+ expect(await readDraft(draftKey(at('saved')))).to.equal(undefined);
110
+ });
111
+ it('★ where drafts cannot be kept, the browser\'s own question comes back while something is unsaved', async () => {
112
+ const { controller } = screen('fallback', { value: 1, base: null, saved: null });
113
+ controller.cannotKeep = true;
114
+ const event = new Event('beforeunload', { cancelable: true });
115
+ window.dispatchEvent(event);
116
+ expect(event.defaultPrevented).to.equal(true);
117
+ controller.cannotKeep = false;
118
+ const kept = new Event('beforeunload', { cancelable: true });
119
+ window.dispatchEvent(kept);
120
+ expect(kept.defaultPrevented).to.equal(false);
121
+ });
122
+ });
123
+ //# sourceMappingURL=drafts.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"drafts.test.js","sourceRoot":"","sources":["../../test/drafts.test.ts"],"names":[],"mappings":"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","sourcesContent":["import { expect } from '@open-wc/testing'\nimport { OxPrompt } from '@operato/popup'\n\nimport {\n draftDecision,\n draftKey,\n readDraft,\n removeDraftsOf,\n writeDraft,\n DRAFT_LIFETIME_MS,\n UnsavedDraftController,\n type DraftAddress\n} from '../src/index.js'\n\n/**\n * Unsaved edits kept as drafts (design/ui/unsaved-drafts.md, architect ruling 2026-09-27): the edit is\n * written as it happens and offered back when the screen opens, so nothing has to ask at reload.\n */\n\nconst at = (target: string, user = 'u1'): DraftAddress => ({ user, domain: 'd', screen: 'test-modeller', target })\n\nclass Host {\n controllers: any[] = []\n addController(c: any) {\n this.controllers.push(c)\n }\n removeController() {}\n requestUpdate() {}\n updateComplete = Promise.resolve(true)\n connect() {\n this.controllers.forEach(c => c.hostConnected?.())\n }\n disconnect() {\n this.controllers.forEach(c => c.hostDisconnected?.())\n }\n}\n\ndescribe('draft decision', () => {\n const now = 1_000_000_000_000\n\n it('★ a draft whose base is the saved copy now is offered', () => {\n expect(draftDecision({ at: now - 60_000, base: 7 }, 7, now)).to.equal('offer')\n expect(draftDecision({ at: now - 60_000, base: null }, null, now)).to.equal('offer')\n })\n\n it('★ saved again since the draft began: offered, and the save will report the conflict', () => {\n expect(draftDecision({ at: now - 60_000, base: 7 }, 8, now)).to.equal('offer-behind')\n expect(draftDecision({ at: now - 60_000, base: null }, 1, now)).to.equal('offer-behind')\n })\n\n it('★ older than seven days is dropped without asking', () => {\n expect(draftDecision({ at: now - DRAFT_LIFETIME_MS - 1, base: 7 }, 7, now)).to.equal('drop')\n })\n\n it('a key never lets one user read another\\'s draft, and survives odd names', () => {\n expect(draftKey(at('x', 'u1'))).to.not.equal(draftKey(at('x', 'u2')))\n expect(draftKey({ user: 'a/b', domain: 'd', screen: 's', target: 't' })).to.not.equal(\n draftKey({ user: 'a', domain: 'b/d', screen: 's', target: 't' })\n )\n })\n})\n\ndescribe('draft store', () => {\n it('★ what is written is read back', async () => {\n const key = draftKey(at('round-trip'))\n await writeDraft({ key, value: { parts: 3 }, base: 7, at: 123 })\n const read = await readDraft<{ parts: number }>(key)\n expect(read?.value.parts).to.equal(3)\n expect(read?.base).to.equal(7)\n })\n\n it('★ sign-out takes one user\\'s drafts, and only theirs', async () => {\n await writeDraft({ key: draftKey(at('a', 'leaving')), value: 1, base: null, at: 1 })\n await writeDraft({ key: draftKey(at('b', 'leaving')), value: 2, base: null, at: 1 })\n await writeDraft({ key: draftKey(at('a', 'staying')), value: 3, base: null, at: 1 })\n\n await removeDraftsOf('leaving')\n\n expect(await readDraft(draftKey(at('a', 'leaving')))).to.equal(undefined)\n expect(await readDraft(draftKey(at('b', 'leaving')))).to.equal(undefined)\n expect((await readDraft(draftKey(at('a', 'staying'))))?.value).to.equal(3)\n })\n})\n\ndescribe('UnsavedDraftController', () => {\n const originalOpen = OxPrompt.open\n afterEach(() => ((OxPrompt as any).open = originalOpen))\n\n function screen(target: string, state: { value?: number; base: number | null; saved: number | null }) {\n const host = new Host()\n const restored: any[] = []\n const controller = new UnsavedDraftController<number>(host as any, {\n address: () => at(target),\n snapshot: () => (state.value == null ? undefined : { value: state.value, base: state.base }),\n savedRevision: () => state.saved,\n restore: (value, base) => restored.push({ value, base })\n })\n host.connect()\n return { host, controller, restored }\n }\n\n it('★ an edit is written; reopening offers it and puts it back with its base', async () => {\n const first = screen('reopen', { value: 42, base: 5, saved: 5 })\n first.controller.changed()\n await first.controller.flush()\n first.host.disconnect()\n\n let asked = ''\n ;(OxPrompt as any).open = async (options: any) => ((asked = options.text), true)\n const again = screen('reopen', { base: 5, saved: 5 })\n expect(await again.controller.offer()).to.equal(true)\n expect(again.restored).to.deep.equal([{ value: 42, base: 5 }])\n expect(asked).to.not.contain('conflict')\n })\n\n it('★ saved again elsewhere: the question says the save will report the conflict', async () => {\n const first = screen('behind', { value: 1, base: 5, saved: 5 })\n await first.controller.flush()\n\n let asked = ''\n ;(OxPrompt as any).open = async (options: any) => ((asked = options.text), true)\n const again = screen('behind', { base: 6, saved: 6 })\n await again.controller.offer()\n expect(asked).to.contain('conflict')\n expect(again.restored[0].base).to.equal(5)\n })\n\n it('★ discard removes it; saving removes it', async () => {\n ;(OxPrompt as any).open = async () => false\n const a = screen('discard', { value: 1, base: null, saved: null })\n await a.controller.flush()\n expect(await a.controller.offer()).to.equal(false)\n expect(await readDraft(draftKey(at('discard')))).to.equal(undefined)\n\n const b = screen('saved', { value: 1, base: null, saved: null })\n await b.controller.flush()\n await b.controller.settled()\n expect(await readDraft(draftKey(at('saved')))).to.equal(undefined)\n })\n\n it('★ where drafts cannot be kept, the browser\\'s own question comes back while something is unsaved', async () => {\n const { controller } = screen('fallback', { value: 1, base: null, saved: null })\n ;(controller as any).cannotKeep = true\n const event = new Event('beforeunload', { cancelable: true })\n window.dispatchEvent(event)\n expect(event.defaultPrevented).to.equal(true)\n\n ;(controller as any).cannotKeep = false\n const kept = new Event('beforeunload', { cancelable: true })\n window.dispatchEvent(kept)\n expect(kept.defaultPrevented).to.equal(false)\n })\n})\n"]}