fabricjs-document-engine 0.1.0 → 0.3.0
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/README.md +162 -21
- package/dist/assets/asset-manifest.cjs +43 -0
- package/dist/assets/asset-manifest.d.cts +16 -0
- package/dist/assets/asset-manifest.d.ts +16 -0
- package/dist/assets/asset-manifest.js +41 -0
- package/dist/assets/asset-pipeline.cjs +174 -0
- package/dist/assets/asset-pipeline.d.cts +31 -0
- package/dist/assets/asset-pipeline.d.ts +31 -0
- package/dist/assets/asset-pipeline.js +172 -0
- package/dist/assets/asset-references.cjs +83 -0
- package/dist/assets/asset-references.js +82 -0
- package/dist/assets/font-check.cjs +59 -0
- package/dist/assets/font-check.d.cts +4 -0
- package/dist/assets/font-check.d.ts +4 -0
- package/dist/assets/font-check.js +57 -0
- package/dist/assets/image-check.cjs +36 -0
- package/dist/assets/image-check.js +33 -0
- package/dist/document/document-format.d.cts +3 -0
- package/dist/document/document-format.d.ts +3 -0
- package/dist/document/validate-document.cjs +5 -0
- package/dist/document/validate-document.js +5 -0
- package/dist/engine/create-document-engine.cjs +82 -18
- package/dist/engine/create-document-engine.d.cts +21 -5
- package/dist/engine/create-document-engine.d.ts +21 -5
- package/dist/engine/create-document-engine.js +82 -18
- package/dist/engine/errors.cjs +7 -0
- package/dist/engine/errors.d.cts +9 -1
- package/dist/engine/errors.d.ts +9 -1
- package/dist/engine/errors.js +7 -1
- package/dist/history/create-history.cjs +3 -1
- package/dist/history/create-history.js +3 -1
- package/dist/index.cjs +4 -1
- package/dist/index.d.cts +11 -3
- package/dist/index.d.ts +11 -3
- package/dist/index.js +3 -2
- package/dist/save/autosave-scheduler.cjs +28 -0
- package/dist/save/autosave-scheduler.d.cts +6 -0
- package/dist/save/autosave-scheduler.d.ts +6 -0
- package/dist/save/autosave-scheduler.js +28 -0
- package/dist/save/retry.cjs +33 -0
- package/dist/save/retry.d.cts +7 -0
- package/dist/save/retry.d.ts +7 -0
- package/dist/save/retry.js +31 -0
- package/dist/save/save-controller.cjs +149 -0
- package/dist/save/save-controller.d.cts +20 -0
- package/dist/save/save-controller.d.ts +20 -0
- package/dist/save/save-controller.js +149 -0
- package/dist/save/unsaved-changes-warning.cjs +12 -0
- package/dist/save/unsaved-changes-warning.d.cts +6 -0
- package/dist/save/unsaved-changes-warning.d.ts +6 -0
- package/dist/save/unsaved-changes-warning.js +12 -0
- package/dist/storage/key-value-storage.cjs +35 -0
- package/dist/storage/key-value-storage.d.cts +14 -0
- package/dist/storage/key-value-storage.d.ts +14 -0
- package/dist/storage/key-value-storage.js +35 -0
- package/dist/storage/local-storage.cjs +24 -0
- package/dist/storage/local-storage.d.cts +8 -0
- package/dist/storage/local-storage.d.ts +8 -0
- package/dist/storage/local-storage.js +24 -0
- package/dist/storage/memory-storage.cjs +17 -0
- package/dist/storage/memory-storage.d.cts +4 -0
- package/dist/storage/memory-storage.d.ts +4 -0
- package/dist/storage/memory-storage.js +17 -0
- package/dist/storage/storage-contract.d.cts +14 -0
- package/dist/storage/storage-contract.d.ts +14 -0
- package/dist/storage.cjs +7 -0
- package/dist/storage.d.cts +5 -0
- package/dist/storage.d.ts +5 -0
- package/dist/storage.js +4 -0
- package/package.json +14 -3
|
@@ -24,7 +24,7 @@ function describePendingChanges(changes) {
|
|
|
24
24
|
return changes.length === 1 ? `${verb} ${changes[0].noun}` : `${verb} ${changes.length} objects`;
|
|
25
25
|
}
|
|
26
26
|
function createHistory(options) {
|
|
27
|
-
const { canvas, serializeObjects, onChange } = options;
|
|
27
|
+
const { canvas, serializeObjects, onChange, onContentChange } = options;
|
|
28
28
|
const stack = require_history_stack.createHistoryStack(Math.max(1, options.limit ?? 100));
|
|
29
29
|
let snapshot = require_snapshot.createSnapshot(serializeObjects());
|
|
30
30
|
let pendingChanges = [];
|
|
@@ -53,6 +53,7 @@ function createHistory(options) {
|
|
|
53
53
|
label,
|
|
54
54
|
...difference
|
|
55
55
|
});
|
|
56
|
+
onContentChange();
|
|
56
57
|
onChange(state());
|
|
57
58
|
return true;
|
|
58
59
|
}
|
|
@@ -155,6 +156,7 @@ function createHistory(options) {
|
|
|
155
156
|
}
|
|
156
157
|
snapshot = require_snapshot.createSnapshot(serializeObjects());
|
|
157
158
|
moveTo(step);
|
|
159
|
+
onContentChange();
|
|
158
160
|
onChange(state());
|
|
159
161
|
return true;
|
|
160
162
|
});
|
|
@@ -24,7 +24,7 @@ function describePendingChanges(changes) {
|
|
|
24
24
|
return changes.length === 1 ? `${verb} ${changes[0].noun}` : `${verb} ${changes.length} objects`;
|
|
25
25
|
}
|
|
26
26
|
function createHistory(options) {
|
|
27
|
-
const { canvas, serializeObjects, onChange } = options;
|
|
27
|
+
const { canvas, serializeObjects, onChange, onContentChange } = options;
|
|
28
28
|
const stack = createHistoryStack(Math.max(1, options.limit ?? 100));
|
|
29
29
|
let snapshot = createSnapshot(serializeObjects());
|
|
30
30
|
let pendingChanges = [];
|
|
@@ -53,6 +53,7 @@ function createHistory(options) {
|
|
|
53
53
|
label,
|
|
54
54
|
...difference
|
|
55
55
|
});
|
|
56
|
+
onContentChange();
|
|
56
57
|
onChange(state());
|
|
57
58
|
return true;
|
|
58
59
|
}
|
|
@@ -155,6 +156,7 @@ function createHistory(options) {
|
|
|
155
156
|
}
|
|
156
157
|
snapshot = createSnapshot(serializeObjects());
|
|
157
158
|
moveTo(step);
|
|
159
|
+
onContentChange();
|
|
158
160
|
onChange(state());
|
|
159
161
|
return true;
|
|
160
162
|
});
|
package/dist/index.cjs
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
const require_errors = require("./engine/errors.cjs");
|
|
2
3
|
const require_document_format = require("./document/document-format.cjs");
|
|
3
4
|
const require_validate_document = require("./document/validate-document.cjs");
|
|
4
|
-
const require_errors = require("./engine/errors.cjs");
|
|
5
5
|
const require_create_document_engine = require("./engine/create-document-engine.cjs");
|
|
6
6
|
const require_keyboard_shortcuts = require("./history/keyboard-shortcuts.cjs");
|
|
7
|
+
const require_unsaved_changes_warning = require("./save/unsaved-changes-warning.cjs");
|
|
7
8
|
exports.CURRENT_SCHEMA_VERSION = require_document_format.CURRENT_SCHEMA_VERSION;
|
|
8
9
|
exports.DocumentEngineError = require_errors.DocumentEngineError;
|
|
9
10
|
exports.bindKeyboardShortcuts = require_keyboard_shortcuts.bindKeyboardShortcuts;
|
|
11
|
+
exports.bindUnsavedChangesWarning = require_unsaved_changes_warning.bindUnsavedChangesWarning;
|
|
12
|
+
exports.createConflictError = require_errors.createConflictError;
|
|
10
13
|
exports.createDocumentEngine = require_create_document_engine.createDocumentEngine;
|
|
11
14
|
exports.isDocumentEngineError = require_errors.isDocumentEngineError;
|
|
12
15
|
exports.validateDocument = require_validate_document.validateDocument;
|
package/dist/index.d.cts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { CURRENT_SCHEMA_VERSION, DocumentCanvas, DocumentInfo, FabricDocument, SerializedFabricObject } from "./document/document-format.cjs";
|
|
2
|
+
import { AssetManifest, FontAsset, ImageAsset } from "./assets/asset-manifest.cjs";
|
|
3
|
+
import { FontLoader } from "./assets/font-check.cjs";
|
|
4
|
+
import { AssetOptions, AssetReport, AssetWarning, AssetWarningCode, UploadRequest } from "./assets/asset-pipeline.cjs";
|
|
2
5
|
import { NewDocumentOptions } from "./document/create-document.cjs";
|
|
3
6
|
import { CustomObjectDefinition } from "./fabric/object-registry.cjs";
|
|
4
7
|
import { HistoryOptions, HistoryState } from "./history/create-history.cjs";
|
|
5
|
-
import {
|
|
8
|
+
import { AutosaveOptions } from "./save/autosave-scheduler.cjs";
|
|
9
|
+
import { RetryOptions } from "./save/retry.cjs";
|
|
10
|
+
import { DocumentEngineError, DocumentEngineErrorDetails, DocumentErrorCode, DocumentIssue, createConflictError, isDocumentEngineError } from "./engine/errors.cjs";
|
|
11
|
+
import { DocumentStorage, SaveContext, SaveResult } from "./storage/storage-contract.cjs";
|
|
12
|
+
import { SaveOptions, SaveRetryEvent, SaveState, SaveStatus } from "./save/save-controller.cjs";
|
|
6
13
|
import { Unsubscribe } from "./engine/event-emitter.cjs";
|
|
7
|
-
import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions,
|
|
14
|
+
import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions, LoadOptions, createDocumentEngine } from "./engine/create-document-engine.cjs";
|
|
8
15
|
import { validateDocument } from "./document/validate-document.cjs";
|
|
9
16
|
import { KeyboardShortcutOptions, UndoRedoTarget, bindKeyboardShortcuts } from "./history/keyboard-shortcuts.cjs";
|
|
10
|
-
|
|
17
|
+
import { UnsavedChangesSource, bindUnsavedChangesWarning } from "./save/unsaved-changes-warning.cjs";
|
|
18
|
+
export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type KeyboardShortcutOptions, type LoadOptions, type NewDocumentOptions, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, isDocumentEngineError, validateDocument };
|
package/dist/index.d.ts
CHANGED
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { CURRENT_SCHEMA_VERSION, DocumentCanvas, DocumentInfo, FabricDocument, SerializedFabricObject } from "./document/document-format.js";
|
|
2
|
+
import { AssetManifest, FontAsset, ImageAsset } from "./assets/asset-manifest.js";
|
|
3
|
+
import { FontLoader } from "./assets/font-check.js";
|
|
4
|
+
import { AssetOptions, AssetReport, AssetWarning, AssetWarningCode, UploadRequest } from "./assets/asset-pipeline.js";
|
|
2
5
|
import { NewDocumentOptions } from "./document/create-document.js";
|
|
3
6
|
import { CustomObjectDefinition } from "./fabric/object-registry.js";
|
|
4
7
|
import { HistoryOptions, HistoryState } from "./history/create-history.js";
|
|
5
|
-
import {
|
|
8
|
+
import { AutosaveOptions } from "./save/autosave-scheduler.js";
|
|
9
|
+
import { RetryOptions } from "./save/retry.js";
|
|
10
|
+
import { DocumentEngineError, DocumentEngineErrorDetails, DocumentErrorCode, DocumentIssue, createConflictError, isDocumentEngineError } from "./engine/errors.js";
|
|
11
|
+
import { DocumentStorage, SaveContext, SaveResult } from "./storage/storage-contract.js";
|
|
12
|
+
import { SaveOptions, SaveRetryEvent, SaveState, SaveStatus } from "./save/save-controller.js";
|
|
6
13
|
import { Unsubscribe } from "./engine/event-emitter.js";
|
|
7
|
-
import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions,
|
|
14
|
+
import { DocumentEngine, DocumentEngineEvents, DocumentEngineOptions, LoadOptions, createDocumentEngine } from "./engine/create-document-engine.js";
|
|
8
15
|
import { validateDocument } from "./document/validate-document.js";
|
|
9
16
|
import { KeyboardShortcutOptions, UndoRedoTarget, bindKeyboardShortcuts } from "./history/keyboard-shortcuts.js";
|
|
10
|
-
|
|
17
|
+
import { UnsavedChangesSource, bindUnsavedChangesWarning } from "./save/unsaved-changes-warning.js";
|
|
18
|
+
export { type AssetManifest, type AssetOptions, type AssetReport, type AssetWarning, type AssetWarningCode, type AutosaveOptions, CURRENT_SCHEMA_VERSION, type CustomObjectDefinition, type DocumentCanvas, type DocumentEngine, DocumentEngineError, type DocumentEngineErrorDetails, type DocumentEngineEvents, type DocumentEngineOptions, type DocumentErrorCode, type DocumentInfo, type DocumentIssue, type DocumentStorage, type FabricDocument, type FontAsset, type FontLoader, type HistoryOptions, type HistoryState, type ImageAsset, type KeyboardShortcutOptions, type LoadOptions, type NewDocumentOptions, type RetryOptions, type SaveContext, type SaveOptions, type SaveResult, type SaveRetryEvent, type SaveState, type SaveStatus, type SerializedFabricObject, type UndoRedoTarget, type UnsavedChangesSource, type Unsubscribe, type UploadRequest, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, isDocumentEngineError, validateDocument };
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import { DocumentEngineError, createConflictError, isDocumentEngineError } from "./engine/errors.js";
|
|
1
2
|
import { CURRENT_SCHEMA_VERSION } from "./document/document-format.js";
|
|
2
3
|
import { validateDocument } from "./document/validate-document.js";
|
|
3
|
-
import { DocumentEngineError, isDocumentEngineError } from "./engine/errors.js";
|
|
4
4
|
import { createDocumentEngine } from "./engine/create-document-engine.js";
|
|
5
5
|
import { bindKeyboardShortcuts } from "./history/keyboard-shortcuts.js";
|
|
6
|
-
|
|
6
|
+
import { bindUnsavedChangesWarning } from "./save/unsaved-changes-warning.js";
|
|
7
|
+
export { CURRENT_SCHEMA_VERSION, DocumentEngineError, bindKeyboardShortcuts, bindUnsavedChangesWarning, createConflictError, createDocumentEngine, isDocumentEngineError, validateDocument };
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/save/autosave-scheduler.ts
|
|
2
|
+
function createAutosaveScheduler(run, options = {}) {
|
|
3
|
+
const delay = options.delay ?? 1e3;
|
|
4
|
+
const maxWait = Math.max(delay, options.maxWait ?? 1e4);
|
|
5
|
+
let timer;
|
|
6
|
+
let firstRequestAt;
|
|
7
|
+
function cancel() {
|
|
8
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
9
|
+
timer = void 0;
|
|
10
|
+
firstRequestAt = void 0;
|
|
11
|
+
}
|
|
12
|
+
function fire() {
|
|
13
|
+
cancel();
|
|
14
|
+
run();
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
schedule() {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
firstRequestAt ?? (firstRequestAt = now);
|
|
20
|
+
const timeLeftBeforeMaxWait = maxWait - (now - firstRequestAt);
|
|
21
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
22
|
+
timer = setTimeout(fire, Math.max(0, Math.min(delay, timeLeftBeforeMaxWait)));
|
|
23
|
+
},
|
|
24
|
+
cancel
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
exports.createAutosaveScheduler = createAutosaveScheduler;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
//#region src/save/autosave-scheduler.ts
|
|
2
|
+
function createAutosaveScheduler(run, options = {}) {
|
|
3
|
+
const delay = options.delay ?? 1e3;
|
|
4
|
+
const maxWait = Math.max(delay, options.maxWait ?? 1e4);
|
|
5
|
+
let timer;
|
|
6
|
+
let firstRequestAt;
|
|
7
|
+
function cancel() {
|
|
8
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
9
|
+
timer = void 0;
|
|
10
|
+
firstRequestAt = void 0;
|
|
11
|
+
}
|
|
12
|
+
function fire() {
|
|
13
|
+
cancel();
|
|
14
|
+
run();
|
|
15
|
+
}
|
|
16
|
+
return {
|
|
17
|
+
schedule() {
|
|
18
|
+
const now = Date.now();
|
|
19
|
+
firstRequestAt ?? (firstRequestAt = now);
|
|
20
|
+
const timeLeftBeforeMaxWait = maxWait - (now - firstRequestAt);
|
|
21
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
22
|
+
timer = setTimeout(fire, Math.max(0, Math.min(delay, timeLeftBeforeMaxWait)));
|
|
23
|
+
},
|
|
24
|
+
cancel
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
//#endregion
|
|
28
|
+
export { createAutosaveScheduler };
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
//#region src/save/retry.ts
|
|
2
|
+
function isRetryable(error) {
|
|
3
|
+
const hints = error ?? {};
|
|
4
|
+
if (hints.code === "SAVE_CONFLICT" || hints.code === "SAVE_CANCELLED") return false;
|
|
5
|
+
return hints.retryable !== false;
|
|
6
|
+
}
|
|
7
|
+
function retryDelay(attempt, options = {}) {
|
|
8
|
+
const baseDelay = options.baseDelay ?? 500;
|
|
9
|
+
const maxDelay = options.maxDelay ?? 8e3;
|
|
10
|
+
const ceiling = Math.min(maxDelay, baseDelay * 2 ** attempt);
|
|
11
|
+
return ceiling / 2 + Math.random() * (ceiling / 2);
|
|
12
|
+
}
|
|
13
|
+
function wait(milliseconds, signal) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
if (signal.aborted) {
|
|
16
|
+
reject(/* @__PURE__ */ new Error("aborted"));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const stop = () => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
reject(/* @__PURE__ */ new Error("aborted"));
|
|
22
|
+
};
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
signal.removeEventListener("abort", stop);
|
|
25
|
+
resolve();
|
|
26
|
+
}, milliseconds);
|
|
27
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
exports.isRetryable = isRetryable;
|
|
32
|
+
exports.retryDelay = retryDelay;
|
|
33
|
+
exports.wait = wait;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
//#region src/save/retry.ts
|
|
2
|
+
function isRetryable(error) {
|
|
3
|
+
const hints = error ?? {};
|
|
4
|
+
if (hints.code === "SAVE_CONFLICT" || hints.code === "SAVE_CANCELLED") return false;
|
|
5
|
+
return hints.retryable !== false;
|
|
6
|
+
}
|
|
7
|
+
function retryDelay(attempt, options = {}) {
|
|
8
|
+
const baseDelay = options.baseDelay ?? 500;
|
|
9
|
+
const maxDelay = options.maxDelay ?? 8e3;
|
|
10
|
+
const ceiling = Math.min(maxDelay, baseDelay * 2 ** attempt);
|
|
11
|
+
return ceiling / 2 + Math.random() * (ceiling / 2);
|
|
12
|
+
}
|
|
13
|
+
function wait(milliseconds, signal) {
|
|
14
|
+
return new Promise((resolve, reject) => {
|
|
15
|
+
if (signal.aborted) {
|
|
16
|
+
reject(/* @__PURE__ */ new Error("aborted"));
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
const stop = () => {
|
|
20
|
+
clearTimeout(timer);
|
|
21
|
+
reject(/* @__PURE__ */ new Error("aborted"));
|
|
22
|
+
};
|
|
23
|
+
const timer = setTimeout(() => {
|
|
24
|
+
signal.removeEventListener("abort", stop);
|
|
25
|
+
resolve();
|
|
26
|
+
}, milliseconds);
|
|
27
|
+
signal.addEventListener("abort", stop, { once: true });
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
//#endregion
|
|
31
|
+
export { isRetryable, retryDelay, wait };
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
const require_errors = require("../engine/errors.cjs");
|
|
2
|
+
const require_autosave_scheduler = require("./autosave-scheduler.cjs");
|
|
3
|
+
const require_retry = require("./retry.cjs");
|
|
4
|
+
//#region src/save/save-controller.ts
|
|
5
|
+
function cancelledError() {
|
|
6
|
+
return new require_errors.DocumentEngineError("SAVE_CANCELLED", "The save was cancelled because another document was opened");
|
|
7
|
+
}
|
|
8
|
+
function toSaveError(error, signal) {
|
|
9
|
+
if (require_errors.isDocumentEngineError(error)) return error;
|
|
10
|
+
if (signal.aborted) return cancelledError();
|
|
11
|
+
const hints = error ?? {};
|
|
12
|
+
const reason = typeof hints.message === "string" ? hints.message : String(error);
|
|
13
|
+
if (hints.code === "SAVE_CONFLICT") return new require_errors.DocumentEngineError("SAVE_CONFLICT", reason, { cause: error });
|
|
14
|
+
return new require_errors.DocumentEngineError("SAVE_FAILED", `Storage could not save the document: ${reason}`, {
|
|
15
|
+
cause: error,
|
|
16
|
+
retryable: require_retry.isRetryable(error)
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function createSaveController(options) {
|
|
20
|
+
const attempts = Math.max(0, options.retry?.attempts ?? 3);
|
|
21
|
+
let contentVersion = 0;
|
|
22
|
+
let savedVersion = 0;
|
|
23
|
+
let revision = 0;
|
|
24
|
+
let lastSavedAt;
|
|
25
|
+
let lastError;
|
|
26
|
+
let saving = false;
|
|
27
|
+
let session = 0;
|
|
28
|
+
let sessionAbort = new AbortController();
|
|
29
|
+
let inFlight = null;
|
|
30
|
+
let queued = null;
|
|
31
|
+
const isDirty = () => contentVersion !== savedVersion;
|
|
32
|
+
function state() {
|
|
33
|
+
let status = isDirty() ? "unsaved" : "saved";
|
|
34
|
+
if (lastError) status = lastError.code === "SAVE_CONFLICT" ? "conflict" : "error";
|
|
35
|
+
if (saving) status = "saving";
|
|
36
|
+
return {
|
|
37
|
+
status,
|
|
38
|
+
isDirty: isDirty(),
|
|
39
|
+
isSaving: saving,
|
|
40
|
+
revision,
|
|
41
|
+
lastSavedAt,
|
|
42
|
+
error: lastError
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const publish = () => options.onStateChange(state());
|
|
46
|
+
const autosave = options.autosave === false || options.autosave === void 0 ? null : require_autosave_scheduler.createAutosaveScheduler(() => {
|
|
47
|
+
if (isDirty()) save().catch(() => void 0);
|
|
48
|
+
}, options.autosave);
|
|
49
|
+
async function sendWithRetries(storage, document, expectedRevision, signal) {
|
|
50
|
+
for (let attempt = 0;; attempt += 1) try {
|
|
51
|
+
return await storage.saveDocument(document, {
|
|
52
|
+
expectedRevision,
|
|
53
|
+
signal
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (signal.aborted || !require_retry.isRetryable(error) || attempt >= attempts) throw error;
|
|
57
|
+
const delay = require_retry.retryDelay(attempt, options.retry);
|
|
58
|
+
options.onRetry({
|
|
59
|
+
attempt: attempt + 1,
|
|
60
|
+
delay,
|
|
61
|
+
error
|
|
62
|
+
});
|
|
63
|
+
await require_retry.wait(delay, signal);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function runSave(saveOptions) {
|
|
67
|
+
const storage = options.getStorage();
|
|
68
|
+
const sessionAtStart = session;
|
|
69
|
+
const { signal } = sessionAbort;
|
|
70
|
+
const versionAtSnapshot = contentVersion;
|
|
71
|
+
const expectedRevision = saveOptions.overwrite === true ? null : revision;
|
|
72
|
+
const creating = options.createDocument();
|
|
73
|
+
saving = true;
|
|
74
|
+
lastError = void 0;
|
|
75
|
+
publish();
|
|
76
|
+
try {
|
|
77
|
+
const document = {
|
|
78
|
+
...await creating,
|
|
79
|
+
revision: revision + 1
|
|
80
|
+
};
|
|
81
|
+
options.onStart(document);
|
|
82
|
+
const result = await sendWithRetries(storage, document, expectedRevision, signal);
|
|
83
|
+
if (sessionAtStart !== session) return document;
|
|
84
|
+
revision = result?.revision ?? document.revision;
|
|
85
|
+
document.revision = revision;
|
|
86
|
+
savedVersion = versionAtSnapshot;
|
|
87
|
+
lastSavedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
88
|
+
options.onSuccess(document);
|
|
89
|
+
return document;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const saveError = toSaveError(error, signal);
|
|
92
|
+
if (sessionAtStart === session) {
|
|
93
|
+
lastError = saveError;
|
|
94
|
+
options.onError(saveError);
|
|
95
|
+
}
|
|
96
|
+
throw saveError;
|
|
97
|
+
} finally {
|
|
98
|
+
if (sessionAtStart === session) {
|
|
99
|
+
saving = false;
|
|
100
|
+
publish();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function save(saveOptions = {}) {
|
|
105
|
+
if (inFlight === null) {
|
|
106
|
+
inFlight = runSave(saveOptions).finally(() => {
|
|
107
|
+
inFlight = null;
|
|
108
|
+
});
|
|
109
|
+
return inFlight;
|
|
110
|
+
}
|
|
111
|
+
if (queued === null) {
|
|
112
|
+
const sessionWhenQueued = session;
|
|
113
|
+
queued = inFlight.catch(() => void 0).then(() => {
|
|
114
|
+
queued = null;
|
|
115
|
+
if (sessionWhenQueued !== session) throw cancelledError();
|
|
116
|
+
return save(saveOptions);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return queued;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
save,
|
|
123
|
+
noteContentChange() {
|
|
124
|
+
contentVersion += 1;
|
|
125
|
+
autosave?.schedule();
|
|
126
|
+
publish();
|
|
127
|
+
},
|
|
128
|
+
startSession(newRevision) {
|
|
129
|
+
session += 1;
|
|
130
|
+
sessionAbort.abort();
|
|
131
|
+
sessionAbort = new AbortController();
|
|
132
|
+
autosave?.cancel();
|
|
133
|
+
saving = false;
|
|
134
|
+
savedVersion = contentVersion;
|
|
135
|
+
revision = newRevision;
|
|
136
|
+
lastSavedAt = void 0;
|
|
137
|
+
lastError = void 0;
|
|
138
|
+
publish();
|
|
139
|
+
},
|
|
140
|
+
state,
|
|
141
|
+
destroy() {
|
|
142
|
+
session += 1;
|
|
143
|
+
sessionAbort.abort();
|
|
144
|
+
autosave?.cancel();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
exports.createSaveController = createSaveController;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { DocumentEngineError } from "../engine/errors.cjs";
|
|
2
|
+
//#region src/save/save-controller.d.ts
|
|
3
|
+
export type SaveStatus = "saved" | "unsaved" | "saving" | "error" | "conflict";
|
|
4
|
+
export interface SaveState {
|
|
5
|
+
status: SaveStatus;
|
|
6
|
+
isDirty: boolean;
|
|
7
|
+
isSaving: boolean;
|
|
8
|
+
revision: number;
|
|
9
|
+
lastSavedAt: string | undefined;
|
|
10
|
+
error: DocumentEngineError | undefined;
|
|
11
|
+
}
|
|
12
|
+
export interface SaveOptions {
|
|
13
|
+
overwrite?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface SaveRetryEvent {
|
|
16
|
+
attempt: number;
|
|
17
|
+
delay: number;
|
|
18
|
+
error: unknown;
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { DocumentEngineError } from "../engine/errors.js";
|
|
2
|
+
//#region src/save/save-controller.d.ts
|
|
3
|
+
export type SaveStatus = "saved" | "unsaved" | "saving" | "error" | "conflict";
|
|
4
|
+
export interface SaveState {
|
|
5
|
+
status: SaveStatus;
|
|
6
|
+
isDirty: boolean;
|
|
7
|
+
isSaving: boolean;
|
|
8
|
+
revision: number;
|
|
9
|
+
lastSavedAt: string | undefined;
|
|
10
|
+
error: DocumentEngineError | undefined;
|
|
11
|
+
}
|
|
12
|
+
export interface SaveOptions {
|
|
13
|
+
overwrite?: boolean;
|
|
14
|
+
}
|
|
15
|
+
export interface SaveRetryEvent {
|
|
16
|
+
attempt: number;
|
|
17
|
+
delay: number;
|
|
18
|
+
error: unknown;
|
|
19
|
+
}
|
|
20
|
+
//#endregion
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { DocumentEngineError, isDocumentEngineError } from "../engine/errors.js";
|
|
2
|
+
import { createAutosaveScheduler } from "./autosave-scheduler.js";
|
|
3
|
+
import { isRetryable, retryDelay, wait } from "./retry.js";
|
|
4
|
+
//#region src/save/save-controller.ts
|
|
5
|
+
function cancelledError() {
|
|
6
|
+
return new DocumentEngineError("SAVE_CANCELLED", "The save was cancelled because another document was opened");
|
|
7
|
+
}
|
|
8
|
+
function toSaveError(error, signal) {
|
|
9
|
+
if (isDocumentEngineError(error)) return error;
|
|
10
|
+
if (signal.aborted) return cancelledError();
|
|
11
|
+
const hints = error ?? {};
|
|
12
|
+
const reason = typeof hints.message === "string" ? hints.message : String(error);
|
|
13
|
+
if (hints.code === "SAVE_CONFLICT") return new DocumentEngineError("SAVE_CONFLICT", reason, { cause: error });
|
|
14
|
+
return new DocumentEngineError("SAVE_FAILED", `Storage could not save the document: ${reason}`, {
|
|
15
|
+
cause: error,
|
|
16
|
+
retryable: isRetryable(error)
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function createSaveController(options) {
|
|
20
|
+
const attempts = Math.max(0, options.retry?.attempts ?? 3);
|
|
21
|
+
let contentVersion = 0;
|
|
22
|
+
let savedVersion = 0;
|
|
23
|
+
let revision = 0;
|
|
24
|
+
let lastSavedAt;
|
|
25
|
+
let lastError;
|
|
26
|
+
let saving = false;
|
|
27
|
+
let session = 0;
|
|
28
|
+
let sessionAbort = new AbortController();
|
|
29
|
+
let inFlight = null;
|
|
30
|
+
let queued = null;
|
|
31
|
+
const isDirty = () => contentVersion !== savedVersion;
|
|
32
|
+
function state() {
|
|
33
|
+
let status = isDirty() ? "unsaved" : "saved";
|
|
34
|
+
if (lastError) status = lastError.code === "SAVE_CONFLICT" ? "conflict" : "error";
|
|
35
|
+
if (saving) status = "saving";
|
|
36
|
+
return {
|
|
37
|
+
status,
|
|
38
|
+
isDirty: isDirty(),
|
|
39
|
+
isSaving: saving,
|
|
40
|
+
revision,
|
|
41
|
+
lastSavedAt,
|
|
42
|
+
error: lastError
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
const publish = () => options.onStateChange(state());
|
|
46
|
+
const autosave = options.autosave === false || options.autosave === void 0 ? null : createAutosaveScheduler(() => {
|
|
47
|
+
if (isDirty()) save().catch(() => void 0);
|
|
48
|
+
}, options.autosave);
|
|
49
|
+
async function sendWithRetries(storage, document, expectedRevision, signal) {
|
|
50
|
+
for (let attempt = 0;; attempt += 1) try {
|
|
51
|
+
return await storage.saveDocument(document, {
|
|
52
|
+
expectedRevision,
|
|
53
|
+
signal
|
|
54
|
+
});
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (signal.aborted || !isRetryable(error) || attempt >= attempts) throw error;
|
|
57
|
+
const delay = retryDelay(attempt, options.retry);
|
|
58
|
+
options.onRetry({
|
|
59
|
+
attempt: attempt + 1,
|
|
60
|
+
delay,
|
|
61
|
+
error
|
|
62
|
+
});
|
|
63
|
+
await wait(delay, signal);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async function runSave(saveOptions) {
|
|
67
|
+
const storage = options.getStorage();
|
|
68
|
+
const sessionAtStart = session;
|
|
69
|
+
const { signal } = sessionAbort;
|
|
70
|
+
const versionAtSnapshot = contentVersion;
|
|
71
|
+
const expectedRevision = saveOptions.overwrite === true ? null : revision;
|
|
72
|
+
const creating = options.createDocument();
|
|
73
|
+
saving = true;
|
|
74
|
+
lastError = void 0;
|
|
75
|
+
publish();
|
|
76
|
+
try {
|
|
77
|
+
const document = {
|
|
78
|
+
...await creating,
|
|
79
|
+
revision: revision + 1
|
|
80
|
+
};
|
|
81
|
+
options.onStart(document);
|
|
82
|
+
const result = await sendWithRetries(storage, document, expectedRevision, signal);
|
|
83
|
+
if (sessionAtStart !== session) return document;
|
|
84
|
+
revision = result?.revision ?? document.revision;
|
|
85
|
+
document.revision = revision;
|
|
86
|
+
savedVersion = versionAtSnapshot;
|
|
87
|
+
lastSavedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
88
|
+
options.onSuccess(document);
|
|
89
|
+
return document;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const saveError = toSaveError(error, signal);
|
|
92
|
+
if (sessionAtStart === session) {
|
|
93
|
+
lastError = saveError;
|
|
94
|
+
options.onError(saveError);
|
|
95
|
+
}
|
|
96
|
+
throw saveError;
|
|
97
|
+
} finally {
|
|
98
|
+
if (sessionAtStart === session) {
|
|
99
|
+
saving = false;
|
|
100
|
+
publish();
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
function save(saveOptions = {}) {
|
|
105
|
+
if (inFlight === null) {
|
|
106
|
+
inFlight = runSave(saveOptions).finally(() => {
|
|
107
|
+
inFlight = null;
|
|
108
|
+
});
|
|
109
|
+
return inFlight;
|
|
110
|
+
}
|
|
111
|
+
if (queued === null) {
|
|
112
|
+
const sessionWhenQueued = session;
|
|
113
|
+
queued = inFlight.catch(() => void 0).then(() => {
|
|
114
|
+
queued = null;
|
|
115
|
+
if (sessionWhenQueued !== session) throw cancelledError();
|
|
116
|
+
return save(saveOptions);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return queued;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
save,
|
|
123
|
+
noteContentChange() {
|
|
124
|
+
contentVersion += 1;
|
|
125
|
+
autosave?.schedule();
|
|
126
|
+
publish();
|
|
127
|
+
},
|
|
128
|
+
startSession(newRevision) {
|
|
129
|
+
session += 1;
|
|
130
|
+
sessionAbort.abort();
|
|
131
|
+
sessionAbort = new AbortController();
|
|
132
|
+
autosave?.cancel();
|
|
133
|
+
saving = false;
|
|
134
|
+
savedVersion = contentVersion;
|
|
135
|
+
revision = newRevision;
|
|
136
|
+
lastSavedAt = void 0;
|
|
137
|
+
lastError = void 0;
|
|
138
|
+
publish();
|
|
139
|
+
},
|
|
140
|
+
state,
|
|
141
|
+
destroy() {
|
|
142
|
+
session += 1;
|
|
143
|
+
sessionAbort.abort();
|
|
144
|
+
autosave?.cancel();
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
//#endregion
|
|
149
|
+
export { createSaveController };
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/save/unsaved-changes-warning.ts
|
|
2
|
+
function bindUnsavedChangesWarning(engine, target = globalThis.window) {
|
|
3
|
+
const handleBeforeUnload = (event) => {
|
|
4
|
+
if (!engine.isDirty()) return;
|
|
5
|
+
event.preventDefault();
|
|
6
|
+
event.returnValue = "";
|
|
7
|
+
};
|
|
8
|
+
target.addEventListener("beforeunload", handleBeforeUnload);
|
|
9
|
+
return () => target.removeEventListener("beforeunload", handleBeforeUnload);
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
exports.bindUnsavedChangesWarning = bindUnsavedChangesWarning;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
//#region src/save/unsaved-changes-warning.ts
|
|
2
|
+
function bindUnsavedChangesWarning(engine, target = globalThis.window) {
|
|
3
|
+
const handleBeforeUnload = (event) => {
|
|
4
|
+
if (!engine.isDirty()) return;
|
|
5
|
+
event.preventDefault();
|
|
6
|
+
event.returnValue = "";
|
|
7
|
+
};
|
|
8
|
+
target.addEventListener("beforeunload", handleBeforeUnload);
|
|
9
|
+
return () => target.removeEventListener("beforeunload", handleBeforeUnload);
|
|
10
|
+
}
|
|
11
|
+
//#endregion
|
|
12
|
+
export { bindUnsavedChangesWarning };
|