@artkit-ai/react 0.0.0-stage → 0.1.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/LICENSE +202 -0
- package/README.md +145 -2
- package/dist/cache.d.ts +35 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +116 -0
- package/dist/cache.js.map +1 -0
- package/dist/client.d.ts +79 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +260 -0
- package/dist/client.js.map +1 -0
- package/dist/components/approval.d.ts +34 -0
- package/dist/components/approval.d.ts.map +1 -0
- package/dist/components/approval.js +80 -0
- package/dist/components/approval.js.map +1 -0
- package/dist/components/card.d.ts +22 -0
- package/dist/components/card.d.ts.map +1 -0
- package/dist/components/card.js +16 -0
- package/dist/components/card.js.map +1 -0
- package/dist/components/editor.d.ts +28 -0
- package/dist/components/editor.d.ts.map +1 -0
- package/dist/components/editor.js +161 -0
- package/dist/components/editor.js.map +1 -0
- package/dist/components/icons.d.ts +8 -0
- package/dist/components/icons.d.ts.map +1 -0
- package/dist/components/icons.js +14 -0
- package/dist/components/icons.js.map +1 -0
- package/dist/components/panel.d.ts +34 -0
- package/dist/components/panel.d.ts.map +1 -0
- package/dist/components/panel.js +216 -0
- package/dist/components/panel.js.map +1 -0
- package/dist/components/shared.d.ts +71 -0
- package/dist/components/shared.d.ts.map +1 -0
- package/dist/components/shared.js +104 -0
- package/dist/components/shared.js.map +1 -0
- package/dist/components/version-diff.d.ts +35 -0
- package/dist/components/version-diff.d.ts.map +1 -0
- package/dist/components/version-diff.js +85 -0
- package/dist/components/version-diff.js.map +1 -0
- package/dist/components/view.d.ts +40 -0
- package/dist/components/view.d.ts.map +1 -0
- package/dist/components/view.js +75 -0
- package/dist/components/view.js.map +1 -0
- package/dist/context.d.ts +59 -0
- package/dist/context.d.ts.map +1 -0
- package/dist/context.js +13 -0
- package/dist/context.js.map +1 -0
- package/dist/controls.d.ts +145 -0
- package/dist/controls.d.ts.map +1 -0
- package/dist/controls.js +188 -0
- package/dist/controls.js.map +1 -0
- package/dist/download.d.ts +4 -0
- package/dist/download.d.ts.map +1 -0
- package/dist/download.js +20 -0
- package/dist/download.js.map +1 -0
- package/dist/editor-session.d.ts +184 -0
- package/dist/editor-session.d.ts.map +1 -0
- package/dist/editor-session.js +614 -0
- package/dist/editor-session.js.map +1 -0
- package/dist/hooks.d.ts +85 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +297 -0
- package/dist/hooks.js.map +1 -0
- package/dist/i18n.d.ts +197 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +207 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +22 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +22 -0
- package/dist/index.js.map +1 -0
- package/dist/kit-css.d.ts +3 -0
- package/dist/kit-css.d.ts.map +1 -0
- package/dist/kit-css.js +5 -0
- package/dist/kit-css.js.map +1 -0
- package/dist/line-diff.d.ts +25 -0
- package/dist/line-diff.d.ts.map +1 -0
- package/dist/line-diff.js +96 -0
- package/dist/line-diff.js.map +1 -0
- package/dist/partial-json.d.ts +8 -0
- package/dist/partial-json.d.ts.map +1 -0
- package/dist/partial-json.js +176 -0
- package/dist/partial-json.js.map +1 -0
- package/dist/provider.d.ts +42 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.js +95 -0
- package/dist/provider.js.map +1 -0
- package/dist/styles.d.ts +25 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/styles.js +203 -0
- package/dist/styles.js.map +1 -0
- package/dist/toolbar.d.ts +90 -0
- package/dist/toolbar.d.ts.map +1 -0
- package/dist/toolbar.js +233 -0
- package/dist/toolbar.js.map +1 -0
- package/package.json +50 -3
- package/src/cache.ts +132 -0
- package/src/client.ts +390 -0
- package/src/components/approval.tsx +219 -0
- package/src/components/card.tsx +87 -0
- package/src/components/editor.tsx +392 -0
- package/src/components/icons.tsx +14 -0
- package/src/components/panel.tsx +591 -0
- package/src/components/shared.tsx +207 -0
- package/src/components/version-diff.tsx +266 -0
- package/src/components/view.tsx +199 -0
- package/src/context.ts +82 -0
- package/src/controls.tsx +587 -0
- package/src/download.ts +23 -0
- package/src/editor-session.ts +738 -0
- package/src/hooks.ts +411 -0
- package/src/i18n.ts +217 -0
- package/src/index.ts +160 -0
- package/src/kit-css.ts +5 -0
- package/src/line-diff.ts +111 -0
- package/src/partial-json.ts +160 -0
- package/src/provider.tsx +176 -0
- package/src/styles.ts +222 -0
- package/src/toolbar.tsx +536 -0
|
@@ -0,0 +1,738 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Artifact,
|
|
3
|
+
ArtifactError,
|
|
4
|
+
type ArtifactRegistry,
|
|
5
|
+
applyOperations,
|
|
6
|
+
type ChangeSummary,
|
|
7
|
+
type CommitResult,
|
|
8
|
+
deepEqual,
|
|
9
|
+
type EmitOptions,
|
|
10
|
+
isArtifactError,
|
|
11
|
+
} from '@artkit-ai/core';
|
|
12
|
+
|
|
13
|
+
/** The editable part of an artifact: content plus the envelope fields operations can change. */
|
|
14
|
+
export interface EnvelopeState {
|
|
15
|
+
content: unknown;
|
|
16
|
+
title: string;
|
|
17
|
+
themeId: string;
|
|
18
|
+
locale: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SendRequest {
|
|
22
|
+
baseVersion: number;
|
|
23
|
+
operations: unknown[];
|
|
24
|
+
idempotencyKey: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface EditorSessionOptions {
|
|
28
|
+
registry: ArtifactRegistry;
|
|
29
|
+
/** Last confirmed version. */
|
|
30
|
+
artifact: Artifact;
|
|
31
|
+
/** Commits a batch (usually `client.apply`). */
|
|
32
|
+
send(request: SendRequest): Promise<CommitResult>;
|
|
33
|
+
/** Loads the latest version, or a specific one, after a conflict or a rebased commit. */
|
|
34
|
+
load(version?: number): Promise<Artifact>;
|
|
35
|
+
/** Typing operations emitted within this window become one batch (default 600 ms). */
|
|
36
|
+
groupingWindowMs?: number;
|
|
37
|
+
/** Upper bound for one typing batch while the user keeps typing (default 5 s). */
|
|
38
|
+
maxGroupingMs?: number;
|
|
39
|
+
/** Undo entries kept (default 100). */
|
|
40
|
+
maxUndo?: number;
|
|
41
|
+
/** Locale of error messages produced locally. */
|
|
42
|
+
locale?: string;
|
|
43
|
+
createKey?(): string;
|
|
44
|
+
onCommitted?(result: CommitResult): void;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export type SessionStatus = 'idle' | 'editing' | 'saving' | 'conflict';
|
|
48
|
+
|
|
49
|
+
export interface SessionConflict {
|
|
50
|
+
/**
|
|
51
|
+
* `server`: the server answered VERSION_CONFLICT; `rebase`: a newer version arrived and the pending
|
|
52
|
+
* operations no longer apply on it; `undo`/`redo`: the artifact changed after the edit being undone.
|
|
53
|
+
*/
|
|
54
|
+
reason: 'server' | 'rebase' | 'undo' | 'redo';
|
|
55
|
+
/** What changed in the newer version, for the conflict diff. */
|
|
56
|
+
changes: ChangeSummary[];
|
|
57
|
+
/** The local operations that would be reapplied. */
|
|
58
|
+
operations: unknown[];
|
|
59
|
+
latest: Artifact;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export type SessionEvent =
|
|
63
|
+
| { type: 'saved'; version: number }
|
|
64
|
+
| { type: 'undone'; version: number }
|
|
65
|
+
| { type: 'redone'; version: number }
|
|
66
|
+
| { type: 'rebased'; version: number }
|
|
67
|
+
| { type: 'rejected'; error: ArtifactError }
|
|
68
|
+
| { type: 'rolled_back'; error: ArtifactError }
|
|
69
|
+
| { type: 'conflict'; reason: SessionConflict['reason'] }
|
|
70
|
+
| { type: 'conflict_resolved'; choice: ConflictChoice };
|
|
71
|
+
|
|
72
|
+
export type ConflictChoice = 'keep-theirs' | 'reapply';
|
|
73
|
+
|
|
74
|
+
export interface EditorSessionState {
|
|
75
|
+
confirmed: Artifact;
|
|
76
|
+
/** Optimistic state: confirmed version plus every unconfirmed local operation. */
|
|
77
|
+
view: EnvelopeState;
|
|
78
|
+
status: SessionStatus;
|
|
79
|
+
/** Local operations not confirmed yet (buffered, queued or in flight). */
|
|
80
|
+
dirty: boolean;
|
|
81
|
+
canUndo: boolean;
|
|
82
|
+
canRedo: boolean;
|
|
83
|
+
error?: ArtifactError;
|
|
84
|
+
conflict?: SessionConflict;
|
|
85
|
+
/** Set after a LOCKED rejection; editing stays disabled until then. */
|
|
86
|
+
lockedUntil?: string;
|
|
87
|
+
/** Last notable event, with a sequence number so hosts can announce each one once. */
|
|
88
|
+
event?: SessionEvent & { seq: number };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
interface UndoEntry {
|
|
92
|
+
/** Missing when the commit was rebased: loaded from `beforeVersion` on demand. */
|
|
93
|
+
before?: EnvelopeState;
|
|
94
|
+
beforeVersion: number;
|
|
95
|
+
after: EnvelopeState;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
type Origin = 'edit' | 'undo' | 'redo' | 'reapply';
|
|
99
|
+
|
|
100
|
+
type QueueItem =
|
|
101
|
+
| { type: 'ops'; ops: unknown[]; key: string; origin: Origin; entry?: UndoEntry }
|
|
102
|
+
| { type: 'undo' }
|
|
103
|
+
| { type: 'redo' };
|
|
104
|
+
|
|
105
|
+
export function envelopeOf(
|
|
106
|
+
artifact: Pick<Artifact, 'content' | 'title' | 'themeId' | 'locale'>,
|
|
107
|
+
): EnvelopeState {
|
|
108
|
+
return {
|
|
109
|
+
content: artifact.content,
|
|
110
|
+
title: artifact.title,
|
|
111
|
+
themeId: artifact.themeId,
|
|
112
|
+
locale: artifact.locale,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function sameEnvelope(a: EnvelopeState, b: EnvelopeState): boolean {
|
|
117
|
+
return (
|
|
118
|
+
a.title === b.title && a.themeId === b.themeId && a.locale === b.locale && deepEqual(a.content, b.content)
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Operations that turn `from` into `to`. Kinds do not provide inverse operations, so undo and redo send
|
|
124
|
+
* `replace_content` with the snapshot (plus envelope operations); the commit still becomes a new version
|
|
125
|
+
* (spec §8.5).
|
|
126
|
+
*/
|
|
127
|
+
export function operationsBetween(from: EnvelopeState, to: EnvelopeState): unknown[] {
|
|
128
|
+
const ops: unknown[] = [];
|
|
129
|
+
if (!deepEqual(from.content, to.content))
|
|
130
|
+
ops.push({ op: 'replace_content', content: structuredClone(to.content) });
|
|
131
|
+
if (from.title !== to.title) ops.push({ op: 'set_title', title: to.title });
|
|
132
|
+
if (from.themeId !== to.themeId) ops.push({ op: 'set_theme', themeId: to.themeId });
|
|
133
|
+
if (from.locale !== to.locale) ops.push({ op: 'set_locale', locale: to.locale });
|
|
134
|
+
return ops;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function toArtifactError(error: unknown): ArtifactError {
|
|
138
|
+
if (isArtifactError(error)) return error;
|
|
139
|
+
return new ArtifactError('INTERNAL_ERROR', 'Internal error while processing the request.', {
|
|
140
|
+
cause: error,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function defaultKey(): string {
|
|
145
|
+
const cryptoApi = (globalThis as { crypto?: { randomUUID?: () => string } }).crypto;
|
|
146
|
+
if (cryptoApi?.randomUUID) return cryptoApi.randomUUID();
|
|
147
|
+
return `ak-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Framework-free editing session behind `ArtifactEditor` (spec §13.6): batching of typing operations,
|
|
152
|
+
* optimistic apply with the kind's own logic, rollback on rejection, local undo/redo over the user's own
|
|
153
|
+
* batches, rebase of pending operations on newer versions, conflict resolution and lock handling.
|
|
154
|
+
*
|
|
155
|
+
* Batches are sent one at a time, each against the version the previous one produced.
|
|
156
|
+
*/
|
|
157
|
+
export class EditorSession {
|
|
158
|
+
private readonly options: EditorSessionOptions;
|
|
159
|
+
private readonly windowMs: number;
|
|
160
|
+
private readonly maxGroupingMs: number;
|
|
161
|
+
private readonly maxUndo: number;
|
|
162
|
+
private confirmed: Artifact;
|
|
163
|
+
private view: EnvelopeState;
|
|
164
|
+
private buffer: unknown[] = [];
|
|
165
|
+
private bufferStartedAt = 0;
|
|
166
|
+
private timer: ReturnType<typeof setTimeout> | undefined;
|
|
167
|
+
private lockTimer: ReturnType<typeof setTimeout> | undefined;
|
|
168
|
+
private queue: QueueItem[] = [];
|
|
169
|
+
private inflight = false;
|
|
170
|
+
private undoStack: UndoEntry[] = [];
|
|
171
|
+
private redoStack: UndoEntry[] = [];
|
|
172
|
+
private error: ArtifactError | undefined;
|
|
173
|
+
private conflict: (SessionConflict & { origin: Origin; entry?: UndoEntry }) | undefined;
|
|
174
|
+
private lockedUntil: string | undefined;
|
|
175
|
+
private deferredRemote: Artifact | undefined;
|
|
176
|
+
private event: (SessionEvent & { seq: number }) | undefined;
|
|
177
|
+
private seq = 0;
|
|
178
|
+
private disposed = false;
|
|
179
|
+
private readonly listeners = new Set<() => void>();
|
|
180
|
+
private idleWaiters: (() => void)[] = [];
|
|
181
|
+
private snapshot: EditorSessionState;
|
|
182
|
+
|
|
183
|
+
constructor(options: EditorSessionOptions) {
|
|
184
|
+
this.options = options;
|
|
185
|
+
this.windowMs = options.groupingWindowMs ?? 600;
|
|
186
|
+
this.maxGroupingMs = options.maxGroupingMs ?? 5000;
|
|
187
|
+
this.maxUndo = options.maxUndo ?? 100;
|
|
188
|
+
this.confirmed = options.artifact;
|
|
189
|
+
this.view = envelopeOf(options.artifact);
|
|
190
|
+
this.snapshot = this.buildSnapshot();
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// -- subscription ---------------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
subscribe = (listener: () => void): (() => void) => {
|
|
196
|
+
this.listeners.add(listener);
|
|
197
|
+
return () => this.listeners.delete(listener);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
getSnapshot = (): EditorSessionState => this.snapshot;
|
|
201
|
+
|
|
202
|
+
/** Resolves once nothing is buffered, queued or in flight (or a conflict is waiting for the user). */
|
|
203
|
+
whenIdle(): Promise<void> {
|
|
204
|
+
if (this.isIdle()) return Promise.resolve();
|
|
205
|
+
return new Promise((resolve) => this.idleWaiters.push(resolve));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
private isIdle(): boolean {
|
|
209
|
+
return (
|
|
210
|
+
this.buffer.length === 0 && !this.inflight && (this.queue.length === 0 || this.conflict !== undefined)
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
private buildSnapshot(): EditorSessionState {
|
|
215
|
+
const pendingEdits = this.queue.filter(
|
|
216
|
+
(item) => item.type === 'ops' && item.origin !== 'undo' && item.origin !== 'redo',
|
|
217
|
+
).length;
|
|
218
|
+
const queuedUndo = this.queue.filter((item) => item.type === 'undo').length;
|
|
219
|
+
const queuedRedo = this.queue.filter((item) => item.type === 'redo').length;
|
|
220
|
+
const dirty = this.buffer.length > 0 || this.queue.length > 0 || this.inflight;
|
|
221
|
+
let status: SessionStatus = 'idle';
|
|
222
|
+
if (this.conflict) status = 'conflict';
|
|
223
|
+
else if (this.inflight || this.queue.length > 0) status = 'saving';
|
|
224
|
+
else if (this.buffer.length > 0) status = 'editing';
|
|
225
|
+
const blocked = this.conflict !== undefined || this.isLocked();
|
|
226
|
+
return {
|
|
227
|
+
confirmed: this.confirmed,
|
|
228
|
+
view: this.view,
|
|
229
|
+
status,
|
|
230
|
+
dirty,
|
|
231
|
+
canUndo:
|
|
232
|
+
!blocked && this.undoStack.length + pendingEdits + (this.buffer.length > 0 ? 1 : 0) > queuedUndo,
|
|
233
|
+
canRedo:
|
|
234
|
+
!blocked && this.buffer.length === 0 && pendingEdits === 0 && this.redoStack.length > queuedRedo,
|
|
235
|
+
...(this.error ? { error: this.error } : {}),
|
|
236
|
+
...(this.conflict
|
|
237
|
+
? {
|
|
238
|
+
conflict: {
|
|
239
|
+
reason: this.conflict.reason,
|
|
240
|
+
changes: this.conflict.changes,
|
|
241
|
+
operations: this.conflict.operations,
|
|
242
|
+
latest: this.conflict.latest,
|
|
243
|
+
},
|
|
244
|
+
}
|
|
245
|
+
: {}),
|
|
246
|
+
...(this.lockedUntil ? { lockedUntil: this.lockedUntil } : {}),
|
|
247
|
+
...(this.event ? { event: this.event } : {}),
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
private notify(): void {
|
|
252
|
+
this.snapshot = this.buildSnapshot();
|
|
253
|
+
for (const listener of [...this.listeners]) listener();
|
|
254
|
+
if (this.isIdle() && this.idleWaiters.length > 0) {
|
|
255
|
+
const waiters = this.idleWaiters;
|
|
256
|
+
this.idleWaiters = [];
|
|
257
|
+
for (const resolve of waiters) resolve();
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
private announce(event: SessionEvent): void {
|
|
262
|
+
this.seq += 1;
|
|
263
|
+
this.event = { ...event, seq: this.seq };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
private isLocked(): boolean {
|
|
267
|
+
return this.lockedUntil !== undefined && Date.parse(this.lockedUntil) > Date.now();
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// -- local application -----------------------------------------------------------------------------
|
|
271
|
+
|
|
272
|
+
private applyLocal(state: EnvelopeState, ops: unknown[]): EnvelopeState {
|
|
273
|
+
const result = applyOperations(
|
|
274
|
+
this.options.registry,
|
|
275
|
+
{ kind: this.confirmed.kind, ...state },
|
|
276
|
+
ops,
|
|
277
|
+
this.options.locale ? { locale: this.options.locale } : {},
|
|
278
|
+
);
|
|
279
|
+
return { content: result.content, title: result.title, themeId: result.themeId, locale: result.locale };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
private pendingOperations(): unknown[] {
|
|
283
|
+
const ops: unknown[] = [];
|
|
284
|
+
for (const item of this.queue) if (item.type === 'ops') ops.push(...item.ops);
|
|
285
|
+
ops.push(...this.buffer);
|
|
286
|
+
return ops;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* Recomputes the optimistic view from the confirmed version. When the unconfirmed operations no
|
|
291
|
+
* longer apply, they move into a `rebase` conflict and the view falls back to the confirmed version.
|
|
292
|
+
*/
|
|
293
|
+
private rebaseView(previous: Artifact): void {
|
|
294
|
+
let state = envelopeOf(this.confirmed);
|
|
295
|
+
try {
|
|
296
|
+
for (const item of this.queue) if (item.type === 'ops') state = this.applyLocal(state, item.ops);
|
|
297
|
+
if (this.buffer.length > 0) state = this.applyLocal(state, this.buffer);
|
|
298
|
+
this.view = state;
|
|
299
|
+
} catch {
|
|
300
|
+
const operations = this.pendingOperations();
|
|
301
|
+
this.clearTimer();
|
|
302
|
+
this.buffer = [];
|
|
303
|
+
// The in-flight batch (if any) is already at the server and settles on its own.
|
|
304
|
+
this.queue = this.inflight ? this.queue.slice(0, 1) : [];
|
|
305
|
+
this.view = envelopeOf(this.confirmed);
|
|
306
|
+
this.conflict = {
|
|
307
|
+
reason: 'rebase',
|
|
308
|
+
changes: this.changesBetween(previous, this.confirmed),
|
|
309
|
+
operations,
|
|
310
|
+
latest: this.confirmed,
|
|
311
|
+
origin: 'edit',
|
|
312
|
+
};
|
|
313
|
+
this.announce({ type: 'conflict', reason: 'rebase' });
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private changesBetween(before: EnvelopeState, after: EnvelopeState): ChangeSummary[] {
|
|
318
|
+
const changes: ChangeSummary[] = [];
|
|
319
|
+
if (before.title !== after.title)
|
|
320
|
+
changes.push({
|
|
321
|
+
type: 'changed',
|
|
322
|
+
target: 'envelope:title',
|
|
323
|
+
summary: `'${before.title}' → '${after.title}'`,
|
|
324
|
+
});
|
|
325
|
+
if (before.themeId !== after.themeId)
|
|
326
|
+
changes.push({
|
|
327
|
+
type: 'changed',
|
|
328
|
+
target: 'envelope:themeId',
|
|
329
|
+
summary: `${before.themeId} → ${after.themeId}`,
|
|
330
|
+
});
|
|
331
|
+
if (before.locale !== after.locale)
|
|
332
|
+
changes.push({
|
|
333
|
+
type: 'changed',
|
|
334
|
+
target: 'envelope:locale',
|
|
335
|
+
summary: `${before.locale} → ${after.locale}`,
|
|
336
|
+
});
|
|
337
|
+
try {
|
|
338
|
+
changes.push(...this.options.registry.get(this.confirmed.kind).diff(before.content, after.content));
|
|
339
|
+
} catch {
|
|
340
|
+
// A diff failure must not hide the conflict itself.
|
|
341
|
+
}
|
|
342
|
+
return changes;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// -- editing ---------------------------------------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Called by the kind editor. Operations are applied optimistically right away; invalid ones are
|
|
349
|
+
* rejected locally (returns false) and never reach the server.
|
|
350
|
+
*/
|
|
351
|
+
emit = (operations: unknown[], options: EmitOptions = {}): boolean => {
|
|
352
|
+
if (
|
|
353
|
+
this.disposed ||
|
|
354
|
+
this.conflict ||
|
|
355
|
+
this.isLocked() ||
|
|
356
|
+
!Array.isArray(operations) ||
|
|
357
|
+
operations.length === 0
|
|
358
|
+
)
|
|
359
|
+
return false;
|
|
360
|
+
try {
|
|
361
|
+
this.view = this.applyLocal(this.view, operations);
|
|
362
|
+
} catch (error) {
|
|
363
|
+
this.error = toArtifactError(error);
|
|
364
|
+
this.announce({ type: 'rejected', error: this.error });
|
|
365
|
+
this.notify();
|
|
366
|
+
return false;
|
|
367
|
+
}
|
|
368
|
+
this.error = undefined;
|
|
369
|
+
this.redoStack = [];
|
|
370
|
+
const maxOps = this.options.registry.batch.maxOperations;
|
|
371
|
+
if (options.kind === 'typing') {
|
|
372
|
+
if (this.buffer.length + operations.length > maxOps) this.flushBuffer();
|
|
373
|
+
if (this.buffer.length === 0) this.bufferStartedAt = Date.now();
|
|
374
|
+
this.buffer.push(...operations);
|
|
375
|
+
if (Date.now() - this.bufferStartedAt >= this.maxGroupingMs) this.flushBuffer();
|
|
376
|
+
else this.schedule();
|
|
377
|
+
} else {
|
|
378
|
+
// Discrete operations and the end of continuous gestures flush right away, after pending typing.
|
|
379
|
+
this.flushBuffer();
|
|
380
|
+
this.queue.push({ type: 'ops', ops: operations, key: this.key(), origin: 'edit' });
|
|
381
|
+
}
|
|
382
|
+
this.pump();
|
|
383
|
+
this.notify();
|
|
384
|
+
return true;
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
/** Sends buffered typing operations now (e.g. on blur or before navigation). */
|
|
388
|
+
flush(): void {
|
|
389
|
+
if (this.buffer.length === 0) return;
|
|
390
|
+
this.flushBuffer();
|
|
391
|
+
this.pump();
|
|
392
|
+
this.notify();
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private key(): string {
|
|
396
|
+
return this.options.createKey?.() ?? defaultKey();
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private schedule(): void {
|
|
400
|
+
this.clearTimer();
|
|
401
|
+
this.timer = setTimeout(() => {
|
|
402
|
+
this.timer = undefined;
|
|
403
|
+
this.flush();
|
|
404
|
+
}, this.windowMs);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private clearTimer(): void {
|
|
408
|
+
if (this.timer !== undefined) clearTimeout(this.timer);
|
|
409
|
+
this.timer = undefined;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private flushBuffer(): void {
|
|
413
|
+
this.clearTimer();
|
|
414
|
+
if (this.buffer.length === 0) return;
|
|
415
|
+
this.queue.push({ type: 'ops', ops: this.buffer, key: this.key(), origin: 'edit' });
|
|
416
|
+
this.buffer = [];
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
undo = (): void => {
|
|
420
|
+
if (!this.snapshot.canUndo) return;
|
|
421
|
+
this.flushBuffer();
|
|
422
|
+
this.queue.push({ type: 'undo' });
|
|
423
|
+
this.pump();
|
|
424
|
+
this.notify();
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
redo = (): void => {
|
|
428
|
+
if (!this.snapshot.canRedo) return;
|
|
429
|
+
this.queue.push({ type: 'redo' });
|
|
430
|
+
this.pump();
|
|
431
|
+
this.notify();
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
dismissError = (): void => {
|
|
435
|
+
if (!this.error) return;
|
|
436
|
+
this.error = undefined;
|
|
437
|
+
this.notify();
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// -- sending ---------------------------------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
private pump(): void {
|
|
443
|
+
if (this.inflight || this.conflict) return;
|
|
444
|
+
const item = this.queue[0];
|
|
445
|
+
if (!item) return;
|
|
446
|
+
if (item.type === 'undo' || item.type === 'redo') {
|
|
447
|
+
this.resolveHistoryCommand(item.type);
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
this.inflight = true;
|
|
451
|
+
const base = this.confirmed;
|
|
452
|
+
this.options
|
|
453
|
+
.send({ baseVersion: base.version, operations: item.ops, idempotencyKey: item.key })
|
|
454
|
+
.then(
|
|
455
|
+
(result) => this.onCommitted(item, base, result),
|
|
456
|
+
(error: unknown) => this.onRejected(item, base, error),
|
|
457
|
+
)
|
|
458
|
+
.finally(() => this.notify());
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
private resolveHistoryCommand(direction: 'undo' | 'redo'): void {
|
|
462
|
+
const stack = direction === 'undo' ? this.undoStack : this.redoStack;
|
|
463
|
+
const entry = stack.pop();
|
|
464
|
+
if (!entry) {
|
|
465
|
+
this.queue.shift();
|
|
466
|
+
this.pump();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (!entry.before) {
|
|
470
|
+
// Rebased commit: the version before it is only known by the server.
|
|
471
|
+
this.inflight = true;
|
|
472
|
+
this.options
|
|
473
|
+
.load(entry.beforeVersion)
|
|
474
|
+
.then(
|
|
475
|
+
(artifact) => {
|
|
476
|
+
entry.before = envelopeOf(artifact);
|
|
477
|
+
stack.push(entry);
|
|
478
|
+
},
|
|
479
|
+
(error: unknown) => {
|
|
480
|
+
this.queue.shift();
|
|
481
|
+
this.error = toArtifactError(error);
|
|
482
|
+
this.announce({ type: 'rolled_back', error: this.error });
|
|
483
|
+
},
|
|
484
|
+
)
|
|
485
|
+
.finally(() => {
|
|
486
|
+
this.inflight = false;
|
|
487
|
+
this.applyDeferred();
|
|
488
|
+
this.pump();
|
|
489
|
+
this.notify();
|
|
490
|
+
});
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
const current = envelopeOf(this.confirmed);
|
|
494
|
+
const expected = direction === 'undo' ? entry.after : entry.before;
|
|
495
|
+
const target = direction === 'undo' ? entry.before : entry.after;
|
|
496
|
+
this.queue.shift();
|
|
497
|
+
if (!sameEnvelope(current, expected)) {
|
|
498
|
+
// Someone else changed the artifact after this edit: undoing blindly would discard their work.
|
|
499
|
+
this.conflict = {
|
|
500
|
+
reason: direction,
|
|
501
|
+
changes: this.changesBetween(expected, current),
|
|
502
|
+
operations: operationsBetween(current, target),
|
|
503
|
+
latest: this.confirmed,
|
|
504
|
+
origin: direction,
|
|
505
|
+
entry,
|
|
506
|
+
};
|
|
507
|
+
this.view = current;
|
|
508
|
+
this.announce({ type: 'conflict', reason: direction });
|
|
509
|
+
return;
|
|
510
|
+
}
|
|
511
|
+
const ops = operationsBetween(current, target);
|
|
512
|
+
if (ops.length === 0) {
|
|
513
|
+
(direction === 'undo' ? this.redoStack : this.undoStack).push(entry);
|
|
514
|
+
this.pump();
|
|
515
|
+
return;
|
|
516
|
+
}
|
|
517
|
+
this.queue.unshift({ type: 'ops', ops, key: this.key(), origin: direction, entry });
|
|
518
|
+
this.rebaseView(this.confirmed);
|
|
519
|
+
this.pump();
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private onCommitted(item: Extract<QueueItem, { type: 'ops' }>, base: Artifact, result: CommitResult): void {
|
|
523
|
+
this.inflight = false;
|
|
524
|
+
this.queue.shift();
|
|
525
|
+
const previous = this.confirmed;
|
|
526
|
+
if (result.artifact.version > this.confirmed.version) this.confirmed = result.artifact;
|
|
527
|
+
const after = envelopeOf(result.artifact);
|
|
528
|
+
if (item.origin === 'undo' && item.entry) {
|
|
529
|
+
this.redoStack.push(item.entry);
|
|
530
|
+
this.announce({ type: 'undone', version: result.version });
|
|
531
|
+
} else if (item.origin === 'redo' && item.entry) {
|
|
532
|
+
this.pushUndo(item.entry);
|
|
533
|
+
this.announce({ type: 'redone', version: result.version });
|
|
534
|
+
} else {
|
|
535
|
+
this.pushUndo({
|
|
536
|
+
...(result.rebased ? {} : { before: envelopeOf(base) }),
|
|
537
|
+
beforeVersion: result.version - 1,
|
|
538
|
+
after,
|
|
539
|
+
});
|
|
540
|
+
this.announce({ type: 'saved', version: result.version });
|
|
541
|
+
}
|
|
542
|
+
try {
|
|
543
|
+
this.options.onCommitted?.(result);
|
|
544
|
+
} catch {
|
|
545
|
+
// A failing host callback must not break the session.
|
|
546
|
+
}
|
|
547
|
+
if (!this.disposed) this.rebaseView(previous);
|
|
548
|
+
this.applyDeferred();
|
|
549
|
+
this.pump();
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/** Incorporates a version that arrived while a request was in flight. */
|
|
553
|
+
private applyDeferred(): void {
|
|
554
|
+
const remote = this.deferredRemote;
|
|
555
|
+
this.deferredRemote = undefined;
|
|
556
|
+
if (remote && remote.version > this.confirmed.version) this.incorporate(remote);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
private pushUndo(entry: UndoEntry): void {
|
|
560
|
+
this.undoStack.push(entry);
|
|
561
|
+
if (this.undoStack.length > this.maxUndo) this.undoStack.splice(0, this.undoStack.length - this.maxUndo);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private onRejected(
|
|
565
|
+
item: Extract<QueueItem, { type: 'ops' }>,
|
|
566
|
+
base: Artifact,
|
|
567
|
+
raw: unknown,
|
|
568
|
+
): Promise<void> | void {
|
|
569
|
+
const error = toArtifactError(raw);
|
|
570
|
+
this.queue.shift();
|
|
571
|
+
if (error.code === 'VERSION_CONFLICT') {
|
|
572
|
+
const operations = [...item.ops, ...this.pendingOperations()];
|
|
573
|
+
this.clearTimer();
|
|
574
|
+
this.queue = [];
|
|
575
|
+
this.buffer = [];
|
|
576
|
+
const changes = Array.isArray(error.details.changesSinceBase)
|
|
577
|
+
? (error.details.changesSinceBase as ChangeSummary[])
|
|
578
|
+
: undefined;
|
|
579
|
+
// Keep `inflight` set while loading so nothing else is sent against a stale base.
|
|
580
|
+
return this.options
|
|
581
|
+
.load()
|
|
582
|
+
.then(
|
|
583
|
+
(latest) => {
|
|
584
|
+
if (latest.version > this.confirmed.version) this.confirmed = latest;
|
|
585
|
+
},
|
|
586
|
+
() => undefined,
|
|
587
|
+
)
|
|
588
|
+
.then(() => {
|
|
589
|
+
this.inflight = false;
|
|
590
|
+
const remote = this.deferredRemote;
|
|
591
|
+
this.deferredRemote = undefined;
|
|
592
|
+
if (remote && remote.version > this.confirmed.version) this.confirmed = remote;
|
|
593
|
+
const reason = item.origin === 'undo' || item.origin === 'redo' ? item.origin : 'server';
|
|
594
|
+
this.conflict = {
|
|
595
|
+
reason,
|
|
596
|
+
changes: changes ?? this.changesBetween(base, this.confirmed),
|
|
597
|
+
operations: item.origin === 'undo' || item.origin === 'redo' ? item.ops : operations,
|
|
598
|
+
latest: this.confirmed,
|
|
599
|
+
origin: item.origin,
|
|
600
|
+
...(item.entry ? { entry: item.entry } : {}),
|
|
601
|
+
};
|
|
602
|
+
this.view = envelopeOf(this.confirmed);
|
|
603
|
+
this.announce({ type: 'conflict', reason });
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
this.inflight = false;
|
|
607
|
+
// Any other rejection rolls back to the last confirmed version (spec §13.6).
|
|
608
|
+
this.clearTimer();
|
|
609
|
+
this.queue = [];
|
|
610
|
+
this.buffer = [];
|
|
611
|
+
if (item.entry) (item.origin === 'undo' ? this.undoStack : this.redoStack).push(item.entry);
|
|
612
|
+
if (error.code === 'LOCKED') this.setLocked(error);
|
|
613
|
+
this.error = error;
|
|
614
|
+
this.view = envelopeOf(this.confirmed);
|
|
615
|
+
this.announce({ type: 'rolled_back', error });
|
|
616
|
+
const remote = this.deferredRemote;
|
|
617
|
+
this.deferredRemote = undefined;
|
|
618
|
+
if (remote && remote.version > this.confirmed.version) {
|
|
619
|
+
this.confirmed = remote;
|
|
620
|
+
this.view = envelopeOf(remote);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
private setLocked(error: ArtifactError): void {
|
|
625
|
+
const expiresAt =
|
|
626
|
+
typeof error.details.expiresAt === 'string'
|
|
627
|
+
? error.details.expiresAt
|
|
628
|
+
: new Date(Date.now() + 60_000).toISOString();
|
|
629
|
+
this.lockedUntil = expiresAt;
|
|
630
|
+
if (this.lockTimer !== undefined) clearTimeout(this.lockTimer);
|
|
631
|
+
const delay = Math.max(0, Date.parse(expiresAt) - Date.now());
|
|
632
|
+
this.lockTimer = setTimeout(() => {
|
|
633
|
+
this.lockTimer = undefined;
|
|
634
|
+
this.lockedUntil = undefined;
|
|
635
|
+
this.notify();
|
|
636
|
+
}, delay);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// -- remote versions ---------------------------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* A version arrived from outside the session (refetch, agent commit, prop). Pending local operations
|
|
643
|
+
* are rebased on it; when they no longer apply, a `rebase` conflict asks the user what to keep.
|
|
644
|
+
*/
|
|
645
|
+
receive = (artifact: Artifact): void => {
|
|
646
|
+
if (this.disposed || artifact.id !== this.confirmed.id) return;
|
|
647
|
+
if (artifact.version < this.confirmed.version) return;
|
|
648
|
+
if (artifact.version === this.confirmed.version) {
|
|
649
|
+
if (
|
|
650
|
+
artifact.status !== this.confirmed.status ||
|
|
651
|
+
artifact.meta.publishedVersion !== this.confirmed.meta.publishedVersion
|
|
652
|
+
) {
|
|
653
|
+
this.confirmed = { ...this.confirmed, status: artifact.status, meta: artifact.meta };
|
|
654
|
+
this.notify();
|
|
655
|
+
}
|
|
656
|
+
return;
|
|
657
|
+
}
|
|
658
|
+
if (this.inflight) {
|
|
659
|
+
// Wait for the in-flight batch: applying it on top of a version that may already contain it
|
|
660
|
+
// would show its operations twice.
|
|
661
|
+
if (!this.deferredRemote || artifact.version > this.deferredRemote.version)
|
|
662
|
+
this.deferredRemote = artifact;
|
|
663
|
+
return;
|
|
664
|
+
}
|
|
665
|
+
this.incorporate(artifact);
|
|
666
|
+
this.notify();
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
private incorporate(artifact: Artifact): void {
|
|
670
|
+
const previous = this.confirmed;
|
|
671
|
+
this.confirmed = artifact;
|
|
672
|
+
if (this.conflict) {
|
|
673
|
+
this.conflict.latest = artifact;
|
|
674
|
+
this.view = envelopeOf(artifact);
|
|
675
|
+
} else {
|
|
676
|
+
this.rebaseView(previous);
|
|
677
|
+
if (!this.conflict) this.announce({ type: 'rebased', version: artifact.version });
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
resolveConflict = (choice: ConflictChoice): void => {
|
|
682
|
+
const conflict = this.conflict;
|
|
683
|
+
if (!conflict) return;
|
|
684
|
+
this.conflict = undefined;
|
|
685
|
+
this.error = undefined;
|
|
686
|
+
this.view = envelopeOf(this.confirmed);
|
|
687
|
+
if (choice === 'reapply') {
|
|
688
|
+
if (conflict.origin === 'undo' || conflict.origin === 'redo') {
|
|
689
|
+
const entry = conflict.entry;
|
|
690
|
+
const target = entry ? (conflict.origin === 'undo' ? entry.before : entry.after) : undefined;
|
|
691
|
+
const ops = target ? operationsBetween(envelopeOf(this.confirmed), target) : [];
|
|
692
|
+
if (ops.length > 0) {
|
|
693
|
+
this.queue.unshift({
|
|
694
|
+
type: 'ops',
|
|
695
|
+
ops,
|
|
696
|
+
key: this.key(),
|
|
697
|
+
origin: conflict.origin,
|
|
698
|
+
...(entry ? { entry } : {}),
|
|
699
|
+
});
|
|
700
|
+
}
|
|
701
|
+
} else {
|
|
702
|
+
try {
|
|
703
|
+
this.applyLocal(envelopeOf(this.confirmed), conflict.operations);
|
|
704
|
+
this.queue.unshift({ type: 'ops', ops: conflict.operations, key: this.key(), origin: 'reapply' });
|
|
705
|
+
} catch (error) {
|
|
706
|
+
this.error = toArtifactError(error);
|
|
707
|
+
this.announce({ type: 'rolled_back', error: this.error });
|
|
708
|
+
this.pump();
|
|
709
|
+
this.notify();
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
this.rebaseView(this.confirmed);
|
|
714
|
+
}
|
|
715
|
+
this.announce({ type: 'conflict_resolved', choice });
|
|
716
|
+
this.pump();
|
|
717
|
+
this.notify();
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* Stops timers and listeners. Buffered typing is still sent unless `discard` is set, so closing the
|
|
722
|
+
* editor right after typing does not lose the last keystrokes.
|
|
723
|
+
*/
|
|
724
|
+
dispose(options: { discard?: boolean } = {}): void {
|
|
725
|
+
if (this.disposed) return;
|
|
726
|
+
if (options.discard) {
|
|
727
|
+
this.buffer = [];
|
|
728
|
+
this.queue = this.inflight ? this.queue.slice(0, 1) : [];
|
|
729
|
+
} else {
|
|
730
|
+
this.flushBuffer();
|
|
731
|
+
this.pump();
|
|
732
|
+
}
|
|
733
|
+
this.clearTimer();
|
|
734
|
+
if (this.lockTimer !== undefined) clearTimeout(this.lockTimer);
|
|
735
|
+
this.disposed = true;
|
|
736
|
+
this.listeners.clear();
|
|
737
|
+
}
|
|
738
|
+
}
|