@saltcorn/builder 1.6.0-alpha.9 → 1.6.0-beta.1

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.
@@ -9,12 +9,12 @@ import React, {
9
9
  useContext,
10
10
  useState,
11
11
  Fragment,
12
- useRef,
13
- memo,
12
+ useRef
14
13
  } from "react";
14
+ import { createPortal } from "react-dom";
15
15
  import useTranslation from "../hooks/useTranslation";
16
16
  import { Editor, Frame, Element, Selector, useEditor, DefaultEventHandlers } from "@craftjs/core";
17
- import { Layers, useLayer } from "@craftjs/layers"
17
+ import { Layers } from "@craftjs/layers"
18
18
  import { Text } from "./elements/Text";
19
19
  import { Field } from "./elements/Field";
20
20
  import { JoinField } from "./elements/JoinField";
@@ -49,7 +49,6 @@ import { Link } from "./elements/Link";
49
49
  import { View } from "./elements/View";
50
50
  import { Container } from "./elements/Container";
51
51
  import { Column } from "./elements/Column";
52
- // import { Layers } from "saltcorn-craft-layers-noeye";
53
52
  import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
54
53
  import {
55
54
  faCopy,
@@ -58,20 +57,22 @@ import {
58
57
  faTrashAlt,
59
58
  faSave,
60
59
  faExclamationTriangle,
61
- faPlus,
62
- faChevronDown,
63
- faChevronUp
60
+ faPlus
64
61
  } from "@fortawesome/free-solid-svg-icons";
65
62
  import {
66
63
  faCaretSquareLeft,
67
64
  faCaretSquareRight,
68
65
  } from "@fortawesome/free-regular-svg-icons";
69
66
  import { Accordion, ErrorBoundary } from "./elements/utils";
67
+ import { Display, Tablet, Phone } from "react-bootstrap-icons";
70
68
  import { InitNewElement, Library, LibraryElem } from "./Library";
71
69
  import { RenderNode } from "./RenderNode";
72
70
  import { ListColumn } from "./elements/ListColumn";
73
71
  import { ListColumns } from "./elements/ListColumns";
72
+ import { Prompt } from "./elements/Prompt";
74
73
  import { recursivelyCloneToElems } from "./elements/Clone";
74
+ import { Page } from "./elements/Page";
75
+ import CustomLayer from "./elements/CustomLayer";
75
76
 
76
77
  const { Provider } = optionsCtx;
77
78
 
@@ -92,11 +93,6 @@ const getSelectedNodes = (selected) => {
92
93
  return [selected];
93
94
  };
94
95
 
95
- const getFirstSelected = (selected) => {
96
- const nodes = getSelectedNodes(selected);
97
- return nodes.length > 0 ? nodes[0] : null;
98
- };
99
-
100
96
  /**
101
97
  *
102
98
  * @returns {div}
@@ -104,7 +100,7 @@ const getFirstSelected = (selected) => {
104
100
  * @subcategory components
105
101
  * @namespace
106
102
  */
107
- const SettingsPanel = () => {
103
+ const SettingsPanel = ({ isEnlarged, setIsEnlarged }) => {
108
104
  const { t } = useTranslation();
109
105
  const options = useContext(optionsCtx);
110
106
 
@@ -160,6 +156,15 @@ const SettingsPanel = () => {
160
156
  const tagName = target.tagName.toLowerCase();
161
157
  const hasSelection = selectedCount > 0;
162
158
  if ((tagName === "body" || tagName === "button") && hasSelection) {
159
+ if (!selected && selectedCount > 1 && (keyCode === 8 || keyCode === 46)) {
160
+ const currentSelected = query.getEvent("selected");
161
+ const nodeIds = getSelectedNodes(currentSelected)
162
+ .map((nodeId) => (typeof nodeId === "string" ? nodeId : nodeId?.id))
163
+ .filter((nodeId) => nodeId && nodeId !== "ROOT");
164
+ nodeIds.forEach((nodeId) => {
165
+ try { actions.delete(nodeId); } catch (e) { /* node may already be deleted */ }
166
+ });
167
+ }
163
168
  if (selected) {
164
169
  if ((keyCode === 8 || keyCode === 46) && selected.id === "ROOT") {
165
170
  deleteChildren();
@@ -298,6 +303,14 @@ const SettingsPanel = () => {
298
303
  actions.history.redo();
299
304
  }
300
305
  }
306
+ if ((tagName === "body" || tagName === "button") &&
307
+ (event.ctrlKey || event.metaKey) && event.keyCode == 65) {
308
+ event.preventDefault();
309
+ const rootChildren = query.node("ROOT").childNodes();
310
+ if (rootChildren.length > 0) {
311
+ actions.selectNode(rootChildren);
312
+ }
313
+ }
301
314
  };
302
315
  useEffect(() => {
303
316
  window.addEventListener("keydown", handleUserKeyPress);
@@ -334,15 +347,181 @@ const SettingsPanel = () => {
334
347
  );
335
348
  };
336
349
 
350
+ const [generating, setGenerating] = useState(false);
351
+ const [generateError, setGenerateError] = useState(null);
352
+
353
+ // Regenerate modal state
354
+ const [showRegenerateModal, setShowRegenerateModal] = useState(false);
355
+ const [regeneratePrompt, setRegeneratePrompt] = useState("");
356
+ const [regenerating, setRegenerating] = useState(false);
357
+ const [regenerateError, setRegenerateError] = useState(null);
358
+
359
+ const handleRegenerate = async () => {
360
+ if (!regeneratePrompt.trim() || !selected) return;
361
+ setRegenerating(true);
362
+ setRegenerateError(null);
363
+ try {
364
+ const selectedNode = query.node(selected.id).get();
365
+ const existingJson = craftToSaltcorn(
366
+ JSON.parse(query.serialize()),
367
+ selected.id
368
+ );
369
+ const res = await fetch("/viewedit/copilot-generate-layout", {
370
+ method: "POST",
371
+ headers: {
372
+ "Content-Type": "application/json",
373
+ "CSRF-Token": options.csrfToken,
374
+ "X-Requested-With": "XMLHttpRequest",
375
+ },
376
+ body: JSON.stringify({
377
+ prompt: regeneratePrompt,
378
+ mode: options.mode,
379
+ table: options.tableName,
380
+ existing: existingJson,
381
+ }),
382
+ });
383
+ const data = await res.json();
384
+ if (data.error) {
385
+ setRegenerateError(data.error);
386
+ } else if (data.layout) {
387
+ const parentId = selectedNode.data.parent || "ROOT";
388
+ const siblings = query.node(parentId).childNodes();
389
+ const sibIx = siblings.findIndex((sib) => sib === selected.id);
390
+ actions.delete(selected.id);
391
+ layoutToNodes(data.layout, query, actions, parentId, options, sibIx);
392
+ setShowRegenerateModal(false);
393
+ setRegeneratePrompt("");
394
+ }
395
+ } catch (err) {
396
+ setRegenerateError(err.message || "Regeneration failed");
397
+ } finally {
398
+ setRegenerating(false);
399
+ }
400
+ };
401
+
402
+ // Find prompt nodes: check children of selected, or siblings if selected is a Prompt
403
+ const findPromptContext = () => {
404
+ if (!selected) return { promptNodes: [], targetParent: null };
405
+ const isSelectedPrompt = selected.displayName === "Prompt";
406
+
407
+ if (isSelectedPrompt && selected.parent) {
408
+ // Selected node is a Prompt — find all Prompt siblings in same parent
409
+ try {
410
+ const siblingIds = query.node(selected.parent).childNodes();
411
+ const promptIds = siblingIds.filter((id) => {
412
+ const n = query.node(id).get();
413
+ return n?.data?.displayName === "Prompt";
414
+ });
415
+ return { promptNodes: promptIds, targetParent: selected.parent };
416
+ } catch {
417
+ return { promptNodes: [], targetParent: null };
418
+ }
419
+ }
420
+
421
+ // Selected node is a container — check its direct children
422
+ if (selected.children && selected.children.length > 0) {
423
+ const promptIds = selected.children.filter((id) => {
424
+ try {
425
+ const n = query.node(id).get();
426
+ return n?.data?.displayName === "Prompt";
427
+ } catch {
428
+ return false;
429
+ }
430
+ });
431
+ if (promptIds.length > 0) {
432
+ return { promptNodes: promptIds, targetParent: selected.id };
433
+ }
434
+ }
435
+
436
+ // Check linked nodes (e.g. Card's inner Column)
437
+ try {
438
+ const nodeData = query.node(selected.id).get();
439
+ const linkedNodes = nodeData?.data?.linkedNodes;
440
+ if (linkedNodes) {
441
+ for (const linkedId of Object.values(linkedNodes)) {
442
+ const linkedChildIds = query.node(linkedId).childNodes();
443
+ const promptIds = linkedChildIds.filter((id) => {
444
+ try {
445
+ const n = query.node(id).get();
446
+ return n?.data?.displayName === "Prompt";
447
+ } catch {
448
+ return false;
449
+ }
450
+ });
451
+ if (promptIds.length > 0) {
452
+ return { promptNodes: promptIds, targetParent: linkedId };
453
+ }
454
+ }
455
+ }
456
+ } catch {
457
+ // ignore
458
+ }
459
+
460
+ return { promptNodes: [], targetParent: null };
461
+ };
462
+
463
+ const { promptNodes, targetParent } = selected
464
+ ? findPromptContext()
465
+ : { promptNodes: [], targetParent: null };
466
+ const hasPromptNodes = promptNodes.length > 0;
467
+
468
+ const handleGenerate = async () => {
469
+ setGenerating(true);
470
+ setGenerateError(null);
471
+ try {
472
+ const prompts = promptNodes.map((childId) => {
473
+ const n = query.node(childId).get();
474
+ const { promptType, promptText } = n.data.props;
475
+ return `[${promptType}]: ${promptText}`;
476
+ });
477
+ const combinedPrompt = prompts.join("\n");
478
+
479
+ const res = await fetch("/viewedit/copilot-generate-layout", {
480
+ method: "POST",
481
+ headers: {
482
+ "Content-Type": "application/json",
483
+ "CSRF-Token": options.csrfToken,
484
+ "X-Requested-With": "XMLHttpRequest",
485
+ },
486
+ body: JSON.stringify({
487
+ prompt: combinedPrompt,
488
+ mode: options.mode,
489
+ table: options.tableName,
490
+ }),
491
+ });
492
+ const data = await res.json();
493
+ if (data.error) {
494
+ setGenerateError(data.error);
495
+ } else if (data.layout) {
496
+ promptNodes.forEach((id) => actions.delete(id));
497
+ layoutToNodes(data.layout, query, actions, targetParent, options);
498
+ }
499
+ } catch (err) {
500
+ setGenerateError(err.message || "Generation failed");
501
+ } finally {
502
+ setGenerating(false);
503
+ }
504
+ };
505
+
337
506
  return (
338
507
  <div className="settings-panel card mt-1">
339
- <div className="card-header px-2 py-1">
340
- {selected && selected.displayName ? (
341
- <Fragment>
342
- <b>{selected.displayName}</b> settings
343
- </Fragment>
344
- ) : (
345
- t("Settings")
508
+ <div className="card-header px-2 py-1 d-flex justify-content-between align-items-center">
509
+ <div>
510
+ {selected && selected.displayName ? (
511
+ <Fragment>
512
+ <b>{selected.displayName}</b> settings
513
+ </Fragment>
514
+ ) : (
515
+ t("Settings")
516
+ )}
517
+ </div>
518
+ {setIsEnlarged && (
519
+ <FontAwesomeIcon
520
+ icon={isEnlarged ? faCaretSquareRight : faCaretSquareLeft}
521
+ className="fa-lg builder-expand-toggle-right"
522
+ onClick={() => setIsEnlarged(!isEnlarged)}
523
+ title={isEnlarged ? t("Shrink") : t("Enlarge")}
524
+ />
346
525
  )}
347
526
  </div>
348
527
  <div className="card-body p-2">
@@ -373,15 +552,76 @@ const SettingsPanel = () => {
373
552
  ) : (
374
553
  <button
375
554
  title={t("Duplicate element with its children")}
376
- className="btn btn-sm btn-secondary ms-2 duplicate-element-builder"
555
+ className="btn btn-sm btn-secondary ms-1 duplicate-element-builder"
377
556
  onClick={duplicate}
378
557
  >
379
558
  <FontAwesomeIcon icon={faCopy} className="me-1" />
380
559
  {t("Clone")}
381
560
  </button>
382
561
  )}
383
- <hr className="my-2" />
384
- {selected.settings && React.createElement(selected.settings)}
562
+ {options.has_copilot_generate && selected.isDeletable && (
563
+ <button
564
+ className="btn btn-sm btn-secondary ms-1"
565
+ onClick={() => setShowRegenerateModal(true)}
566
+ >
567
+ {t("Edit with AI")}
568
+ </button>
569
+ )}
570
+ <div className="mt-2">
571
+ {selected.settings && React.createElement(selected.settings)}
572
+ </div>
573
+ {showRegenerateModal && (
574
+ <div className="modal d-block" tabIndex="-1" style={{ backgroundColor: "rgba(0,0,0,0.5)" }}>
575
+ <div className="modal-dialog">
576
+ <div className="modal-content">
577
+ <div className="modal-header">
578
+ <h5 className="modal-title">{t("Generate Element")}</h5>
579
+ <button
580
+ type="button"
581
+ className="btn-close"
582
+ onClick={() => {
583
+ setShowRegenerateModal(false);
584
+ setRegenerateError(null);
585
+ }}
586
+ ></button>
587
+ </div>
588
+ <div className="modal-body">
589
+ <p className="text-muted small">
590
+ {t("Describe how you want to regenerate the selected element.")}
591
+ </p>
592
+ <textarea
593
+ className="form-control"
594
+ rows={3}
595
+ value={regeneratePrompt}
596
+ onChange={(e) => setRegeneratePrompt(e.target.value)}
597
+ placeholder={t("Enter your prompt...")}
598
+ />
599
+ {regenerateError && (
600
+ <div className="alert alert-danger mt-2 mb-0">{regenerateError}</div>
601
+ )}
602
+ </div>
603
+ <div className="modal-footer">
604
+ <button
605
+ className="btn btn-secondary"
606
+ onClick={() => {
607
+ setShowRegenerateModal(false);
608
+ setRegenerateError(null);
609
+ }}
610
+ >
611
+ {t("Cancel")}
612
+ </button>
613
+ <button
614
+ className="btn btn-primary"
615
+ onClick={handleRegenerate}
616
+ disabled={regenerating || !regeneratePrompt.trim()}
617
+ >
618
+ {regenerating ? t("Generating...") : t("Generate")}
619
+ </button>
620
+ </div>
621
+ </div>
622
+ </div>
623
+ </div>
624
+ )}
385
625
  </Fragment>
386
626
  ) : (
387
627
  t("No element selected")
@@ -417,113 +657,6 @@ function useWindowDimensions() {
417
657
  return windowDimensions;
418
658
  }
419
659
 
420
- /**
421
- * Custom Layer Component for Craft.js Layers panel
422
- * Must be defined outside Builder component and memoized to prevent infinite re-renders
423
- * Added defensive checks for layer properties
424
- * @category saltcorn-builder
425
- * @subcategory components
426
- * @namespace
427
- */
428
-
429
- const hiddenColumnParents = new Set(["Card", "Container", "Tabs", "Table"]);
430
-
431
- const CustomLayerComponent = memo(({ children }) => {
432
- const {
433
- id,
434
- depth,
435
- expanded,
436
- hovered,
437
- actions: { toggleLayer, setExpandedState },
438
- connectors: { layer, drag },
439
- } = useLayer((layer) => {
440
- return {
441
- hovered: layer?.event?.hovered,
442
- expanded: layer?.expanded,
443
- };
444
- });
445
-
446
- const { displayName, hasNodes, isHiddenColumn } = useEditor((state) => {
447
- const node = state.nodes[id];
448
- const data = node?.data;
449
-
450
- let name = data?.displayName || data?.name || id;
451
- if (name === "ROOT" || name === "Canvas") {
452
- name = data?.name || name;
453
- }
454
-
455
- const nodes = data?.nodes;
456
- const linkedNodes = data?.linkedNodes;
457
- const hasChildren = (nodes && nodes.length > 0) || (linkedNodes && Object.keys(linkedNodes).length > 0);
458
-
459
- // Check if this Column is a linked node of a Card/Container/Tabs/Table
460
- let shouldHide = false;
461
- if (name === "Column" && data?.parent) {
462
- const parentNode = state.nodes[data.parent];
463
- const parentName = parentNode?.data?.displayName || parentNode?.data?.name;
464
- if (hiddenColumnParents.has(parentName)) {
465
- const parentLinked = parentNode?.data?.linkedNodes;
466
- if (parentLinked && Object.values(parentLinked).includes(id)) {
467
- shouldHide = true;
468
- }
469
- }
470
- }
471
-
472
- return {
473
- displayName: name,
474
- hasNodes: hasChildren,
475
- isHiddenColumn: shouldHide
476
- };
477
- });
478
-
479
- // Auto-expand hidden linked-node Columns so their children are always
480
- // visible through the transparent wrapper. Uses setExpandedState(true)
481
- // instead of toggleLayer() — it's idempotent (no-op when already true),
482
- // so it won't conflict with craft.js internals or cause toggle loops.
483
- useEffect(() => {
484
- if (isHiddenColumn && !expanded) {
485
- setExpandedState(true);
486
- }
487
- }, [isHiddenColumn, expanded, setExpandedState]);
488
-
489
- if (isHiddenColumn) {
490
- return (
491
- <div
492
- ref={(dom) => { layer(dom); drag(dom); }}
493
- style={{ marginLeft: "-20px" }}
494
- >
495
- {children}
496
- </div>
497
- );
498
- }
499
-
500
- return (
501
- <div>
502
- <div
503
- ref={(dom) => { layer(dom); drag(dom); }}
504
- className={`builder-layer-node ${hovered ? "hovered" : ""}`}
505
- style={{
506
- paddingLeft: `${depth * 20 + 10}px`,
507
- }}
508
- >
509
- <span className="layer-name" style={{ flexGrow: 1 }}>{displayName}</span>
510
-
511
- {hasNodes && (
512
- <span
513
- onClick={(e) => {
514
- e.stopPropagation();
515
- toggleLayer();
516
- }}
517
- >
518
- <FontAwesomeIcon icon={expanded ? faChevronUp : faChevronDown} fontSize={14} className="float-end fa-lg" />
519
- </span>
520
- )}
521
- </div>
522
- {children}
523
- </div>
524
- );
525
- });
526
-
527
660
  const AddColumnButton = () => {
528
661
  const { t } = useTranslation();
529
662
  const { query, actions } = useEditor(() => {});
@@ -545,6 +678,38 @@ const AddColumnButton = () => {
545
678
  );
546
679
  };
547
680
 
681
+ const DEVICE_WIDTHS = {
682
+ desktop: null,
683
+ tablet: 768,
684
+ mobile: 576,
685
+ };
686
+
687
+ const DevicePreviewToolbar = ({ previewDevice, setPreviewDevice }) => {
688
+ const { t } = useTranslation();
689
+ const devices = [
690
+ { key: "desktop", icon: Display, label: t("Desktop") },
691
+ { key: "tablet", icon: Tablet, label: t("Tablet") },
692
+ { key: "mobile", icon: Phone, label: t("Mobile") },
693
+ ];
694
+
695
+ return (
696
+ <div className="device-preview-toolbar">
697
+ {devices.map(({ key, icon: Icon, label }) => (
698
+ <button
699
+ key={key}
700
+ className={`btn btn-sm ${
701
+ previewDevice === key ? "btn-primary" : "btn-outline-secondary"
702
+ } device-preview-btn`}
703
+ onClick={() => setPreviewDevice(key)}
704
+ title={label}
705
+ >
706
+ <Icon size={16} />
707
+ </button>
708
+ ))}
709
+ </div>
710
+ );
711
+ };
712
+
548
713
  /**
549
714
  * @returns {Fragment}
550
715
  * @category saltcorn-builder
@@ -559,26 +724,26 @@ const HistoryPanel = () => {
559
724
  }));
560
725
 
561
726
  return (
562
- <Fragment>
563
- {canUndo && (
564
- <button
565
- className="btn btn-sm btn-secondary ms-2 me-2 undo-builder"
566
- title={t("Undo")}
567
- onClick={() => actions.history.undo()}
568
- >
569
- <FontAwesomeIcon icon={faUndo} />
570
- </button>
571
- )}
572
- {canRedo && (
573
- <button
574
- className="btn btn-sm btn-secondary redo-builder"
575
- title={t("Redo")}
576
- onClick={() => actions.history.redo()}
577
- >
578
- <FontAwesomeIcon icon={faRedo} />
579
- </button>
580
- )}
581
- </Fragment>
727
+ <div className="d-flex gap-1">
728
+ <button
729
+ className="btn btn-sm btn-secondary redo-builder"
730
+ title={t("Redo")}
731
+ onClick={() => actions.history.redo()}
732
+ disabled={!canRedo}
733
+ style={!canRedo ? { opacity: 0.4, pointerEvents: "none" } : {}}
734
+ >
735
+ <FontAwesomeIcon icon={faRedo} />
736
+ </button>
737
+ <button
738
+ className="btn btn-sm btn-secondary undo-builder"
739
+ title={t("Undo")}
740
+ onClick={() => actions.history.undo()}
741
+ disabled={!canUndo}
742
+ style={!canUndo ? { opacity: 0.4, pointerEvents: "none" } : {}}
743
+ >
744
+ <FontAwesomeIcon icon={faUndo} />
745
+ </button>
746
+ </div>
582
747
  );
583
748
  };
584
749
 
@@ -595,7 +760,7 @@ const NextButton = ({ layout }) => {
595
760
  const options = useContext(optionsCtx);
596
761
 
597
762
  useEffect(() => {
598
- layoutToNodes(layout, query, actions, "ROOT", options);
763
+ layoutToNodes(layout, query, actions.history.ignore(), "ROOT", options);
599
764
  }, []);
600
765
 
601
766
  /**
@@ -644,6 +809,7 @@ const Builder = ({ options, layout, mode }) => {
644
809
  const [isEnlarged, setIsEnlarged] = useState(false);
645
810
  const [isLeftEnlarged, setIsLeftEnlarged] = useState(false);
646
811
  const [relationsCache, setRelationsCache] = useState({});
812
+ const [previewDevice, setPreviewDevice] = useState("desktop");
647
813
  const { windowWidth, windowHeight } = useWindowDimensions();
648
814
 
649
815
  const [builderHeight, setBuilderHeight] = useState(0);
@@ -699,11 +865,13 @@ const Builder = ({ options, layout, mode }) => {
699
865
  ListColumn,
700
866
  ListColumns,
701
867
  LibraryElem,
868
+ Prompt,
869
+ Page
702
870
  }}
703
871
  >
704
872
  <Provider value={options}>
705
873
  <PreviewCtx.Provider
706
- value={{ previews, setPreviews, uploadedFiles, setUploadedFiles }}
874
+ value={{ previews, setPreviews, uploadedFiles, setUploadedFiles, previewDevice }}
707
875
  >
708
876
  <RelationsCtx.Provider
709
877
  value={{
@@ -767,9 +935,9 @@ const Builder = ({ options, layout, mode }) => {
767
935
  </div>
768
936
  {showLayers && (
769
937
  <div className="card-body p-0 builder-layers">
770
- <Layers
938
+ <Layers
771
939
  expandRootOnLoad={true}
772
- renderLayer={CustomLayerComponent}
940
+ renderLayer={CustomLayer}
773
941
  />
774
942
  </div>
775
943
  )}
@@ -782,40 +950,53 @@ const Builder = ({ options, layout, mode }) => {
782
950
  options.mode !== "list" ? "emptymsg" : ""
783
951
  }`}
784
952
  >
785
- <div>
786
- <Frame>
787
- {options.mode === "list" ? (
788
- <Element canvas is={ListColumns}></Element>
789
- ) : (
790
- <Element canvas is={Column}></Element>
791
- )}
792
- </Frame>
793
- {options.mode === "list" ? <AddColumnButton /> : null}
953
+ <div className="device-preview-scroll-area">
954
+ <div
955
+ className={`device-preview-canvas-wrapper ${
956
+ previewDevice !== "desktop" && options.mode !== "list" ? "device-preview-constrained" : ""
957
+ }`}
958
+ style={{
959
+ maxWidth: options.mode !== "list" && DEVICE_WIDTHS[previewDevice]
960
+ ? `${DEVICE_WIDTHS[previewDevice]}px`
961
+ : "none",
962
+ }}
963
+ >
964
+ <Frame>
965
+ {options.mode === "list" ? (
966
+ <Element canvas is={ListColumns}></Element>
967
+ ) : (
968
+ <Element canvas is={Column}></Element>
969
+ )}
970
+ </Frame>
971
+ {options.mode === "list" ? <AddColumnButton /> : null}
972
+ </div>
794
973
  </div>
795
974
  </div>
796
975
  <div className="col-sm-auto builder-sidebar">
797
- <div style={{ width: isEnlarged ? "28rem" : "16rem" }}>
798
- <NextButton layout={layout} />
799
- <HistoryPanel />
800
- <FontAwesomeIcon
801
- icon={faSave}
802
- className={savingState.isSaving ? "d-inline" : "d-none"}
803
- />
804
- <FontAwesomeIcon
805
- icon={faExclamationTriangle}
806
- color="#ff0033"
807
- className={savingState.error ? "d-inline" : "d-none"}
808
- />
809
- <FontAwesomeIcon
810
- icon={
811
- isEnlarged ? faCaretSquareRight : faCaretSquareLeft
812
- }
813
- className={
814
- "float-end me-2 mt-1 fa-lg builder-expand-toggle-right"
815
- }
816
- onClick={() => setIsEnlarged(!isEnlarged)}
817
- title={isEnlarged ? t("Shrink") : t("Enlarge")}
818
- />
976
+ <div style={{ width: isEnlarged ? "28rem" : "16.5rem" }}>
977
+ {document.getElementById("builder-header-actions") &&
978
+ createPortal(
979
+ <Fragment>
980
+ <FontAwesomeIcon
981
+ icon={faSave}
982
+ className={savingState.isSaving ? "d-inline" : "d-none"}
983
+ />
984
+ <FontAwesomeIcon
985
+ icon={faExclamationTriangle}
986
+ color="#ff0033"
987
+ className={savingState.error ? "d-inline" : "d-none"}
988
+ />
989
+ <HistoryPanel />
990
+ {options.mode !== "list" && (
991
+ <DevicePreviewToolbar
992
+ previewDevice={previewDevice}
993
+ setPreviewDevice={setPreviewDevice}
994
+ />
995
+ )}
996
+ <NextButton layout={layout} />
997
+ </Fragment>,
998
+ document.getElementById("builder-header-actions")
999
+ )}
819
1000
  <div
820
1001
  className={` ${
821
1002
  savingState.error ? "d-block" : "d-none"
@@ -823,7 +1004,7 @@ const Builder = ({ options, layout, mode }) => {
823
1004
  >
824
1005
  {t("your work is not being saved")}
825
1006
  </div>
826
- <SettingsPanel />
1007
+ <SettingsPanel isEnlarged={isEnlarged} setIsEnlarged={setIsEnlarged} />
827
1008
  </div>
828
1009
  </div>
829
1010
  </div>