slice-machine-ui 2.19.2-beta.1 → 2.19.2-beta.3

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 (40) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/chunks/125-00b909bdbab2ca15.js +1 -0
  3. package/out/_next/static/chunks/{344-fdb3008f4bb3b0c1.js → 344-b64f09e670634ed1.js} +1 -1
  4. package/out/_next/static/chunks/{500-d3989390f5e8da53.js → 444-d39213143f782fec.js} +1 -1
  5. package/out/_next/static/chunks/{489-ce3053e1d81ade83.js → 489-32281540712d98bb.js} +1 -1
  6. package/out/_next/static/chunks/593-7ffd1197c3405ef8.js +1 -0
  7. package/out/_next/static/chunks/633-275b9968b5aaa920.js +1 -0
  8. package/out/_next/static/chunks/66-d9d3bcb5d041cb6d.js +1 -0
  9. package/out/_next/static/chunks/882-48d61b2fabee28d8.js +1 -0
  10. package/out/_next/static/chunks/pages/{_app-6abab3a0500ed1ed.js → _app-9cc2da8ff60c3087.js} +1 -1
  11. package/out/_next/static/chunks/pages/{changes-8af4acbb8f974cb2.js → changes-e66094f57453cf9c.js} +1 -1
  12. package/out/_next/static/chunks/pages/custom-types/{[customTypeId]-af9376721beb489e.js → [customTypeId]-6d613b67e6967ae5.js} +1 -1
  13. package/out/_next/static/chunks/pages/page-types/{[pageTypeId]-a24665e91b882169.js → [pageTypeId]-40207b66190e3fcd.js} +1 -1
  14. package/out/_next/static/chunks/pages/slices/[lib]/[sliceName]/{[variation]-e973a443d8b8a75d.js → [variation]-22ffa2561ac66557.js} +1 -1
  15. package/out/_next/static/chunks/pages/{slices-81c1c3f1bcad60f4.js → slices-a9b4d6d022cfcd88.js} +1 -1
  16. package/out/_next/static/uC4K1_hgf7dK4FL02cbUH/_buildManifest.js +1 -0
  17. package/out/changelog.html +1 -1
  18. package/out/changes.html +1 -1
  19. package/out/custom-types/[customTypeId].html +1 -1
  20. package/out/custom-types.html +1 -1
  21. package/out/index.html +1 -1
  22. package/out/labs.html +1 -1
  23. package/out/page-types/[pageTypeId].html +1 -1
  24. package/out/slices/[lib]/[sliceName]/[variation]/simulator.html +1 -1
  25. package/out/slices/[lib]/[sliceName]/[variation].html +1 -1
  26. package/out/slices.html +1 -1
  27. package/package.json +3 -3
  28. package/src/features/customTypes/customTypesBuilder/CreateSliceFromImageModal/CreateSliceFromImageModal.tsx +602 -238
  29. package/src/features/customTypes/customTypesBuilder/CreateSliceFromImageModal/SliceCard.tsx +50 -13
  30. package/src/icons/FigmaIcon.tsx +33 -34
  31. package/src/icons/FigmaIconSquare.tsx +45 -0
  32. package/src/legacy/components/ScreenshotChangesModal/index.tsx +2 -2
  33. package/src/legacy/lib/builders/CustomTypeBuilder/SliceZone/index.tsx +1 -1
  34. package/test/__setup__.ts +7 -0
  35. package/out/_next/static/MQ0zmvVfr20Ca-bgbzvFk/_buildManifest.js +0 -1
  36. package/out/_next/static/chunks/34-28725deef8b874b1.js +0 -1
  37. package/out/_next/static/chunks/658-8231c0b729e0124a.js +0 -1
  38. package/out/_next/static/chunks/907-180eb33eefccc237.js +0 -1
  39. package/out/_next/static/chunks/918-fa4f2563cb5fd014.js +0 -1
  40. /package/out/_next/static/{MQ0zmvVfr20Ca-bgbzvFk → uC4K1_hgf7dK4FL02cbUH}/_ssgManifest.js +0 -0
@@ -1,10 +1,8 @@
1
1
  import {
2
2
  BlankSlate,
3
- BlankSlateActions,
4
- BlankSlateDescription,
5
3
  BlankSlateIcon,
6
- BlankSlateTitle,
7
4
  Box,
5
+ Button,
8
6
  Dialog,
9
7
  DialogActionButton,
10
8
  DialogActions,
@@ -14,16 +12,22 @@ import {
14
12
  DialogHeader,
15
13
  FileDropZone,
16
14
  FileUploadButton,
15
+ ProgressCircle,
17
16
  ScrollArea,
17
+ Text,
18
18
  } from "@prismicio/editor-ui";
19
19
  import { SharedSlice } from "@prismicio/types-internal/lib/customtypes";
20
20
  import { useEffect, useRef, useState } from "react";
21
+ import { useHotkeys } from "react-hotkeys-hook";
21
22
  import { toast } from "react-toastify";
23
+ import { z } from "zod";
22
24
 
23
25
  import { getState, telemetry } from "@/apiClient";
24
26
  import { addAiFeedback } from "@/features/aiFeedback";
25
27
  import { useOnboarding } from "@/features/onboarding/useOnboarding";
26
28
  import { useAutoSync } from "@/features/sync/AutoSyncProvider";
29
+ import { useExperimentVariant } from "@/hooks/useExperimentVariant";
30
+ import { FigmaIcon } from "@/icons/FigmaIcon";
27
31
  import { managerClient } from "@/managerClient";
28
32
  import useSliceMachineActions from "@/modules/useSliceMachineActions";
29
33
 
@@ -34,25 +38,29 @@ const IMAGE_UPLOAD_LIMIT = 10;
34
38
  interface CreateSliceFromImageModalProps {
35
39
  open: boolean;
36
40
  location: "custom_type" | "page_type" | "slices";
37
- onSuccess: (args: {
38
- slices: {
39
- model: SharedSlice;
40
- langSmithUrl?: string;
41
- }[];
42
- library: string;
43
- }) => void;
41
+ onSuccess: (args: { slices: SharedSlice[]; library: string }) => void;
44
42
  onClose: () => void;
45
43
  }
46
44
 
45
+ const clipboardDataSchema = z.object({
46
+ __type: z.literal("figma-to-prismic/clipboard-data"),
47
+ name: z.string(),
48
+ image: z.string().startsWith("data:image/"),
49
+ });
50
+
47
51
  export function CreateSliceFromImageModal(
48
52
  props: CreateSliceFromImageModalProps,
49
53
  ) {
50
54
  const { open, location, onSuccess, onClose } = props;
51
55
  const [slices, setSlices] = useState<Slice[]>([]);
52
- const [isCreatingSlices, setIsCreatingSlices] = useState(false);
56
+ const [isSubmitting, setIsSubmitting] = useState(false);
57
+
53
58
  const { syncChanges } = useAutoSync();
54
59
  const { createSliceSuccess } = useSliceMachineActions();
55
60
  const { completeStep } = useOnboarding();
61
+ const existingSlices = useExistingSlices({ open });
62
+ const isFigmaEnabled = useIsFigmaEnabled();
63
+ const { libraryID, isLoading: isLoadingLibraryID } = useLibraryID();
56
64
 
57
65
  /**
58
66
  * Keeps track of the current instance id.
@@ -60,6 +68,15 @@ export function CreateSliceFromImageModal(
60
68
  */
61
69
  const id = useRef(crypto.randomUUID());
62
70
 
71
+ useHotkeys(
72
+ ["meta+v", "ctrl+v"],
73
+ (event) => {
74
+ event.preventDefault();
75
+ void handlePaste();
76
+ },
77
+ { enabled: open && isFigmaEnabled },
78
+ );
79
+
63
80
  const setSlice = (args: {
64
81
  index: number;
65
82
  slice: (prevSlice: Slice) => Slice;
@@ -68,14 +85,9 @@ export function CreateSliceFromImageModal(
68
85
  setSlices((slices) => slices.map((s, i) => (i === index ? slice(s) : s)));
69
86
  };
70
87
 
71
- const onOpenChange = (open: boolean) => {
72
- if (open || isCreatingSlices) return;
73
- onClose();
74
- id.current = crypto.randomUUID();
75
- setSlices([]);
76
- };
77
-
78
88
  const onImagesSelected = (images: File[]) => {
89
+ if (hasTriggeredGeneration) return;
90
+
79
91
  if (images.length > IMAGE_UPLOAD_LIMIT) {
80
92
  toast.error(
81
93
  `You can only upload ${IMAGE_UPLOAD_LIMIT} images at a time.`,
@@ -83,18 +95,30 @@ export function CreateSliceFromImageModal(
83
95
  return;
84
96
  }
85
97
 
86
- setSlices(
87
- images.map((image) => ({
88
- status: "uploading",
89
- image,
90
- })),
91
- );
92
-
93
- images.forEach((image, index) => uploadImage({ index, image }));
98
+ const startIndex = slices.length;
99
+ setSlices((prevSlices) => [
100
+ ...prevSlices,
101
+ ...images.map(
102
+ (image): Slice => ({
103
+ source: "upload",
104
+ status: "uploading",
105
+ image,
106
+ }),
107
+ ),
108
+ ]);
109
+
110
+ images.forEach((image, relativeIndex) => {
111
+ const index = startIndex + relativeIndex;
112
+ void uploadImage({ index, image, source: "upload" });
113
+ });
94
114
  };
95
115
 
96
- const uploadImage = (args: { index: number; image: File }) => {
97
- const { index, image } = args;
116
+ const uploadImage = async (args: {
117
+ index: number;
118
+ image: File;
119
+ source: "figma" | "upload";
120
+ }) => {
121
+ const { index, image, source } = args;
98
122
  const currentId = id.current;
99
123
 
100
124
  setSlice({
@@ -102,33 +126,45 @@ export function CreateSliceFromImageModal(
102
126
  slice: (prevSlice) => ({
103
127
  ...prevSlice,
104
128
  status: "uploading",
129
+ image,
130
+ source,
105
131
  }),
106
132
  });
107
133
 
108
- getImageUrl({ image }).then(
109
- (imageUrl) => {
110
- if (currentId !== id.current) return;
111
- inferSlice({ index, imageUrl });
112
- },
113
- () => {
114
- if (currentId !== id.current) return;
115
- setSlice({
116
- index,
117
- slice: (prevSlice) => ({
118
- ...prevSlice,
119
- status: "uploadError",
120
- onRetry: () => uploadImage({ index, image }),
121
- }),
122
- });
123
- },
124
- );
134
+ try {
135
+ const imageUrl = await getImageUrl({ image });
136
+ if (currentId !== id.current) return;
137
+
138
+ setSlice({
139
+ index,
140
+ slice: (prevSlice) => ({
141
+ ...prevSlice,
142
+ status: "pending",
143
+ thumbnailUrl: imageUrl,
144
+ }),
145
+ });
146
+ } catch {
147
+ if (currentId !== id.current) return;
148
+ setSlice({
149
+ index,
150
+ slice: (prevSlice) => ({
151
+ ...prevSlice,
152
+ status: "uploadError",
153
+ onRetry: () => void uploadImage({ index, image, source }),
154
+ }),
155
+ });
156
+ }
125
157
  };
126
158
 
127
- const existingSlices = useExistingSlices({ open });
159
+ const inferSlice = async (args: {
160
+ index: number;
161
+ imageUrl: string;
162
+ source: "figma" | "upload";
163
+ }) => {
164
+ if (libraryID === undefined) return;
128
165
 
129
- const inferSlice = (args: { index: number; imageUrl: string }) => {
130
- const { index, imageUrl } = args;
131
- const currentId = id.current;
166
+ const { index, imageUrl, source } = args;
167
+ let currentId = id.current;
132
168
 
133
169
  setSlice({
134
170
  index,
@@ -139,149 +175,449 @@ export function CreateSliceFromImageModal(
139
175
  }),
140
176
  });
141
177
 
142
- managerClient.customTypes.inferSlice({ imageUrl }).then(
143
- ({ slice, langSmithUrl }) => {
144
- if (currentId !== id.current) return;
178
+ try {
179
+ const inferResult = await managerClient.customTypes.inferSlice({
180
+ source,
181
+ libraryID,
182
+ imageUrl,
183
+ });
145
184
 
146
- setSlices((prevSlices) =>
147
- prevSlices.map((prevSlice, i) =>
148
- i === index
149
- ? {
150
- ...prevSlice,
151
- status: "success",
152
- thumbnailUrl: imageUrl,
153
- model: sliceWithoutConflicts({
154
- existingSlices: existingSlices.current,
155
- newSlices: prevSlices,
156
- slice,
157
- }),
158
- langSmithUrl,
159
- }
160
- : prevSlice,
161
- ),
162
- );
163
- },
164
- () => {
165
- if (currentId !== id.current) return;
166
- setSlice({
167
- index,
168
- slice: (prevSlice) => ({
185
+ if (currentId !== id.current) return;
186
+
187
+ const model = sliceWithoutConflicts({
188
+ existingSlices: existingSlices.current,
189
+ newSlices: slices,
190
+ slice: inferResult.slice,
191
+ });
192
+
193
+ setSlices((prevSlices) => {
194
+ return prevSlices.map((prevSlice, i) => {
195
+ if (i !== index) return prevSlice;
196
+ return {
169
197
  ...prevSlice,
170
- status: "generateError",
198
+ status: "success",
171
199
  thumbnailUrl: imageUrl,
172
- onRetry: () => inferSlice({ index, imageUrl }),
173
- }),
200
+ model,
201
+ langSmithUrl: inferResult.langSmithUrl,
202
+ };
174
203
  });
175
- },
176
- );
177
- };
204
+ });
178
205
 
179
- const onSubmit = () => {
180
- const newSlices = slices.reduce<NewSlice[]>((acc, slice) => {
181
- if (slice.status === "success") acc.push(slice);
182
- return acc;
183
- }, []);
184
- if (!newSlices.length) return;
206
+ if (source === "upload") {
207
+ currentId = id.current;
208
+ const currentSlice = slices[index];
185
209
 
186
- const currentId = id.current;
187
- setIsCreatingSlices(true);
188
- addSlices(newSlices)
189
- .then(async ({ slices, library }) => {
190
- if (currentId !== id.current) return;
191
-
192
- const serverState = await getState();
193
- createSliceSuccess(serverState.libraries);
194
- syncChanges();
195
-
196
- onSuccess({ slices, library });
197
-
198
- setIsCreatingSlices(false);
199
- id.current = crypto.randomUUID();
200
- setSlices([]);
201
-
202
- void completeStep("createSlice");
203
-
204
- for (const { model, langSmithUrl } of slices) {
205
- void telemetry.track({
206
- event: "slice:created",
207
- id: model.id,
208
- name: model.name,
209
- library,
210
- location,
211
- mode: "ai",
212
- langSmithUrl,
213
- });
214
-
215
- addAiFeedback({
216
- type: "model",
217
- library,
218
- sliceId: model.id,
219
- variationId: model.variations[0].id,
220
- langSmithUrl,
221
- });
210
+ const { errors } = await managerClient.slices.createSlice({
211
+ libraryID,
212
+ model: model,
213
+ });
214
+ if (errors.length) {
215
+ throw new Error(`Failed to create slice ${model.id}.`);
222
216
  }
223
- })
224
- .catch(() => {
217
+
218
+ await managerClient.slices.updateSliceScreenshot({
219
+ libraryID,
220
+ sliceID: model.id,
221
+ variationID: model.variations[0].id,
222
+ data: currentSlice.image,
223
+ });
224
+
225
225
  if (currentId !== id.current) return;
226
- setIsCreatingSlices(false);
227
- toast.error("An unexpected error happened while adding slices.");
226
+ }
227
+
228
+ void completeStep("createSlice");
229
+
230
+ void telemetry.track({
231
+ event: "slice:created",
232
+ id: model.id,
233
+ name: model.name,
234
+ library: libraryID,
235
+ location,
236
+ mode: "ai",
237
+ langSmithUrl: inferResult.langSmithUrl,
228
238
  });
239
+
240
+ addAiFeedback({
241
+ type: "model",
242
+ library: libraryID,
243
+ sliceId: model.id,
244
+ variationId: model.variations[0].id,
245
+ langSmithUrl: inferResult.langSmithUrl,
246
+ });
247
+ } catch (error) {
248
+ if (currentId !== id.current) return;
249
+
250
+ setSlice({
251
+ index,
252
+ slice: (prevSlice) => ({
253
+ ...prevSlice,
254
+ status: "generateError",
255
+ thumbnailUrl: imageUrl,
256
+ onRetry: () => {
257
+ void inferSlice({ index, imageUrl, source });
258
+ },
259
+ }),
260
+ });
261
+ }
229
262
  };
230
263
 
231
- const areSlicesLoading = slices.some(
232
- (slice) => slice.status === "uploading" || slice.status === "generating",
233
- );
234
- const readySlices = slices.filter((slice) => slice.status === "success");
235
- const someSlicesReady = readySlices.length > 0;
264
+ const generatePendingSlices = () => {
265
+ if (libraryID === undefined) return;
266
+
267
+ slices.forEach((slice, index) => {
268
+ if (slice.status === "pending") {
269
+ void inferSlice({
270
+ index,
271
+ imageUrl: slice.thumbnailUrl,
272
+ source: slice.source,
273
+ });
274
+ }
275
+ });
276
+ };
277
+
278
+ const generatingSliceCount = slices.filter((slice) => {
279
+ return slice.status === "generating";
280
+ }).length;
281
+
282
+ const uploadingSliceCount = slices.filter((slice) => {
283
+ return slice.status === "uploading";
284
+ }).length;
285
+
286
+ const loadingSliceCount = generatingSliceCount + uploadingSliceCount;
287
+
288
+ const pendingSliceCount = slices.filter((slice) => {
289
+ return slice.status === "pending";
290
+ }).length;
291
+
292
+ const completedSliceCount = slices.filter((slice) => {
293
+ return slice.status === "success";
294
+ }).length;
295
+
296
+ const hasTriggeredGeneration = slices.some((slice) => {
297
+ return slice.status === "generating" || slice.status === "success";
298
+ });
299
+
300
+ const generateSliceCount = loadingSliceCount + pendingSliceCount;
301
+
302
+ const closeModal = () => {
303
+ if (loadingSliceCount > 0) return;
304
+ onClose();
305
+ id.current = crypto.randomUUID();
306
+ setTimeout(() => setSlices([]), 250); // wait for the modal fade animation
307
+ };
308
+
309
+ const onSubmit = async () => {
310
+ if (libraryID === undefined) return;
311
+
312
+ try {
313
+ setIsSubmitting(true);
314
+
315
+ const serverState = await getState();
316
+ createSliceSuccess(serverState.libraries);
317
+ syncChanges();
318
+
319
+ onSuccess({
320
+ slices: slices.flatMap((slice) =>
321
+ slice.status === "success" ? slice.model : [],
322
+ ),
323
+ library: libraryID,
324
+ });
325
+
326
+ closeModal();
327
+ } finally {
328
+ setIsSubmitting(false);
329
+ }
330
+ };
331
+
332
+ const handlePaste = async () => {
333
+ if (
334
+ !open ||
335
+ !isFigmaEnabled ||
336
+ // For now we only support one Figma slice at a time
337
+ slices.some((slice) => slice.source === "figma")
338
+ ) {
339
+ return;
340
+ }
341
+
342
+ // Don't allow pasting while uploads or generation are in progress
343
+ const isLoading = slices.some(
344
+ (slice) => slice.status === "uploading" || slice.status === "generating",
345
+ );
346
+ if (isLoading) return;
347
+
348
+ const supportsClipboardRead =
349
+ typeof navigator.clipboard?.read === "function";
350
+
351
+ if (!supportsClipboardRead) {
352
+ toast.error("Clipboard paste is not supported in this browser.");
353
+ return;
354
+ }
355
+
356
+ try {
357
+ const clipboardItems = await navigator.clipboard.read();
358
+ if (clipboardItems.length === 0) {
359
+ toast.error("No data found in clipboard.");
360
+ return;
361
+ }
362
+
363
+ let imageName = "pasted-image.png";
364
+ let imageBlob: Blob | null = null;
365
+ let success = false;
366
+
367
+ // Method 1: Try to extract image from clipboard image/png blob (preferred)
368
+ for (const item of clipboardItems) {
369
+ const imageType = item.types.find((type) => type.startsWith("image/"));
370
+ if (imageType !== undefined) {
371
+ imageBlob = await item.getType(imageType);
372
+ break;
373
+ }
374
+ }
375
+
376
+ // Method 2: Read JSON from text/plain to get metadata and base64 image as fallback
377
+ for (const item of clipboardItems) {
378
+ if (item.types.includes("text/plain")) {
379
+ try {
380
+ const textBlob = await item.getType("text/plain");
381
+ const text = await textBlob.text();
382
+
383
+ const result = clipboardDataSchema.safeParse(JSON.parse(text));
384
+ if (result.success) {
385
+ success = true;
386
+ const data = result.data;
387
+ imageName = `${data.name}.png`;
388
+
389
+ // Use base64 image as fallback if no blob was found
390
+ if (!imageBlob) {
391
+ const response = await fetch(data.image);
392
+ imageBlob = await response.blob();
393
+ }
394
+ } else {
395
+ console.warn("Clipboard data validation failed:", result.error);
396
+ }
397
+ } catch (error) {
398
+ console.warn("Failed to parse JSON from clipboard:", error);
399
+ // Continue - we may still have imageBlob from Method 1
400
+ }
401
+ }
402
+ }
403
+
404
+ if (!imageBlob) {
405
+ if (success) {
406
+ toast.error(
407
+ "Could not extract Figma data from clipboard. Please try copying again using the Prismic Figma plugin.",
408
+ );
409
+ } else {
410
+ toast.error(
411
+ "No Figma data found in clipboard. Make sure you've copied a design using the Prismic Figma plugin.",
412
+ );
413
+ }
414
+ return;
415
+ }
416
+
417
+ // Check if we're at the limit
418
+ const currentSliceCount = slices.length;
419
+ if (currentSliceCount >= IMAGE_UPLOAD_LIMIT) {
420
+ toast.error(
421
+ `You can only upload ${IMAGE_UPLOAD_LIMIT} images at a time.`,
422
+ );
423
+ return;
424
+ }
425
+
426
+ // Create File object from blob and append to existing slices
427
+ const image = new File([imageBlob], imageName, {
428
+ type: imageBlob.type,
429
+ });
430
+ const newIndex = currentSliceCount;
431
+
432
+ // Append new slice to existing ones
433
+ setSlices((prevSlices) => [
434
+ ...prevSlices,
435
+ {
436
+ source: "figma",
437
+ status: "uploading",
438
+ image,
439
+ },
440
+ ]);
441
+
442
+ // Start uploading the new image
443
+ void uploadImage({ index: newIndex, image, source: "figma" });
444
+
445
+ toast.success(`Pasted ${imageName}${success ? " from Figma" : ""}`);
446
+ } catch (error) {
447
+ console.error("Failed to paste from clipboard:", error);
448
+ toast.error(
449
+ "Failed to paste from clipboard. Please check browser permissions and try again.",
450
+ );
451
+ }
452
+ };
236
453
 
237
454
  return (
238
- <Dialog open={open} onOpenChange={onOpenChange}>
239
- <DialogHeader title="Generate from image" />
455
+ <Dialog open={open} onOpenChange={(open) => !open && closeModal()}>
456
+ <DialogHeader title="Generate with AI" />
240
457
  <DialogContent gap={0}>
241
458
  <DialogDescription hidden>
242
459
  Upload images to generate slices with AI
243
460
  </DialogDescription>
244
- {slices.length === 0 ? (
245
- <Box padding={16} height="100%">
246
- <FileDropZone
247
- onFilesSelected={onImagesSelected}
248
- assetType="image"
249
- maxFiles={IMAGE_UPLOAD_LIMIT}
250
- overlay={
251
- <UploadBlankSlate
461
+ {!isLoadingLibraryID ? (
462
+ <>
463
+ {slices.length === 0 ? (
464
+ <Box
465
+ padding={16}
466
+ height="100%"
467
+ gap={16}
468
+ display="flex"
469
+ flexDirection="column"
470
+ >
471
+ {isFigmaEnabled && (
472
+ <Box
473
+ display="flex"
474
+ gap={16}
475
+ alignItems="center"
476
+ backgroundColor="grey2"
477
+ padding={16}
478
+ borderRadius={12}
479
+ >
480
+ <Box
481
+ display="flex"
482
+ gap={8}
483
+ alignItems="center"
484
+ flexGrow={1}
485
+ >
486
+ <Box
487
+ width={48}
488
+ height={48}
489
+ backgroundColor="grey12"
490
+ borderRadius="100%"
491
+ display="flex"
492
+ alignItems="center"
493
+ justifyContent="center"
494
+ >
495
+ <FigmaIcon height={25} />
496
+ </Box>
497
+ <Box display="flex" flexDirection="column" flexGrow={1}>
498
+ <Text variant="bold">Want to work faster?</Text>
499
+ <Text variant="small" color="grey11">
500
+ Copy frames from Figma with the Slice Machine plugin
501
+ and paste them here.
502
+ </Text>
503
+ </Box>
504
+ </Box>
505
+ <Button
506
+ endIcon="arrowForward"
507
+ color="indigo"
508
+ onClick={() =>
509
+ window.open(
510
+ "https://www.figma.com/community/plugin/TODO",
511
+ "_blank",
512
+ )
513
+ }
514
+ sx={{ marginRight: 8 }}
515
+ invisible
516
+ >
517
+ Install plugin
518
+ </Button>
519
+ </Box>
520
+ )}
521
+ <FileDropZone
252
522
  onFilesSelected={onImagesSelected}
253
- droppingFiles
254
- />
255
- }
256
- >
257
- <UploadBlankSlate onFilesSelected={onImagesSelected} />
258
- </FileDropZone>
259
- </Box>
523
+ assetType="image"
524
+ maxFiles={IMAGE_UPLOAD_LIMIT}
525
+ overlay={
526
+ <UploadBlankSlate
527
+ onFilesSelected={onImagesSelected}
528
+ onPaste={() => void handlePaste()}
529
+ droppingFiles
530
+ />
531
+ }
532
+ >
533
+ <UploadBlankSlate
534
+ onFilesSelected={onImagesSelected}
535
+ onPaste={() => void handlePaste()}
536
+ />
537
+ </FileDropZone>
538
+ </Box>
539
+ ) : (
540
+ <>
541
+ <Box
542
+ display="flex"
543
+ alignItems="center"
544
+ justifyContent="space-between"
545
+ padding={16}
546
+ >
547
+ <Text variant="h3">Design</Text>
548
+ <FileUploadButton
549
+ size="medium"
550
+ color="grey"
551
+ onFilesSelected={onImagesSelected}
552
+ startIcon="attachFile"
553
+ disabled={hasTriggeredGeneration}
554
+ >
555
+ Add images
556
+ </FileUploadButton>
557
+ </Box>
558
+ <ScrollArea stableScrollbar={false}>
559
+ <Box
560
+ display="grid"
561
+ gridTemplateColumns="1fr 1fr"
562
+ gap={16}
563
+ padding={16}
564
+ >
565
+ {slices.map((slice, index) => (
566
+ <SliceCard slice={slice} key={`slice-${index}`} />
567
+ ))}
568
+ </Box>
569
+ </ScrollArea>
570
+ </>
571
+ )}
572
+ <DialogActions>
573
+ <DialogCancelButton
574
+ onClick={() => closeModal()}
575
+ size="medium"
576
+ disabled={loadingSliceCount > 0}
577
+ sx={{ marginRight: 8 }}
578
+ invisible
579
+ >
580
+ Close
581
+ </DialogCancelButton>
582
+ {completedSliceCount === 0 || loadingSliceCount > 0 ? (
583
+ <DialogActionButton
584
+ color="purple"
585
+ startIcon="autoFixHigh"
586
+ onClick={() => void generatePendingSlices()}
587
+ disabled={
588
+ hasTriggeredGeneration ||
589
+ loadingSliceCount > 0 ||
590
+ pendingSliceCount === 0
591
+ }
592
+ loading={loadingSliceCount > 0}
593
+ size="medium"
594
+ >
595
+ Generate{" "}
596
+ {generateSliceCount > 0 ? `(${generateSliceCount}) ` : ""}
597
+ {generateSliceCount === 1 ? "Slice" : "Slices"}
598
+ </DialogActionButton>
599
+ ) : (
600
+ <DialogActionButton
601
+ color="purple"
602
+ onClick={() => void onSubmit()}
603
+ loading={isSubmitting}
604
+ size="medium"
605
+ >
606
+ {getSubmitButtonLabel(location, completedSliceCount)}
607
+ </DialogActionButton>
608
+ )}
609
+ </DialogActions>
610
+ </>
260
611
  ) : (
261
- <ScrollArea stableScrollbar={false}>
262
- <Box
263
- display="grid"
264
- gridTemplateColumns="1fr 1fr"
265
- gap={16}
266
- padding={16}
267
- >
268
- {slices.map((slice, index) => (
269
- <SliceCard slice={slice} key={`slice-${index}`} />
270
- ))}
271
- </Box>
272
- </ScrollArea>
273
- )}
274
-
275
- <DialogActions>
276
- <DialogCancelButton disabled={isCreatingSlices} />
277
- <DialogActionButton
278
- disabled={!someSlicesReady || areSlicesLoading}
279
- loading={isCreatingSlices}
280
- onClick={onSubmit}
612
+ <Box
613
+ display="flex"
614
+ justifyContent="center"
615
+ alignItems="center"
616
+ height="100%"
281
617
  >
282
- {getSubmitButtonLabel(location)} ({readySlices.length})
283
- </DialogActionButton>
284
- </DialogActions>
618
+ <ProgressCircle color="purple9" />
619
+ </Box>
620
+ )}
285
621
  </DialogContent>
286
622
  </Dialog>
287
623
  );
@@ -290,8 +626,10 @@ export function CreateSliceFromImageModal(
290
626
  function UploadBlankSlate(props: {
291
627
  droppingFiles?: boolean;
292
628
  onFilesSelected: (files: File[]) => void;
629
+ onPaste: () => void;
293
630
  }) {
294
- const { droppingFiles = false, onFilesSelected } = props;
631
+ const { droppingFiles = false, onFilesSelected, onPaste } = props;
632
+ const isFigmaEnabled = useIsFigmaEnabled();
295
633
 
296
634
  return (
297
635
  <Box
@@ -302,27 +640,70 @@ function UploadBlankSlate(props: {
302
640
  border
303
641
  borderStyle="dashed"
304
642
  borderColor={droppingFiles ? "purple9" : "grey6"}
643
+ borderRadius={12}
644
+ flexGrow={1}
305
645
  >
306
646
  <BlankSlate>
307
- <BlankSlateIcon
308
- lineColor="purple11"
309
- backgroundColor="purple5"
310
- name="cloudUpload"
311
- size="large"
312
- />
313
- <BlankSlateTitle>Upload your design images.</BlankSlateTitle>
314
- <BlankSlateDescription>
315
- Once uploaded, you can generate slices automatically using AI.
316
- </BlankSlateDescription>
317
- <BlankSlateActions>
318
- <FileUploadButton
319
- startIcon="attachFile"
320
- onFilesSelected={onFilesSelected}
321
- color="grey"
647
+ <Box display="flex" flexDirection="column" gap={16} alignItems="center">
648
+ <BlankSlateIcon
649
+ lineColor="purple11"
650
+ backgroundColor="purple5"
651
+ name="cloudUpload"
652
+ size="large"
653
+ />
654
+ <Box
655
+ display="flex"
656
+ flexDirection="column"
657
+ gap={4}
658
+ alignItems="center"
322
659
  >
323
- Add images
324
- </FileUploadButton>
325
- </BlankSlateActions>
660
+ {isFigmaEnabled ? (
661
+ <>
662
+ <Text>Generate slices from your designs</Text>
663
+ <Text variant="small" color="grey11">
664
+ Upload your design images or paste them directly from Figma.
665
+ </Text>
666
+ </>
667
+ ) : (
668
+ <>
669
+ <Text>Upload your design images.</Text>
670
+ <Text variant="small" color="grey11">
671
+ Once uploaded, you can generate slices automatically using AI.
672
+ </Text>
673
+ </>
674
+ )}
675
+ </Box>
676
+ <Box display="flex" alignItems="center" gap={16}>
677
+ {isFigmaEnabled ? (
678
+ <>
679
+ <Button
680
+ size="small"
681
+ renderStartIcon={() => <FigmaIcon height={16} />}
682
+ color="grey"
683
+ onClick={onPaste}
684
+ >
685
+ Paste from Figma
686
+ </Button>
687
+ <FileUploadButton
688
+ size="small"
689
+ onFilesSelected={onFilesSelected}
690
+ color="purple"
691
+ invisible
692
+ >
693
+ Add images
694
+ </FileUploadButton>
695
+ </>
696
+ ) : (
697
+ <FileUploadButton
698
+ startIcon="attachFile"
699
+ onFilesSelected={onFilesSelected}
700
+ color="grey"
701
+ >
702
+ Add images
703
+ </FileUploadButton>
704
+ )}
705
+ </Box>
706
+ </Box>
326
707
  </BlankSlate>
327
708
  </Box>
328
709
  );
@@ -345,12 +726,6 @@ async function getImageUrl({ image }: { image: File }) {
345
726
  return url;
346
727
  }
347
728
 
348
- type NewSlice = {
349
- image: File;
350
- model: SharedSlice;
351
- langSmithUrl?: string;
352
- };
353
-
354
729
  /**
355
730
  * Keeps track of the existing slices in the project.
356
731
  * Re-fetches them when the modal is opened.
@@ -422,53 +797,42 @@ function sliceWithoutConflicts({
422
797
  };
423
798
  }
424
799
 
425
- async function addSlices(newSlices: NewSlice[]) {
426
- // use the first library
427
- const { libraries = [] } =
428
- await managerClient.project.getSliceMachineConfig();
429
- const library = libraries[0];
430
- if (!library) {
431
- throw new Error("No library found in the config.");
432
- }
433
-
434
- for (const { model } of newSlices) {
435
- const { errors } = await managerClient.slices.createSlice({
436
- libraryID: library,
437
- model,
438
- });
439
- if (errors.length) {
440
- throw new Error(`Failed to create slice ${model.id}.`);
441
- }
442
- }
443
-
444
- // for each added slice, set the variation screenshot
445
- const slices = await Promise.all(
446
- newSlices.map(async ({ model, image, langSmithUrl }) => {
447
- await managerClient.slices.updateSliceScreenshot({
448
- libraryID: library,
449
- sliceID: model.id,
450
- variationID: model.variations[0].id,
451
- data: image,
452
- });
453
- return {
454
- model,
455
- langSmithUrl,
456
- };
457
- }),
458
- );
459
-
460
- return { library, slices };
461
- }
462
-
463
800
  const getSubmitButtonLabel = (
464
801
  location: "custom_type" | "page_type" | "slices",
802
+ completedSliceCount: number,
465
803
  ) => {
466
804
  switch (location) {
467
805
  case "custom_type":
468
- return "Add to type";
806
+ return `Add to type (${completedSliceCount})`;
469
807
  case "page_type":
470
- return "Add to page";
808
+ return `Add to page (${completedSliceCount})`;
471
809
  case "slices":
472
- return "Add to slices";
810
+ return "Done";
473
811
  }
474
812
  };
813
+
814
+ function useIsFigmaEnabled() {
815
+ const experiment = useExperimentVariant("llm-proxy-access");
816
+ return experiment?.value === "on";
817
+ }
818
+
819
+ function useLibraryID() {
820
+ const [libraryID, setLibraryID] = useState<string | undefined>();
821
+
822
+ useEffect(() => {
823
+ managerClient.project
824
+ .getSliceMachineConfig()
825
+ .then((smConfig) => {
826
+ const libraryID = smConfig?.libraries?.[0];
827
+ if (libraryID === undefined) {
828
+ throw new Error("No library found in the config.");
829
+ }
830
+ setLibraryID(libraryID);
831
+ })
832
+ .catch(() => {
833
+ throw new Error("Could not get library ID from the config.");
834
+ });
835
+ }, []);
836
+
837
+ return { libraryID, isLoading: libraryID === undefined };
838
+ }