@saltcorn/builder 1.6.0-alpha.1 → 1.6.0-alpha.11

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/dist/builder_bundle.js +85 -1
  2. package/dist/builder_bundle.js.LICENSE.txt +18 -51
  3. package/package.json +31 -27
  4. package/src/components/Builder.js +445 -155
  5. package/src/components/Library.js +25 -13
  6. package/src/components/RenderNode.js +26 -8
  7. package/src/components/Toolbox.js +333 -269
  8. package/src/components/elements/Action.js +144 -29
  9. package/src/components/elements/Aggregation.js +20 -23
  10. package/src/components/elements/ArrayManager.js +17 -10
  11. package/src/components/elements/BoxModelEditor.js +19 -17
  12. package/src/components/elements/Card.js +47 -34
  13. package/src/components/elements/Clone.js +74 -2
  14. package/src/components/elements/Column.js +1 -1
  15. package/src/components/elements/Columns.js +130 -121
  16. package/src/components/elements/Container.js +185 -92
  17. package/src/components/elements/DropDownFilter.js +10 -8
  18. package/src/components/elements/DropMenu.js +18 -9
  19. package/src/components/elements/Field.js +9 -7
  20. package/src/components/elements/HTMLCode.js +3 -1
  21. package/src/components/elements/Image.js +20 -15
  22. package/src/components/elements/JoinField.js +15 -11
  23. package/src/components/elements/Link.js +18 -16
  24. package/src/components/elements/ListColumn.js +7 -3
  25. package/src/components/elements/ListColumns.js +4 -1
  26. package/src/components/elements/MonacoEditor.js +4 -2
  27. package/src/components/elements/Page.js +7 -4
  28. package/src/components/elements/RelationBadges.js +16 -11
  29. package/src/components/elements/RelationOnDemandPicker.js +18 -12
  30. package/src/components/elements/SearchBar.js +37 -10
  31. package/src/components/elements/Table.js +72 -65
  32. package/src/components/elements/Tabs.js +18 -15
  33. package/src/components/elements/Text.js +19 -14
  34. package/src/components/elements/ToggleFilter.js +28 -25
  35. package/src/components/elements/View.js +36 -18
  36. package/src/components/elements/ViewLink.js +15 -11
  37. package/src/components/elements/utils.js +224 -55
  38. package/src/components/storage.js +33 -134
  39. package/src/hooks/useTranslation.js +11 -0
  40. package/src/index.js +6 -3
@@ -10,8 +10,12 @@ import React, {
10
10
  useState,
11
11
  Fragment,
12
12
  useRef,
13
+ memo,
13
14
  } from "react";
14
- import { Editor, Frame, Element, Selector, useEditor } from "@craftjs/core";
15
+ import { createPortal } from "react-dom";
16
+ import useTranslation from "../hooks/useTranslation";
17
+ import { Editor, Frame, Element, Selector, useEditor, DefaultEventHandlers } from "@craftjs/core";
18
+ import { Layers, useLayer } from "@craftjs/layers"
15
19
  import { Text } from "./elements/Text";
16
20
  import { Field } from "./elements/Field";
17
21
  import { JoinField } from "./elements/JoinField";
@@ -46,7 +50,7 @@ import { Link } from "./elements/Link";
46
50
  import { View } from "./elements/View";
47
51
  import { Container } from "./elements/Container";
48
52
  import { Column } from "./elements/Column";
49
- import { Layers } from "saltcorn-craft-layers-noeye";
53
+ // import { Layers } from "saltcorn-craft-layers-noeye";
50
54
  import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
51
55
  import {
52
56
  faCopy,
@@ -56,13 +60,16 @@ import {
56
60
  faSave,
57
61
  faExclamationTriangle,
58
62
  faPlus,
63
+ faChevronDown,
64
+ faChevronUp
59
65
  } from "@fortawesome/free-solid-svg-icons";
60
66
  import {
61
67
  faCaretSquareLeft,
62
68
  faCaretSquareRight,
63
69
  } from "@fortawesome/free-regular-svg-icons";
64
70
  import { Accordion, ErrorBoundary } from "./elements/utils";
65
- import { InitNewElement, Library } from "./Library";
71
+ import { Display, Tablet, Phone } from "react-bootstrap-icons";
72
+ import { InitNewElement, Library, LibraryElem } from "./Library";
66
73
  import { RenderNode } from "./RenderNode";
67
74
  import { ListColumn } from "./elements/ListColumn";
68
75
  import { ListColumns } from "./elements/ListColumns";
@@ -70,6 +77,28 @@ import { recursivelyCloneToElems } from "./elements/Clone";
70
77
 
71
78
  const { Provider } = optionsCtx;
72
79
 
80
+ const getSelectedNodes = (selected) => {
81
+ if (!selected) return [];
82
+ if (typeof selected.all === "function") {
83
+ return selected.all();
84
+ }
85
+ if (Array.isArray(selected.all)) {
86
+ return selected.all;
87
+ }
88
+ if (typeof selected.values === "function") {
89
+ return Array.from(selected.values());
90
+ }
91
+ if (typeof selected.has === "function") {
92
+ return [...selected];
93
+ }
94
+ return [selected];
95
+ };
96
+
97
+ const getFirstSelected = (selected) => {
98
+ const nodes = getSelectedNodes(selected);
99
+ return nodes.length > 0 ? nodes[0] : null;
100
+ };
101
+
73
102
  /**
74
103
  *
75
104
  * @returns {div}
@@ -77,11 +106,13 @@ const { Provider } = optionsCtx;
77
106
  * @subcategory components
78
107
  * @namespace
79
108
  */
80
- const SettingsPanel = () => {
109
+ const SettingsPanel = ({ isEnlarged, setIsEnlarged }) => {
110
+ const { t } = useTranslation();
81
111
  const options = useContext(optionsCtx);
82
112
 
83
- const { actions, selected, query } = useEditor((state, query) => {
84
- const currentNodeId = state.events.selected;
113
+ const { actions, selected, selectedCount, query } = useEditor((state, query) => {
114
+ const selectedNodes = getSelectedNodes(state.events.selected);
115
+ const currentNodeId = selectedNodes.length === 1 ? selectedNodes[0] : null;
85
116
  let selected;
86
117
 
87
118
  if (currentNodeId) {
@@ -104,6 +135,7 @@ const SettingsPanel = () => {
104
135
 
105
136
  return {
106
137
  selected,
138
+ selectedCount: selectedNodes.length,
107
139
  };
108
140
  });
109
141
 
@@ -128,74 +160,135 @@ const SettingsPanel = () => {
128
160
  const handleUserKeyPress = (event) => {
129
161
  const { keyCode, target } = event;
130
162
  const tagName = target.tagName.toLowerCase();
131
- if ((tagName === "body" || tagName === "button") && selected) {
132
- //8 backsp, 46 del
133
- if ((keyCode === 8 || keyCode === 46) && selected.id === "ROOT") {
134
- deleteChildren();
135
- }
136
- if (keyCode === 8) {
137
- //backspace
138
- const prevSib = otherSibling(-1);
139
- const parent = selected.parent;
140
- deleteThis();
141
- if (prevSib) actions.selectNode(prevSib);
142
- else actions.selectNode(parent);
143
- }
144
- if (keyCode === 46) {
145
- //del
146
- const nextSib = otherSibling(1);
147
- deleteThis();
148
- if (nextSib) actions.selectNode(nextSib);
163
+ const hasSelection = selectedCount > 0;
164
+ if ((tagName === "body" || tagName === "button") && hasSelection) {
165
+ if (!selected && selectedCount > 1 && (keyCode === 8 || keyCode === 46)) {
166
+ const currentSelected = query.getEvent("selected");
167
+ const nodeIds = getSelectedNodes(currentSelected)
168
+ .map((nodeId) => (typeof nodeId === "string" ? nodeId : nodeId?.id))
169
+ .filter((nodeId) => nodeId && nodeId !== "ROOT");
170
+ nodeIds.forEach((nodeId) => {
171
+ try { actions.delete(nodeId); } catch (e) { /* node may already be deleted */ }
172
+ });
149
173
  }
150
- if (keyCode === 37 && selected.parent)
151
- //left
152
- actions.selectNode(selected.parent);
153
-
154
- if (keyCode === 39) {
155
- //right
156
- if (selected.children && selected.children.length > 0) {
157
- actions.selectNode(selected.children[0]);
158
- } else if (selected.displayName === "Columns") {
159
- const node = query.node(selected.id).get();
160
- const child = node?.data?.linkedNodes?.Col0;
161
- if (child) actions.selectNode(child);
174
+ if (selected) {
175
+ if ((keyCode === 8 || keyCode === 46) && selected.id === "ROOT") {
176
+ deleteChildren();
177
+ }
178
+ if (keyCode === 8) {
179
+ //backspace
180
+ const prevSib = otherSibling(-1);
181
+ const parent = selected.parent;
182
+ deleteThis();
183
+ if (prevSib) actions.selectNode(prevSib);
184
+ else actions.selectNode(parent);
185
+ }
186
+ if (keyCode === 46) {
187
+ //del
188
+ const nextSib = otherSibling(1);
189
+ deleteThis();
190
+ if (nextSib) actions.selectNode(nextSib);
191
+ }
192
+ if (keyCode === 37 && selected.parent)
193
+ //left
194
+ actions.selectNode(selected.parent);
195
+
196
+ if (keyCode === 39) {
197
+ //right
198
+ if (selected.children && selected.children.length > 0) {
199
+ actions.selectNode(selected.children[0]);
200
+ } else if (selected.displayName === "Columns") {
201
+ const node = query.node(selected.id).get();
202
+ const child = node?.data?.linkedNodes?.Col0;
203
+ if (child) actions.selectNode(child);
204
+ }
205
+ }
206
+ if (keyCode === 38 && selected.parent) {
207
+ //up
208
+ const prevSib = otherSibling(-1);
209
+ if (prevSib) actions.selectNode(prevSib);
210
+ event.preventDefault();
211
+ }
212
+ if (keyCode === 40 && selected.parent) {
213
+ //down
214
+ const nextSib = otherSibling(1);
215
+ if (nextSib) actions.selectNode(nextSib);
216
+ event.preventDefault();
162
217
  }
163
218
  }
164
- if (keyCode === 38 && selected.parent) {
165
- //up
166
- const prevSib = otherSibling(-1);
167
- if (prevSib) actions.selectNode(prevSib);
168
- event.preventDefault();
169
- }
170
- if (keyCode === 40 && selected.parent) {
171
- //down
172
- const nextSib = otherSibling(1);
173
- if (nextSib) actions.selectNode(nextSib);
174
- event.preventDefault();
175
- }
176
- // Ctrl+C or Cmd+C pressed?
177
- if ((event.ctrlKey || event.metaKey) && event.keyCode == 67 && selected) {
178
- // copy elem in json format to clipboard
179
- const { layout } = craftToSaltcorn(
180
- JSON.parse(query.serialize()),
181
- selected?.id,
182
- options
183
- );
184
- navigator.clipboard.writeText(JSON.stringify(layout, null, 2));
219
+ if ((event.ctrlKey || event.metaKey) && event.keyCode == 67) {
220
+ const serialized = JSON.parse(query.serialize());
221
+ const serializedIds = new Set(Object.keys(serialized));
222
+ const currentSelected = query.getEvent("selected");
223
+ const rawSelected = getSelectedNodes(currentSelected);
224
+ if (rawSelected.length === 0 && selected?.id) rawSelected.push(selected.id);
225
+ const selectedNodes = rawSelected
226
+ .map((nodeId) => (typeof nodeId === "string" ? nodeId : nodeId?.id))
227
+ .filter(
228
+ (nodeId) =>
229
+ nodeId && nodeId !== "ROOT" && serializedIds.has(nodeId)
230
+ );
231
+ if (selectedNodes.length === 0) return;
232
+
233
+ if (selectedNodes.length === 1) {
234
+ const { layout } = craftToSaltcorn(
235
+ serialized,
236
+ selectedNodes[0],
237
+ options
238
+ );
239
+ navigator.clipboard.writeText(JSON.stringify(layout, null, 2));
240
+ } else {
241
+ const layouts = selectedNodes.map((nodeId) => {
242
+ const { layout } = craftToSaltcorn(
243
+ serialized,
244
+ nodeId,
245
+ options
246
+ );
247
+ return layout;
248
+ });
249
+ navigator.clipboard.writeText(
250
+ JSON.stringify({ above: layouts }, null, 2)
251
+ );
252
+ }
185
253
  }
186
- if ((event.ctrlKey || event.metaKey) && event.keyCode == 88 && selected) {
187
- // cut elem in json format to clipboard
188
- const { layout } = craftToSaltcorn(
189
- JSON.parse(query.serialize()),
190
- selected?.id,
191
- options
192
- );
193
- navigator.clipboard.writeText(JSON.stringify(layout, null, 2));
194
- deleteThis();
254
+ if ((event.ctrlKey || event.metaKey) && event.keyCode == 88) {
255
+ const serialized = JSON.parse(query.serialize());
256
+ const serializedIds = new Set(Object.keys(serialized));
257
+ const currentSelected = query.getEvent("selected");
258
+ const rawSelected = getSelectedNodes(currentSelected);
259
+ if (rawSelected.length === 0 && selected?.id) rawSelected.push(selected.id);
260
+ const selectedNodes = rawSelected
261
+ .map((nodeId) => (typeof nodeId === "string" ? nodeId : nodeId?.id))
262
+ .filter(
263
+ (nodeId) =>
264
+ nodeId && nodeId !== "ROOT" && serializedIds.has(nodeId)
265
+ );
266
+ if (selectedNodes.length === 0) return;
267
+
268
+ if (selectedNodes.length === 1) {
269
+ const { layout } = craftToSaltcorn(
270
+ serialized,
271
+ selectedNodes[0],
272
+ options
273
+ );
274
+ navigator.clipboard.writeText(JSON.stringify(layout, null, 2));
275
+ actions.delete(selectedNodes[0]);
276
+ } else {
277
+ const layouts = selectedNodes.map((nodeId) => {
278
+ const { layout } = craftToSaltcorn(
279
+ serialized,
280
+ nodeId,
281
+ options
282
+ );
283
+ return layout;
284
+ });
285
+ navigator.clipboard.writeText(
286
+ JSON.stringify({ above: layouts }, null, 2)
287
+ );
288
+ selectedNodes.forEach((nodeId) => actions.delete(nodeId));
289
+ }
195
290
  }
196
291
  if ((event.ctrlKey || event.metaKey) && event.keyCode == 86) {
197
- // paste elem from clipboard into container element
198
-
199
292
  navigator.clipboard.readText().then((clipText) => {
200
293
  const layout = JSON.parse(clipText);
201
294
  layoutToNodes(
@@ -216,6 +309,14 @@ const SettingsPanel = () => {
216
309
  actions.history.redo();
217
310
  }
218
311
  }
312
+ if ((tagName === "body" || tagName === "button") &&
313
+ (event.ctrlKey || event.metaKey) && event.keyCode == 65) {
314
+ event.preventDefault();
315
+ const rootChildren = query.node("ROOT").childNodes();
316
+ if (rootChildren.length > 0) {
317
+ actions.selectNode(rootChildren);
318
+ }
319
+ }
219
320
  };
220
321
  useEffect(() => {
221
322
  window.addEventListener("keydown", handleUserKeyPress);
@@ -245,7 +346,6 @@ const SettingsPanel = () => {
245
346
  const siblings = query.node(selected.parent).childNodes();
246
347
  const sibIx = siblings.findIndex((sib) => sib === selected.id);
247
348
  const elem = recursivelyCloneToElems(query)(selected.id);
248
- //console.log(elem);
249
349
  actions.addNodeTree(
250
350
  query.parseReactElement(elem).toNodeTree(),
251
351
  parent || "ROOT",
@@ -255,17 +355,32 @@ const SettingsPanel = () => {
255
355
 
256
356
  return (
257
357
  <div className="settings-panel card mt-1">
258
- <div className="card-header px-2 py-1">
259
- {selected && selected.displayName ? (
260
- <Fragment>
261
- <b>{selected.displayName}</b> settings
262
- </Fragment>
263
- ) : (
264
- "Settings"
358
+ <div className="card-header px-2 py-1 d-flex justify-content-between align-items-center">
359
+ <div>
360
+ {selected && selected.displayName ? (
361
+ <Fragment>
362
+ <b>{selected.displayName}</b> settings
363
+ </Fragment>
364
+ ) : (
365
+ t("Settings")
366
+ )}
367
+ </div>
368
+ {setIsEnlarged && (
369
+ <FontAwesomeIcon
370
+ icon={isEnlarged ? faCaretSquareRight : faCaretSquareLeft}
371
+ className="fa-lg builder-expand-toggle-right"
372
+ onClick={() => setIsEnlarged(!isEnlarged)}
373
+ title={isEnlarged ? t("Shrink") : t("Enlarge")}
374
+ />
265
375
  )}
266
376
  </div>
267
377
  <div className="card-body p-2">
268
- {selected ? (
378
+ {selectedCount > 1 ? (
379
+ <div>
380
+ <p><strong>{selectedCount} {t("elements selected")}</strong></p>
381
+ <p className="text-muted small">{t("Multi-selection active. Use Shift+Click to add/remove elements.")}</p>
382
+ </div>
383
+ ) : selected ? (
269
384
  <Fragment>
270
385
  {selected.isDeletable && (
271
386
  <button
@@ -273,7 +388,7 @@ const SettingsPanel = () => {
273
388
  onClick={deleteThis}
274
389
  >
275
390
  <FontAwesomeIcon icon={faTrashAlt} className="me-1" />
276
- Delete
391
+ {t("Delete")}
277
392
  </button>
278
393
  )}
279
394
  {hasChildren && !selected.isDeletable ? (
@@ -282,23 +397,23 @@ const SettingsPanel = () => {
282
397
  onClick={deleteChildren}
283
398
  >
284
399
  <FontAwesomeIcon icon={faTrashAlt} className="me-1" />
285
- Delete contents
400
+ {t("Delete contents")}
286
401
  </button>
287
402
  ) : (
288
403
  <button
289
- title="Duplicate element with its children"
404
+ title={t("Duplicate element with its children")}
290
405
  className="btn btn-sm btn-secondary ms-2 duplicate-element-builder"
291
406
  onClick={duplicate}
292
407
  >
293
408
  <FontAwesomeIcon icon={faCopy} className="me-1" />
294
- Clone
409
+ {t("Clone")}
295
410
  </button>
296
411
  )}
297
412
  <hr className="my-2" />
298
413
  {selected.settings && React.createElement(selected.settings)}
299
414
  </Fragment>
300
415
  ) : (
301
- "No element selected"
416
+ t("No element selected")
302
417
  )}
303
418
  </div>
304
419
  </div>
@@ -331,7 +446,120 @@ function useWindowDimensions() {
331
446
  return windowDimensions;
332
447
  }
333
448
 
449
+ /**
450
+ * Custom Layer Component for Craft.js Layers panel
451
+ * Must be defined outside Builder component and memoized to prevent infinite re-renders
452
+ * Added defensive checks for layer properties
453
+ * @category saltcorn-builder
454
+ * @subcategory components
455
+ * @namespace
456
+ */
457
+
458
+ const hiddenColumnParents = new Set(["Card", "Container", "Tabs", "Table", "DropMenu", "ListColumn"]);
459
+
460
+ const CustomLayerComponent = memo(({ children }) => {
461
+ const {
462
+ id,
463
+ depth,
464
+ expanded,
465
+ hovered,
466
+ actions: { toggleLayer, setExpandedState },
467
+ connectors: { layer, drag, layerHeader },
468
+ } = useLayer((layer) => {
469
+ return {
470
+ hovered: layer?.event?.hovered,
471
+ expanded: layer?.expanded,
472
+ };
473
+ });
474
+
475
+ const { displayName, hasNodes, isHiddenColumn, selected, connectors: editorConnectors } = useEditor((state) => {
476
+ const node = state.nodes[id];
477
+ const data = node?.data;
478
+
479
+ let name = data?.displayName || data?.name || id;
480
+ if (name === "ROOT" || name === "Canvas") {
481
+ name = data?.name || name;
482
+ }
483
+
484
+ const nodes = data?.nodes;
485
+ const linkedNodes = data?.linkedNodes;
486
+ const hasChildren = (nodes && nodes.length > 0) || (linkedNodes && Object.keys(linkedNodes).length > 0);
487
+
488
+ // Check if this Column is a linked node of a Card/Container/Tabs/Table
489
+ let shouldHide = false;
490
+ if (name === "Column" && data?.parent) {
491
+ const parentNode = state.nodes[data.parent];
492
+ const parentName = parentNode?.data?.displayName || parentNode?.data?.name;
493
+ if (hiddenColumnParents.has(parentName)) {
494
+ const parentLinked = parentNode?.data?.linkedNodes;
495
+ if (parentLinked && Object.values(parentLinked).includes(id)) {
496
+ shouldHide = true;
497
+ }
498
+ }
499
+ }
500
+
501
+ const isSelected = state.events?.selected?.has?.(id) || (state.events?.selected === id);
502
+
503
+ return {
504
+ displayName: name,
505
+ hasNodes: hasChildren,
506
+ isHiddenColumn: shouldHide,
507
+ selected: isSelected
508
+ };
509
+ });
510
+
511
+ const isRoot = id === "ROOT";
512
+
513
+ // Auto-expand hidden linked-node Columns so their children are always
514
+ // visible through the transparent wrapper. Uses setExpandedState(true)
515
+ // instead of toggleLayer() — it's idempotent (no-op when already true),
516
+ // so it won't conflict with craft.js internals or cause toggle loops.
517
+ useEffect(() => {
518
+ if ((isHiddenColumn || isRoot) && !expanded) {
519
+ setExpandedState(true);
520
+ }
521
+ }, [isHiddenColumn, isRoot, expanded, setExpandedState]);
522
+
523
+ if (isHiddenColumn || isRoot) {
524
+ return (
525
+ <div
526
+ ref={(dom) => { layer(dom); if (dom) editorConnectors.drop(dom, id); }}
527
+ style={{ marginLeft: "-14px" }}
528
+ >
529
+ {children}
530
+ </div>
531
+ );
532
+ }
533
+
534
+ return (
535
+ <div ref={(dom) => { layer(dom); if (dom) editorConnectors.drop(dom, id); }}>
536
+ <div
537
+ ref={(dom) => { drag(dom); layerHeader(dom); }}
538
+ className={`builder-layer-node ${hovered ? "hovered" : ""} ${selected ? "selected" : ""}`}
539
+ style={{
540
+ paddingLeft: `${depth * 14 + 10}px`,
541
+ }}
542
+ >
543
+ <span className="layer-name" style={{ flexGrow: 1 }}>{displayName}</span>
544
+
545
+ {hasNodes && (
546
+ <span
547
+ onClick={(e) => {
548
+ e.stopPropagation();
549
+ toggleLayer();
550
+ }}
551
+ >
552
+ <FontAwesomeIcon icon={expanded ? faChevronUp : faChevronDown} fontSize={14} className="float-end fa-lg" />
553
+ </span>
554
+ )}
555
+ </div>
556
+ {children}
557
+ </div>
558
+ );
559
+ });
560
+
334
561
  const AddColumnButton = () => {
562
+ const { t } = useTranslation();
335
563
  const { query, actions } = useEditor(() => {});
336
564
  const options = useContext(optionsCtx);
337
565
  const addColumn = () => {
@@ -346,11 +574,43 @@ const AddColumnButton = () => {
346
574
  onClick={addColumn}
347
575
  >
348
576
  <FontAwesomeIcon icon={faPlus} className="me-2" />
349
- Add column
577
+ {t("Add column")}
350
578
  </button>
351
579
  );
352
580
  };
353
581
 
582
+ const DEVICE_WIDTHS = {
583
+ desktop: null,
584
+ tablet: 768,
585
+ mobile: 375,
586
+ };
587
+
588
+ const DevicePreviewToolbar = ({ previewDevice, setPreviewDevice }) => {
589
+ const { t } = useTranslation();
590
+ const devices = [
591
+ { key: "desktop", icon: Display, label: t("Desktop") },
592
+ { key: "tablet", icon: Tablet, label: t("Tablet") },
593
+ { key: "mobile", icon: Phone, label: t("Mobile") },
594
+ ];
595
+
596
+ return (
597
+ <div className="device-preview-toolbar">
598
+ {devices.map(({ key, icon: Icon, label }) => (
599
+ <button
600
+ key={key}
601
+ className={`btn btn-sm ${
602
+ previewDevice === key ? "btn-primary" : "btn-outline-secondary"
603
+ } device-preview-btn`}
604
+ onClick={() => setPreviewDevice(key)}
605
+ title={label}
606
+ >
607
+ <Icon size={16} />
608
+ </button>
609
+ ))}
610
+ </div>
611
+ );
612
+ };
613
+
354
614
  /**
355
615
  * @returns {Fragment}
356
616
  * @category saltcorn-builder
@@ -358,6 +618,7 @@ const AddColumnButton = () => {
358
618
  * @namespace
359
619
  */
360
620
  const HistoryPanel = () => {
621
+ const { t } = useTranslation();
361
622
  const { canUndo, canRedo, actions } = useEditor((state, query) => ({
362
623
  canUndo: query.history.canUndo(),
363
624
  canRedo: query.history.canRedo(),
@@ -368,7 +629,7 @@ const HistoryPanel = () => {
368
629
  {canUndo && (
369
630
  <button
370
631
  className="btn btn-sm btn-secondary ms-2 me-2 undo-builder"
371
- title="Undo"
632
+ title={t("Undo")}
372
633
  onClick={() => actions.history.undo()}
373
634
  >
374
635
  <FontAwesomeIcon icon={faUndo} />
@@ -377,7 +638,7 @@ const HistoryPanel = () => {
377
638
  {canRedo && (
378
639
  <button
379
640
  className="btn btn-sm btn-secondary redo-builder"
380
- title="Redo"
641
+ title={t("Redo")}
381
642
  onClick={() => actions.history.redo()}
382
643
  >
383
644
  <FontAwesomeIcon icon={faRedo} />
@@ -437,7 +698,10 @@ const NextButton = ({ layout }) => {
437
698
  * @subcategory components
438
699
  * @namespace
439
700
  */
701
+
702
+
440
703
  const Builder = ({ options, layout, mode }) => {
704
+ const { t } = useTranslation();
441
705
  const [showLayers, setShowLayers] = useState(true);
442
706
  const [previews, setPreviews] = useState({});
443
707
  const [uploadedFiles, setUploadedFiles] = useState([]);
@@ -446,6 +710,7 @@ const Builder = ({ options, layout, mode }) => {
446
710
  const [isEnlarged, setIsEnlarged] = useState(false);
447
711
  const [isLeftEnlarged, setIsLeftEnlarged] = useState(false);
448
712
  const [relationsCache, setRelationsCache] = useState({});
713
+ const [previewDevice, setPreviewDevice] = useState("desktop");
449
714
  const { windowWidth, windowHeight } = useWindowDimensions();
450
715
 
451
716
  const [builderHeight, setBuilderHeight] = useState(0);
@@ -453,21 +718,59 @@ const Builder = ({ options, layout, mode }) => {
453
718
 
454
719
  const ref = useRef(null);
455
720
 
456
- useEffect(() => {
457
- if (!ref.current) return;
458
- setBuilderHeight(ref.current.clientHeight);
459
- const rect = ref.current.getBoundingClientRect();
460
- setBuilderTop(rect.top);
461
- });
721
+ useEffect(() => {
722
+ if (!ref.current) return;
723
+ setBuilderHeight(ref.current.clientHeight);
724
+ const rect = ref.current.getBoundingClientRect();
725
+ setBuilderTop(rect.top);
726
+ });
462
727
 
463
728
  const canvasHeight =
464
729
  Math.max(windowHeight - builderTop, builderHeight, 600) - 10;
465
730
  return (
466
731
  <ErrorBoundary>
467
- <Editor onRender={RenderNode}>
732
+ <Editor
733
+ onRender={RenderNode}
734
+ indicator={{
735
+ success: "#28a745",
736
+ thickness: 2,
737
+ className: "builder-drop-indicator",
738
+ }}
739
+ handlers={(store) => new DefaultEventHandlers({
740
+ store,
741
+ isMultiSelectEnabled: (e) => e?.shiftKey || false
742
+ })}
743
+ resolver={{
744
+ Text,
745
+ Empty,
746
+ Columns,
747
+ JoinField,
748
+ Field,
749
+ ViewLink,
750
+ Action,
751
+ HTMLCode,
752
+ LineBreak,
753
+ Aggregation,
754
+ Card,
755
+ Image,
756
+ Link,
757
+ View,
758
+ SearchBar,
759
+ Container,
760
+ Column,
761
+ DropDownFilter,
762
+ DropMenu,
763
+ Tabs,
764
+ Table,
765
+ ToggleFilter,
766
+ ListColumn,
767
+ ListColumns,
768
+ LibraryElem,
769
+ }}
770
+ >
468
771
  <Provider value={options}>
469
772
  <PreviewCtx.Provider
470
- value={{ previews, setPreviews, uploadedFiles, setUploadedFiles }}
773
+ value={{ previews, setPreviews, uploadedFiles, setUploadedFiles, previewDevice }}
471
774
  >
472
775
  <RelationsCtx.Provider
473
776
  value={{
@@ -496,16 +799,16 @@ const Builder = ({ options, layout, mode }) => {
496
799
  savingState={savingState}
497
800
  />
498
801
  <Accordion>
499
- <div className="card mt-1" accordiontitle="Components">
802
+ <div className="card mt-1" accordiontitle={t("Components")}>
500
803
  {{
501
804
  show: <ToolboxShow expanded={isLeftEnlarged} />,
502
805
  list: <ToolboxList expanded={isLeftEnlarged} />,
503
806
  edit: <ToolboxEdit expanded={isLeftEnlarged} />,
504
807
  page: <ToolboxPage expanded={isLeftEnlarged} />,
505
808
  filter: <ToolboxFilter expanded={isLeftEnlarged} />,
506
- }[mode] || <div>Missing mode</div>}
809
+ }[mode] || <div>{t("Missing mode")}</div>}
507
810
  </div>
508
- <div accordiontitle="Library">
811
+ <div accordiontitle={t("Library")}>
509
812
  <Library expanded={isLeftEnlarged} />
510
813
  </div>
511
814
  </Accordion>
@@ -515,7 +818,7 @@ const Builder = ({ options, layout, mode }) => {
515
818
  style={isLeftEnlarged ? { width: "13.4rem" } : {}}
516
819
  >
517
820
  <div className="card-header p-2 d-flex justify-content-between">
518
- <div>Layers</div>
821
+ <div>{t("Layers")}</div>
519
822
  <FontAwesomeIcon
520
823
  icon={
521
824
  isLeftEnlarged
@@ -526,12 +829,15 @@ const Builder = ({ options, layout, mode }) => {
526
829
  "float-end fa-lg builder-expand-toggle-left"
527
830
  }
528
831
  onClick={() => setIsLeftEnlarged(!isLeftEnlarged)}
529
- title={isLeftEnlarged ? "Shrink" : "Enlarge"}
832
+ title={isLeftEnlarged ? t("Shrink") : t("Enlarge")}
530
833
  />
531
834
  </div>
532
835
  {showLayers && (
533
836
  <div className="card-body p-0 builder-layers">
534
- <Layers expandRootOnLoad={true} />
837
+ <Layers
838
+ expandRootOnLoad={true}
839
+ renderLayer={CustomLayerComponent}
840
+ />
535
841
  </div>
536
842
  )}
537
843
  </div>
@@ -543,75 +849,59 @@ const Builder = ({ options, layout, mode }) => {
543
849
  options.mode !== "list" ? "emptymsg" : ""
544
850
  }`}
545
851
  >
546
- <div>
547
- <Frame
548
- resolver={{
549
- Text,
550
- Empty,
551
- Columns,
552
- JoinField,
553
- Field,
554
- ViewLink,
555
- Action,
556
- HTMLCode,
557
- LineBreak,
558
- Aggregation,
559
- Card,
560
- Image,
561
- Link,
562
- View,
563
- SearchBar,
564
- Container,
565
- Column,
566
- DropDownFilter,
567
- DropMenu,
568
- Tabs,
569
- Table,
570
- ToggleFilter,
571
- ListColumn,
572
- ListColumns,
852
+ <div className="device-preview-scroll-area">
853
+ <div
854
+ className={`device-preview-canvas-wrapper ${
855
+ previewDevice !== "desktop" ? "device-preview-constrained" : ""
856
+ }`}
857
+ style={{
858
+ maxWidth: DEVICE_WIDTHS[previewDevice]
859
+ ? `${DEVICE_WIDTHS[previewDevice]}px`
860
+ : "none",
573
861
  }}
574
862
  >
575
- {options.mode === "list" ? (
576
- <Element canvas is={ListColumns}></Element>
577
- ) : (
578
- <Element canvas is={Column}></Element>
579
- )}
580
- </Frame>
581
- {options.mode === "list" ? <AddColumnButton /> : null}
863
+ <Frame>
864
+ {options.mode === "list" ? (
865
+ <Element canvas is={ListColumns}></Element>
866
+ ) : (
867
+ <Element canvas is={Column}></Element>
868
+ )}
869
+ </Frame>
870
+ {options.mode === "list" ? <AddColumnButton /> : null}
871
+ </div>
582
872
  </div>
583
873
  </div>
584
874
  <div className="col-sm-auto builder-sidebar">
585
875
  <div style={{ width: isEnlarged ? "28rem" : "16rem" }}>
586
- <NextButton layout={layout} />
587
- <HistoryPanel />
588
- <FontAwesomeIcon
589
- icon={faSave}
590
- className={savingState.isSaving ? "d-inline" : "d-none"}
591
- />
592
- <FontAwesomeIcon
593
- icon={faExclamationTriangle}
594
- color="#ff0033"
595
- className={savingState.error ? "d-inline" : "d-none"}
596
- />
597
- <FontAwesomeIcon
598
- icon={
599
- isEnlarged ? faCaretSquareRight : faCaretSquareLeft
600
- }
601
- className={
602
- "float-end me-2 mt-1 fa-lg builder-expand-toggle-right"
603
- }
604
- onClick={() => setIsEnlarged(!isEnlarged)}
605
- title={isEnlarged ? "Shrink" : "Enlarge"}
876
+ <DevicePreviewToolbar
877
+ previewDevice={previewDevice}
878
+ setPreviewDevice={setPreviewDevice}
606
879
  />
880
+ {document.getElementById("builder-header-actions") &&
881
+ createPortal(
882
+ <Fragment>
883
+ <FontAwesomeIcon
884
+ icon={faSave}
885
+ className={savingState.isSaving ? "d-inline" : "d-none"}
886
+ />
887
+ <FontAwesomeIcon
888
+ icon={faExclamationTriangle}
889
+ color="#ff0033"
890
+ className={savingState.error ? "d-inline" : "d-none"}
891
+ />
892
+ <HistoryPanel />
893
+ <NextButton layout={layout} />
894
+ </Fragment>,
895
+ document.getElementById("builder-header-actions")
896
+ )}
607
897
  <div
608
898
  className={` ${
609
899
  savingState.error ? "d-block" : "d-none"
610
900
  } my-2 fw-bold`}
611
901
  >
612
- your work is not being saved
902
+ {t("your work is not being saved")}
613
903
  </div>
614
- <SettingsPanel />
904
+ <SettingsPanel isEnlarged={isEnlarged} setIsEnlarged={setIsEnlarged} />
615
905
  </div>
616
906
  </div>
617
907
  </div>