@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,292 @@
|
|
|
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';
|
|
6
|
+
import { useArtifact } from '../hooks.js';
|
|
7
|
+
import { hashString } from '../styles.js';
|
|
8
|
+
import { cx, ErrorBoundary, ErrorMessage, Loading, rootAttributes, type Slots, slot } from './shared.js';
|
|
9
|
+
import { ArtifactView, renderContext } from './view.js';
|
|
10
|
+
|
|
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';
|
|
30
|
+
|
|
31
|
+
export interface ArtifactEditorProps {
|
|
32
|
+
/** Artifact to edit. Its content is the starting point of the draft. */
|
|
33
|
+
artifact?: Artifact;
|
|
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;
|
|
42
|
+
className?: string;
|
|
43
|
+
slots?: Slots<ArtifactEditorPart>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
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.
|
|
49
|
+
*/
|
|
50
|
+
export function ArtifactEditor(props: ArtifactEditorProps) {
|
|
51
|
+
const ctx = useArtkit();
|
|
52
|
+
const fetched = useArtifact(props.artifact ? undefined : props.id);
|
|
53
|
+
const artifact = props.artifact ?? fetched.artifact;
|
|
54
|
+
|
|
55
|
+
if (!artifact) {
|
|
56
|
+
return (
|
|
57
|
+
<div className={cx('ak-root ak-editor', props.className)} {...rootAttributes(ctx)}>
|
|
58
|
+
{fetched.error ? (
|
|
59
|
+
<ErrorMessage error={fetched.error} onRetry={() => void fetched.refresh().catch(() => undefined)} />
|
|
60
|
+
) : (
|
|
61
|
+
<Loading />
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const editable = !props.readOnly && ctx.hasEditor(artifact.format) && ctx.registry.has(artifact.format);
|
|
68
|
+
if (!editable) {
|
|
69
|
+
return (
|
|
70
|
+
<div
|
|
71
|
+
className={cx('ak-root ak-editor', props.className)}
|
|
72
|
+
{...rootAttributes(ctx, artifact.themeId)}
|
|
73
|
+
data-readonly="true"
|
|
74
|
+
>
|
|
75
|
+
<ArtifactView artifact={artifact} />
|
|
76
|
+
</div>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
return <EditingHost key={artifact.id} {...props} artifact={artifact} />;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function EditingHost({
|
|
83
|
+
artifact,
|
|
84
|
+
readOnly,
|
|
85
|
+
onSave,
|
|
86
|
+
onChange,
|
|
87
|
+
className,
|
|
88
|
+
slots,
|
|
89
|
+
}: ArtifactEditorProps & { artifact: Artifact }) {
|
|
90
|
+
const ctx = useArtkit();
|
|
91
|
+
const [session] = useState(
|
|
92
|
+
() => new DraftSession({ registry: ctx.registry, artifact, locale: ctx.locale }),
|
|
93
|
+
);
|
|
94
|
+
const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
|
|
95
|
+
const [saving, setSaving] = useState(false);
|
|
96
|
+
const [saveError, setSaveError] = useState<unknown>();
|
|
97
|
+
|
|
98
|
+
// A newer artifact from the application (after a save, or a reload) moves the draft forward.
|
|
99
|
+
useEffect(() => {
|
|
100
|
+
if (
|
|
101
|
+
artifact.version !== session.getSnapshot().baseVersion ||
|
|
102
|
+
artifact.id !== session.getSnapshot().artifact.id
|
|
103
|
+
) {
|
|
104
|
+
session.load(artifact);
|
|
105
|
+
}
|
|
106
|
+
}, [artifact, session]);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
onChange?.(snapshot);
|
|
110
|
+
}, [snapshot, onChange]);
|
|
111
|
+
|
|
112
|
+
const draft = snapshot.artifact;
|
|
113
|
+
const canSave = Boolean(onSave) && snapshot.dirty && !saving && !readOnly;
|
|
114
|
+
|
|
115
|
+
async function save() {
|
|
116
|
+
if (!canSave || !onSave) return;
|
|
117
|
+
setSaving(true);
|
|
118
|
+
setSaveError(undefined);
|
|
119
|
+
try {
|
|
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);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
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
|
+
};
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
// biome-ignore lint/a11y/noStaticElementInteractions: the shortcuts (Ctrl/Cmd+S, Z, Y) cover the whole editor.
|
|
169
|
+
<section
|
|
170
|
+
className={cx('ak-root ak-editor', className)}
|
|
171
|
+
{...rootAttributes(ctx, draft.themeId)}
|
|
172
|
+
data-dirty={snapshot.dirty || undefined}
|
|
173
|
+
onKeyDown={onKeyDown}
|
|
174
|
+
>
|
|
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 />}>
|
|
181
|
+
<LoadedEditor
|
|
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}
|
|
189
|
+
emit={emit}
|
|
190
|
+
readOnly={Boolean(readOnly)}
|
|
191
|
+
lint={snapshot.lint}
|
|
192
|
+
/>
|
|
193
|
+
</Suspense>
|
|
194
|
+
</ErrorBoundary>
|
|
195
|
+
</div>
|
|
196
|
+
</section>
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function LoadedEditor({
|
|
201
|
+
format,
|
|
202
|
+
content,
|
|
203
|
+
ctx,
|
|
204
|
+
themeId,
|
|
205
|
+
locale,
|
|
206
|
+
emit,
|
|
207
|
+
readOnly,
|
|
208
|
+
lint,
|
|
209
|
+
actions,
|
|
210
|
+
slots,
|
|
211
|
+
}: {
|
|
212
|
+
slots?: Slots<ArtifactEditorPart>;
|
|
213
|
+
actions: EditorActions;
|
|
214
|
+
format: string;
|
|
215
|
+
content: unknown;
|
|
216
|
+
ctx: ArtkitContextValue;
|
|
217
|
+
themeId: string;
|
|
218
|
+
locale: string;
|
|
219
|
+
emit(operations: unknown[], options?: EmitOptions): void;
|
|
220
|
+
readOnly: boolean;
|
|
221
|
+
lint: DraftSnapshot['lint'];
|
|
222
|
+
}) {
|
|
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
|
+
);
|
|
228
|
+
return (
|
|
229
|
+
<>
|
|
230
|
+
{css ? (
|
|
231
|
+
<style href={`artkit-editor-${format}-${hashString(css)}`} precedence="artkit" nonce={ctx.nonce}>
|
|
232
|
+
{css}
|
|
233
|
+
</style>
|
|
234
|
+
) : null}
|
|
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
|
+
/>
|
|
244
|
+
</>
|
|
245
|
+
);
|
|
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
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { File, FileText, type LucideIcon, Presentation, Sheet, Workflow } from 'lucide-react';
|
|
2
|
+
|
|
3
|
+
/** Lucide icon per native kind; unknown kinds get a generic file. */
|
|
4
|
+
export const KIND_ICONS: Record<string, LucideIcon> = {
|
|
5
|
+
document: FileText,
|
|
6
|
+
sheet: Sheet,
|
|
7
|
+
slides: Presentation,
|
|
8
|
+
diagram: Workflow,
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
export function KindIcon({ format, className }: { format: string; className?: string }) {
|
|
12
|
+
const Icon = KIND_ICONS[format] ?? File;
|
|
13
|
+
return <Icon aria-hidden="true" focusable="false" className={className} />;
|
|
14
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import type { Artifact } from '@artkit-ai/core';
|
|
2
|
+
import { ChevronDown, Copy, Download, X } from 'lucide-react';
|
|
3
|
+
import {
|
|
4
|
+
type KeyboardEvent,
|
|
5
|
+
type ReactNode,
|
|
6
|
+
useCallback,
|
|
7
|
+
useEffect,
|
|
8
|
+
useId,
|
|
9
|
+
useMemo,
|
|
10
|
+
useRef,
|
|
11
|
+
useState,
|
|
12
|
+
} from 'react';
|
|
13
|
+
import { useArtkit } from '../context.js';
|
|
14
|
+
import type { DraftSnapshot } from '../editor-session.js';
|
|
15
|
+
import { useArtifact, useArtifactExport } from '../hooks.js';
|
|
16
|
+
import type { UiMessageKey } from '../i18n.js';
|
|
17
|
+
import { ArtifactEditor, type ArtifactEditorProps } from './editor.js';
|
|
18
|
+
import {
|
|
19
|
+
cx,
|
|
20
|
+
ErrorMessage,
|
|
21
|
+
formatLabel,
|
|
22
|
+
Loading,
|
|
23
|
+
rootAttributes,
|
|
24
|
+
type Slots,
|
|
25
|
+
slot,
|
|
26
|
+
useAnnouncer,
|
|
27
|
+
} from './shared.js';
|
|
28
|
+
import { ArtifactView } from './view.js';
|
|
29
|
+
|
|
30
|
+
export type PanelTab = 'view' | 'edit' | 'code';
|
|
31
|
+
export type ArtifactPanelPart = 'header' | 'title' | 'toolbar' | 'tabs' | 'body' | 'code';
|
|
32
|
+
|
|
33
|
+
export interface ArtifactPanelProps {
|
|
34
|
+
id?: string;
|
|
35
|
+
artifact?: Artifact;
|
|
36
|
+
tab?: PanelTab;
|
|
37
|
+
defaultTab?: PanelTab;
|
|
38
|
+
onTabChange?(tab: PanelTab): void;
|
|
39
|
+
/** Save action of the editor tab: the application decides what saving means. */
|
|
40
|
+
onSave?: ArtifactEditorProps['onSave'];
|
|
41
|
+
/** Draft changes, forwarded from the editor tab. */
|
|
42
|
+
onChange?(snapshot: DraftSnapshot): void;
|
|
43
|
+
/** Export formats; defaults to the provider's server formats plus browser exporters. */
|
|
44
|
+
exportFormats?: string[];
|
|
45
|
+
onClose?(): void;
|
|
46
|
+
/** Extra controls rendered in the header. */
|
|
47
|
+
headerActions?: ReactNode;
|
|
48
|
+
className?: string;
|
|
49
|
+
slots?: Slots<ArtifactPanelPart>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const TABS: PanelTab[] = ['view', 'edit', 'code'];
|
|
53
|
+
const TAB_LABEL: Record<PanelTab, UiMessageKey> = { view: 'tabView', edit: 'tabEdit', code: 'tabCode' };
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Side panel of one artifact: View, Edit and Code tabs and the export menu (spec §13.1). There is no history, approval
|
|
57
|
+
* or archive here: the application owns versions and publication.
|
|
58
|
+
*/
|
|
59
|
+
export function ArtifactPanel(props: ArtifactPanelProps) {
|
|
60
|
+
const ctx = useArtkit();
|
|
61
|
+
const { t } = ctx;
|
|
62
|
+
const id = props.id ?? props.artifact?.id;
|
|
63
|
+
const latestQuery = useArtifact(id, props.artifact ? { initial: props.artifact } : {});
|
|
64
|
+
const latest = latestQuery.artifact;
|
|
65
|
+
|
|
66
|
+
const [ownTab, setOwnTab] = useState<PanelTab>(props.defaultTab ?? 'view');
|
|
67
|
+
const tab = props.tab ?? ownTab;
|
|
68
|
+
const selectTab = (next: PanelTab) => {
|
|
69
|
+
setOwnTab(next);
|
|
70
|
+
props.onTabChange?.(next);
|
|
71
|
+
};
|
|
72
|
+
const exporter = useArtifactExport(id ?? '', latest?.format);
|
|
73
|
+
const [live, announce] = useAnnouncer();
|
|
74
|
+
const baseId = useId();
|
|
75
|
+
|
|
76
|
+
if (!latest || !id) {
|
|
77
|
+
return (
|
|
78
|
+
<aside className={cx('ak-root ak-panel', props.className)} {...rootAttributes(ctx)}>
|
|
79
|
+
{latestQuery.error ? (
|
|
80
|
+
<ErrorMessage
|
|
81
|
+
error={latestQuery.error}
|
|
82
|
+
onRetry={() => void latestQuery.refresh().catch(() => undefined)}
|
|
83
|
+
/>
|
|
84
|
+
) : (
|
|
85
|
+
<Loading />
|
|
86
|
+
)}
|
|
87
|
+
</aside>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function exportAs(format: string) {
|
|
92
|
+
try {
|
|
93
|
+
const result = await exporter.exportArtifact(format, {});
|
|
94
|
+
announce(t('exported', { filename: result.filename }));
|
|
95
|
+
} catch {
|
|
96
|
+
// The hook keeps the error; it is rendered below.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const tabId = (name: PanelTab) => `${baseId}-tab-${name}`;
|
|
101
|
+
const panelId = (name: PanelTab) => `${baseId}-panel-${name}`;
|
|
102
|
+
const onTabKeyDown = (event: KeyboardEvent<HTMLButtonElement>) => {
|
|
103
|
+
const index = TABS.indexOf(tab);
|
|
104
|
+
let next: PanelTab | undefined;
|
|
105
|
+
if (event.key === 'ArrowRight') next = TABS[(index + 1) % TABS.length];
|
|
106
|
+
else if (event.key === 'ArrowLeft') next = TABS[(index + TABS.length - 1) % TABS.length];
|
|
107
|
+
else if (event.key === 'Home') next = TABS[0];
|
|
108
|
+
else if (event.key === 'End') next = TABS[TABS.length - 1];
|
|
109
|
+
if (!next) return;
|
|
110
|
+
event.preventDefault();
|
|
111
|
+
selectTab(next);
|
|
112
|
+
document.getElementById(tabId(next))?.focus();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
const exportFormats = props.exportFormats ?? exporter.formats;
|
|
116
|
+
|
|
117
|
+
return (
|
|
118
|
+
<aside
|
|
119
|
+
className={cx('ak-root ak-panel', props.className)}
|
|
120
|
+
{...rootAttributes(ctx, latest.themeId)}
|
|
121
|
+
aria-labelledby={`${baseId}-title`}
|
|
122
|
+
data-format={latest.format}
|
|
123
|
+
>
|
|
124
|
+
{live}
|
|
125
|
+
<header {...slot(props.slots, 'header', 'ak-panel-header')}>
|
|
126
|
+
<h2 id={`${baseId}-title`} {...slot(props.slots, 'title', 'ak-panel-title')}>
|
|
127
|
+
{latest.title}
|
|
128
|
+
</h2>
|
|
129
|
+
<span className="ak-history-meta">{formatLabel(ctx, latest.format)}</span>
|
|
130
|
+
<span className="ak-badge">{t('versionShort', { version: latest.version })}</span>
|
|
131
|
+
{props.headerActions}
|
|
132
|
+
<ExportMenu
|
|
133
|
+
formats={exportFormats}
|
|
134
|
+
pending={exporter.pending}
|
|
135
|
+
onOpen={() => void exporter.loadFormats()}
|
|
136
|
+
onSelect={(format) => void exportAs(format)}
|
|
137
|
+
/>
|
|
138
|
+
{props.onClose ? (
|
|
139
|
+
<button
|
|
140
|
+
type="button"
|
|
141
|
+
className="ak-button"
|
|
142
|
+
data-variant="quiet"
|
|
143
|
+
onClick={props.onClose}
|
|
144
|
+
aria-label={t('close')}
|
|
145
|
+
>
|
|
146
|
+
<X aria-hidden="true" />
|
|
147
|
+
</button>
|
|
148
|
+
) : null}
|
|
149
|
+
</header>
|
|
150
|
+
{exporter.error ? <ErrorMessage error={exporter.error} className="ak-panel-notice" /> : null}
|
|
151
|
+
|
|
152
|
+
<div role="tablist" aria-label={latest.title} {...slot(props.slots, 'tabs', 'ak-tabs')}>
|
|
153
|
+
{TABS.map((name) => (
|
|
154
|
+
<button
|
|
155
|
+
key={name}
|
|
156
|
+
id={tabId(name)}
|
|
157
|
+
type="button"
|
|
158
|
+
role="tab"
|
|
159
|
+
className="ak-tab"
|
|
160
|
+
aria-selected={tab === name}
|
|
161
|
+
aria-controls={panelId(name)}
|
|
162
|
+
tabIndex={tab === name ? 0 : -1}
|
|
163
|
+
onClick={() => selectTab(name)}
|
|
164
|
+
onKeyDown={onTabKeyDown}
|
|
165
|
+
>
|
|
166
|
+
{t(TAB_LABEL[name])}
|
|
167
|
+
</button>
|
|
168
|
+
))}
|
|
169
|
+
</div>
|
|
170
|
+
|
|
171
|
+
<div
|
|
172
|
+
id={panelId(tab)}
|
|
173
|
+
role="tabpanel"
|
|
174
|
+
aria-labelledby={tabId(tab)}
|
|
175
|
+
{...slot(props.slots, 'body', 'ak-panel-body')}
|
|
176
|
+
>
|
|
177
|
+
{tab === 'view' ? <ArtifactView artifact={latest} /> : null}
|
|
178
|
+
{tab === 'edit' ? (
|
|
179
|
+
<ArtifactEditor
|
|
180
|
+
artifact={latest}
|
|
181
|
+
{...(props.onSave ? { onSave: props.onSave } : {})}
|
|
182
|
+
{...(props.onChange ? { onChange: props.onChange } : {})}
|
|
183
|
+
/>
|
|
184
|
+
) : null}
|
|
185
|
+
{tab === 'code' ? <CodeTab artifact={latest} slots={props.slots} /> : null}
|
|
186
|
+
</div>
|
|
187
|
+
</aside>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function CodeTab({
|
|
192
|
+
artifact,
|
|
193
|
+
slots,
|
|
194
|
+
}: {
|
|
195
|
+
artifact: Artifact | undefined;
|
|
196
|
+
slots?: Slots<ArtifactPanelPart> | undefined;
|
|
197
|
+
}) {
|
|
198
|
+
const { t } = useArtkit();
|
|
199
|
+
const [copied, setCopied] = useState(false);
|
|
200
|
+
const text = useMemo(() => (artifact ? JSON.stringify(artifact.content, null, 2) : ''), [artifact]);
|
|
201
|
+
if (!artifact) return <Loading />;
|
|
202
|
+
const clipboard = typeof navigator === 'undefined' ? undefined : navigator.clipboard;
|
|
203
|
+
return (
|
|
204
|
+
<div className="ak-code-tab">
|
|
205
|
+
{clipboard ? (
|
|
206
|
+
<button
|
|
207
|
+
type="button"
|
|
208
|
+
className="ak-button"
|
|
209
|
+
onClick={() => {
|
|
210
|
+
clipboard.writeText(text).then(
|
|
211
|
+
() => setCopied(true),
|
|
212
|
+
() => setCopied(false),
|
|
213
|
+
);
|
|
214
|
+
}}
|
|
215
|
+
>
|
|
216
|
+
<Copy aria-hidden="true" />
|
|
217
|
+
{copied ? t('copied') : t('copy')}
|
|
218
|
+
</button>
|
|
219
|
+
) : null}
|
|
220
|
+
<pre {...slot(slots, 'code', 'ak-code')}>
|
|
221
|
+
<code>{text}</code>
|
|
222
|
+
</pre>
|
|
223
|
+
</div>
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function ExportMenu(props: {
|
|
228
|
+
formats: string[];
|
|
229
|
+
pending: string | undefined;
|
|
230
|
+
onOpen(): void;
|
|
231
|
+
onSelect(format: string): void;
|
|
232
|
+
}) {
|
|
233
|
+
const { t } = useArtkit();
|
|
234
|
+
const [open, setOpen] = useState(false);
|
|
235
|
+
const button = useRef<HTMLButtonElement>(null);
|
|
236
|
+
const list = useRef<HTMLDivElement>(null);
|
|
237
|
+
const menuId = useId();
|
|
238
|
+
|
|
239
|
+
const items = useCallback(
|
|
240
|
+
() => [...(list.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? [])],
|
|
241
|
+
[],
|
|
242
|
+
);
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
if (open) items()[0]?.focus();
|
|
245
|
+
}, [open, items]);
|
|
246
|
+
|
|
247
|
+
const close = (refocus: boolean) => {
|
|
248
|
+
setOpen(false);
|
|
249
|
+
if (refocus) button.current?.focus();
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
const onMenuKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
|
253
|
+
const all = items();
|
|
254
|
+
const index = all.indexOf(document.activeElement as HTMLButtonElement);
|
|
255
|
+
let next: HTMLButtonElement | undefined;
|
|
256
|
+
if (event.key === 'ArrowDown') next = all[(index + 1) % all.length];
|
|
257
|
+
else if (event.key === 'ArrowUp') next = all[(index - 1 + all.length) % all.length];
|
|
258
|
+
else if (event.key === 'Home') next = all[0];
|
|
259
|
+
else if (event.key === 'End') next = all[all.length - 1];
|
|
260
|
+
else if (event.key === 'Escape') {
|
|
261
|
+
event.preventDefault();
|
|
262
|
+
close(true);
|
|
263
|
+
return;
|
|
264
|
+
} else if (event.key === 'Tab') {
|
|
265
|
+
close(false);
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
if (next) {
|
|
269
|
+
event.preventDefault();
|
|
270
|
+
next.focus();
|
|
271
|
+
}
|
|
272
|
+
};
|
|
273
|
+
|
|
274
|
+
return (
|
|
275
|
+
<div className="ak-menu">
|
|
276
|
+
<button
|
|
277
|
+
ref={button}
|
|
278
|
+
type="button"
|
|
279
|
+
className="ak-button"
|
|
280
|
+
aria-haspopup="menu"
|
|
281
|
+
aria-expanded={open}
|
|
282
|
+
aria-controls={open ? menuId : undefined}
|
|
283
|
+
disabled={props.pending !== undefined}
|
|
284
|
+
onClick={() => {
|
|
285
|
+
if (!open) props.onOpen();
|
|
286
|
+
setOpen(!open);
|
|
287
|
+
}}
|
|
288
|
+
onKeyDown={(event) => {
|
|
289
|
+
if (event.key === 'ArrowDown' && !open) {
|
|
290
|
+
event.preventDefault();
|
|
291
|
+
props.onOpen();
|
|
292
|
+
setOpen(true);
|
|
293
|
+
}
|
|
294
|
+
}}
|
|
295
|
+
>
|
|
296
|
+
<Download aria-hidden="true" />
|
|
297
|
+
{props.pending ? t('exporting') : t('export')}
|
|
298
|
+
<ChevronDown aria-hidden="true" />
|
|
299
|
+
</button>
|
|
300
|
+
{open ? (
|
|
301
|
+
<div
|
|
302
|
+
ref={list}
|
|
303
|
+
id={menuId}
|
|
304
|
+
role="menu"
|
|
305
|
+
className="ak-menu-list"
|
|
306
|
+
aria-label={t('export')}
|
|
307
|
+
tabIndex={-1}
|
|
308
|
+
onKeyDown={onMenuKeyDown}
|
|
309
|
+
>
|
|
310
|
+
{props.formats.length === 0 ? (
|
|
311
|
+
<p className="ak-menu-item">{t('noExportFormats')}</p>
|
|
312
|
+
) : (
|
|
313
|
+
props.formats.map((format) => (
|
|
314
|
+
<button
|
|
315
|
+
key={format}
|
|
316
|
+
type="button"
|
|
317
|
+
role="menuitem"
|
|
318
|
+
className="ak-menu-item"
|
|
319
|
+
tabIndex={-1}
|
|
320
|
+
aria-label={t('exportAs', { format: format.toUpperCase() })}
|
|
321
|
+
onClick={() => {
|
|
322
|
+
close(true);
|
|
323
|
+
props.onSelect(format);
|
|
324
|
+
}}
|
|
325
|
+
>
|
|
326
|
+
{format.toUpperCase()}
|
|
327
|
+
</button>
|
|
328
|
+
))
|
|
329
|
+
)}
|
|
330
|
+
</div>
|
|
331
|
+
) : null}
|
|
332
|
+
</div>
|
|
333
|
+
);
|
|
334
|
+
}
|