@artkit-ai/react 0.1.0 → 0.2.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.
Files changed (89) hide show
  1. package/dist/cache.d.ts +7 -11
  2. package/dist/cache.d.ts.map +1 -1
  3. package/dist/cache.js +32 -53
  4. package/dist/cache.js.map +1 -1
  5. package/dist/client.d.ts +4 -15
  6. package/dist/client.d.ts.map +1 -1
  7. package/dist/client.js +2 -29
  8. package/dist/client.js.map +1 -1
  9. package/dist/components/card.d.ts +2 -2
  10. package/dist/components/card.d.ts.map +1 -1
  11. package/dist/components/card.js +3 -3
  12. package/dist/components/card.js.map +1 -1
  13. package/dist/components/editor.d.ts +31 -18
  14. package/dist/components/editor.d.ts.map +1 -1
  15. package/dist/components/editor.js +81 -131
  16. package/dist/components/editor.js.map +1 -1
  17. package/dist/components/icons.d.ts +2 -2
  18. package/dist/components/icons.d.ts.map +1 -1
  19. package/dist/components/icons.js +2 -2
  20. package/dist/components/icons.js.map +1 -1
  21. package/dist/components/panel.d.ts +13 -16
  22. package/dist/components/panel.d.ts.map +1 -1
  23. package/dist/components/panel.js +13 -97
  24. package/dist/components/panel.js.map +1 -1
  25. package/dist/components/shared.d.ts +2 -6
  26. package/dist/components/shared.d.ts.map +1 -1
  27. package/dist/components/shared.js +2 -6
  28. package/dist/components/shared.js.map +1 -1
  29. package/dist/components/view.d.ts +7 -8
  30. package/dist/components/view.d.ts.map +1 -1
  31. package/dist/components/view.js +16 -15
  32. package/dist/components/view.js.map +1 -1
  33. package/dist/context.d.ts +14 -9
  34. package/dist/context.d.ts.map +1 -1
  35. package/dist/context.js.map +1 -1
  36. package/dist/editor-session.d.ts +77 -165
  37. package/dist/editor-session.d.ts.map +1 -1
  38. package/dist/editor-session.js +164 -586
  39. package/dist/editor-session.js.map +1 -1
  40. package/dist/hooks.d.ts +2 -23
  41. package/dist/hooks.d.ts.map +1 -1
  42. package/dist/hooks.js +24 -116
  43. package/dist/hooks.js.map +1 -1
  44. package/dist/i18n.d.ts +4 -66
  45. package/dist/i18n.d.ts.map +1 -1
  46. package/dist/i18n.js +6 -68
  47. package/dist/i18n.js.map +1 -1
  48. package/dist/index.d.ts +6 -9
  49. package/dist/index.d.ts.map +1 -1
  50. package/dist/index.js +4 -8
  51. package/dist/index.js.map +1 -1
  52. package/dist/provider.d.ts +1 -1
  53. package/dist/provider.d.ts.map +1 -1
  54. package/dist/provider.js +12 -12
  55. package/dist/provider.js.map +1 -1
  56. package/dist/styles.d.ts +1 -1
  57. package/dist/styles.d.ts.map +1 -1
  58. package/dist/styles.js +4 -4
  59. package/package.json +2 -2
  60. package/src/cache.ts +32 -55
  61. package/src/client.ts +7 -55
  62. package/src/components/card.tsx +6 -7
  63. package/src/components/editor.tsx +208 -308
  64. package/src/components/icons.tsx +2 -2
  65. package/src/components/panel.tsx +43 -300
  66. package/src/components/shared.tsx +3 -12
  67. package/src/components/view.tsx +26 -29
  68. package/src/context.ts +16 -11
  69. package/src/editor-session.ts +197 -675
  70. package/src/hooks.ts +25 -140
  71. package/src/i18n.ts +6 -68
  72. package/src/index.ts +7 -39
  73. package/src/provider.tsx +13 -13
  74. package/src/styles.ts +4 -4
  75. package/dist/components/approval.d.ts +0 -34
  76. package/dist/components/approval.d.ts.map +0 -1
  77. package/dist/components/approval.js +0 -80
  78. package/dist/components/approval.js.map +0 -1
  79. package/dist/components/version-diff.d.ts +0 -35
  80. package/dist/components/version-diff.d.ts.map +0 -1
  81. package/dist/components/version-diff.js +0 -85
  82. package/dist/components/version-diff.js.map +0 -1
  83. package/dist/line-diff.d.ts +0 -25
  84. package/dist/line-diff.d.ts.map +0 -1
  85. package/dist/line-diff.js +0 -96
  86. package/dist/line-diff.js.map +0 -1
  87. package/src/components/approval.tsx +0 -219
  88. package/src/components/version-diff.tsx +0 -266
  89. package/src/line-diff.ts +0 -111
@@ -1,614 +1,192 @@
1
- import { ArtifactError, applyOperations, deepEqual, isArtifactError, } from '@artkit-ai/core';
2
- export function envelopeOf(artifact) {
3
- return {
4
- content: artifact.content,
5
- title: artifact.title,
6
- themeId: artifact.themeId,
7
- locale: artifact.locale,
8
- };
9
- }
10
- function sameEnvelope(a, b) {
11
- return (a.title === b.title && a.themeId === b.themeId && a.locale === b.locale && deepEqual(a.content, b.content));
12
- }
13
- /**
14
- * Operations that turn `from` into `to`. Kinds do not provide inverse operations, so undo and redo send
15
- * `replace_content` with the snapshot (plus envelope operations); the commit still becomes a new version
16
- * (spec §8.5).
17
- */
18
- export function operationsBetween(from, to) {
19
- const ops = [];
20
- if (!deepEqual(from.content, to.content))
21
- ops.push({ op: 'replace_content', content: structuredClone(to.content) });
22
- if (from.title !== to.title)
23
- ops.push({ op: 'set_title', title: to.title });
24
- if (from.themeId !== to.themeId)
25
- ops.push({ op: 'set_theme', themeId: to.themeId });
26
- if (from.locale !== to.locale)
27
- ops.push({ op: 'set_locale', locale: to.locale });
28
- return ops;
29
- }
30
- function toArtifactError(error) {
31
- if (isArtifactError(error))
32
- return error;
33
- return new ArtifactError('INTERNAL_ERROR', 'Internal error while processing the request.', {
34
- cause: error,
35
- });
36
- }
37
- function defaultKey() {
38
- const cryptoApi = globalThis.crypto;
39
- if (cryptoApi?.randomUUID)
40
- return cryptoApi.randomUUID();
41
- return `ak-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
42
- }
43
- /**
44
- * Framework-free editing session behind `ArtifactEditor` (spec §13.6): batching of typing operations,
45
- * optimistic apply with the kind's own logic, rollback on rejection, local undo/redo over the user's own
46
- * batches, rebase of pending operations on newer versions, conflict resolution and lock handling.
47
- *
48
- * Batches are sent one at a time, each against the version the previous one produced.
49
- */
50
- export class EditorSession {
51
- options;
52
- windowMs;
53
- maxGroupingMs;
1
+ import { ArtifactError, applyOperations, isArtifactError, } from '@artkit-ai/core';
2
+ const DEFAULT_COALESCE_MS = 1000;
3
+ const DEFAULT_MAX_UNDO = 200;
4
+ /** Draft of one artifact with local undo and redo. Batches are applied in order, one at a time. */
5
+ export class DraftSession {
6
+ registry;
7
+ locale;
8
+ coalesceMs;
54
9
  maxUndo;
55
- confirmed;
56
- view;
57
- buffer = [];
58
- bufferStartedAt = 0;
59
- timer;
60
- lockTimer;
61
- queue = [];
62
- inflight = false;
10
+ now;
11
+ base;
12
+ current;
63
13
  undoStack = [];
64
14
  redoStack = [];
15
+ queue = Promise.resolve();
16
+ pending = 0;
65
17
  error;
66
- conflict;
67
- lockedUntil;
68
- deferredRemote;
69
- event;
70
- seq = 0;
71
- disposed = false;
72
- listeners = new Set();
73
- idleWaiters = [];
18
+ lint;
19
+ changes = [];
74
20
  snapshot;
21
+ listeners = new Set();
22
+ disposed = false;
75
23
  constructor(options) {
76
- this.options = options;
77
- this.windowMs = options.groupingWindowMs ?? 600;
78
- this.maxGroupingMs = options.maxGroupingMs ?? 5000;
79
- this.maxUndo = options.maxUndo ?? 100;
80
- this.confirmed = options.artifact;
81
- this.view = envelopeOf(options.artifact);
24
+ this.registry = options.registry;
25
+ this.locale = options.locale ?? 'en';
26
+ this.coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS;
27
+ this.maxUndo = options.maxUndo ?? DEFAULT_MAX_UNDO;
28
+ this.now = options.now ?? (() => Date.now());
29
+ this.base = options.artifact;
30
+ this.current = { artifact: options.artifact, operations: [], at: 0, typing: false };
31
+ this.lint = this.lintOf(options.artifact);
82
32
  this.snapshot = this.buildSnapshot();
83
33
  }
84
- // -- subscription ---------------------------------------------------------------------------------
34
+ /** The current snapshot. Its identity changes only when the state changes. */
35
+ getSnapshot = () => this.snapshot;
85
36
  subscribe = (listener) => {
86
37
  this.listeners.add(listener);
87
38
  return () => this.listeners.delete(listener);
88
39
  };
89
- getSnapshot = () => this.snapshot;
90
- /** Resolves once nothing is buffered, queued or in flight (or a conflict is waiting for the user). */
91
- whenIdle() {
92
- if (this.isIdle())
93
- return Promise.resolve();
94
- return new Promise((resolve) => this.idleWaiters.push(resolve));
95
- }
96
- isIdle() {
97
- return (this.buffer.length === 0 && !this.inflight && (this.queue.length === 0 || this.conflict !== undefined));
98
- }
99
- buildSnapshot() {
100
- const pendingEdits = this.queue.filter((item) => item.type === 'ops' && item.origin !== 'undo' && item.origin !== 'redo').length;
101
- const queuedUndo = this.queue.filter((item) => item.type === 'undo').length;
102
- const queuedRedo = this.queue.filter((item) => item.type === 'redo').length;
103
- const dirty = this.buffer.length > 0 || this.queue.length > 0 || this.inflight;
104
- let status = 'idle';
105
- if (this.conflict)
106
- status = 'conflict';
107
- else if (this.inflight || this.queue.length > 0)
108
- status = 'saving';
109
- else if (this.buffer.length > 0)
110
- status = 'editing';
111
- const blocked = this.conflict !== undefined || this.isLocked();
112
- return {
113
- confirmed: this.confirmed,
114
- view: this.view,
115
- status,
116
- dirty,
117
- canUndo: !blocked && this.undoStack.length + pendingEdits + (this.buffer.length > 0 ? 1 : 0) > queuedUndo,
118
- canRedo: !blocked && this.buffer.length === 0 && pendingEdits === 0 && this.redoStack.length > queuedRedo,
119
- ...(this.error ? { error: this.error } : {}),
120
- ...(this.conflict
121
- ? {
122
- conflict: {
123
- reason: this.conflict.reason,
124
- changes: this.conflict.changes,
125
- operations: this.conflict.operations,
126
- latest: this.conflict.latest,
127
- },
128
- }
129
- : {}),
130
- ...(this.lockedUntil ? { lockedUntil: this.lockedUntil } : {}),
131
- ...(this.event ? { event: this.event } : {}),
132
- };
133
- }
134
- notify() {
135
- this.snapshot = this.buildSnapshot();
136
- for (const listener of [...this.listeners])
137
- listener();
138
- if (this.isIdle() && this.idleWaiters.length > 0) {
139
- const waiters = this.idleWaiters;
140
- this.idleWaiters = [];
141
- for (const resolve of waiters)
142
- resolve();
143
- }
144
- }
145
- announce(event) {
146
- this.seq += 1;
147
- this.event = { ...event, seq: this.seq };
148
- }
149
- isLocked() {
150
- return this.lockedUntil !== undefined && Date.parse(this.lockedUntil) > Date.now();
151
- }
152
- // -- local application -----------------------------------------------------------------------------
153
- applyLocal(state, ops) {
154
- const result = applyOperations(this.options.registry, { kind: this.confirmed.kind, ...state }, ops, this.options.locale ? { locale: this.options.locale } : {});
155
- return { content: result.content, title: result.title, themeId: result.themeId, locale: result.locale };
156
- }
157
- pendingOperations() {
158
- const ops = [];
159
- for (const item of this.queue)
160
- if (item.type === 'ops')
161
- ops.push(...item.ops);
162
- ops.push(...this.buffer);
163
- return ops;
164
- }
165
- /**
166
- * Recomputes the optimistic view from the confirmed version. When the unconfirmed operations no
167
- * longer apply, they move into a `rebase` conflict and the view falls back to the confirmed version.
168
- */
169
- rebaseView(previous) {
170
- let state = envelopeOf(this.confirmed);
171
- try {
172
- for (const item of this.queue)
173
- if (item.type === 'ops')
174
- state = this.applyLocal(state, item.ops);
175
- if (this.buffer.length > 0)
176
- state = this.applyLocal(state, this.buffer);
177
- this.view = state;
178
- }
179
- catch {
180
- const operations = this.pendingOperations();
181
- this.clearTimer();
182
- this.buffer = [];
183
- // The in-flight batch (if any) is already at the server and settles on its own.
184
- this.queue = this.inflight ? this.queue.slice(0, 1) : [];
185
- this.view = envelopeOf(this.confirmed);
186
- this.conflict = {
187
- reason: 'rebase',
188
- changes: this.changesBetween(previous, this.confirmed),
189
- operations,
190
- latest: this.confirmed,
191
- origin: 'edit',
192
- };
193
- this.announce({ type: 'conflict', reason: 'rebase' });
194
- }
195
- }
196
- changesBetween(before, after) {
197
- const changes = [];
198
- if (before.title !== after.title)
199
- changes.push({
200
- type: 'changed',
201
- target: 'envelope:title',
202
- summary: `'${before.title}' → '${after.title}'`,
203
- });
204
- if (before.themeId !== after.themeId)
205
- changes.push({
206
- type: 'changed',
207
- target: 'envelope:themeId',
208
- summary: `${before.themeId} → ${after.themeId}`,
209
- });
210
- if (before.locale !== after.locale)
211
- changes.push({
212
- type: 'changed',
213
- target: 'envelope:locale',
214
- summary: `${before.locale} → ${after.locale}`,
215
- });
216
- try {
217
- changes.push(...this.options.registry.get(this.confirmed.kind).diff(before.content, after.content));
218
- }
219
- catch {
220
- // A diff failure must not hide the conflict itself.
221
- }
222
- return changes;
223
- }
224
- // -- editing ---------------------------------------------------------------------------------------
225
40
  /**
226
- * Called by the kind editor. Operations are applied optimistically right away; invalid ones are
227
- * rejected locally (returns false) and never reach the server.
41
+ * Applies a batch to the draft. Resolves to `true` when the batch was accepted. A rejected batch leaves the draft
42
+ * untouched and sets `snapshot.error`.
228
43
  */
229
- emit = (operations, options = {}) => {
230
- if (this.disposed ||
231
- this.conflict ||
232
- this.isLocked() ||
233
- !Array.isArray(operations) ||
234
- operations.length === 0)
235
- return false;
236
- try {
237
- this.view = this.applyLocal(this.view, operations);
238
- }
239
- catch (error) {
240
- this.error = toArtifactError(error);
241
- this.announce({ type: 'rejected', error: this.error });
242
- this.notify();
44
+ apply(operations, options = {}) {
45
+ if (operations.length === 0)
46
+ return Promise.resolve(true);
47
+ this.pending += 1;
48
+ this.publish();
49
+ const run = this.queue.then(async () => {
50
+ if (this.disposed)
51
+ return false;
52
+ const typing = options.kind === 'typing' || options.kind === 'continuous';
53
+ try {
54
+ const result = await applyOperations(this.registry, this.current.artifact, operations, {
55
+ locale: this.locale,
56
+ });
57
+ const next = {
58
+ ...this.current.artifact,
59
+ formatVersion: result.formatVersion,
60
+ title: result.title,
61
+ locale: result.locale,
62
+ themeId: result.themeId,
63
+ content: result.content,
64
+ };
65
+ const now = this.now();
66
+ const coalesce = typing && this.current.typing && now - this.current.at <= this.coalesceMs;
67
+ if (!coalesce) {
68
+ this.undoStack.push(this.current);
69
+ if (this.undoStack.length > this.maxUndo)
70
+ this.undoStack.shift();
71
+ }
72
+ this.redoStack = [];
73
+ this.current = {
74
+ artifact: next,
75
+ operations: [...this.current.operations, ...operations],
76
+ at: now,
77
+ typing,
78
+ };
79
+ this.lint = result.lint;
80
+ this.changes = result.diff;
81
+ this.error = undefined;
82
+ return true;
83
+ }
84
+ catch (error) {
85
+ this.error = errorOf(error);
86
+ return false;
87
+ }
88
+ });
89
+ this.queue = run.finally(() => {
90
+ this.pending -= 1;
91
+ this.publish();
92
+ });
93
+ return run.then((accepted) => {
94
+ this.publish();
95
+ return accepted;
96
+ });
97
+ }
98
+ /** Steps back one edit (local). Returns false when there is nothing to undo. */
99
+ undo() {
100
+ const previous = this.undoStack.pop();
101
+ if (!previous)
243
102
  return false;
244
- }
245
- this.error = undefined;
246
- this.redoStack = [];
247
- const maxOps = this.options.registry.batch.maxOperations;
248
- if (options.kind === 'typing') {
249
- if (this.buffer.length + operations.length > maxOps)
250
- this.flushBuffer();
251
- if (this.buffer.length === 0)
252
- this.bufferStartedAt = Date.now();
253
- this.buffer.push(...operations);
254
- if (Date.now() - this.bufferStartedAt >= this.maxGroupingMs)
255
- this.flushBuffer();
256
- else
257
- this.schedule();
258
- }
259
- else {
260
- // Discrete operations and the end of continuous gestures flush right away, after pending typing.
261
- this.flushBuffer();
262
- this.queue.push({ type: 'ops', ops: operations, key: this.key(), origin: 'edit' });
263
- }
264
- this.pump();
265
- this.notify();
103
+ this.redoStack.push(this.current);
104
+ this.restore(previous);
266
105
  return true;
267
- };
268
- /** Sends buffered typing operations now (e.g. on blur or before navigation). */
269
- flush() {
270
- if (this.buffer.length === 0)
271
- return;
272
- this.flushBuffer();
273
- this.pump();
274
- this.notify();
275
- }
276
- key() {
277
- return this.options.createKey?.() ?? defaultKey();
278
- }
279
- schedule() {
280
- this.clearTimer();
281
- this.timer = setTimeout(() => {
282
- this.timer = undefined;
283
- this.flush();
284
- }, this.windowMs);
285
106
  }
286
- clearTimer() {
287
- if (this.timer !== undefined)
288
- clearTimeout(this.timer);
289
- this.timer = undefined;
107
+ /** Steps forward after an undo. Returns false when there is nothing to redo. */
108
+ redo() {
109
+ const next = this.redoStack.pop();
110
+ if (!next)
111
+ return false;
112
+ this.undoStack.push(this.current);
113
+ this.restore(next);
114
+ return true;
290
115
  }
291
- flushBuffer() {
292
- this.clearTimer();
293
- if (this.buffer.length === 0)
294
- return;
295
- this.queue.push({ type: 'ops', ops: this.buffer, key: this.key(), origin: 'edit' });
296
- this.buffer = [];
116
+ /**
117
+ * Adopts an artifact loaded or saved by the application. A clean draft follows the new artifact. A draft that has
118
+ * changes keeps its content, and the base moves to the new version so the next save is a complete one.
119
+ */
120
+ load(artifact) {
121
+ if (artifact.id !== this.base.id)
122
+ throw new ArtifactError('INVALID_OPERATION', 'Load the same artifact.');
123
+ const clean = !this.buildSnapshot().dirty;
124
+ const sameContent = JSON.stringify(artifact.content) === JSON.stringify(this.current.artifact.content);
125
+ this.base = artifact;
126
+ if (clean || sameContent) {
127
+ this.current = { artifact, operations: [], at: 0, typing: false };
128
+ this.undoStack = [];
129
+ this.redoStack = [];
130
+ this.lint = this.lintOf(artifact);
131
+ this.changes = [];
132
+ }
133
+ this.publish();
134
+ }
135
+ /** Stops the session: later batches are ignored and listeners are dropped. */
136
+ dispose() {
137
+ this.disposed = true;
138
+ this.listeners.clear();
297
139
  }
298
- undo = () => {
299
- if (!this.snapshot.canUndo)
300
- return;
301
- this.flushBuffer();
302
- this.queue.push({ type: 'undo' });
303
- this.pump();
304
- this.notify();
305
- };
306
- redo = () => {
307
- if (!this.snapshot.canRedo)
308
- return;
309
- this.queue.push({ type: 'redo' });
310
- this.pump();
311
- this.notify();
312
- };
313
- dismissError = () => {
314
- if (!this.error)
315
- return;
140
+ restore(step) {
141
+ this.current = step;
142
+ this.lint = this.lintOf(step.artifact);
143
+ this.changes = [];
316
144
  this.error = undefined;
317
- this.notify();
318
- };
319
- // -- sending ---------------------------------------------------------------------------------------
320
- pump() {
321
- if (this.inflight || this.conflict)
322
- return;
323
- const item = this.queue[0];
324
- if (!item)
325
- return;
326
- if (item.type === 'undo' || item.type === 'redo') {
327
- this.resolveHistoryCommand(item.type);
328
- return;
329
- }
330
- this.inflight = true;
331
- const base = this.confirmed;
332
- this.options
333
- .send({ baseVersion: base.version, operations: item.ops, idempotencyKey: item.key })
334
- .then((result) => this.onCommitted(item, base, result), (error) => this.onRejected(item, base, error))
335
- .finally(() => this.notify());
336
- }
337
- resolveHistoryCommand(direction) {
338
- const stack = direction === 'undo' ? this.undoStack : this.redoStack;
339
- const entry = stack.pop();
340
- if (!entry) {
341
- this.queue.shift();
342
- this.pump();
343
- return;
344
- }
345
- if (!entry.before) {
346
- // Rebased commit: the version before it is only known by the server.
347
- this.inflight = true;
348
- this.options
349
- .load(entry.beforeVersion)
350
- .then((artifact) => {
351
- entry.before = envelopeOf(artifact);
352
- stack.push(entry);
353
- }, (error) => {
354
- this.queue.shift();
355
- this.error = toArtifactError(error);
356
- this.announce({ type: 'rolled_back', error: this.error });
357
- })
358
- .finally(() => {
359
- this.inflight = false;
360
- this.applyDeferred();
361
- this.pump();
362
- this.notify();
363
- });
364
- return;
365
- }
366
- const current = envelopeOf(this.confirmed);
367
- const expected = direction === 'undo' ? entry.after : entry.before;
368
- const target = direction === 'undo' ? entry.before : entry.after;
369
- this.queue.shift();
370
- if (!sameEnvelope(current, expected)) {
371
- // Someone else changed the artifact after this edit: undoing blindly would discard their work.
372
- this.conflict = {
373
- reason: direction,
374
- changes: this.changesBetween(expected, current),
375
- operations: operationsBetween(current, target),
376
- latest: this.confirmed,
377
- origin: direction,
378
- entry,
379
- };
380
- this.view = current;
381
- this.announce({ type: 'conflict', reason: direction });
382
- return;
383
- }
384
- const ops = operationsBetween(current, target);
385
- if (ops.length === 0) {
386
- (direction === 'undo' ? this.redoStack : this.undoStack).push(entry);
387
- this.pump();
388
- return;
389
- }
390
- this.queue.unshift({ type: 'ops', ops, key: this.key(), origin: direction, entry });
391
- this.rebaseView(this.confirmed);
392
- this.pump();
393
- }
394
- onCommitted(item, base, result) {
395
- this.inflight = false;
396
- this.queue.shift();
397
- const previous = this.confirmed;
398
- if (result.artifact.version > this.confirmed.version)
399
- this.confirmed = result.artifact;
400
- const after = envelopeOf(result.artifact);
401
- if (item.origin === 'undo' && item.entry) {
402
- this.redoStack.push(item.entry);
403
- this.announce({ type: 'undone', version: result.version });
404
- }
405
- else if (item.origin === 'redo' && item.entry) {
406
- this.pushUndo(item.entry);
407
- this.announce({ type: 'redone', version: result.version });
408
- }
409
- else {
410
- this.pushUndo({
411
- ...(result.rebased ? {} : { before: envelopeOf(base) }),
412
- beforeVersion: result.version - 1,
413
- after,
414
- });
415
- this.announce({ type: 'saved', version: result.version });
416
- }
417
- try {
418
- this.options.onCommitted?.(result);
419
- }
420
- catch {
421
- // A failing host callback must not break the session.
422
- }
423
- if (!this.disposed)
424
- this.rebaseView(previous);
425
- this.applyDeferred();
426
- this.pump();
145
+ this.publish();
427
146
  }
428
- /** Incorporates a version that arrived while a request was in flight. */
429
- applyDeferred() {
430
- const remote = this.deferredRemote;
431
- this.deferredRemote = undefined;
432
- if (remote && remote.version > this.confirmed.version)
433
- this.incorporate(remote);
147
+ lintOf(artifact) {
148
+ const adapter = this.registry.get(artifact.format, this.locale);
149
+ return adapter.lint(artifact.content, {
150
+ locale: artifact.locale,
151
+ theme: this.registry.theme(artifact.themeId, this.locale),
152
+ });
434
153
  }
435
- pushUndo(entry) {
436
- this.undoStack.push(entry);
437
- if (this.undoStack.length > this.maxUndo)
438
- this.undoStack.splice(0, this.undoStack.length - this.maxUndo);
439
- }
440
- onRejected(item, base, raw) {
441
- const error = toArtifactError(raw);
442
- this.queue.shift();
443
- if (error.code === 'VERSION_CONFLICT') {
444
- const operations = [...item.ops, ...this.pendingOperations()];
445
- this.clearTimer();
446
- this.queue = [];
447
- this.buffer = [];
448
- const changes = Array.isArray(error.details.changesSinceBase)
449
- ? error.details.changesSinceBase
450
- : undefined;
451
- // Keep `inflight` set while loading so nothing else is sent against a stale base.
452
- return this.options
453
- .load()
454
- .then((latest) => {
455
- if (latest.version > this.confirmed.version)
456
- this.confirmed = latest;
457
- }, () => undefined)
458
- .then(() => {
459
- this.inflight = false;
460
- const remote = this.deferredRemote;
461
- this.deferredRemote = undefined;
462
- if (remote && remote.version > this.confirmed.version)
463
- this.confirmed = remote;
464
- const reason = item.origin === 'undo' || item.origin === 'redo' ? item.origin : 'server';
465
- this.conflict = {
466
- reason,
467
- changes: changes ?? this.changesBetween(base, this.confirmed),
468
- operations: item.origin === 'undo' || item.origin === 'redo' ? item.ops : operations,
469
- latest: this.confirmed,
470
- origin: item.origin,
471
- ...(item.entry ? { entry: item.entry } : {}),
472
- };
473
- this.view = envelopeOf(this.confirmed);
474
- this.announce({ type: 'conflict', reason });
475
- });
476
- }
477
- this.inflight = false;
478
- // Any other rejection rolls back to the last confirmed version (spec §13.6).
479
- this.clearTimer();
480
- this.queue = [];
481
- this.buffer = [];
482
- if (item.entry)
483
- (item.origin === 'undo' ? this.undoStack : this.redoStack).push(item.entry);
484
- if (error.code === 'LOCKED')
485
- this.setLocked(error);
486
- this.error = error;
487
- this.view = envelopeOf(this.confirmed);
488
- this.announce({ type: 'rolled_back', error });
489
- const remote = this.deferredRemote;
490
- this.deferredRemote = undefined;
491
- if (remote && remote.version > this.confirmed.version) {
492
- this.confirmed = remote;
493
- this.view = envelopeOf(remote);
494
- }
154
+ publish() {
155
+ this.snapshot = this.buildSnapshot();
156
+ for (const listener of this.listeners)
157
+ listener();
495
158
  }
496
- setLocked(error) {
497
- const expiresAt = typeof error.details.expiresAt === 'string'
498
- ? error.details.expiresAt
499
- : new Date(Date.now() + 60_000).toISOString();
500
- this.lockedUntil = expiresAt;
501
- if (this.lockTimer !== undefined)
502
- clearTimeout(this.lockTimer);
503
- const delay = Math.max(0, Date.parse(expiresAt) - Date.now());
504
- this.lockTimer = setTimeout(() => {
505
- this.lockTimer = undefined;
506
- this.lockedUntil = undefined;
507
- this.notify();
508
- }, delay);
159
+ buildSnapshot() {
160
+ const dirty = this.current.operations.length > 0 ||
161
+ JSON.stringify(this.current.artifact.content) !== JSON.stringify(this.base.content) ||
162
+ this.current.artifact.title !== this.base.title;
163
+ return {
164
+ baseVersion: this.base.version,
165
+ artifact: this.current.artifact,
166
+ operations: this.current.operations,
167
+ dirty,
168
+ canUndo: this.undoStack.length > 0,
169
+ canRedo: this.redoStack.length > 0,
170
+ pending: this.pending > 0,
171
+ ...(this.error ? { error: this.error } : {}),
172
+ lint: this.lint,
173
+ changes: this.changes,
174
+ };
509
175
  }
510
- // -- remote versions ---------------------------------------------------------------------------------
511
- /**
512
- * A version arrived from outside the session (refetch, agent commit, prop). Pending local operations
513
- * are rebased on it; when they no longer apply, a `rebase` conflict asks the user what to keep.
514
- */
515
- receive = (artifact) => {
516
- if (this.disposed || artifact.id !== this.confirmed.id)
517
- return;
518
- if (artifact.version < this.confirmed.version)
519
- return;
520
- if (artifact.version === this.confirmed.version) {
521
- if (artifact.status !== this.confirmed.status ||
522
- artifact.meta.publishedVersion !== this.confirmed.meta.publishedVersion) {
523
- this.confirmed = { ...this.confirmed, status: artifact.status, meta: artifact.meta };
524
- this.notify();
525
- }
526
- return;
527
- }
528
- if (this.inflight) {
529
- // Wait for the in-flight batch: applying it on top of a version that may already contain it
530
- // would show its operations twice.
531
- if (!this.deferredRemote || artifact.version > this.deferredRemote.version)
532
- this.deferredRemote = artifact;
533
- return;
534
- }
535
- this.incorporate(artifact);
536
- this.notify();
537
- };
538
- incorporate(artifact) {
539
- const previous = this.confirmed;
540
- this.confirmed = artifact;
541
- if (this.conflict) {
542
- this.conflict.latest = artifact;
543
- this.view = envelopeOf(artifact);
544
- }
545
- else {
546
- this.rebaseView(previous);
547
- if (!this.conflict)
548
- this.announce({ type: 'rebased', version: artifact.version });
549
- }
176
+ }
177
+ function errorOf(error) {
178
+ if (isArtifactError(error)) {
179
+ const payload = error.toPayload();
180
+ return {
181
+ code: payload.error.code,
182
+ message: payload.error.message,
183
+ issues: payload.error.issues ?? [],
184
+ };
550
185
  }
551
- resolveConflict = (choice) => {
552
- const conflict = this.conflict;
553
- if (!conflict)
554
- return;
555
- this.conflict = undefined;
556
- this.error = undefined;
557
- this.view = envelopeOf(this.confirmed);
558
- if (choice === 'reapply') {
559
- if (conflict.origin === 'undo' || conflict.origin === 'redo') {
560
- const entry = conflict.entry;
561
- const target = entry ? (conflict.origin === 'undo' ? entry.before : entry.after) : undefined;
562
- const ops = target ? operationsBetween(envelopeOf(this.confirmed), target) : [];
563
- if (ops.length > 0) {
564
- this.queue.unshift({
565
- type: 'ops',
566
- ops,
567
- key: this.key(),
568
- origin: conflict.origin,
569
- ...(entry ? { entry } : {}),
570
- });
571
- }
572
- }
573
- else {
574
- try {
575
- this.applyLocal(envelopeOf(this.confirmed), conflict.operations);
576
- this.queue.unshift({ type: 'ops', ops: conflict.operations, key: this.key(), origin: 'reapply' });
577
- }
578
- catch (error) {
579
- this.error = toArtifactError(error);
580
- this.announce({ type: 'rolled_back', error: this.error });
581
- this.pump();
582
- this.notify();
583
- return;
584
- }
585
- }
586
- this.rebaseView(this.confirmed);
587
- }
588
- this.announce({ type: 'conflict_resolved', choice });
589
- this.pump();
590
- this.notify();
186
+ return {
187
+ code: 'INTERNAL_ERROR',
188
+ message: error instanceof Error ? error.message : String(error),
189
+ issues: [],
591
190
  };
592
- /**
593
- * Stops timers and listeners. Buffered typing is still sent unless `discard` is set, so closing the
594
- * editor right after typing does not lose the last keystrokes.
595
- */
596
- dispose(options = {}) {
597
- if (this.disposed)
598
- return;
599
- if (options.discard) {
600
- this.buffer = [];
601
- this.queue = this.inflight ? this.queue.slice(0, 1) : [];
602
- }
603
- else {
604
- this.flushBuffer();
605
- this.pump();
606
- }
607
- this.clearTimer();
608
- if (this.lockTimer !== undefined)
609
- clearTimeout(this.lockTimer);
610
- this.disposed = true;
611
- this.listeners.clear();
612
- }
613
191
  }
614
192
  //# sourceMappingURL=editor-session.js.map