slice-machine-ui 2.19.2-beta.2 → 2.19.2-beta.4

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 (34) hide show
  1. package/out/404.html +1 -1
  2. package/out/_next/static/LhgS5bFIDwBpgBpbMYvj5/_buildManifest.js +1 -0
  3. package/out/_next/static/chunks/{489-ce3053e1d81ade83.js → 489-32281540712d98bb.js} +1 -1
  4. package/out/_next/static/chunks/593-7ffd1197c3405ef8.js +1 -0
  5. package/out/_next/static/chunks/633-6ef64d197d323bf7.js +1 -0
  6. package/out/_next/static/chunks/882-48d61b2fabee28d8.js +1 -0
  7. package/out/_next/static/chunks/pages/{_app-83c4f5b209504941.js → _app-0f3a7a06eafa1943.js} +1 -1
  8. package/out/_next/static/chunks/pages/{changes-7f24b37f5bf872ae.js → changes-e66094f57453cf9c.js} +1 -1
  9. package/out/_next/static/chunks/pages/custom-types/{[customTypeId]-1b47424a37b49dff.js → [customTypeId]-6d613b67e6967ae5.js} +1 -1
  10. package/out/_next/static/chunks/pages/page-types/{[pageTypeId]-385f933c203e8b16.js → [pageTypeId]-40207b66190e3fcd.js} +1 -1
  11. package/out/_next/static/chunks/pages/slices/[lib]/[sliceName]/{[variation]-08cdeefc96106c0c.js → [variation]-22ffa2561ac66557.js} +1 -1
  12. package/out/_next/static/chunks/pages/{slices-bedcb854fbdca8cd.js → slices-a9b4d6d022cfcd88.js} +1 -1
  13. package/out/changelog.html +1 -1
  14. package/out/changes.html +1 -1
  15. package/out/custom-types/[customTypeId].html +1 -1
  16. package/out/custom-types.html +1 -1
  17. package/out/index.html +1 -1
  18. package/out/labs.html +1 -1
  19. package/out/page-types/[pageTypeId].html +1 -1
  20. package/out/slices/[lib]/[sliceName]/[variation]/simulator.html +1 -1
  21. package/out/slices/[lib]/[sliceName]/[variation].html +1 -1
  22. package/out/slices.html +1 -1
  23. package/package.json +3 -3
  24. package/src/features/customTypes/customTypesBuilder/CreateSliceFromImageModal/CreateSliceFromImageModal.tsx +525 -241
  25. package/src/features/customTypes/customTypesBuilder/CreateSliceFromImageModal/SliceCard.tsx +59 -15
  26. package/src/icons/FigmaIcon.tsx +33 -34
  27. package/src/icons/FigmaIconSquare.tsx +45 -0
  28. package/src/legacy/components/ScreenshotChangesModal/index.tsx +2 -2
  29. package/src/legacy/lib/builders/CustomTypeBuilder/SliceZone/index.tsx +1 -1
  30. package/out/_next/static/4ZbwQOH1s2JwSCJTqy_83/_buildManifest.js +0 -1
  31. package/out/_next/static/chunks/34-28725deef8b874b1.js +0 -1
  32. package/out/_next/static/chunks/658-8231c0b729e0124a.js +0 -1
  33. package/out/_next/static/chunks/907-bf4215e6fc238ea0.js +0 -1
  34. /package/out/_next/static/{4ZbwQOH1s2JwSCJTqy_83 → LhgS5bFIDwBpgBpbMYvj5}/_ssgManifest.js +0 -0
@@ -1,10 +1,9 @@
1
+ import { useStableCallback } from "@prismicio/editor-support/React";
1
2
  import {
2
3
  BlankSlate,
3
- BlankSlateActions,
4
- BlankSlateDescription,
5
4
  BlankSlateIcon,
6
- BlankSlateTitle,
7
5
  Box,
6
+ Button,
8
7
  Dialog,
9
8
  DialogActionButton,
10
9
  DialogActions,
@@ -14,9 +13,12 @@ import {
14
13
  DialogHeader,
15
14
  FileDropZone,
16
15
  FileUploadButton,
16
+ ProgressCircle,
17
17
  ScrollArea,
18
+ Text,
18
19
  } from "@prismicio/editor-ui";
19
20
  import { SharedSlice } from "@prismicio/types-internal/lib/customtypes";
21
+ import { useRouter } from "next/router";
20
22
  import { useEffect, useRef, useState } from "react";
21
23
  import { useHotkeys } from "react-hotkeys-hook";
22
24
  import { toast } from "react-toastify";
@@ -27,6 +29,7 @@ import { addAiFeedback } from "@/features/aiFeedback";
27
29
  import { useOnboarding } from "@/features/onboarding/useOnboarding";
28
30
  import { useAutoSync } from "@/features/sync/AutoSyncProvider";
29
31
  import { useExperimentVariant } from "@/hooks/useExperimentVariant";
32
+ import { FigmaIcon } from "@/icons/FigmaIcon";
30
33
  import { managerClient } from "@/managerClient";
31
34
  import useSliceMachineActions from "@/modules/useSliceMachineActions";
32
35
 
@@ -37,13 +40,7 @@ const IMAGE_UPLOAD_LIMIT = 10;
37
40
  interface CreateSliceFromImageModalProps {
38
41
  open: boolean;
39
42
  location: "custom_type" | "page_type" | "slices";
40
- onSuccess: (args: {
41
- slices: {
42
- model: SharedSlice;
43
- langSmithUrl?: string;
44
- }[];
45
- library: string;
46
- }) => void;
43
+ onSuccess: (args: { slices: SharedSlice[]; library: string }) => void;
47
44
  onClose: () => void;
48
45
  }
49
46
 
@@ -57,12 +54,21 @@ export function CreateSliceFromImageModal(
57
54
  props: CreateSliceFromImageModalProps,
58
55
  ) {
59
56
  const { open, location, onSuccess, onClose } = props;
57
+ const router = useRouter();
58
+
60
59
  const [slices, setSlices] = useState<Slice[]>([]);
61
- const [isCreatingSlices, setIsCreatingSlices] = useState(false);
60
+ const [isSubmitting, setIsSubmitting] = useState(false);
61
+ const [showCancelConfirmation, setShowCancelConfirmation] = useState(false);
62
+
62
63
  const { syncChanges } = useAutoSync();
63
64
  const { createSliceSuccess } = useSliceMachineActions();
64
65
  const { completeStep } = useOnboarding();
66
+ const existingSlices = useExistingSlices({ open });
65
67
  const isFigmaEnabled = useIsFigmaEnabled();
68
+ const { libraryID, isLoading: isLoadingLibraryID } = useLibraryID();
69
+ const stableCancelGeneratingRequests = useStableCallback(
70
+ cancelGeneratingRequests,
71
+ );
66
72
 
67
73
  /**
68
74
  * Keeps track of the current instance id.
@@ -74,11 +80,29 @@ export function CreateSliceFromImageModal(
74
80
  ["meta+v", "ctrl+v"],
75
81
  (event) => {
76
82
  event.preventDefault();
77
- void handlePaste();
83
+ void onPaste();
78
84
  },
79
85
  { enabled: open && isFigmaEnabled },
80
86
  );
81
87
 
88
+ useEffect(() => {
89
+ if (!slices.some((slice) => slice.status === "generating")) return;
90
+
91
+ const onBeforeUnload = (event: BeforeUnloadEvent) => {
92
+ stableCancelGeneratingRequests();
93
+ const message = "Your current generating slices will be cancelled.";
94
+ event.returnValue = message;
95
+ return message;
96
+ };
97
+
98
+ router.events.on("routeChangeStart", stableCancelGeneratingRequests);
99
+ window.addEventListener("beforeunload", onBeforeUnload);
100
+ return () => {
101
+ router.events.off("routeChangeStart", stableCancelGeneratingRequests);
102
+ window.removeEventListener("beforeunload", onBeforeUnload);
103
+ };
104
+ }, [slices, router.events, stableCancelGeneratingRequests]);
105
+
82
106
  const setSlice = (args: {
83
107
  index: number;
84
108
  slice: (prevSlice: Slice) => Slice;
@@ -87,14 +111,9 @@ export function CreateSliceFromImageModal(
87
111
  setSlices((slices) => slices.map((s, i) => (i === index ? slice(s) : s)));
88
112
  };
89
113
 
90
- const onOpenChange = (open: boolean) => {
91
- if (open || isCreatingSlices) return;
92
- onClose();
93
- id.current = crypto.randomUUID();
94
- setSlices([]);
95
- };
96
-
97
114
  const onImagesSelected = (images: File[]) => {
115
+ if (hasTriggeredGeneration) return;
116
+
98
117
  if (images.length > IMAGE_UPLOAD_LIMIT) {
99
118
  toast.error(
100
119
  `You can only upload ${IMAGE_UPLOAD_LIMIT} images at a time.`,
@@ -102,23 +121,28 @@ export function CreateSliceFromImageModal(
102
121
  return;
103
122
  }
104
123
 
105
- setSlices(
106
- images.map((image) => ({
107
- source: "upload",
108
- status: "uploading",
109
- image,
110
- })),
111
- );
124
+ const startIndex = slices.length;
125
+ setSlices((prevSlices) => [
126
+ ...prevSlices,
127
+ ...images.map(
128
+ (image): Slice => ({
129
+ source: "upload",
130
+ status: "uploading",
131
+ image,
132
+ }),
133
+ ),
134
+ ]);
112
135
 
113
- images.forEach((image, index) =>
114
- uploadImage({ index, image, source: "upload" }),
115
- );
136
+ images.forEach((image, relativeIndex) => {
137
+ const index = startIndex + relativeIndex;
138
+ void uploadImage({ index, image, source: "upload" });
139
+ });
116
140
  };
117
141
 
118
- const uploadImage = (args: {
142
+ const uploadImage = async (args: {
119
143
  index: number;
120
144
  image: File;
121
- source: "upload" | "figma";
145
+ source: "figma" | "upload";
122
146
  }) => {
123
147
  const { index, image, source } = args;
124
148
  const currentId = id.current;
@@ -128,40 +152,47 @@ export function CreateSliceFromImageModal(
128
152
  slice: (prevSlice) => ({
129
153
  ...prevSlice,
130
154
  status: "uploading",
155
+ image,
131
156
  source,
132
157
  }),
133
158
  });
134
159
 
135
- getImageUrl({ image }).then(
136
- (imageUrl) => {
137
- if (currentId !== id.current) return;
138
- void inferSlice({ index, imageUrl, source });
139
- },
140
- () => {
141
- if (currentId !== id.current) return;
142
- setSlice({
143
- index,
144
- slice: (prevSlice) => ({
145
- ...prevSlice,
146
- status: "uploadError",
147
- onRetry: () => uploadImage({ index, image, source }),
148
- }),
149
- });
150
- },
151
- );
152
- };
160
+ try {
161
+ const imageUrl = await getImageUrl({ image });
162
+ if (currentId !== id.current) return;
153
163
 
154
- const existingSlices = useExistingSlices({ open });
164
+ setSlice({
165
+ index,
166
+ slice: (prevSlice) => ({
167
+ ...prevSlice,
168
+ status: "pending",
169
+ thumbnailUrl: imageUrl,
170
+ }),
171
+ });
172
+ } catch {
173
+ if (currentId !== id.current) return;
174
+ setSlice({
175
+ index,
176
+ slice: (prevSlice) => ({
177
+ ...prevSlice,
178
+ status: "uploadError",
179
+ onRetry: () => void uploadImage({ index, image, source }),
180
+ }),
181
+ });
182
+ }
183
+ };
155
184
 
156
185
  const inferSlice = async (args: {
157
186
  index: number;
158
187
  imageUrl: string;
159
- source: "upload" | "figma";
188
+ source: "figma" | "upload";
160
189
  }) => {
190
+ if (libraryID === undefined) return;
191
+
161
192
  const { index, imageUrl, source } = args;
162
- const currentId = id.current;
193
+ let currentId = id.current;
163
194
 
164
- const libraryID = await getLibraryID();
195
+ const requestId = crypto.randomUUID();
165
196
 
166
197
  setSlice({
167
198
  index,
@@ -169,15 +200,18 @@ export function CreateSliceFromImageModal(
169
200
  ...prevSlice,
170
201
  status: "generating",
171
202
  thumbnailUrl: imageUrl,
203
+ requestId,
172
204
  }),
173
205
  });
174
206
 
175
207
  try {
176
208
  const inferResult = await managerClient.customTypes.inferSlice({
177
- imageUrl,
178
209
  source,
179
210
  libraryID,
211
+ imageUrl,
212
+ requestId,
180
213
  });
214
+
181
215
  if (currentId !== id.current) return;
182
216
 
183
217
  const model = sliceWithoutConflicts({
@@ -186,88 +220,156 @@ export function CreateSliceFromImageModal(
186
220
  slice: inferResult.slice,
187
221
  });
188
222
 
189
- setSlices((prevSlices) =>
190
- prevSlices.map((prevSlice, i) =>
191
- i === index
192
- ? {
193
- ...prevSlice,
194
- status: "success",
195
- thumbnailUrl: imageUrl,
196
- langSmithUrl: inferResult.langSmithUrl,
197
- model,
198
- }
199
- : prevSlice,
200
- ),
201
- );
202
- } catch {
223
+ setSlices((prevSlices) => {
224
+ return prevSlices.map((prevSlice, i) => {
225
+ if (i !== index) return prevSlice;
226
+ return {
227
+ ...prevSlice,
228
+ status: "success",
229
+ thumbnailUrl: imageUrl,
230
+ model,
231
+ langSmithUrl: inferResult.langSmithUrl,
232
+ };
233
+ });
234
+ });
235
+
236
+ if (source === "upload") {
237
+ currentId = id.current;
238
+ const currentSlice = slices[index];
239
+
240
+ const { errors } = await managerClient.slices.createSlice({
241
+ libraryID,
242
+ model: model,
243
+ });
244
+ if (errors.length) {
245
+ throw new Error(`Failed to create slice ${model.id}.`);
246
+ }
247
+
248
+ await managerClient.slices.updateSliceScreenshot({
249
+ libraryID,
250
+ sliceID: model.id,
251
+ variationID: model.variations[0].id,
252
+ data: currentSlice.image,
253
+ });
254
+
255
+ if (currentId !== id.current) return;
256
+ }
257
+
258
+ void completeStep("createSlice");
259
+
260
+ void telemetry.track({
261
+ event: "slice:created",
262
+ id: model.id,
263
+ name: model.name,
264
+ library: libraryID,
265
+ location,
266
+ mode: "ai",
267
+ langSmithUrl: inferResult.langSmithUrl,
268
+ });
269
+
270
+ addAiFeedback({
271
+ type: "model",
272
+ library: libraryID,
273
+ sliceId: model.id,
274
+ variationId: model.variations[0].id,
275
+ langSmithUrl: inferResult.langSmithUrl,
276
+ });
277
+ } catch (error) {
203
278
  if (currentId !== id.current) return;
279
+
204
280
  setSlice({
205
281
  index,
206
282
  slice: (prevSlice) => ({
207
283
  ...prevSlice,
208
- status: "generateError",
284
+ status:
285
+ error instanceof Error && error.name === "AbortError"
286
+ ? "cancelled"
287
+ : "generateError",
209
288
  thumbnailUrl: imageUrl,
210
- onRetry: () => void inferSlice({ index, imageUrl, source }),
289
+ onRetry: () => {
290
+ void inferSlice({ index, imageUrl, source });
291
+ },
211
292
  }),
212
293
  });
213
294
  }
214
295
  };
215
296
 
216
- const onSubmit = () => {
217
- const newSlices = slices.reduce<NewSlice[]>((acc, slice) => {
218
- if (slice.status === "success" && slice.source === "upload") {
219
- acc.push(slice);
297
+ const generatePendingSlices = () => {
298
+ if (libraryID === undefined) return;
299
+
300
+ slices.forEach((slice, index) => {
301
+ if (slice.status === "pending") {
302
+ void inferSlice({
303
+ index,
304
+ imageUrl: slice.thumbnailUrl,
305
+ source: slice.source,
306
+ });
220
307
  }
221
- return acc;
222
- }, []);
223
- if (!newSlices.length) return;
308
+ });
309
+ };
224
310
 
225
- const currentId = id.current;
226
- setIsCreatingSlices(true);
227
- addSlices(newSlices)
228
- .then(async ({ slices, library }) => {
229
- if (currentId !== id.current) return;
311
+ const totals = slices.reduce(
312
+ (result, slice) => {
313
+ if (slice.status === "generating") {
314
+ result.generating++;
315
+ } else if (slice.status === "uploading") {
316
+ result.uploading++;
317
+ } else if (slice.status === "pending") {
318
+ result.pending++;
319
+ } else if (slice.status === "success") {
320
+ result.completed++;
321
+ }
322
+ result.loading = result.generating + result.uploading;
230
323
 
231
- const serverState = await getState();
232
- createSliceSuccess(serverState.libraries);
233
- syncChanges();
234
-
235
- onSuccess({ slices, library });
236
-
237
- setIsCreatingSlices(false);
238
- id.current = crypto.randomUUID();
239
- setSlices([]);
240
-
241
- void completeStep("createSlice");
242
-
243
- for (const { model, langSmithUrl } of slices) {
244
- void telemetry.track({
245
- event: "slice:created",
246
- id: model.id,
247
- name: model.name,
248
- library,
249
- location,
250
- mode: "ai",
251
- langSmithUrl,
252
- });
253
-
254
- addAiFeedback({
255
- type: "model",
256
- library,
257
- sliceId: model.id,
258
- variationId: model.variations[0].id,
259
- langSmithUrl,
260
- });
261
- }
262
- })
263
- .catch(() => {
264
- if (currentId !== id.current) return;
265
- setIsCreatingSlices(false);
266
- toast.error("An unexpected error happened while adding slices.");
324
+ /** Total count for the generate button.
325
+ * Avoids resetting to zero when switching status for better UX. */
326
+ result.generate = result.loading + result.pending;
327
+
328
+ return result;
329
+ },
330
+ {
331
+ generating: 0,
332
+ uploading: 0,
333
+ pending: 0,
334
+ completed: 0,
335
+ loading: 0,
336
+ generate: 0,
337
+ },
338
+ );
339
+
340
+ const hasTriggeredGeneration = totals.generating > 0 || totals.completed > 0;
341
+
342
+ const closeModal = () => {
343
+ if (totals.loading > 0) return;
344
+ onClose();
345
+ id.current = crypto.randomUUID();
346
+ setTimeout(() => setSlices([]), 250); // wait for the modal fade animation
347
+ };
348
+
349
+ const onSubmit = async () => {
350
+ if (libraryID === undefined) return;
351
+
352
+ try {
353
+ setIsSubmitting(true);
354
+
355
+ const serverState = await getState();
356
+ createSliceSuccess(serverState.libraries);
357
+ syncChanges();
358
+
359
+ onSuccess({
360
+ slices: slices.flatMap((slice) =>
361
+ slice.status === "success" ? slice.model : [],
362
+ ),
363
+ library: libraryID,
267
364
  });
365
+
366
+ closeModal();
367
+ } finally {
368
+ setIsSubmitting(false);
369
+ }
268
370
  };
269
371
 
270
- const handlePaste = async () => {
372
+ const onPaste = async () => {
271
373
  if (
272
374
  !open ||
273
375
  !isFigmaEnabled ||
@@ -362,7 +464,7 @@ export function CreateSliceFromImageModal(
362
464
  }
363
465
 
364
466
  // Create File object from blob and append to existing slices
365
- const imageData = new File([imageBlob], imageName, {
467
+ const image = new File([imageBlob], imageName, {
366
468
  type: imageBlob.type,
367
469
  });
368
470
  const newIndex = currentSliceCount;
@@ -373,12 +475,12 @@ export function CreateSliceFromImageModal(
373
475
  {
374
476
  source: "figma",
375
477
  status: "uploading",
376
- image: imageData,
478
+ image,
377
479
  },
378
480
  ]);
379
481
 
380
482
  // Start uploading the new image
381
- void uploadImage({ index: newIndex, image: imageData, source: "figma" });
483
+ void uploadImage({ index: newIndex, image, source: "figma" });
382
484
 
383
485
  toast.success(`Pasted ${imageName}${success ? " from Figma" : ""}`);
384
486
  } catch (error) {
@@ -389,61 +491,230 @@ export function CreateSliceFromImageModal(
389
491
  }
390
492
  };
391
493
 
392
- const areSlicesLoading = slices.some(
393
- (slice) => slice.status === "uploading" || slice.status === "generating",
394
- );
395
- const readySlices = slices.filter((slice) => slice.status === "success");
396
- const someSlicesReady = readySlices.length > 0;
494
+ function cancelGeneratingRequests() {
495
+ const cancelableIds = slices.flatMap((slice) => {
496
+ return slice.status === "generating" ? [slice.requestId] : [];
497
+ });
498
+ if (cancelableIds.length === 0) return;
499
+
500
+ cancelableIds.forEach((requestId) => {
501
+ void managerClient.customTypes.cancelInferSlice({ requestId });
502
+ });
503
+ }
504
+
505
+ const onCancelConfirm = () => {
506
+ setShowCancelConfirmation(false);
507
+ cancelGeneratingRequests();
508
+ };
397
509
 
398
510
  return (
399
- <Dialog open={open} onOpenChange={onOpenChange}>
400
- <DialogHeader title="Generate from image" />
511
+ <Dialog open={open} onOpenChange={(open) => !open && closeModal()}>
512
+ <DialogHeader title="Generate with AI" />
401
513
  <DialogContent gap={0}>
402
514
  <DialogDescription hidden>
403
515
  Upload images to generate slices with AI
404
516
  </DialogDescription>
405
- {slices.length === 0 ? (
406
- <Box padding={16} height="100%">
407
- <FileDropZone
408
- onFilesSelected={onImagesSelected}
409
- assetType="image"
410
- maxFiles={IMAGE_UPLOAD_LIMIT}
411
- overlay={
412
- <UploadBlankSlate
517
+ {!isLoadingLibraryID ? (
518
+ <>
519
+ {slices.length === 0 ? (
520
+ <Box
521
+ padding={16}
522
+ height="100%"
523
+ gap={16}
524
+ display="flex"
525
+ flexDirection="column"
526
+ >
527
+ {isFigmaEnabled && (
528
+ <Box
529
+ display="flex"
530
+ gap={16}
531
+ alignItems="center"
532
+ backgroundColor="grey2"
533
+ padding={16}
534
+ borderRadius={12}
535
+ >
536
+ <Box
537
+ display="flex"
538
+ gap={8}
539
+ alignItems="center"
540
+ flexGrow={1}
541
+ >
542
+ <Box
543
+ width={48}
544
+ height={48}
545
+ backgroundColor="grey12"
546
+ borderRadius="100%"
547
+ display="flex"
548
+ alignItems="center"
549
+ justifyContent="center"
550
+ >
551
+ <FigmaIcon height={25} />
552
+ </Box>
553
+ <Box display="flex" flexDirection="column" flexGrow={1}>
554
+ <Text variant="bold">Want to work faster?</Text>
555
+ <Text variant="small" color="grey11">
556
+ Copy frames from Figma with the Slice Machine plugin
557
+ and paste them here.
558
+ </Text>
559
+ </Box>
560
+ </Box>
561
+ <Button
562
+ endIcon="arrowForward"
563
+ color="indigo"
564
+ onClick={() =>
565
+ window.open(
566
+ "https://www.figma.com/community/plugin/1567955296461153730/figma-to-slice",
567
+ "_blank",
568
+ )
569
+ }
570
+ sx={{ marginRight: 8 }}
571
+ invisible
572
+ >
573
+ Install plugin
574
+ </Button>
575
+ </Box>
576
+ )}
577
+ <FileDropZone
413
578
  onFilesSelected={onImagesSelected}
414
- droppingFiles
415
- />
416
- }
417
- >
418
- <UploadBlankSlate onFilesSelected={onImagesSelected} />
419
- </FileDropZone>
420
- </Box>
579
+ assetType="image"
580
+ maxFiles={IMAGE_UPLOAD_LIMIT}
581
+ overlay={
582
+ <UploadBlankSlate
583
+ onFilesSelected={onImagesSelected}
584
+ onPaste={() => void onPaste()}
585
+ droppingFiles
586
+ />
587
+ }
588
+ >
589
+ <UploadBlankSlate
590
+ onFilesSelected={onImagesSelected}
591
+ onPaste={() => void onPaste()}
592
+ />
593
+ </FileDropZone>
594
+ </Box>
595
+ ) : (
596
+ <>
597
+ <Box
598
+ display="flex"
599
+ alignItems="center"
600
+ justifyContent="space-between"
601
+ padding={16}
602
+ >
603
+ <Text variant="h3">Design</Text>
604
+ <FileUploadButton
605
+ size="medium"
606
+ color="grey"
607
+ onFilesSelected={onImagesSelected}
608
+ startIcon="attachFile"
609
+ disabled={hasTriggeredGeneration}
610
+ >
611
+ Add images
612
+ </FileUploadButton>
613
+ </Box>
614
+ <ScrollArea stableScrollbar={false}>
615
+ <Box
616
+ display="grid"
617
+ gridTemplateColumns="1fr 1fr"
618
+ gap={16}
619
+ padding={16}
620
+ >
621
+ {slices.map((slice, index) => (
622
+ <SliceCard slice={slice} key={`slice-${index}`} />
623
+ ))}
624
+ </Box>
625
+ </ScrollArea>
626
+ </>
627
+ )}
628
+ <DialogActions>
629
+ {totals.generating > 0 ? (
630
+ <DialogCancelButton
631
+ onClick={() => setShowCancelConfirmation(true)}
632
+ size="medium"
633
+ sx={{ marginRight: 8 }}
634
+ invisible
635
+ >
636
+ Cancel
637
+ </DialogCancelButton>
638
+ ) : (
639
+ <DialogCancelButton
640
+ onClick={() => closeModal()}
641
+ size="medium"
642
+ sx={{ marginRight: 8 }}
643
+ invisible
644
+ >
645
+ Close
646
+ </DialogCancelButton>
647
+ )}
648
+ {totals.completed === 0 || totals.loading > 0 ? (
649
+ <DialogActionButton
650
+ color="purple"
651
+ startIcon="autoFixHigh"
652
+ onClick={generatePendingSlices}
653
+ disabled={
654
+ hasTriggeredGeneration ||
655
+ totals.loading > 0 ||
656
+ totals.pending === 0
657
+ }
658
+ loading={totals.loading > 0}
659
+ size="medium"
660
+ >
661
+ Generate {totals.generate > 0 ? `(${totals.generate}) ` : ""}
662
+ {totals.generate === 1 ? "Slice" : "Slices"}
663
+ </DialogActionButton>
664
+ ) : (
665
+ <DialogActionButton
666
+ color="purple"
667
+ onClick={() => void onSubmit()}
668
+ loading={isSubmitting}
669
+ size="medium"
670
+ >
671
+ {getSubmitButtonLabel(location, totals.completed)}
672
+ </DialogActionButton>
673
+ )}
674
+ </DialogActions>
675
+ </>
421
676
  ) : (
422
- <ScrollArea stableScrollbar={false}>
423
- <Box
424
- display="grid"
425
- gridTemplateColumns="1fr 1fr"
426
- gap={16}
427
- padding={16}
428
- >
429
- {slices.map((slice, index) => (
430
- <SliceCard slice={slice} key={`slice-${index}`} />
431
- ))}
432
- </Box>
433
- </ScrollArea>
434
- )}
435
-
436
- <DialogActions>
437
- <DialogCancelButton disabled={isCreatingSlices} />
438
- <DialogActionButton
439
- disabled={!someSlicesReady || areSlicesLoading}
440
- loading={isCreatingSlices}
441
- onClick={onSubmit}
677
+ <Box
678
+ display="flex"
679
+ justifyContent="center"
680
+ alignItems="center"
681
+ height="100%"
442
682
  >
443
- {getSubmitButtonLabel(location)} ({readySlices.length})
444
- </DialogActionButton>
445
- </DialogActions>
683
+ <ProgressCircle color="purple9" />
684
+ </Box>
685
+ )}
446
686
  </DialogContent>
687
+ <Dialog
688
+ size="small"
689
+ open={showCancelConfirmation}
690
+ onOpenChange={setShowCancelConfirmation}
691
+ >
692
+ <DialogHeader title="Cancel generation" />
693
+ <DialogContent>
694
+ <DialogDescription>
695
+ <Box display="flex" flexDirection="column" padding={{ inline: 16 }}>
696
+ <Text variant="bold">
697
+ Are you sure you want to cancel the generation for all slices?
698
+ </Text>
699
+ </Box>
700
+ </DialogDescription>
701
+ <DialogActions>
702
+ <DialogCancelButton
703
+ onClick={() => setShowCancelConfirmation(false)}
704
+ size="small"
705
+ >
706
+ Keep generating
707
+ </DialogCancelButton>
708
+ <DialogActionButton
709
+ color="tomato"
710
+ onClick={onCancelConfirm}
711
+ size="small"
712
+ >
713
+ Confirm
714
+ </DialogActionButton>
715
+ </DialogActions>
716
+ </DialogContent>
717
+ </Dialog>
447
718
  </Dialog>
448
719
  );
449
720
  }
@@ -451,8 +722,10 @@ export function CreateSliceFromImageModal(
451
722
  function UploadBlankSlate(props: {
452
723
  droppingFiles?: boolean;
453
724
  onFilesSelected: (files: File[]) => void;
725
+ onPaste: () => void;
454
726
  }) {
455
- const { droppingFiles = false, onFilesSelected } = props;
727
+ const { droppingFiles = false, onFilesSelected, onPaste } = props;
728
+ const isFigmaEnabled = useIsFigmaEnabled();
456
729
 
457
730
  return (
458
731
  <Box
@@ -463,27 +736,70 @@ function UploadBlankSlate(props: {
463
736
  border
464
737
  borderStyle="dashed"
465
738
  borderColor={droppingFiles ? "purple9" : "grey6"}
739
+ borderRadius={12}
740
+ flexGrow={1}
466
741
  >
467
742
  <BlankSlate>
468
- <BlankSlateIcon
469
- lineColor="purple11"
470
- backgroundColor="purple5"
471
- name="cloudUpload"
472
- size="large"
473
- />
474
- <BlankSlateTitle>Upload your design images.</BlankSlateTitle>
475
- <BlankSlateDescription>
476
- Once uploaded, you can generate slices automatically using AI.
477
- </BlankSlateDescription>
478
- <BlankSlateActions>
479
- <FileUploadButton
480
- startIcon="attachFile"
481
- onFilesSelected={onFilesSelected}
482
- color="grey"
743
+ <Box display="flex" flexDirection="column" gap={16} alignItems="center">
744
+ <BlankSlateIcon
745
+ lineColor="purple11"
746
+ backgroundColor="purple5"
747
+ name="cloudUpload"
748
+ size="large"
749
+ />
750
+ <Box
751
+ display="flex"
752
+ flexDirection="column"
753
+ gap={4}
754
+ alignItems="center"
483
755
  >
484
- Add images
485
- </FileUploadButton>
486
- </BlankSlateActions>
756
+ {isFigmaEnabled ? (
757
+ <>
758
+ <Text>Generate slices from your designs</Text>
759
+ <Text variant="small" color="grey11">
760
+ Upload your design images or paste them directly from Figma.
761
+ </Text>
762
+ </>
763
+ ) : (
764
+ <>
765
+ <Text>Upload your design images.</Text>
766
+ <Text variant="small" color="grey11">
767
+ Once uploaded, you can generate slices automatically using AI.
768
+ </Text>
769
+ </>
770
+ )}
771
+ </Box>
772
+ <Box display="flex" alignItems="center" gap={16}>
773
+ {isFigmaEnabled ? (
774
+ <>
775
+ <Button
776
+ size="small"
777
+ renderStartIcon={() => <FigmaIcon height={16} />}
778
+ color="grey"
779
+ onClick={onPaste}
780
+ >
781
+ Paste from Figma
782
+ </Button>
783
+ <FileUploadButton
784
+ size="small"
785
+ onFilesSelected={onFilesSelected}
786
+ color="purple"
787
+ invisible
788
+ >
789
+ Add images
790
+ </FileUploadButton>
791
+ </>
792
+ ) : (
793
+ <FileUploadButton
794
+ startIcon="attachFile"
795
+ onFilesSelected={onFilesSelected}
796
+ color="grey"
797
+ >
798
+ Add images
799
+ </FileUploadButton>
800
+ )}
801
+ </Box>
802
+ </Box>
487
803
  </BlankSlate>
488
804
  </Box>
489
805
  );
@@ -506,12 +822,6 @@ async function getImageUrl({ image }: { image: File }) {
506
822
  return url;
507
823
  }
508
824
 
509
- type NewSlice = {
510
- image: File;
511
- model: SharedSlice;
512
- langSmithUrl?: string;
513
- };
514
-
515
825
  /**
516
826
  * Keeps track of the existing slices in the project.
517
827
  * Re-fetches them when the modal is opened.
@@ -583,54 +893,17 @@ function sliceWithoutConflicts({
583
893
  };
584
894
  }
585
895
 
586
- async function addSlices(newSlices: NewSlice[]) {
587
- // use the first library
588
- const { libraries = [] } =
589
- await managerClient.project.getSliceMachineConfig();
590
- const library = libraries[0];
591
- if (!library) {
592
- throw new Error("No library found in the config.");
593
- }
594
-
595
- for (const { model } of newSlices) {
596
- const { errors } = await managerClient.slices.createSlice({
597
- libraryID: library,
598
- model,
599
- });
600
- if (errors.length) {
601
- throw new Error(`Failed to create slice ${model.id}.`);
602
- }
603
- }
604
-
605
- // for each added slice, set the variation screenshot
606
- const slices = await Promise.all(
607
- newSlices.map(async ({ model, image, langSmithUrl }) => {
608
- await managerClient.slices.updateSliceScreenshot({
609
- libraryID: library,
610
- sliceID: model.id,
611
- variationID: model.variations[0].id,
612
- data: image,
613
- });
614
- return {
615
- model,
616
- langSmithUrl,
617
- };
618
- }),
619
- );
620
-
621
- return { library, slices };
622
- }
623
-
624
896
  const getSubmitButtonLabel = (
625
897
  location: "custom_type" | "page_type" | "slices",
898
+ completedSliceCount: number,
626
899
  ) => {
627
900
  switch (location) {
628
901
  case "custom_type":
629
- return "Add to type";
902
+ return `Add to type (${completedSliceCount})`;
630
903
  case "page_type":
631
- return "Add to page";
904
+ return `Add to page (${completedSliceCount})`;
632
905
  case "slices":
633
- return "Add to slices";
906
+ return "Done";
634
907
  }
635
908
  };
636
909
 
@@ -639,12 +912,23 @@ function useIsFigmaEnabled() {
639
912
  return experiment?.value === "on";
640
913
  }
641
914
 
642
- function getLibraryID() {
643
- return managerClient.project.getSliceMachineConfig().then((smConfig) => {
644
- const libraryID = smConfig?.libraries?.[0];
645
- if (libraryID === undefined) {
646
- throw new Error("No library found in the config.");
647
- }
648
- return libraryID;
649
- });
915
+ function useLibraryID() {
916
+ const [libraryID, setLibraryID] = useState<string | undefined>();
917
+
918
+ useEffect(() => {
919
+ managerClient.project
920
+ .getSliceMachineConfig()
921
+ .then((smConfig) => {
922
+ const libraryID = smConfig?.libraries?.[0];
923
+ if (libraryID === undefined) {
924
+ throw new Error("No library found in the config.");
925
+ }
926
+ setLibraryID(libraryID);
927
+ })
928
+ .catch(() => {
929
+ throw new Error("Could not get library ID from the config.");
930
+ });
931
+ }, []);
932
+
933
+ return { libraryID, isLoading: libraryID === undefined };
650
934
  }