@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,76 +1,62 @@
1
- import type { Actor, Artifact, ArtifactError, CommitResult, EmitOptions, LintIssue } from '@artkit-ai/core';
2
- import { Redo2, RotateCcw, Undo2 } from 'lucide-react';
3
- import {
4
- type FocusEvent,
5
- type KeyboardEvent,
6
- Suspense,
7
- use,
8
- useCallback,
9
- useEffect,
10
- useId,
11
- useMemo,
12
- useRef,
13
- useState,
14
- useSyncExternalStore,
15
- } from 'react';
16
- import { useArtkit } from '../context.js';
17
- import { EditorSession, type EditorSessionState } from '../editor-session.js';
1
+ import type { Artifact, EditorActions, EmitOptions } from '@artkit-ai/core';
2
+ import { Redo2, Save, Undo2 } from 'lucide-react';
3
+ import { type KeyboardEvent, Suspense, use, useEffect, useMemo, useState, useSyncExternalStore } from 'react';
4
+ import { type ArtkitContextValue, useArtkit } from '../context.js';
5
+ import { DraftSession, type DraftSnapshot } from '../editor-session.js';
18
6
  import { useArtifact } from '../hooks.js';
19
- import type { UiMessageKey } from '../i18n.js';
20
7
  import { hashString } from '../styles.js';
21
- import {
22
- ChangeList,
23
- cx,
24
- ErrorBoundary,
25
- ErrorMessage,
26
- errorMessage,
27
- formatDate,
28
- Loading,
29
- rootAttributes,
30
- type Slots,
31
- slot,
32
- useAnnouncer,
33
- } from './shared.js';
34
- import { ArtifactView, KindRenderer, renderContext } from './view.js';
8
+ import { cx, ErrorBoundary, ErrorMessage, Loading, rootAttributes, type Slots, slot } from './shared.js';
9
+ import { ArtifactView, renderContext } from './view.js';
35
10
 
36
- export type EditorPermission = 'read' | 'edit';
37
- export type ArtifactEditorPart = 'toolbar' | 'status' | 'body' | 'conflict' | 'error' | 'notice';
11
+ /**
12
+ * What the application receives when the user clicks Save. The library does not store anything: the application
13
+ * decides what saving means (a new version, a database row, a sync to another system, nothing at all).
14
+ */
15
+ export interface SaveEvent {
16
+ artifactId: string;
17
+ format: string;
18
+ /** Version the draft started from. Pass it back as `baseVersion` when you apply `operations` yourself. */
19
+ baseVersion: number;
20
+ title: string;
21
+ locale: string;
22
+ themeId: string;
23
+ /** Full content of the draft. */
24
+ content: unknown;
25
+ /** Operations applied since `baseVersion`, in order. */
26
+ operations: unknown[];
27
+ }
28
+
29
+ export type ArtifactEditorPart = 'toolbar' | 'status' | 'body';
38
30
 
39
31
  export interface ArtifactEditorProps {
40
- id?: string;
41
- /** Latest known artifact (e.g. from a chat message); newer versions from the cache win. */
32
+ /** Artifact to edit. Its content is the starting point of the draft. */
42
33
  artifact?: Artifact;
43
- /** Set by the application (spec §13.6). `comment` arrives in phase 3. Default `edit`. */
44
- permission?: EditorPermission;
45
- /** Another actor holds the lock: the editor turns read-only and shows `partial` as a streaming preview. */
46
- lockedBy?: Actor | null;
47
- /** Streamed content shown while locked (see `ArtifactView`). */
48
- partial?: unknown;
49
- /** Typing batches are grouped within this window (default 600 ms). */
50
- groupingWindowMs?: number;
51
- onCommitted?(result: CommitResult): void;
52
- onError?(error: ArtifactError): void;
34
+ /** Load the artifact by id through the client when `artifact` is not given. */
35
+ id?: string;
36
+ /** Read-only edition: the format editor is hidden and the view is shown instead. */
37
+ readOnly?: boolean;
38
+ /** Save action. Called with the draft; the application persists it and passes the saved artifact back. */
39
+ onSave?(event: SaveEvent): void | Promise<void>;
40
+ /** Called after every change of the draft. */
41
+ onChange?(snapshot: DraftSnapshot): void;
53
42
  className?: string;
54
43
  slots?: Slots<ArtifactEditorPart>;
55
44
  }
56
45
 
57
46
  /**
58
- * Kind editor host (spec §13.1, §13.6). Falls back to `ArtifactView` when the kind has no editor loader,
59
- * when the permission is `read` or while another actor holds the lock. The editor module is only loaded
60
- * here, when editing starts.
47
+ * Editor of one artifact (spec §11.6). Edits are applied to a local draft; nothing reaches the server. The Save button
48
+ * emits the draft through `onSave`, and Ctrl/Cmd+S does the same. Undo and redo are local.
61
49
  */
62
50
  export function ArtifactEditor(props: ArtifactEditorProps) {
63
51
  const ctx = useArtkit();
64
- const { t } = ctx;
65
- const id = props.id ?? props.artifact?.id;
66
- const query = useArtifact(id, props.artifact ? { initial: props.artifact } : {});
67
- const artifact = query.artifact;
52
+ const fetched = useArtifact(props.artifact ? undefined : props.id);
53
+ const artifact = props.artifact ?? fetched.artifact;
68
54
 
69
55
  if (!artifact) {
70
56
  return (
71
57
  <div className={cx('ak-root ak-editor', props.className)} {...rootAttributes(ctx)}>
72
- {query.error ? (
73
- <ErrorMessage error={query.error} onRetry={() => void query.refresh().catch(() => undefined)} />
58
+ {fetched.error ? (
59
+ <ErrorMessage error={fetched.error} onRetry={() => void fetched.refresh().catch(() => undefined)} />
74
60
  ) : (
75
61
  <Loading />
76
62
  )}
@@ -78,239 +64,131 @@ export function ArtifactEditor(props: ArtifactEditorProps) {
78
64
  );
79
65
  }
80
66
 
81
- const editable =
82
- (props.permission ?? 'edit') === 'edit' &&
83
- ctx.hasEditor(artifact.kind) &&
84
- ctx.registry.has(artifact.kind);
85
- if (!editable || props.lockedBy) {
67
+ const editable = !props.readOnly && ctx.hasEditor(artifact.format) && ctx.registry.has(artifact.format);
68
+ if (!editable) {
86
69
  return (
87
70
  <div
88
71
  className={cx('ak-root ak-editor', props.className)}
89
72
  {...rootAttributes(ctx, artifact.themeId)}
90
73
  data-readonly="true"
91
74
  >
92
- {props.lockedBy ? (
93
- <p {...slot(props.slots, 'notice', 'ak-notice')} role="status">
94
- {t('lockedBy', { actor: t(`actor_${props.lockedBy.type}` as UiMessageKey) })}
95
- </p>
96
- ) : null}
97
- <ArtifactView
98
- artifact={artifact}
99
- {...(props.lockedBy && props.partial !== undefined ? { partial: props.partial } : {})}
100
- />
75
+ <ArtifactView artifact={artifact} />
101
76
  </div>
102
77
  );
103
78
  }
104
79
  return <EditingHost key={artifact.id} {...props} artifact={artifact} />;
105
80
  }
106
81
 
107
- function EditingHost(props: ArtifactEditorProps & { artifact: Artifact }) {
82
+ function EditingHost({
83
+ artifact,
84
+ readOnly,
85
+ onSave,
86
+ onChange,
87
+ className,
88
+ slots,
89
+ }: ArtifactEditorProps & { artifact: Artifact }) {
108
90
  const ctx = useArtkit();
109
- const { t, client, cache, registry } = ctx;
110
- const callbacks = useRef(props);
111
- callbacks.current = props;
112
- const artifactId = props.artifact.id;
113
-
114
91
  const [session] = useState(
115
- () =>
116
- new EditorSession({
117
- registry,
118
- artifact: props.artifact,
119
- locale: ctx.locale,
120
- ...(props.groupingWindowMs === undefined ? {} : { groupingWindowMs: props.groupingWindowMs }),
121
- send: (request) => client.apply(artifactId, request),
122
- load: (version) => cache.load(artifactId, version, version === undefined),
123
- onCommitted: (result) => {
124
- cache.put(result.artifact);
125
- callbacks.current.onCommitted?.(result);
126
- },
127
- }),
92
+ () => new DraftSession({ registry: ctx.registry, artifact, locale: ctx.locale }),
128
93
  );
129
- const state = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
130
-
131
- // Newer versions from the cache or props (agent commits, refetches) are rebased into the session.
132
- useEffect(() => {
133
- session.receive(props.artifact);
134
- }, [session, props.artifact]);
135
-
136
- useEffect(() => {
137
- cache.put(state.confirmed);
138
- }, [cache, state.confirmed]);
94
+ const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
95
+ const [saving, setSaving] = useState(false);
96
+ const [saveError, setSaveError] = useState<unknown>();
139
97
 
140
- // Unmounting must not lose the last keystrokes still waiting in the grouping window.
141
- useEffect(() => () => session.flush(), [session]);
142
-
143
- const [live, announce] = useAnnouncer();
144
- const lastSeq = useRef(0);
98
+ // A newer artifact from the application (after a save, or a reload) moves the draft forward.
145
99
  useEffect(() => {
146
- const event = state.event;
147
- if (!event || event.seq === lastSeq.current) return;
148
- lastSeq.current = event.seq;
149
- switch (event.type) {
150
- case 'saved':
151
- announce(t('savedVersion', { version: event.version }));
152
- break;
153
- case 'undone':
154
- announce(t('undone'));
155
- break;
156
- case 'redone':
157
- announce(t('redone'));
158
- break;
159
- case 'rebased':
160
- announce(t('agentUpdated', { version: event.version }));
161
- break;
162
- case 'rejected':
163
- case 'rolled_back':
164
- announce(t('rolledBack', { message: errorMessage(event.error, event.error.message) }));
165
- callbacks.current.onError?.(event.error);
166
- break;
167
- case 'conflict':
168
- announce(t('conflictTitle'));
169
- break;
170
- case 'conflict_resolved':
171
- break;
100
+ if (
101
+ artifact.version !== session.getSnapshot().baseVersion ||
102
+ artifact.id !== session.getSnapshot().artifact.id
103
+ ) {
104
+ session.load(artifact);
172
105
  }
173
- }, [state.event, announce, t]);
106
+ }, [artifact, session]);
174
107
 
175
- const [, forceRender] = useState(0);
176
108
  useEffect(() => {
177
- if (!state.lockedUntil) return;
178
- const delay = Date.parse(state.lockedUntil) - Date.now();
179
- if (delay <= 0) return;
180
- const timer = setTimeout(() => forceRender((n) => n + 1), delay + 10);
181
- return () => clearTimeout(timer);
182
- }, [state.lockedUntil]);
183
- const locked = state.lockedUntil !== undefined && Date.parse(state.lockedUntil) > Date.now();
184
- const readOnly = state.status === 'conflict' || locked;
109
+ onChange?.(snapshot);
110
+ }, [snapshot, onChange]);
185
111
 
186
- const onKeyDown = useCallback(
187
- (event: KeyboardEvent<HTMLDivElement>) => {
188
- if (event.defaultPrevented || !(event.ctrlKey || event.metaKey) || event.altKey) return;
189
- const key = event.key.toLowerCase();
190
- if (key === 'z' && !event.shiftKey) {
191
- event.preventDefault();
192
- session.undo();
193
- } else if ((key === 'z' && event.shiftKey) || key === 'y') {
194
- event.preventDefault();
195
- session.redo();
196
- }
197
- },
198
- [session],
199
- );
112
+ const draft = snapshot.artifact;
113
+ const canSave = Boolean(onSave) && snapshot.dirty && !saving && !readOnly;
200
114
 
201
- const onBlur = useCallback(
202
- (event: FocusEvent<HTMLDivElement>) => {
203
- if (!event.currentTarget.contains(event.relatedTarget as Node | null)) session.flush();
204
- },
205
- [session],
206
- );
207
-
208
- const view = state.view;
209
- const renderCtx = useMemo(
210
- () => renderContext(ctx, view.themeId, view.locale, 'screen', false),
211
- [ctx, view.themeId, view.locale],
212
- );
213
- const lint = useMemo<LintIssue[]>(() => {
115
+ async function save() {
116
+ if (!canSave || !onSave) return;
117
+ setSaving(true);
118
+ setSaveError(undefined);
214
119
  try {
215
- return registry
216
- .get(props.artifact.kind)
217
- .lint(view.content, { locale: view.locale, theme: ctx.themeFor(view.themeId) });
218
- } catch {
219
- return [];
120
+ await onSave({
121
+ artifactId: draft.id,
122
+ format: draft.format,
123
+ baseVersion: snapshot.baseVersion,
124
+ title: draft.title,
125
+ locale: draft.locale,
126
+ themeId: draft.themeId,
127
+ content: draft.content,
128
+ operations: snapshot.operations,
129
+ });
130
+ } catch (error) {
131
+ setSaveError(error);
132
+ } finally {
133
+ setSaving(false);
220
134
  }
221
- }, [ctx, registry, props.artifact.kind, view]);
222
- const emit = useCallback(
223
- (operations: unknown[], options?: EmitOptions) => {
224
- session.emit(operations, options);
225
- },
226
- [session],
227
- );
135
+ }
228
136
 
229
- const statusText =
230
- state.status === 'saving'
231
- ? t('saving')
232
- : state.status === 'editing'
233
- ? t('unsaved')
234
- : state.status === 'conflict'
235
- ? t('conflictTitle')
236
- : t('saved');
137
+ function onKeyDown(event: KeyboardEvent<HTMLElement>) {
138
+ if (event.defaultPrevented || !(event.metaKey || event.ctrlKey)) return;
139
+ const key = event.key.toLowerCase();
140
+ if (key === 's') {
141
+ event.preventDefault();
142
+ void save();
143
+ } else if (key === 'z' && !event.shiftKey) {
144
+ event.preventDefault();
145
+ session.undo();
146
+ } else if (key === 'y' || (key === 'z' && event.shiftKey)) {
147
+ event.preventDefault();
148
+ session.redo();
149
+ }
150
+ }
151
+
152
+ const emit = (operations: unknown[], options?: EmitOptions) => {
153
+ void session.apply(operations, { ...(options?.kind ? { kind: options.kind } : {}) });
154
+ };
155
+
156
+ const actions: EditorActions = {
157
+ canUndo: snapshot.canUndo,
158
+ canRedo: snapshot.canRedo,
159
+ undo: () => session.undo(),
160
+ redo: () => session.redo(),
161
+ dirty: snapshot.dirty,
162
+ saving,
163
+ canSave,
164
+ save: () => void save(),
165
+ };
237
166
 
238
167
  return (
168
+ // biome-ignore lint/a11y/noStaticElementInteractions: the shortcuts (Ctrl/Cmd+S, Z, Y) cover the whole editor.
239
169
  <section
240
- className={cx('ak-root ak-editor', props.className)}
241
- {...rootAttributes(ctx, view.themeId)}
242
- aria-label={t('editorLabel', { title: view.title })}
243
- lang={view.locale}
170
+ className={cx('ak-root ak-editor', className)}
171
+ {...rootAttributes(ctx, draft.themeId)}
172
+ data-dirty={snapshot.dirty || undefined}
244
173
  onKeyDown={onKeyDown}
245
- onBlur={onBlur}
246
- data-status={state.status}
247
174
  >
248
- {live}
249
- <div
250
- {...slot(props.slots, 'toolbar', 'ak-editor-toolbar')}
251
- role="toolbar"
252
- aria-label={t('editorLabel', { title: view.title })}
253
- >
254
- <button
255
- type="button"
256
- className="ak-button"
257
- onClick={session.undo}
258
- disabled={!state.canUndo}
259
- aria-keyshortcuts="Control+Z Meta+Z"
260
- >
261
- <Undo2 aria-hidden="true" />
262
- {t('undo')}
263
- </button>
264
- <button
265
- type="button"
266
- className="ak-button"
267
- onClick={session.redo}
268
- disabled={!state.canRedo}
269
- aria-keyshortcuts="Control+Shift+Z Meta+Shift+Z Control+Y"
270
- >
271
- <Redo2 aria-hidden="true" />
272
- {t('redo')}
273
- </button>
274
- {lint.length > 0 ? (
275
- <span className="ak-badge">{t('lintWarnings', { count: lint.length })}</span>
276
- ) : null}
277
- <span {...slot(props.slots, 'status', 'ak-editor-status')} aria-hidden="true">
278
- {statusText}
279
- </span>
280
- </div>
281
- {locked && state.lockedUntil ? (
282
- <p {...slot(props.slots, 'notice', 'ak-notice')} role="status">
283
- {t('lockedUntil', { time: formatDate(state.lockedUntil, ctx.locale) })}
284
- </p>
285
- ) : null}
286
- {state.error && state.status !== 'conflict' ? (
287
- <ErrorMessage
288
- error={state.error}
289
- onDismiss={session.dismissError}
290
- {...slot(props.slots, 'error', '')}
291
- />
292
- ) : null}
293
- {state.conflict ? <ConflictBox state={state} session={session} slots={props.slots} /> : null}
294
- <div {...slot(props.slots, 'body', 'ak-view-body')}>
295
- <ErrorBoundary
296
- onReset={() => ctx.resetLoad('editor', props.artifact.kind)}
297
- fallback={(error, reset) => (
298
- <>
299
- <ErrorMessage error={error} onRetry={reset} />
300
- <KindRenderer kind={props.artifact.kind} content={view.content} ctx={renderCtx} />
301
- </>
302
- )}
303
- >
304
- <Suspense
305
- fallback={<KindRenderer kind={props.artifact.kind} content={view.content} ctx={renderCtx} />}
306
- >
175
+ {snapshot.error ? <ErrorMessage error={snapshot.error} onDismiss={() => session.undo()} /> : null}
176
+ {saveError ? <ErrorMessage error={saveError} /> : null}
177
+
178
+ <div {...slot(slots, 'body', 'ak-editor-body')}>
179
+ <ErrorBoundary fallback={(error) => <ErrorMessage error={error} />}>
180
+ <Suspense fallback={<Loading />}>
307
181
  <LoadedEditor
308
- kind={props.artifact.kind}
309
- content={view.content}
310
- ctx={renderCtx}
182
+ slots={slots}
183
+ actions={actions}
184
+ format={draft.format}
185
+ content={draft.content}
186
+ ctx={ctx}
187
+ themeId={draft.themeId}
188
+ locale={draft.locale}
311
189
  emit={emit}
312
- readOnly={readOnly}
313
- lint={lint}
190
+ readOnly={Boolean(readOnly)}
191
+ lint={snapshot.lint}
314
192
  />
315
193
  </Suspense>
316
194
  </ErrorBoundary>
@@ -319,74 +197,96 @@ function EditingHost(props: ArtifactEditorProps & { artifact: Artifact }) {
319
197
  );
320
198
  }
321
199
 
322
- function ConflictBox({
323
- state,
324
- session,
325
- slots,
326
- }: {
327
- state: EditorSessionState;
328
- session: EditorSession;
329
- slots?: Slots<ArtifactEditorPart> | undefined;
330
- }) {
331
- const { t } = useArtkit();
332
- const conflict = state.conflict!;
333
- const first = useRef<HTMLButtonElement>(null);
334
- const titleId = useId();
335
- useEffect(() => {
336
- first.current?.focus();
337
- }, []);
338
- const undoing = conflict.reason === 'undo' || conflict.reason === 'redo';
339
- return (
340
- <section {...slot(slots, 'conflict', 'ak-conflict')} role="alert" aria-labelledby={titleId}>
341
- <h3 className="ak-conflict-title" id={titleId}>
342
- {t('conflictTitle')}
343
- </h3>
344
- <p>{undoing ? t('conflictUndoBody') : t('conflictBody', { version: conflict.latest.version })}</p>
345
- <ChangeList changes={conflict.changes} />
346
- <div className="ak-approval-actions">
347
- <button
348
- ref={first}
349
- type="button"
350
- className="ak-button"
351
- data-variant="primary"
352
- onClick={() => session.resolveConflict('keep-theirs')}
353
- >
354
- {t('keepTheirs')}
355
- </button>
356
- <button type="button" className="ak-button" onClick={() => session.resolveConflict('reapply')}>
357
- <RotateCcw aria-hidden="true" />
358
- {t('reapplyMine')}
359
- </button>
360
- </div>
361
- </section>
362
- );
363
- }
364
-
365
200
  function LoadedEditor({
366
- kind,
201
+ format,
367
202
  content,
368
- ctx: renderCtx,
203
+ ctx,
204
+ themeId,
205
+ locale,
369
206
  emit,
370
207
  readOnly,
371
208
  lint,
209
+ actions,
210
+ slots,
372
211
  }: {
373
- kind: string;
212
+ slots?: Slots<ArtifactEditorPart>;
213
+ actions: EditorActions;
214
+ format: string;
374
215
  content: unknown;
375
- ctx: ReturnType<typeof renderContext>;
216
+ ctx: ArtkitContextValue;
217
+ themeId: string;
218
+ locale: string;
376
219
  emit(operations: unknown[], options?: EmitOptions): void;
377
220
  readOnly: boolean;
378
- lint: LintIssue[];
221
+ lint: DraftSnapshot['lint'];
379
222
  }) {
380
- const ctx = useArtkit();
381
- const { Editor, css } = use(ctx.loadEditor(kind));
223
+ const { Editor, css, toolbar } = use(ctx.loadEditor(format));
224
+ const renderCtx = useMemo(
225
+ () => renderContext(ctx, themeId, locale, 'screen', false),
226
+ [ctx, themeId, locale],
227
+ );
382
228
  return (
383
229
  <>
384
230
  {css ? (
385
- <style href={`artkit-editor-${kind}-${hashString(css)}`} precedence="artkit" nonce={ctx.nonce}>
231
+ <style href={`artkit-editor-${format}-${hashString(css)}`} precedence="artkit" nonce={ctx.nonce}>
386
232
  {css}
387
233
  </style>
388
234
  ) : null}
389
- <Editor content={content} ctx={renderCtx} emit={emit} readOnly={readOnly} lint={lint} />
235
+ {toolbar === 'format' ? null : <HostToolbar actions={actions} slots={slots} />}
236
+ <Editor
237
+ content={content}
238
+ ctx={renderCtx}
239
+ emit={emit}
240
+ readOnly={readOnly}
241
+ lint={lint}
242
+ actions={actions}
243
+ />
390
244
  </>
391
245
  );
392
246
  }
247
+
248
+ /** Undo, redo, save status and save, drawn by the host above editors that do not draw their own toolbar. */
249
+ function HostToolbar({ actions, slots }: { actions: EditorActions; slots?: Slots<ArtifactEditorPart> }) {
250
+ const { t } = useArtkit();
251
+ return (
252
+ <header {...slot(slots, 'toolbar', 'ak-editor-toolbar')}>
253
+ <div className="ak-editor-history">
254
+ <button
255
+ type="button"
256
+ className="ak-button"
257
+ data-variant="quiet"
258
+ disabled={!actions.canUndo}
259
+ onClick={actions.undo}
260
+ aria-label={t('undo')}
261
+ title={t('undo')}
262
+ >
263
+ <Undo2 aria-hidden="true" />
264
+ </button>
265
+ <button
266
+ type="button"
267
+ className="ak-button"
268
+ data-variant="quiet"
269
+ disabled={!actions.canRedo}
270
+ onClick={actions.redo}
271
+ aria-label={t('redo')}
272
+ title={t('redo')}
273
+ >
274
+ <Redo2 aria-hidden="true" />
275
+ </button>
276
+ </div>
277
+ <span {...slot(slots, 'status', 'ak-editor-status')} role="status" aria-live="polite">
278
+ {actions.dirty ? t('unsaved') : t('saved')}
279
+ </span>
280
+ <button
281
+ type="button"
282
+ className="ak-button"
283
+ data-variant="primary"
284
+ disabled={!actions.canSave}
285
+ onClick={actions.save}
286
+ >
287
+ <Save aria-hidden="true" />
288
+ {actions.saving ? t('saving') : t('save')}
289
+ </button>
290
+ </header>
291
+ );
292
+ }
@@ -8,7 +8,7 @@ export const KIND_ICONS: Record<string, LucideIcon> = {
8
8
  diagram: Workflow,
9
9
  };
10
10
 
11
- export function KindIcon({ kind, className }: { kind: string; className?: string }) {
12
- const Icon = KIND_ICONS[kind] ?? File;
11
+ export function KindIcon({ format, className }: { format: string; className?: string }) {
12
+ const Icon = KIND_ICONS[format] ?? File;
13
13
  return <Icon aria-hidden="true" focusable="false" className={className} />;
14
14
  }