@pixerate/editor 0.277.0 → 0.279.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.
@@ -21,6 +21,8 @@ export interface RecoveryMeta {
21
21
  time?: number;
22
22
  /** Document name, for the "Restore unsaved changes from …?" prompt. */
23
23
  name?: string;
24
+ /** The server / host version the edits in the snapshot were made on (`baseVersion` option). */
25
+ baseVersion?: number;
24
26
  }
25
27
  export interface AutosaveRecovery<T = unknown> {
26
28
  /** The parsed snapshot (`undefined` when `deserialize` threw; see `error`). */
@@ -65,6 +67,18 @@ export interface AutosaveControllerOptions<T> {
65
67
  clearTimeout?: (handle: any) => void;
66
68
  /** Start hydrated, i.e. as if `ready()` was already called (default `false`). */
67
69
  ready?: boolean;
70
+ /**
71
+ * The server / host version of the document that is loaded now. It is stored in each
72
+ * snapshot's meta, and `checkRecovery` retires (deletes and does not return) a snapshot whose
73
+ * edits were made on an older version, so it never overwrites newer server state. A snapshot
74
+ * without a stored version counts as version 0.
75
+ */
76
+ baseVersion?: number | (() => number | undefined);
77
+ }
78
+ /** Options for `AutosaveController.checkRecovery`. */
79
+ export interface CheckRecoveryOptions {
80
+ /** Retire snapshots made on a version older than this (default the `baseVersion` option). */
81
+ minimumBaseVersion?: number;
68
82
  }
69
83
  export interface AutosaveController<T = unknown> {
70
84
  /** Current status. */
@@ -91,7 +105,7 @@ export interface AutosaveController<T = unknown> {
91
105
  * A dirty snapshot left by an earlier session, or `null`. Returns `null` once
92
106
  * this session has written its own snapshot.
93
107
  */
94
- checkRecovery(): Promise<AutosaveRecovery<T> | null>;
108
+ checkRecovery(options?: CheckRecoveryOptions): Promise<AutosaveRecovery<T> | null>;
95
109
  /** Deletes an earlier session's snapshot. No-op once this session wrote its own. */
96
110
  discardRecovery(): Promise<boolean>;
97
111
  /** Runs one scheduling step now (normally driven by `notify` and timers). */
@@ -21,6 +21,8 @@ export interface RecoveryMeta {
21
21
  time?: number;
22
22
  /** Document name, for the "Restore unsaved changes from …?" prompt. */
23
23
  name?: string;
24
+ /** The server / host version the edits in the snapshot were made on (`baseVersion` option). */
25
+ baseVersion?: number;
24
26
  }
25
27
  export interface AutosaveRecovery<T = unknown> {
26
28
  /** The parsed snapshot (`undefined` when `deserialize` threw; see `error`). */
@@ -65,6 +67,18 @@ export interface AutosaveControllerOptions<T> {
65
67
  clearTimeout?: (handle: any) => void;
66
68
  /** Start hydrated, i.e. as if `ready()` was already called (default `false`). */
67
69
  ready?: boolean;
70
+ /**
71
+ * The server / host version of the document that is loaded now. It is stored in each
72
+ * snapshot's meta, and `checkRecovery` retires (deletes and does not return) a snapshot whose
73
+ * edits were made on an older version, so it never overwrites newer server state. A snapshot
74
+ * without a stored version counts as version 0.
75
+ */
76
+ baseVersion?: number | (() => number | undefined);
77
+ }
78
+ /** Options for `AutosaveController.checkRecovery`. */
79
+ export interface CheckRecoveryOptions {
80
+ /** Retire snapshots made on a version older than this (default the `baseVersion` option). */
81
+ minimumBaseVersion?: number;
68
82
  }
69
83
  export interface AutosaveController<T = unknown> {
70
84
  /** Current status. */
@@ -91,7 +105,7 @@ export interface AutosaveController<T = unknown> {
91
105
  * A dirty snapshot left by an earlier session, or `null`. Returns `null` once
92
106
  * this session has written its own snapshot.
93
107
  */
94
- checkRecovery(): Promise<AutosaveRecovery<T> | null>;
108
+ checkRecovery(options?: CheckRecoveryOptions): Promise<AutosaveRecovery<T> | null>;
95
109
  /** Deletes an earlier session's snapshot. No-op once this session wrote its own. */
96
110
  discardRecovery(): Promise<boolean>;
97
111
  /** Runs one scheduling step now (normally driven by `notify` and timers). */
@@ -11,6 +11,12 @@ export interface EditorAutosaveOptions extends Omit<AutosaveControllerOptions<Ed
11
11
  * saved state needs no snapshot. Without it, every change counts until `markClean()`.
12
12
  */
13
13
  dirty?: DirtyFlagSource;
14
+ /**
15
+ * The server / host version of the loaded document, stored with each snapshot.
16
+ * `checkRecovery` / `recoverIfNewer` retire snapshots made on an older version instead of
17
+ * offering them (see `AutosaveControllerOptions.baseVersion`).
18
+ */
19
+ baseVersion?: number | (() => number | undefined);
14
20
  /**
15
21
  * For documents with `assets`: after `markClean` or `discard` retire a snapshot, delete the
16
22
  * stored assets nothing refers to any more (`collectEditorAssetGarbage`, best effort, keeping
@@ -21,8 +27,13 @@ export interface EditorAutosaveOptions extends Omit<AutosaveControllerOptions<Ed
21
27
  export interface EditorAutosave {
22
28
  readonly controller: AutosaveController<EditorDocumentFile>;
23
29
  readonly revisions: RevisionTracker;
24
- /** An earlier session's unsaved snapshot of this document, or `null`. */
30
+ /** An earlier session's unsaved snapshot of this document, or `null` (snapshots made on a version older than `baseVersion` are retired). */
25
31
  checkRecovery(): Promise<AutosaveRecovery<EditorDocumentFile> | null>;
32
+ /**
33
+ * Checks for an earlier session's unsaved snapshot, restoring it into `doc` if it is valid and
34
+ * at least as new as `baseVersion`, or retiring it and starting autosave cleanly if stale/invalid.
35
+ */
36
+ recoverIfNewer(minimumBaseVersion?: number): Promise<LoadReport | null>;
26
37
  /** Loads a recovered snapshot into the document and starts autosaving. */
27
38
  restore(recovery: AutosaveRecovery<EditorDocumentFile>): LoadReport;
28
39
  /** Deletes the earlier session's snapshot and starts autosaving. */
@@ -11,6 +11,12 @@ export interface EditorAutosaveOptions extends Omit<AutosaveControllerOptions<Ed
11
11
  * saved state needs no snapshot. Without it, every change counts until `markClean()`.
12
12
  */
13
13
  dirty?: DirtyFlagSource;
14
+ /**
15
+ * The server / host version of the loaded document, stored with each snapshot.
16
+ * `checkRecovery` / `recoverIfNewer` retire snapshots made on an older version instead of
17
+ * offering them (see `AutosaveControllerOptions.baseVersion`).
18
+ */
19
+ baseVersion?: number | (() => number | undefined);
14
20
  /**
15
21
  * For documents with `assets`: after `markClean` or `discard` retire a snapshot, delete the
16
22
  * stored assets nothing refers to any more (`collectEditorAssetGarbage`, best effort, keeping
@@ -21,8 +27,13 @@ export interface EditorAutosaveOptions extends Omit<AutosaveControllerOptions<Ed
21
27
  export interface EditorAutosave {
22
28
  readonly controller: AutosaveController<EditorDocumentFile>;
23
29
  readonly revisions: RevisionTracker;
24
- /** An earlier session's unsaved snapshot of this document, or `null`. */
30
+ /** An earlier session's unsaved snapshot of this document, or `null` (snapshots made on a version older than `baseVersion` are retired). */
25
31
  checkRecovery(): Promise<AutosaveRecovery<EditorDocumentFile> | null>;
32
+ /**
33
+ * Checks for an earlier session's unsaved snapshot, restoring it into `doc` if it is valid and
34
+ * at least as new as `baseVersion`, or retiring it and starting autosave cleanly if stale/invalid.
35
+ */
36
+ recoverIfNewer(minimumBaseVersion?: number): Promise<LoadReport | null>;
26
37
  /** Loads a recovered snapshot into the document and starts autosaving. */
27
38
  restore(recovery: AutosaveRecovery<EditorDocumentFile>): LoadReport;
28
39
  /** Deletes the earlier session's snapshot and starts autosaving. */
@@ -19,7 +19,9 @@
19
19
 
20
20
 
21
21
 
22
- var _chunkT2HUQBRCcjs = require('../chunk-T2HUQBRC.cjs');
22
+
23
+
24
+ var _chunkDK33HLA2cjs = require('../chunk-DK33HLA2.cjs');
23
25
 
24
26
 
25
27
 
@@ -92,4 +94,6 @@ require('../chunk-2BSBFBOA.cjs');
92
94
 
93
95
 
94
96
 
95
- exports.EDITOR_ASSET_PREFIX = _chunkSJ2CPTQCcjs.EDITOR_ASSET_PREFIX; exports.EDITOR_DOCUMENT_FORMAT = _chunkSJ2CPTQCcjs.EDITOR_DOCUMENT_FORMAT; exports.EditorDocumentLoadError = _chunkSJ2CPTQCcjs.EditorDocumentLoadError; exports.InjectedStorageError = _chunkT2HUQBRCcjs.InjectedStorageError; exports.StorageUnavailableError = _chunkT2HUQBRCcjs.StorageUnavailableError; exports.collectAssetRefs = _chunkSJ2CPTQCcjs.collectAssetRefs; exports.collectEditorAssetGarbage = _chunkSJ2CPTQCcjs.collectEditorAssetGarbage; exports.collectRetiredEditorAssets = _chunkSJ2CPTQCcjs.collectRetiredEditorAssets; exports.createAutosaveController = _chunkT2HUQBRCcjs.createAutosaveController; exports.createDocumentToolProvider = _chunkT2HUQBRCcjs.createDocumentToolProvider; exports.createEditorAssetRegistry = _chunkSJ2CPTQCcjs.createEditorAssetRegistry; exports.createEditorAutosave = _chunkT2HUQBRCcjs.createEditorAutosave; exports.createEditorDocumentStore = _chunkT2HUQBRCcjs.createEditorDocumentStore; exports.createIndexedDbAdapter = _chunkT2HUQBRCcjs.createIndexedDbAdapter; exports.createLoadReport = _chunkSJ2CPTQCcjs.createLoadReport; exports.createLocalStorageAdapter = _chunkT2HUQBRCcjs.createLocalStorageAdapter; exports.createMemoryStorage = _chunkT2HUQBRCcjs.createMemoryStorage; exports.createOpfsAdapter = _chunkT2HUQBRCcjs.createOpfsAdapter; exports.createRecoveryPolicy = _chunkT2HUQBRCcjs.createRecoveryPolicy; exports.createRevisionTracker = _chunkT2HUQBRCcjs.createRevisionTracker; exports.defineEditorDocument = _chunkSJ2CPTQCcjs.defineEditorDocument; exports.exponentialBackoff = _chunkT2HUQBRCcjs.exponentialBackoff; exports.externalizeAssets = _chunkSJ2CPTQCcjs.externalizeAssets; exports.formatRecoveryMessage = _chunkT2HUQBRCcjs.formatRecoveryMessage; exports.hasLoadProblems = _chunkSJ2CPTQCcjs.hasLoadProblems; exports.internalizeAssets = _chunkSJ2CPTQCcjs.internalizeAssets; exports.isEditorDocumentFile = _chunkSJ2CPTQCcjs.isEditorDocumentFile; exports.isIndexedDbAvailable = _chunkT2HUQBRCcjs.isIndexedDbAvailable; exports.isInlineAsset = _chunkSJ2CPTQCcjs.isInlineAsset; exports.isLocalStorageAvailable = _chunkT2HUQBRCcjs.isLocalStorageAvailable; exports.isOpfsAvailable = _chunkT2HUQBRCcjs.isOpfsAvailable; exports.loadDocument = _chunkSJ2CPTQCcjs.loadDocument; exports.loadSections = _chunkSJ2CPTQCcjs.loadSections; exports.migrateDocument = _chunkSJ2CPTQCcjs.migrateDocument; exports.parseEditorDocument = _chunkSJ2CPTQCcjs.parseEditorDocument; exports.persistEditorAssets = _chunkSJ2CPTQCcjs.persistEditorAssets; exports.preloadEditorAssets = _chunkSJ2CPTQCcjs.preloadEditorAssets; exports.recoveryMetaKey = _chunkT2HUQBRCcjs.recoveryMetaKey; exports.serializeEditorDocument = _chunkSJ2CPTQCcjs.serializeEditorDocument; exports.slugifyDocumentName = _chunkT2HUQBRCcjs.slugifyDocumentName; exports.toEditorDocumentFile = _chunkSJ2CPTQCcjs.toEditorDocumentFile; exports.tryCollectEditorAssetGarbage = _chunkSJ2CPTQCcjs.tryCollectEditorAssetGarbage; exports.withEditorAssets = _chunkSJ2CPTQCcjs.withEditorAssets; exports.withStorageFailures = _chunkT2HUQBRCcjs.withStorageFailures;
97
+
98
+
99
+ exports.EDITOR_ASSET_PREFIX = _chunkSJ2CPTQCcjs.EDITOR_ASSET_PREFIX; exports.EDITOR_DOCUMENT_FORMAT = _chunkSJ2CPTQCcjs.EDITOR_DOCUMENT_FORMAT; exports.EditorDocumentLoadError = _chunkSJ2CPTQCcjs.EditorDocumentLoadError; exports.EditorRemoteConflictError = _chunkDK33HLA2cjs.EditorRemoteConflictError; exports.InjectedStorageError = _chunkDK33HLA2cjs.InjectedStorageError; exports.StorageUnavailableError = _chunkDK33HLA2cjs.StorageUnavailableError; exports.collectAssetRefs = _chunkSJ2CPTQCcjs.collectAssetRefs; exports.collectEditorAssetGarbage = _chunkSJ2CPTQCcjs.collectEditorAssetGarbage; exports.collectRetiredEditorAssets = _chunkSJ2CPTQCcjs.collectRetiredEditorAssets; exports.createAutosaveController = _chunkDK33HLA2cjs.createAutosaveController; exports.createDocumentToolProvider = _chunkDK33HLA2cjs.createDocumentToolProvider; exports.createEditorAssetRegistry = _chunkSJ2CPTQCcjs.createEditorAssetRegistry; exports.createEditorAutosave = _chunkDK33HLA2cjs.createEditorAutosave; exports.createEditorDocumentStore = _chunkDK33HLA2cjs.createEditorDocumentStore; exports.createEditorRemoteSync = _chunkDK33HLA2cjs.createEditorRemoteSync; exports.createIndexedDbAdapter = _chunkDK33HLA2cjs.createIndexedDbAdapter; exports.createLoadReport = _chunkSJ2CPTQCcjs.createLoadReport; exports.createLocalStorageAdapter = _chunkDK33HLA2cjs.createLocalStorageAdapter; exports.createMemoryStorage = _chunkDK33HLA2cjs.createMemoryStorage; exports.createOpfsAdapter = _chunkDK33HLA2cjs.createOpfsAdapter; exports.createRecoveryPolicy = _chunkDK33HLA2cjs.createRecoveryPolicy; exports.createRevisionTracker = _chunkDK33HLA2cjs.createRevisionTracker; exports.defineEditorDocument = _chunkSJ2CPTQCcjs.defineEditorDocument; exports.exponentialBackoff = _chunkDK33HLA2cjs.exponentialBackoff; exports.externalizeAssets = _chunkSJ2CPTQCcjs.externalizeAssets; exports.formatRecoveryMessage = _chunkDK33HLA2cjs.formatRecoveryMessage; exports.hasLoadProblems = _chunkSJ2CPTQCcjs.hasLoadProblems; exports.internalizeAssets = _chunkSJ2CPTQCcjs.internalizeAssets; exports.isEditorDocumentFile = _chunkSJ2CPTQCcjs.isEditorDocumentFile; exports.isIndexedDbAvailable = _chunkDK33HLA2cjs.isIndexedDbAvailable; exports.isInlineAsset = _chunkSJ2CPTQCcjs.isInlineAsset; exports.isLocalStorageAvailable = _chunkDK33HLA2cjs.isLocalStorageAvailable; exports.isOpfsAvailable = _chunkDK33HLA2cjs.isOpfsAvailable; exports.loadDocument = _chunkSJ2CPTQCcjs.loadDocument; exports.loadSections = _chunkSJ2CPTQCcjs.loadSections; exports.migrateDocument = _chunkSJ2CPTQCcjs.migrateDocument; exports.parseEditorDocument = _chunkSJ2CPTQCcjs.parseEditorDocument; exports.persistEditorAssets = _chunkSJ2CPTQCcjs.persistEditorAssets; exports.preloadEditorAssets = _chunkSJ2CPTQCcjs.preloadEditorAssets; exports.recoveryMetaKey = _chunkDK33HLA2cjs.recoveryMetaKey; exports.serializeEditorDocument = _chunkSJ2CPTQCcjs.serializeEditorDocument; exports.slugifyDocumentName = _chunkDK33HLA2cjs.slugifyDocumentName; exports.toEditorDocumentFile = _chunkSJ2CPTQCcjs.toEditorDocumentFile; exports.tryCollectEditorAssetGarbage = _chunkSJ2CPTQCcjs.tryCollectEditorAssetGarbage; exports.withEditorAssets = _chunkSJ2CPTQCcjs.withEditorAssets; exports.withStorageFailures = _chunkDK33HLA2cjs.withStorageFailures;
@@ -5,6 +5,7 @@ export * from "./migrate.cjs";
5
5
  export * from "./revisions.cjs";
6
6
  export * from "./editorDocument.cjs";
7
7
  export * from "./editorAutosave.cjs";
8
+ export * from "./remoteSync.cjs";
8
9
  export * from "./documentStore.cjs";
9
10
  export * from "./documentTools.cjs";
10
11
  export * from "./assets.cjs";
@@ -5,6 +5,7 @@ export * from "./migrate.js";
5
5
  export * from "./revisions.js";
6
6
  export * from "./editorDocument.js";
7
7
  export * from "./editorAutosave.js";
8
+ export * from "./remoteSync.js";
8
9
  export * from "./documentStore.js";
9
10
  export * from "./documentTools.js";
10
11
  export * from "./assets.js";
@@ -1,10 +1,12 @@
1
1
  import {
2
+ EditorRemoteConflictError,
2
3
  InjectedStorageError,
3
4
  StorageUnavailableError,
4
5
  createAutosaveController,
5
6
  createDocumentToolProvider,
6
7
  createEditorAutosave,
7
8
  createEditorDocumentStore,
9
+ createEditorRemoteSync,
8
10
  createIndexedDbAdapter,
9
11
  createLocalStorageAdapter,
10
12
  createMemoryStorage,
@@ -19,7 +21,7 @@ import {
19
21
  recoveryMetaKey,
20
22
  slugifyDocumentName,
21
23
  withStorageFailures
22
- } from "../chunk-JV7DKLMX.js";
24
+ } from "../chunk-KBH6CM3U.js";
23
25
  import {
24
26
  EDITOR_ASSET_PREFIX,
25
27
  EDITOR_DOCUMENT_FORMAT,
@@ -51,6 +53,7 @@ export {
51
53
  EDITOR_ASSET_PREFIX,
52
54
  EDITOR_DOCUMENT_FORMAT,
53
55
  EditorDocumentLoadError,
56
+ EditorRemoteConflictError,
54
57
  InjectedStorageError,
55
58
  StorageUnavailableError,
56
59
  collectAssetRefs,
@@ -61,6 +64,7 @@ export {
61
64
  createEditorAssetRegistry,
62
65
  createEditorAutosave,
63
66
  createEditorDocumentStore,
67
+ createEditorRemoteSync,
64
68
  createIndexedDbAdapter,
65
69
  createLoadReport,
66
70
  createLocalStorageAdapter,
@@ -0,0 +1,152 @@
1
+ import type { EditorDocument } from "./editorDocument.cjs";
2
+ import type { EditorAutosave } from "./editorAutosave.cjs";
3
+ export type EditorRemoteSyncState = "idle" | "pending" | "saving" | "saved" | "error";
4
+ export interface EditorRemoteSyncStatus {
5
+ status: EditorRemoteSyncState;
6
+ version: number;
7
+ savedVersion: number;
8
+ dirty: boolean;
9
+ lastSavedAt: number | null;
10
+ error?: unknown;
11
+ }
12
+ export interface EditorRemoteSaveContext<TDocument = unknown, TDiff = unknown> {
13
+ document: TDocument;
14
+ /** The version to write. */
15
+ version: number;
16
+ /**
17
+ * The server version this save builds on (the last one confirmed). For a compare-and-set
18
+ * write, update only where the stored version still equals it, and return `{ conflict: true }`
19
+ * when nothing was updated.
20
+ */
21
+ baseVersion: number;
22
+ /** Changes since the last saved snapshot (with the `diff` option). */
23
+ diff?: TDiff;
24
+ }
25
+ export interface EditorRemoteSaveResult {
26
+ version?: number;
27
+ /**
28
+ * The server holds a newer version than `baseVersion`, so nothing was written. The controller
29
+ * calls `onConflict` (or reports an `EditorRemoteConflictError` without it) and keeps the edits.
30
+ */
31
+ conflict?: boolean;
32
+ }
33
+ export interface EditorRemoteConflictContext<TDocument = unknown> {
34
+ /** What this session tried to save. */
35
+ document: TDocument;
36
+ /** The last snapshot the server confirmed: the common base for a three-way merge. */
37
+ base: TDocument;
38
+ /** The server version reported with the conflict, when `save` returned one. */
39
+ serverVersion?: number;
40
+ /**
41
+ * Runs `fn` (e.g. loading the merged document into the editor) without it counting as a local
42
+ * change, so it is not broadcast as one; the retry save picks the result up.
43
+ */
44
+ apply<TResult>(fn: () => TResult): TResult;
45
+ }
46
+ export interface EditorRemoteConflictResolution<TDocument = unknown> {
47
+ /** The server's version the merge was made on; the retry save builds on it. */
48
+ serverVersion: number;
49
+ /** The server's document, used as the base for the retry's `diff` (default: the old base). */
50
+ serverDocument?: TDocument;
51
+ }
52
+ /** Reported through `onError` / `status().error` when a save conflicts and is not resolved. */
53
+ export declare class EditorRemoteConflictError extends Error {
54
+ readonly serverVersion?: number | undefined;
55
+ constructor(serverVersion?: number | undefined);
56
+ }
57
+ export interface EditorRemoteLocalChangeEvent<TDocument = unknown, TDiff = unknown> {
58
+ document: TDocument;
59
+ baseline: TDocument;
60
+ diff?: TDiff;
61
+ version: number;
62
+ }
63
+ export interface EditorRemoteSyncOptions<TDocument = unknown, TDiff = unknown> {
64
+ /**
65
+ * Persists the latest document snapshot and version to the remote backend.
66
+ * May return `{ version }` or a numeric `version` if the server advanced the version higher.
67
+ */
68
+ save: (context: EditorRemoteSaveContext<TDocument, TDiff>) => Promise<EditorRemoteSaveResult | number | void>;
69
+ /** Initial known server version of the document (default `0`). */
70
+ initialVersion?: number;
71
+ /** Debounce delay in milliseconds after a local edit before calling `save` (default `500`). */
72
+ debounceMs?: number;
73
+ /**
74
+ * Optional diff calculator. `onLocalChange` receives the diff from the previous local snapshot
75
+ * (the one edit); `save` receives the diff from the last saved snapshot (every edit it covers).
76
+ */
77
+ diff?: (current: TDocument, baseline: TDocument) => TDiff;
78
+ /**
79
+ * Optional predicate returning `true` when `diff` represents no meaningful change.
80
+ * When omitted, compares `JSON.stringify(current) === JSON.stringify(baseline)`.
81
+ */
82
+ isEmptyDiff?: (diff: TDiff, current: TDocument, baseline: TDocument) => boolean;
83
+ /**
84
+ * Invoked immediately whenever a local document change is detected (before `save` debounce),
85
+ * e.g. for broadcasting incremental diffs to realtime collaborators.
86
+ */
87
+ onLocalChange?: (event: EditorRemoteLocalChangeEvent<TDocument, TDiff>) => void;
88
+ /** Invoked when a remote `save` completes. */
89
+ onSaved?: (event: {
90
+ document: TDocument;
91
+ version: number;
92
+ }) => void;
93
+ /** Invoked when a remote `save` throws. */
94
+ onError?: (error: unknown) => void;
95
+ /**
96
+ * Resolves a conflicting save (`save` returned `{ conflict: true }`): fetch the server's
97
+ * document, merge this session's edits into it (`base` → `document`), load the result with
98
+ * `apply`, and return the server's version. The controller then saves again on top of it.
99
+ */
100
+ onConflict?: (context: EditorRemoteConflictContext<TDocument>) => Promise<EditorRemoteConflictResolution<TDocument>> | EditorRemoteConflictResolution<TDocument>;
101
+ /** Conflicts resolved in a row before the controller gives up with an error (default `3`). */
102
+ maxConflictRetries?: number;
103
+ /**
104
+ * Optional crash-recovery autosave instance. When a remote save succeeds and no newer
105
+ * local edits occurred while it was in flight, `autosave.markClean()` is called automatically.
106
+ */
107
+ autosave?: Pick<EditorAutosave, "markClean">;
108
+ /** Wall clock (default `Date.now`). */
109
+ now?: () => number;
110
+ }
111
+ export interface EditorRemoteSync<TDocument = unknown, TDiff = unknown> {
112
+ /** Current local version number. */
113
+ version(): number;
114
+ /** Last version confirmed saved by the remote server. */
115
+ savedVersion(): number;
116
+ /** Whether local changes are waiting to be saved or currently saving. */
117
+ dirty(): boolean;
118
+ /** Current sync status snapshot. */
119
+ status(): EditorRemoteSyncStatus;
120
+ /** Last committed local baseline snapshot. */
121
+ baseline(): TDocument;
122
+ /** Checks the document for changes against `baseline()` (called automatically via `doc.subscribe`). */
123
+ notify(): boolean;
124
+ /**
125
+ * Runs `fn` while suppressing local change detection (so incoming collaborator diffs or
126
+ * server loads do not echo back a save), then updates `baseline` and advances `version` /
127
+ * `savedVersion` to `Math.max(version, remoteVersion)`.
128
+ */
129
+ applyRemote<TResult>(fn: () => TResult, remoteVersion?: number): TResult;
130
+ /** Resets the baseline to `doc.getDocument()` and updates `version` / `savedVersion`. */
131
+ resetBaseline(remoteVersion?: number): void;
132
+ /** Cancels any debounce timer and immediately saves pending changes. */
133
+ flush(): Promise<boolean>;
134
+ /** Resolves when any pending or in-flight save has settled. */
135
+ whenIdle(): Promise<void>;
136
+ /** Subscribes to status changes. */
137
+ subscribe(listener: (status: EditorRemoteSyncStatus) => void): () => void;
138
+ /** Cancels timers and unsubscribes from `doc`, without discarding in-flight work. */
139
+ destroy(): void;
140
+ }
141
+ /**
142
+ * Reactive, version-safe remote persistence controller for an `EditorDocument`.
143
+ *
144
+ * - Subscribes to `doc` so every edit (history steps, undo/redo, node drags/resizes, inline
145
+ * node data updates, and companion `extras` changes) automatically diffs against the last
146
+ * baseline, emits `onLocalChange` for realtime collaboration, and schedules a debounced save.
147
+ * - Serializes remote `save` calls so two saves never overlap on the wire; edits made while a
148
+ * save is in flight are coalesced into a single follow-up save.
149
+ * - Advances versions monotonically (`Math.max(localVersion, remoteVersion) + 1`), preventing
150
+ * version-mismatch wedging when collaborators or server triggers advance the remote version.
151
+ */
152
+ export declare function createEditorRemoteSync<TDocument = unknown, TDiff = unknown>(doc: EditorDocument<TDocument>, options: EditorRemoteSyncOptions<TDocument, TDiff>): EditorRemoteSync<TDocument, TDiff>;
@@ -0,0 +1,152 @@
1
+ import type { EditorDocument } from "./editorDocument.js";
2
+ import type { EditorAutosave } from "./editorAutosave.js";
3
+ export type EditorRemoteSyncState = "idle" | "pending" | "saving" | "saved" | "error";
4
+ export interface EditorRemoteSyncStatus {
5
+ status: EditorRemoteSyncState;
6
+ version: number;
7
+ savedVersion: number;
8
+ dirty: boolean;
9
+ lastSavedAt: number | null;
10
+ error?: unknown;
11
+ }
12
+ export interface EditorRemoteSaveContext<TDocument = unknown, TDiff = unknown> {
13
+ document: TDocument;
14
+ /** The version to write. */
15
+ version: number;
16
+ /**
17
+ * The server version this save builds on (the last one confirmed). For a compare-and-set
18
+ * write, update only where the stored version still equals it, and return `{ conflict: true }`
19
+ * when nothing was updated.
20
+ */
21
+ baseVersion: number;
22
+ /** Changes since the last saved snapshot (with the `diff` option). */
23
+ diff?: TDiff;
24
+ }
25
+ export interface EditorRemoteSaveResult {
26
+ version?: number;
27
+ /**
28
+ * The server holds a newer version than `baseVersion`, so nothing was written. The controller
29
+ * calls `onConflict` (or reports an `EditorRemoteConflictError` without it) and keeps the edits.
30
+ */
31
+ conflict?: boolean;
32
+ }
33
+ export interface EditorRemoteConflictContext<TDocument = unknown> {
34
+ /** What this session tried to save. */
35
+ document: TDocument;
36
+ /** The last snapshot the server confirmed: the common base for a three-way merge. */
37
+ base: TDocument;
38
+ /** The server version reported with the conflict, when `save` returned one. */
39
+ serverVersion?: number;
40
+ /**
41
+ * Runs `fn` (e.g. loading the merged document into the editor) without it counting as a local
42
+ * change, so it is not broadcast as one; the retry save picks the result up.
43
+ */
44
+ apply<TResult>(fn: () => TResult): TResult;
45
+ }
46
+ export interface EditorRemoteConflictResolution<TDocument = unknown> {
47
+ /** The server's version the merge was made on; the retry save builds on it. */
48
+ serverVersion: number;
49
+ /** The server's document, used as the base for the retry's `diff` (default: the old base). */
50
+ serverDocument?: TDocument;
51
+ }
52
+ /** Reported through `onError` / `status().error` when a save conflicts and is not resolved. */
53
+ export declare class EditorRemoteConflictError extends Error {
54
+ readonly serverVersion?: number | undefined;
55
+ constructor(serverVersion?: number | undefined);
56
+ }
57
+ export interface EditorRemoteLocalChangeEvent<TDocument = unknown, TDiff = unknown> {
58
+ document: TDocument;
59
+ baseline: TDocument;
60
+ diff?: TDiff;
61
+ version: number;
62
+ }
63
+ export interface EditorRemoteSyncOptions<TDocument = unknown, TDiff = unknown> {
64
+ /**
65
+ * Persists the latest document snapshot and version to the remote backend.
66
+ * May return `{ version }` or a numeric `version` if the server advanced the version higher.
67
+ */
68
+ save: (context: EditorRemoteSaveContext<TDocument, TDiff>) => Promise<EditorRemoteSaveResult | number | void>;
69
+ /** Initial known server version of the document (default `0`). */
70
+ initialVersion?: number;
71
+ /** Debounce delay in milliseconds after a local edit before calling `save` (default `500`). */
72
+ debounceMs?: number;
73
+ /**
74
+ * Optional diff calculator. `onLocalChange` receives the diff from the previous local snapshot
75
+ * (the one edit); `save` receives the diff from the last saved snapshot (every edit it covers).
76
+ */
77
+ diff?: (current: TDocument, baseline: TDocument) => TDiff;
78
+ /**
79
+ * Optional predicate returning `true` when `diff` represents no meaningful change.
80
+ * When omitted, compares `JSON.stringify(current) === JSON.stringify(baseline)`.
81
+ */
82
+ isEmptyDiff?: (diff: TDiff, current: TDocument, baseline: TDocument) => boolean;
83
+ /**
84
+ * Invoked immediately whenever a local document change is detected (before `save` debounce),
85
+ * e.g. for broadcasting incremental diffs to realtime collaborators.
86
+ */
87
+ onLocalChange?: (event: EditorRemoteLocalChangeEvent<TDocument, TDiff>) => void;
88
+ /** Invoked when a remote `save` completes. */
89
+ onSaved?: (event: {
90
+ document: TDocument;
91
+ version: number;
92
+ }) => void;
93
+ /** Invoked when a remote `save` throws. */
94
+ onError?: (error: unknown) => void;
95
+ /**
96
+ * Resolves a conflicting save (`save` returned `{ conflict: true }`): fetch the server's
97
+ * document, merge this session's edits into it (`base` → `document`), load the result with
98
+ * `apply`, and return the server's version. The controller then saves again on top of it.
99
+ */
100
+ onConflict?: (context: EditorRemoteConflictContext<TDocument>) => Promise<EditorRemoteConflictResolution<TDocument>> | EditorRemoteConflictResolution<TDocument>;
101
+ /** Conflicts resolved in a row before the controller gives up with an error (default `3`). */
102
+ maxConflictRetries?: number;
103
+ /**
104
+ * Optional crash-recovery autosave instance. When a remote save succeeds and no newer
105
+ * local edits occurred while it was in flight, `autosave.markClean()` is called automatically.
106
+ */
107
+ autosave?: Pick<EditorAutosave, "markClean">;
108
+ /** Wall clock (default `Date.now`). */
109
+ now?: () => number;
110
+ }
111
+ export interface EditorRemoteSync<TDocument = unknown, TDiff = unknown> {
112
+ /** Current local version number. */
113
+ version(): number;
114
+ /** Last version confirmed saved by the remote server. */
115
+ savedVersion(): number;
116
+ /** Whether local changes are waiting to be saved or currently saving. */
117
+ dirty(): boolean;
118
+ /** Current sync status snapshot. */
119
+ status(): EditorRemoteSyncStatus;
120
+ /** Last committed local baseline snapshot. */
121
+ baseline(): TDocument;
122
+ /** Checks the document for changes against `baseline()` (called automatically via `doc.subscribe`). */
123
+ notify(): boolean;
124
+ /**
125
+ * Runs `fn` while suppressing local change detection (so incoming collaborator diffs or
126
+ * server loads do not echo back a save), then updates `baseline` and advances `version` /
127
+ * `savedVersion` to `Math.max(version, remoteVersion)`.
128
+ */
129
+ applyRemote<TResult>(fn: () => TResult, remoteVersion?: number): TResult;
130
+ /** Resets the baseline to `doc.getDocument()` and updates `version` / `savedVersion`. */
131
+ resetBaseline(remoteVersion?: number): void;
132
+ /** Cancels any debounce timer and immediately saves pending changes. */
133
+ flush(): Promise<boolean>;
134
+ /** Resolves when any pending or in-flight save has settled. */
135
+ whenIdle(): Promise<void>;
136
+ /** Subscribes to status changes. */
137
+ subscribe(listener: (status: EditorRemoteSyncStatus) => void): () => void;
138
+ /** Cancels timers and unsubscribes from `doc`, without discarding in-flight work. */
139
+ destroy(): void;
140
+ }
141
+ /**
142
+ * Reactive, version-safe remote persistence controller for an `EditorDocument`.
143
+ *
144
+ * - Subscribes to `doc` so every edit (history steps, undo/redo, node drags/resizes, inline
145
+ * node data updates, and companion `extras` changes) automatically diffs against the last
146
+ * baseline, emits `onLocalChange` for realtime collaboration, and schedules a debounced save.
147
+ * - Serializes remote `save` calls so two saves never overlap on the wire; edits made while a
148
+ * save is in flight are coalesced into a single follow-up save.
149
+ * - Advances versions monotonically (`Math.max(localVersion, remoteVersion) + 1`), preventing
150
+ * version-mismatch wedging when collaborators or server triggers advance the remote version.
151
+ */
152
+ export declare function createEditorRemoteSync<TDocument = unknown, TDiff = unknown>(doc: EditorDocument<TDocument>, options: EditorRemoteSyncOptions<TDocument, TDiff>): EditorRemoteSync<TDocument, TDiff>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pixerate/editor",
3
- "version": "0.277.0",
3
+ "version": "0.279.0",
4
4
  "description": "UI-agnostic rich text and media editing core library",
5
5
  "author": "Pixerate",
6
6
  "license": "MIT",
@@ -11,7 +11,7 @@ seed: its grain is a height map (peaks and valleys) that grain-responsive brushe
11
11
  is never part of a layer. Coordinates are document pixels, (0, 0) top-left, y down. See
12
12
  `pixerate-agent-tools` for errors, namespacing and batching.
13
13
 
14
- - **Brush colours.** Choosing a brush with a default colour (highlighter yellow, ballpoint blue, chalk light or dark for the colour scheme) makes it the paint colour, unless the colour is locked (`set_color` `locked`). Set the colour after choosing the brush, or lock it first.
14
+ - **Brush colours.** Choosing a brush with a default colour (highlighter yellow, ballpoint blue, chalk light or dark for the colour scheme) makes it the paint colour, unless the colour is locked (`set_color` `locked`). Set the colour after choosing the brush, or lock it first. Strokes painted in a light / dark colour (a brush's pair, or `set_color` `dualColor`) record the pair. Paint them on a stroke layer (`add_layer` type `strokes`) when they should follow the viewer's theme: the document keeps the light side and each viewer sees their own scheme's; a pixel layer keeps the side it was painted in.
15
15
  - **Brushes** come from the shared brush library (`pixerate-brushes`: `list_brush_presets`, `create_brush`, `preview_brush`…) and are made of facets: a tip (size, hardness, spacing, angle, roundness, opacity, flow, blend), dynamics (pressure, tilt, velocity, jitter, a **rotation** mode `fixed`, `stroke` or `barrel`), behaviour (paper **grain** response, pick-up, wet or thick surface), marks, light, ferrofluid, motion and colour. `set_brush` chooses one and lays facet changes over it; `get_brush` reads it with the engine it paints with: `dab` (most media), `eraser`, `mixer` (the Mixer Brush: a reservoir of paint plus paint it picks up), `blender` (mixes the colours under and near the tip) or `smudge` (drags paint along the stroke), `wet` (watercolour, gouache, oil and the wet tools, below), `impasto` (thick paint with relief and material, below), `particle` (procedural particle brushes), `bristle` (a tuft of bristles) or `sticker` (sprays editable stickers).
16
16
  - **Wet media** (engine `wet`: `behaviour.surface` `wet`, and the wet tools; categories `watercolor` and `wet-tool`): a stroke lays water and pigment on the layer's **wet layer**, a fluid simulation that keeps running after the stroke: the water spreads until its edge is pinned by the paper, carries pigment out to the rim as it dries (edge darkening), drops pigment into the paper's valleys (granulation), pushes pigment back where new water flows in (blooms), bleeds between touching wet colours and runs downhill on a tilted canvas. Paint stays wet until it dries by itself (a few thousand steps) or `dry_paint` dries it; a dried wash is fixed, so the next wash **glazes** over it. A wet brush paints, or is a tool (`tool` `water`, `dryer`, `blow`, `blot`, `wet-blend`, `wet-drag`); `behaviour.wet` `water`, `pigment` (concentration), `body` (0 transparent watercolour .. 1 opaque gouache) and `rewet`, and `behaviour.strength`, set it up. Every result says whether paint is still wet (`wet` / `stillWet`).
17
17
  - **Behaviour** (`behaviour.exchange`, dab and mixer brushes) is how a brush treats paint already on the canvas: a **pick-up ratio** (how much paint each dab lifts and carries), a **mix ratio** (how much of the carried paint goes into what it lays down), **dirty** (keep the carried colour into the next stroke) and **multi-colour** (`tip`: several colours side by side across the tip; `dab`: the colour changes along the stroke). A Mixer Brush's `mixer.wet` / `mixer.mix` are the same two ratios. `get_mixer_brush` reads what the brush carries; `clean_mixer_brush` wipes it.
@@ -233,7 +233,7 @@ Listing, saving, sharing and previewing brushes are the brush library's tools (`
233
233
  | Tool | Use |
234
234
  | --- | --- |
235
235
  | `mix_colors` | Predict a mix: `colors` with `weight` parts (and optional tinting `strength`), `model` `pigment` (default, Kubelka-Munk: what pigment blends, the Mixer Brush, pigment blending tools and the pad do) or `rgb` (what normal blending and rgb mixing do). Returns `mixed` by the model plus `pigment` and `rgbLerp` for comparison. Changes nothing. Use it to choose stroke colours. |
236
- | `set_color` | Set the default paint `color` (and `secondaryColor`); back to one colour after gradient paint or effect paint. `locked: true` keeps it when you choose a brush with a default colour (by default choosing the highlighter makes your colour its yellow). Returns both colours and `locked`. |
236
+ | `set_color` | Set the default paint `color` (and `secondaryColor`); back to one colour after gradient paint or effect paint. `dualColor: { light, dark }` sets a pair for the light and dark schemes instead: strokes record it, and on a stroke layer every viewer sees their own scheme's side. `locked: true` keeps it when you choose a brush with a default colour (by default choosing the highlighter makes your colour its yellow). Returns both colours and `locked` (and `dualColor` when given). |
237
237
  | `set_paint` | Set the paint: one colour, or gradient paint (`stops`, `space`, `map`, `softness`; see Gradient paint). Ends effect paint. Errors say what is wrong (unsorted stops, a bad mapping). Returns `paint`, `color`, `secondaryColor`. |
238
238
  | `pick_color` | Read the colour at `x`, `y`: of the painting with its paper, or of one layer (with alpha). |
239
239
  | `get_mixer_brush` | Read what the brush carries: the Mixer Brush `reservoir` and `amount` left, and `pickup` (paint picked up, kept by dirty brushes). |