@artkit-ai/react 0.0.0-stage → 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.
- package/LICENSE +202 -0
- package/README.md +145 -2
- package/dist/cache.d.ts +31 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +95 -0
- package/dist/cache.js.map +1 -0
- package/dist/client.d.ts +68 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +233 -0
- package/dist/client.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 +41 -0
- package/dist/components/editor.d.ts.map +1 -0
- package/dist/components/editor.js +111 -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 +31 -0
- package/dist/components/panel.d.ts.map +1 -0
- package/dist/components/panel.js +132 -0
- package/dist/components/panel.js.map +1 -0
- package/dist/components/shared.d.ts +67 -0
- package/dist/components/shared.d.ts.map +1 -0
- package/dist/components/shared.js +100 -0
- package/dist/components/shared.js.map +1 -0
- package/dist/components/view.d.ts +39 -0
- package/dist/components/view.d.ts.map +1 -0
- package/dist/components/view.js +76 -0
- package/dist/components/view.js.map +1 -0
- package/dist/context.d.ts +64 -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 +96 -0
- package/dist/editor-session.d.ts.map +1 -0
- package/dist/editor-session.js +192 -0
- package/dist/editor-session.js.map +1 -0
- package/dist/hooks.d.ts +64 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +205 -0
- package/dist/hooks.js.map +1 -0
- package/dist/i18n.d.ts +135 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.js +145 -0
- package/dist/i18n.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +18 -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/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 +109 -0
- package/src/client.ts +342 -0
- package/src/components/card.tsx +86 -0
- package/src/components/editor.tsx +292 -0
- package/src/components/icons.tsx +14 -0
- package/src/components/panel.tsx +334 -0
- package/src/components/shared.tsx +198 -0
- package/src/components/view.tsx +196 -0
- package/src/context.ts +87 -0
- package/src/controls.tsx +587 -0
- package/src/download.ts +23 -0
- package/src/editor-session.ts +260 -0
- package/src/hooks.ts +296 -0
- package/src/i18n.ts +155 -0
- package/src/index.ts +128 -0
- package/src/kit-css.ts +5 -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,260 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type Artifact,
|
|
3
|
+
ArtifactError,
|
|
4
|
+
type ArtifactRegistry,
|
|
5
|
+
applyOperations,
|
|
6
|
+
type ChangeSummary,
|
|
7
|
+
isArtifactError,
|
|
8
|
+
type LintIssue,
|
|
9
|
+
} from '@artkit-ai/core';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Local draft of one artifact (spec §11.6). The editor never talks to the server while the user edits: every batch is
|
|
13
|
+
* applied here, with the same pure engine as the server (`applyOperations`), and kept until the application saves.
|
|
14
|
+
* Saving is the application's decision: the editor emits the draft and the application persists it (or not).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
export interface DraftSnapshot {
|
|
18
|
+
/** Version of the artifact the draft started from (the last one the application loaded or saved). */
|
|
19
|
+
baseVersion: number;
|
|
20
|
+
/** The draft as the user sees it. */
|
|
21
|
+
artifact: Artifact;
|
|
22
|
+
/** Operations applied since `baseVersion`, in order. */
|
|
23
|
+
operations: unknown[];
|
|
24
|
+
/** True when the draft differs from the base. */
|
|
25
|
+
dirty: boolean;
|
|
26
|
+
canUndo: boolean;
|
|
27
|
+
canRedo: boolean;
|
|
28
|
+
/** An edit is being applied. */
|
|
29
|
+
pending: boolean;
|
|
30
|
+
/** Last rejected batch, cleared by the next accepted one. */
|
|
31
|
+
error?: { code: string; message: string; issues: { path: string; message: string; hint?: string }[] };
|
|
32
|
+
/** Lint issues of the draft. */
|
|
33
|
+
lint: LintIssue[];
|
|
34
|
+
/** Changes of the last accepted batch. */
|
|
35
|
+
changes: ChangeSummary[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface DraftSessionOptions {
|
|
39
|
+
registry: ArtifactRegistry;
|
|
40
|
+
artifact: Artifact;
|
|
41
|
+
/** Locale of the model-facing messages produced by the engine (pt-BR or en). */
|
|
42
|
+
locale?: string;
|
|
43
|
+
/** Times within which consecutive typing batches share one undo step. */
|
|
44
|
+
coalesceMs?: number;
|
|
45
|
+
/** Undo steps kept in memory. */
|
|
46
|
+
maxUndo?: number;
|
|
47
|
+
now?: () => number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Step {
|
|
51
|
+
artifact: Artifact;
|
|
52
|
+
operations: unknown[];
|
|
53
|
+
at: number;
|
|
54
|
+
typing: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface EmitOptions {
|
|
58
|
+
/** Continuous gestures (typing, dragging) share one undo step; discrete changes always get their own. */
|
|
59
|
+
kind?: 'typing' | 'continuous' | 'discrete';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
type Listener = () => void;
|
|
63
|
+
|
|
64
|
+
const DEFAULT_COALESCE_MS = 1000;
|
|
65
|
+
const DEFAULT_MAX_UNDO = 200;
|
|
66
|
+
|
|
67
|
+
/** Draft of one artifact with local undo and redo. Batches are applied in order, one at a time. */
|
|
68
|
+
export class DraftSession {
|
|
69
|
+
private readonly registry: ArtifactRegistry;
|
|
70
|
+
private readonly locale: string;
|
|
71
|
+
private readonly coalesceMs: number;
|
|
72
|
+
private readonly maxUndo: number;
|
|
73
|
+
private readonly now: () => number;
|
|
74
|
+
private base: Artifact;
|
|
75
|
+
private current: Step;
|
|
76
|
+
private undoStack: Step[] = [];
|
|
77
|
+
private redoStack: Step[] = [];
|
|
78
|
+
private queue: Promise<unknown> = Promise.resolve();
|
|
79
|
+
private pending = 0;
|
|
80
|
+
private error: DraftSnapshot['error'];
|
|
81
|
+
private lint: LintIssue[];
|
|
82
|
+
private changes: ChangeSummary[] = [];
|
|
83
|
+
private snapshot: DraftSnapshot;
|
|
84
|
+
private readonly listeners = new Set<Listener>();
|
|
85
|
+
private disposed = false;
|
|
86
|
+
|
|
87
|
+
constructor(options: DraftSessionOptions) {
|
|
88
|
+
this.registry = options.registry;
|
|
89
|
+
this.locale = options.locale ?? 'en';
|
|
90
|
+
this.coalesceMs = options.coalesceMs ?? DEFAULT_COALESCE_MS;
|
|
91
|
+
this.maxUndo = options.maxUndo ?? DEFAULT_MAX_UNDO;
|
|
92
|
+
this.now = options.now ?? (() => Date.now());
|
|
93
|
+
this.base = options.artifact;
|
|
94
|
+
this.current = { artifact: options.artifact, operations: [], at: 0, typing: false };
|
|
95
|
+
this.lint = this.lintOf(options.artifact);
|
|
96
|
+
this.snapshot = this.buildSnapshot();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The current snapshot. Its identity changes only when the state changes. */
|
|
100
|
+
getSnapshot = (): DraftSnapshot => this.snapshot;
|
|
101
|
+
|
|
102
|
+
subscribe = (listener: Listener): (() => void) => {
|
|
103
|
+
this.listeners.add(listener);
|
|
104
|
+
return () => this.listeners.delete(listener);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Applies a batch to the draft. Resolves to `true` when the batch was accepted. A rejected batch leaves the draft
|
|
109
|
+
* untouched and sets `snapshot.error`.
|
|
110
|
+
*/
|
|
111
|
+
apply(operations: unknown[], options: EmitOptions = {}): Promise<boolean> {
|
|
112
|
+
if (operations.length === 0) return Promise.resolve(true);
|
|
113
|
+
this.pending += 1;
|
|
114
|
+
this.publish();
|
|
115
|
+
const run = this.queue.then(async () => {
|
|
116
|
+
if (this.disposed) return false;
|
|
117
|
+
const typing = options.kind === 'typing' || options.kind === 'continuous';
|
|
118
|
+
try {
|
|
119
|
+
const result = await applyOperations(this.registry, this.current.artifact, operations, {
|
|
120
|
+
locale: this.locale,
|
|
121
|
+
});
|
|
122
|
+
const next: Artifact = {
|
|
123
|
+
...this.current.artifact,
|
|
124
|
+
formatVersion: result.formatVersion,
|
|
125
|
+
title: result.title,
|
|
126
|
+
locale: result.locale,
|
|
127
|
+
themeId: result.themeId,
|
|
128
|
+
content: result.content,
|
|
129
|
+
};
|
|
130
|
+
const now = this.now();
|
|
131
|
+
const coalesce = typing && this.current.typing && now - this.current.at <= this.coalesceMs;
|
|
132
|
+
if (!coalesce) {
|
|
133
|
+
this.undoStack.push(this.current);
|
|
134
|
+
if (this.undoStack.length > this.maxUndo) this.undoStack.shift();
|
|
135
|
+
}
|
|
136
|
+
this.redoStack = [];
|
|
137
|
+
this.current = {
|
|
138
|
+
artifact: next,
|
|
139
|
+
operations: [...this.current.operations, ...operations],
|
|
140
|
+
at: now,
|
|
141
|
+
typing,
|
|
142
|
+
};
|
|
143
|
+
this.lint = result.lint;
|
|
144
|
+
this.changes = result.diff;
|
|
145
|
+
this.error = undefined;
|
|
146
|
+
return true;
|
|
147
|
+
} catch (error) {
|
|
148
|
+
this.error = errorOf(error);
|
|
149
|
+
return false;
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
this.queue = run.finally(() => {
|
|
153
|
+
this.pending -= 1;
|
|
154
|
+
this.publish();
|
|
155
|
+
});
|
|
156
|
+
return run.then((accepted) => {
|
|
157
|
+
this.publish();
|
|
158
|
+
return accepted;
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Steps back one edit (local). Returns false when there is nothing to undo. */
|
|
163
|
+
undo(): boolean {
|
|
164
|
+
const previous = this.undoStack.pop();
|
|
165
|
+
if (!previous) return false;
|
|
166
|
+
this.redoStack.push(this.current);
|
|
167
|
+
this.restore(previous);
|
|
168
|
+
return true;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Steps forward after an undo. Returns false when there is nothing to redo. */
|
|
172
|
+
redo(): boolean {
|
|
173
|
+
const next = this.redoStack.pop();
|
|
174
|
+
if (!next) return false;
|
|
175
|
+
this.undoStack.push(this.current);
|
|
176
|
+
this.restore(next);
|
|
177
|
+
return true;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Adopts an artifact loaded or saved by the application. A clean draft follows the new artifact. A draft that has
|
|
182
|
+
* changes keeps its content, and the base moves to the new version so the next save is a complete one.
|
|
183
|
+
*/
|
|
184
|
+
load(artifact: Artifact): void {
|
|
185
|
+
if (artifact.id !== this.base.id) throw new ArtifactError('INVALID_OPERATION', 'Load the same artifact.');
|
|
186
|
+
const clean = !this.buildSnapshot().dirty;
|
|
187
|
+
const sameContent = JSON.stringify(artifact.content) === JSON.stringify(this.current.artifact.content);
|
|
188
|
+
this.base = artifact;
|
|
189
|
+
if (clean || sameContent) {
|
|
190
|
+
this.current = { artifact, operations: [], at: 0, typing: false };
|
|
191
|
+
this.undoStack = [];
|
|
192
|
+
this.redoStack = [];
|
|
193
|
+
this.lint = this.lintOf(artifact);
|
|
194
|
+
this.changes = [];
|
|
195
|
+
}
|
|
196
|
+
this.publish();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Stops the session: later batches are ignored and listeners are dropped. */
|
|
200
|
+
dispose(): void {
|
|
201
|
+
this.disposed = true;
|
|
202
|
+
this.listeners.clear();
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
private restore(step: Step): void {
|
|
206
|
+
this.current = step;
|
|
207
|
+
this.lint = this.lintOf(step.artifact);
|
|
208
|
+
this.changes = [];
|
|
209
|
+
this.error = undefined;
|
|
210
|
+
this.publish();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
private lintOf(artifact: Artifact): LintIssue[] {
|
|
214
|
+
const adapter = this.registry.get(artifact.format, this.locale);
|
|
215
|
+
return adapter.lint(artifact.content as never, {
|
|
216
|
+
locale: artifact.locale,
|
|
217
|
+
theme: this.registry.theme(artifact.themeId, this.locale),
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
private publish(): void {
|
|
222
|
+
this.snapshot = this.buildSnapshot();
|
|
223
|
+
for (const listener of this.listeners) listener();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private buildSnapshot(): DraftSnapshot {
|
|
227
|
+
const dirty =
|
|
228
|
+
this.current.operations.length > 0 ||
|
|
229
|
+
JSON.stringify(this.current.artifact.content) !== JSON.stringify(this.base.content) ||
|
|
230
|
+
this.current.artifact.title !== this.base.title;
|
|
231
|
+
return {
|
|
232
|
+
baseVersion: this.base.version,
|
|
233
|
+
artifact: this.current.artifact,
|
|
234
|
+
operations: this.current.operations,
|
|
235
|
+
dirty,
|
|
236
|
+
canUndo: this.undoStack.length > 0,
|
|
237
|
+
canRedo: this.redoStack.length > 0,
|
|
238
|
+
pending: this.pending > 0,
|
|
239
|
+
...(this.error ? { error: this.error } : {}),
|
|
240
|
+
lint: this.lint,
|
|
241
|
+
changes: this.changes,
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function errorOf(error: unknown): NonNullable<DraftSnapshot['error']> {
|
|
247
|
+
if (isArtifactError(error)) {
|
|
248
|
+
const payload = error.toPayload();
|
|
249
|
+
return {
|
|
250
|
+
code: payload.error.code,
|
|
251
|
+
message: payload.error.message,
|
|
252
|
+
issues: payload.error.issues ?? [],
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
code: 'INTERNAL_ERROR',
|
|
257
|
+
message: error instanceof Error ? error.message : String(error),
|
|
258
|
+
issues: [],
|
|
259
|
+
};
|
|
260
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type AnyArtifactExporter,
|
|
3
|
+
type Artifact,
|
|
4
|
+
ArtifactError,
|
|
5
|
+
type CommitResult,
|
|
6
|
+
isArtifactError,
|
|
7
|
+
silentLogger,
|
|
8
|
+
slugify,
|
|
9
|
+
} from '@artkit-ai/core';
|
|
10
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
|
|
11
|
+
import type { CacheEntry } from './cache.js';
|
|
12
|
+
import type { ClientExportResult } from './client.js';
|
|
13
|
+
import { useArtkit } from './context.js';
|
|
14
|
+
import { triggerDownload } from './download.js';
|
|
15
|
+
|
|
16
|
+
const NO_ENTRY: CacheEntry = Object.freeze({ loading: false }) as CacheEntry;
|
|
17
|
+
const noop = () => {};
|
|
18
|
+
const noSubscription = () => noop;
|
|
19
|
+
|
|
20
|
+
function toArtifactError(error: unknown): ArtifactError {
|
|
21
|
+
if (isArtifactError(error)) return error;
|
|
22
|
+
return new ArtifactError('INTERNAL_ERROR', 'Internal error while processing the request.', {
|
|
23
|
+
cause: error,
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface UseArtifactOptions {
|
|
28
|
+
/** Already-known artifact (e.g. from a chat message), shown immediately and cached. */
|
|
29
|
+
initial?: Artifact;
|
|
30
|
+
/** Revalidate the latest version on mount (default true). */
|
|
31
|
+
revalidate?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface UseArtifactResult {
|
|
35
|
+
artifact: Artifact | undefined;
|
|
36
|
+
error: unknown;
|
|
37
|
+
loading: boolean;
|
|
38
|
+
/** Fetches the latest version again (specific versions never change). */
|
|
39
|
+
refresh(): Promise<Artifact | undefined>;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Loads an artifact through the client with a shared cache (spec §13.2). Pass undefined to skip. */
|
|
43
|
+
export function useArtifact(id: string | undefined, options: UseArtifactOptions = {}): UseArtifactResult {
|
|
44
|
+
const { cache } = useArtkit();
|
|
45
|
+
const { initial, revalidate = true } = options;
|
|
46
|
+
const subscribe = useCallback(
|
|
47
|
+
(listener: () => void) => (id ? cache.subscribe(id, listener) : noop),
|
|
48
|
+
[cache, id],
|
|
49
|
+
);
|
|
50
|
+
const read = () => (id ? cache.entry(id) : NO_ENTRY);
|
|
51
|
+
const entry = useSyncExternalStore(id ? subscribe : noSubscription, read, read);
|
|
52
|
+
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (initial && initial.id === id) cache.put(initial);
|
|
55
|
+
}, [cache, id, initial]);
|
|
56
|
+
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (!id) return;
|
|
59
|
+
cache.load(id, revalidate).catch(noop);
|
|
60
|
+
}, [cache, id, revalidate]);
|
|
61
|
+
|
|
62
|
+
const usableInitial = initial && initial.id === id ? initial : undefined;
|
|
63
|
+
const artifact =
|
|
64
|
+
entry.artifact && (!usableInitial || entry.artifact.version >= usableInitial.version)
|
|
65
|
+
? entry.artifact
|
|
66
|
+
: (usableInitial ?? entry.artifact);
|
|
67
|
+
const refresh = useCallback(async () => {
|
|
68
|
+
if (!id) return undefined;
|
|
69
|
+
return cache.load(id, true);
|
|
70
|
+
}, [cache, id]);
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
artifact,
|
|
74
|
+
error: artifact ? undefined : entry.error,
|
|
75
|
+
loading: !artifact && !entry.error && id !== undefined,
|
|
76
|
+
refresh,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Latest known version of an artifact in the cache, updated when commits arrive. */
|
|
81
|
+
export function useLatestVersion(id: string | undefined): number | undefined {
|
|
82
|
+
const { cache } = useArtkit();
|
|
83
|
+
const subscribe = useCallback(
|
|
84
|
+
(listener: () => void) => (id ? cache.subscribe(id, listener) : noop),
|
|
85
|
+
[cache, id],
|
|
86
|
+
);
|
|
87
|
+
const read = () => (id ? cache.latest(id)?.version : undefined);
|
|
88
|
+
return useSyncExternalStore(subscribe, read, read);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ApplyConflict {
|
|
92
|
+
operations: unknown[];
|
|
93
|
+
baseVersion: number;
|
|
94
|
+
currentVersion?: number;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export interface ApplyOptions {
|
|
98
|
+
/** Defaults to the latest cached version (loaded when unknown). */
|
|
99
|
+
baseVersion?: number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export interface UseApplyOperationsResult {
|
|
103
|
+
apply(operations: unknown[], options?: ApplyOptions): Promise<CommitResult>;
|
|
104
|
+
pending: boolean;
|
|
105
|
+
error: ArtifactError | undefined;
|
|
106
|
+
/** Set on VERSION_CONFLICT: what changed since the base, and the operations that were refused. */
|
|
107
|
+
conflict: ApplyConflict | undefined;
|
|
108
|
+
/** Sends the refused operations again on the latest version. */
|
|
109
|
+
retry(): Promise<CommitResult>;
|
|
110
|
+
reset(): void;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Sends operations with `baseVersion` and surfaces conflicts (spec §13.2). */
|
|
114
|
+
export function useApplyOperations(id: string): UseApplyOperationsResult {
|
|
115
|
+
const { client, cache } = useArtkit();
|
|
116
|
+
const [state, setState] = useState<{ pending: boolean; error?: ArtifactError; conflict?: ApplyConflict }>({
|
|
117
|
+
pending: false,
|
|
118
|
+
});
|
|
119
|
+
const conflictRef = useRef<ApplyConflict | undefined>(undefined);
|
|
120
|
+
|
|
121
|
+
const apply = useCallback(
|
|
122
|
+
async (operations: unknown[], options: ApplyOptions = {}) => {
|
|
123
|
+
setState({ pending: true });
|
|
124
|
+
let baseVersion = options.baseVersion;
|
|
125
|
+
try {
|
|
126
|
+
baseVersion ??= cache.latest(id)?.version ?? (await cache.load(id)).version;
|
|
127
|
+
const result = await client.apply(id, { baseVersion, operations });
|
|
128
|
+
cache.put(result.artifact);
|
|
129
|
+
conflictRef.current = undefined;
|
|
130
|
+
setState({ pending: false });
|
|
131
|
+
return result;
|
|
132
|
+
} catch (caught) {
|
|
133
|
+
const error = toArtifactError(caught);
|
|
134
|
+
let conflict: ApplyConflict | undefined;
|
|
135
|
+
if (error.code === 'VERSION_CONFLICT') {
|
|
136
|
+
const current = error.details.currentVersion;
|
|
137
|
+
conflict = {
|
|
138
|
+
operations,
|
|
139
|
+
baseVersion: baseVersion ?? 0,
|
|
140
|
+
...(typeof current === 'number' ? { currentVersion: current } : {}),
|
|
141
|
+
};
|
|
142
|
+
cache.load(id, true).catch(noop);
|
|
143
|
+
}
|
|
144
|
+
conflictRef.current = conflict;
|
|
145
|
+
setState({ pending: false, error, ...(conflict ? { conflict } : {}) });
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
},
|
|
149
|
+
[client, cache, id],
|
|
150
|
+
);
|
|
151
|
+
|
|
152
|
+
const retry = useCallback(async () => {
|
|
153
|
+
const conflict = conflictRef.current;
|
|
154
|
+
if (!conflict) throw new ArtifactError('NOT_FOUND', 'There is no conflicting batch to retry.');
|
|
155
|
+
const latest = await cache.load(id, true);
|
|
156
|
+
return apply(conflict.operations, { baseVersion: latest.version });
|
|
157
|
+
}, [apply, cache, id]);
|
|
158
|
+
|
|
159
|
+
const reset = useCallback(() => {
|
|
160
|
+
conflictRef.current = undefined;
|
|
161
|
+
setState({ pending: false });
|
|
162
|
+
}, []);
|
|
163
|
+
|
|
164
|
+
return { apply, pending: state.pending, error: state.error, conflict: state.conflict, retry, reset };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export interface ExportOptions {
|
|
168
|
+
/** Triggers a browser download (default true). */
|
|
169
|
+
download?: boolean;
|
|
170
|
+
options?: Record<string, unknown>;
|
|
171
|
+
/** `auto` (default) exports in the browser when a browser-capable exporter is loaded. */
|
|
172
|
+
prefer?: 'auto' | 'browser' | 'server';
|
|
173
|
+
signal?: AbortSignal;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export interface UseArtifactExportResult {
|
|
177
|
+
exportArtifact(format: string, options?: ExportOptions): Promise<ClientExportResult>;
|
|
178
|
+
pending: string | undefined;
|
|
179
|
+
error: ArtifactError | undefined;
|
|
180
|
+
warnings: string[];
|
|
181
|
+
/** Formats available for the artifact's kind (server formats plus browser exporters). */
|
|
182
|
+
formats: string[];
|
|
183
|
+
/** Loads the kind's browser exporters so `formats` includes them. */
|
|
184
|
+
loadFormats(): Promise<void>;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Exports in the browser when a loaded exporter with runtime `any`/`browser` handles the format, else
|
|
189
|
+
* through the client (spec §13.2), and triggers a download.
|
|
190
|
+
*/
|
|
191
|
+
export function useArtifactExport(id: string, format?: string): UseArtifactExportResult {
|
|
192
|
+
const ctx = useArtkit();
|
|
193
|
+
const { client, cache, registry } = ctx;
|
|
194
|
+
const [pending, setPending] = useState<string | undefined>(undefined);
|
|
195
|
+
const [error, setError] = useState<ArtifactError | undefined>(undefined);
|
|
196
|
+
const [warnings, setWarnings] = useState<string[]>([]);
|
|
197
|
+
const [loaded, setLoaded] = useState<AnyArtifactExporter[]>([]);
|
|
198
|
+
const knownFormat = format ?? cache.latest(id)?.format;
|
|
199
|
+
|
|
200
|
+
const browserExporters = useCallback(
|
|
201
|
+
async (artifactKind: string): Promise<AnyArtifactExporter[]> => {
|
|
202
|
+
const fromRegistry = registry.exporters(artifactKind).filter((exporter) => exporter.runtime !== 'node');
|
|
203
|
+
if (!ctx.hasExporters(artifactKind)) return fromRegistry;
|
|
204
|
+
const lazy = (await ctx.loadExporters(artifactKind)).filter(
|
|
205
|
+
(exporter) => exporter.format === artifactKind && exporter.runtime !== 'node',
|
|
206
|
+
);
|
|
207
|
+
return [...lazy, ...fromRegistry];
|
|
208
|
+
},
|
|
209
|
+
[ctx, registry],
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const loadFormats = useCallback(async () => {
|
|
213
|
+
if (!knownFormat) return;
|
|
214
|
+
try {
|
|
215
|
+
setLoaded(await browserExporters(knownFormat));
|
|
216
|
+
} catch {
|
|
217
|
+
// Without browser exporters the server formats still work.
|
|
218
|
+
}
|
|
219
|
+
}, [browserExporters, knownFormat]);
|
|
220
|
+
|
|
221
|
+
const formats = useMemo(() => {
|
|
222
|
+
if (!knownFormat) return [];
|
|
223
|
+
const set = new Set<string>(ctx.exportFormats[knownFormat] ?? []);
|
|
224
|
+
for (const exporter of registry.exporters(knownFormat))
|
|
225
|
+
if (exporter.runtime !== 'node') set.add(exporter.target);
|
|
226
|
+
for (const exporter of loaded) set.add(exporter.target);
|
|
227
|
+
return [...set];
|
|
228
|
+
}, [ctx.exportFormats, knownFormat, loaded, registry]);
|
|
229
|
+
|
|
230
|
+
const exportArtifact = useCallback(
|
|
231
|
+
async (format: string, options: ExportOptions = {}) => {
|
|
232
|
+
setPending(format);
|
|
233
|
+
setError(undefined);
|
|
234
|
+
setWarnings([]);
|
|
235
|
+
try {
|
|
236
|
+
const artifact = cache.latest(id) ?? (await cache.load(id));
|
|
237
|
+
let result: ClientExportResult | undefined;
|
|
238
|
+
if (options.prefer !== 'server') {
|
|
239
|
+
const exporter = (await browserExporters(artifact.format).catch(() => [])).find(
|
|
240
|
+
(candidate) => candidate.target === format,
|
|
241
|
+
);
|
|
242
|
+
if (exporter) {
|
|
243
|
+
const collected: string[] = [];
|
|
244
|
+
let bytes: Uint8Array;
|
|
245
|
+
try {
|
|
246
|
+
bytes = await exporter.export(artifact.content, {
|
|
247
|
+
title: artifact.title,
|
|
248
|
+
theme: ctx.themeFor(artifact.themeId),
|
|
249
|
+
locale: artifact.locale,
|
|
250
|
+
assets: ctx.assets ?? {
|
|
251
|
+
resolve: (ref) =>
|
|
252
|
+
Promise.reject(
|
|
253
|
+
new ArtifactError('NOT_FOUND', `Asset not available in the browser: ${ref}.`),
|
|
254
|
+
),
|
|
255
|
+
},
|
|
256
|
+
logger: silentLogger,
|
|
257
|
+
warn: (message) => collected.push(message),
|
|
258
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
259
|
+
...(options.options ? { options: options.options } : {}),
|
|
260
|
+
});
|
|
261
|
+
} catch (cause) {
|
|
262
|
+
throw isArtifactError(cause)
|
|
263
|
+
? cause
|
|
264
|
+
: new ArtifactError('EXPORT_FAILED', `Failed to export to ${format}.`, { cause });
|
|
265
|
+
}
|
|
266
|
+
result = {
|
|
267
|
+
bytes,
|
|
268
|
+
mimeType: exporter.mimeType,
|
|
269
|
+
filename: `${slugify(artifact.title)}.${exporter.extension}`,
|
|
270
|
+
warnings: collected,
|
|
271
|
+
};
|
|
272
|
+
} else if (options.prefer === 'browser') {
|
|
273
|
+
throw new ArtifactError('EXPORT_FAILED', `No browser exporter for format ${format}.`);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
result ??= await client.export(id, {
|
|
277
|
+
format,
|
|
278
|
+
...(options.options ? { options: options.options } : {}),
|
|
279
|
+
...(options.signal ? { signal: options.signal } : {}),
|
|
280
|
+
});
|
|
281
|
+
setWarnings(result.warnings);
|
|
282
|
+
if (options.download !== false) triggerDownload(result);
|
|
283
|
+
return result;
|
|
284
|
+
} catch (caught) {
|
|
285
|
+
const failure = toArtifactError(caught);
|
|
286
|
+
setError(failure);
|
|
287
|
+
throw failure;
|
|
288
|
+
} finally {
|
|
289
|
+
setPending(undefined);
|
|
290
|
+
}
|
|
291
|
+
},
|
|
292
|
+
[browserExporters, cache, client, ctx, id],
|
|
293
|
+
);
|
|
294
|
+
|
|
295
|
+
return { exportArtifact, pending, error, warnings, formats, loadFormats };
|
|
296
|
+
}
|