@saltcorn/builder 1.7.0-alpha.1 → 1.7.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.
@@ -57,6 +57,7 @@ const RenderNode = ({ render }) => {
57
57
  }));
58
58
 
59
59
  const currentRef = useRef();
60
+ const borderRef = useRef();
60
61
 
61
62
  const getZoomFactor = useCallback(() => {
62
63
  const bw = document.body.offsetWidth;
@@ -65,11 +66,30 @@ const RenderNode = ({ render }) => {
65
66
  return factor || 1;
66
67
  }, []);
67
68
 
69
+ // a display:contents element (e.g. the LibraryInstance boundary) has no
70
+ // box of its own, so getBoundingClientRect() on it is all zeros - fall
71
+ // back to the union of its children's boxes so selection still has
72
+ // something real to draw a border around
73
+ const getRect = (dom) => {
74
+ if (!dom) return { top: 0, left: 0, bottom: 0, right: 0, height: 0, width: 0 };
75
+ const rect = dom.getBoundingClientRect();
76
+ if (rect.width || rect.height) return rect;
77
+ const childRects = Array.from(dom.children || []).map(getRect);
78
+ if (!childRects.length) return rect;
79
+ const top = Math.min(...childRects.map((r) => r.top));
80
+ const left = Math.min(...childRects.map((r) => r.left));
81
+ const bottom = Math.max(...childRects.map((r) => r.bottom));
82
+ const right = Math.max(...childRects.map((r) => r.right));
83
+ return { top, left, bottom, right, width: right - left, height: bottom - top };
84
+ };
85
+
68
86
  const getPos = useCallback((dom) => {
69
87
  const zoom = getZoomFactor();
70
- const { top, left, bottom, height, width, right } = dom
71
- ? dom.getBoundingClientRect()
72
- : { top: 0, left: 0, bottom: 0, right: 0, height: 0, width: 0 };
88
+ const ownRect = dom?.getBoundingClientRect();
89
+ const hasOwnBox = !!(ownRect && (ownRect.width || ownRect.height));
90
+ const { top, left, bottom, height, width, right } = hasOwnBox
91
+ ? ownRect
92
+ : getRect(dom);
73
93
  const topAdj = (top > 0 ? top : bottom) / zoom;
74
94
  const leftAdj = left / zoom;
75
95
  const rightPos = window.innerWidth / zoom - right / zoom;
@@ -83,21 +103,23 @@ const RenderNode = ({ render }) => {
83
103
  height: height / zoom,
84
104
  width: width / zoom,
85
105
  bottom: bottom / zoom,
106
+ hasOwnBox,
86
107
  };
87
108
  }, [getZoomFactor]);
88
109
 
89
110
  const scroll = useCallback(() => {
90
- const { current: currentDOM } = currentRef;
91
- if (!currentDOM) return;
92
111
  const pos = getPos(dom);
93
- currentDOM.style.top = pos.top;
94
- if (options.isRTL) {
95
- currentDOM.style.right = pos.right;
96
- currentDOM.style.left = 'auto';
97
- } else {
98
- currentDOM.style.left = pos.left;
99
- currentDOM.style.right = 'auto';
100
- }
112
+ [currentRef.current, borderRef.current].forEach((el) => {
113
+ if (!el) return;
114
+ el.style.top = pos.top;
115
+ if (options.isRTL) {
116
+ el.style.right = pos.right;
117
+ el.style.left = "auto";
118
+ } else {
119
+ el.style.left = pos.left;
120
+ el.style.right = "auto";
121
+ }
122
+ });
101
123
  }, [dom, getPos, options.isRTL]);
102
124
 
103
125
  const hiddenColumnParents = new Set(["Card", "Container", "Table", "DropMenu"]);
@@ -212,6 +234,24 @@ const RenderNode = ({ render }) => {
212
234
  document.querySelector("#builder-main-canvas")
213
235
  )
214
236
  : null}
237
+ {isActive && id !== "ROOT" && !getPos(dom).hasOwnBox
238
+ ? ReactDOM.createPortal(
239
+ <div
240
+ ref={borderRef}
241
+ className="selected-indicator-border"
242
+ style={{
243
+ ...(options.isRTL
244
+ ? { right: getPos(dom).right, left: "auto" }
245
+ : { left: getPos(dom).left, right: "auto" }),
246
+ top: getPos(dom).top,
247
+ width: getPos(dom).width,
248
+ height: getPos(dom).height,
249
+ zIndex: 1028,
250
+ }}
251
+ />,
252
+ document.querySelector("#builder-main-canvas")
253
+ )
254
+ : null}
215
255
  {render}
216
256
  </>
217
257
  );
@@ -27,6 +27,8 @@ import { Container } from "./elements/Container";
27
27
  import { Image } from "./elements/Image";
28
28
  import { View } from "./elements/View";
29
29
  import { SearchBar } from "./elements/SearchBar";
30
+ import { MessageList } from "./elements/MessageList";
31
+ import { MessageForm } from "./elements/MessageForm";
30
32
  import { Link } from "./elements/Link";
31
33
  import { Page } from "./elements/Page";
32
34
  import { Prompt } from "./elements/Prompt";
@@ -866,6 +868,48 @@ const ToolboxEdit = ({ expanded }) => {
866
868
  );
867
869
  };
868
870
 
871
+ /**
872
+ * @param {object} props
873
+ * @param {object} props.connectors
874
+ * @returns {WrapElem}
875
+ * @category saltcorn-builder
876
+ * @subcategory components / Toolbox
877
+ * @namespace
878
+ */
879
+ const MessageListElem = ({ connectors }) => {
880
+ const { t } = useTranslation();
881
+ return (
882
+ <WrapElem
883
+ connectors={connectors}
884
+ icon="fas fa-comments"
885
+ title={t("The list of messages in the room")}
886
+ label="Messages"
887
+ >
888
+ <Element canvas is={MessageList}></Element>
889
+ </WrapElem>
890
+ );
891
+ };
892
+ /**
893
+ * @param {object} props
894
+ * @param {object} props.connectors
895
+ * @returns {WrapElem}
896
+ * @category saltcorn-builder
897
+ * @subcategory components / Toolbox
898
+ * @namespace
899
+ */
900
+ const MessageFormElem = ({ connectors }) => {
901
+ const { t } = useTranslation();
902
+ return (
903
+ <WrapElem
904
+ connectors={connectors}
905
+ icon="fas fa-paper-plane"
906
+ title={t("The form for sending a new message")}
907
+ label="Send"
908
+ >
909
+ <Element canvas is={MessageForm}></Element>
910
+ </WrapElem>
911
+ );
912
+ };
869
913
  export /**
870
914
  * @returns {Fragment}
871
915
  * @category saltcorn-builder
@@ -901,3 +945,33 @@ const ToolboxPage = ({ expanded }) => {
901
945
  expanded
902
946
  );
903
947
  };
948
+
949
+ export /**
950
+ * @returns {Fragment}
951
+ * @category saltcorn-builder
952
+ * @subcategory components / Toolbox
953
+ * @namespace
954
+ */
955
+ const ToolboxRoom = ({ expanded }) => {
956
+ const { connectors } = useEditor();
957
+ const options = useContext(optionsCtx);
958
+ const { views, images, pages } = options;
959
+ return chunkToolBox(
960
+ [
961
+ <MessageListElem connectors={connectors} />,
962
+ <MessageFormElem connectors={connectors} />,
963
+ <TextElem connectors={connectors} />,
964
+ <ColumnsElem connectors={connectors} />,
965
+ <LineBreakElem connectors={connectors} />,
966
+ <ContainerElem connectors={connectors} />,
967
+ <CardElem connectors={connectors} />,
968
+ <TabsElem connectors={connectors} />,
969
+ <LinkElem connectors={connectors} />,
970
+ <ViewElem connectors={connectors} views={views} />,
971
+ <ImageElem connectors={connectors} images={images} />,
972
+ <HTMLElem connectors={connectors} />,
973
+ <PageElem connectors={connectors} pages={pages} />,
974
+ ],
975
+ expanded
976
+ );
977
+ };
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @category saltcorn-builder
3
+ * @module components/elements/LibraryInstance
4
+ * @subcategory components / elements
5
+ */
6
+
7
+ import React from "react";
8
+ import { useNode } from "@craftjs/core";
9
+ import useTranslation from "../../hooks/useTranslation";
10
+
11
+ // A placed copy of a shared Library component - edits here save back to the
12
+ // shared component, so every other page using it updates too.
13
+ export /**
14
+ * @param {object} props
15
+ * @param {*} props.children
16
+ * @returns {div}
17
+ * @category saltcorn-builder
18
+ * @subcategory components
19
+ * @namespace
20
+ */
21
+ const LibraryInstance = ({ children }) => {
22
+ const {
23
+ selected,
24
+ connectors: { connect, drag },
25
+ } = useNode((node) => ({ selected: node.events.selected }));
26
+
27
+ // a real box (not display:contents) so drops can actually be targeted
28
+ // inside it - display:contents has no geometry, so the builder's own
29
+ // drop-position hit-testing skips straight past it to its parent
30
+ return (
31
+ <div
32
+ ref={(dom) => connect(drag(dom))}
33
+ className={`library-instance ${selected ? "selected-node" : ""}`}
34
+ >
35
+ {children}
36
+ </div>
37
+ );
38
+ };
39
+
40
+ export /**
41
+ * @returns {div}
42
+ * @category saltcorn-builder
43
+ * @subcategory components
44
+ * @namespace
45
+ */
46
+ const LibraryInstanceSettings = () => {
47
+ const { library_name } = useNode((node) => ({
48
+ library_name: node.data.props.library_name,
49
+ }));
50
+ const { t } = useTranslation();
51
+ return (
52
+ <div>
53
+ <p>
54
+ {t("Shared component")}: <strong>{library_name}</strong>
55
+ </p>
56
+ <small className="text-muted d-block">
57
+ {t(
58
+ "Editing its contents changes every page and view that uses it."
59
+ )}
60
+ </small>
61
+ </div>
62
+ );
63
+ };
64
+
65
+ /**
66
+ * @type {object}
67
+ */
68
+ LibraryInstance.craft = {
69
+ displayName: "LibraryInstance",
70
+ // declared here rather than relying on every caller to wrap it in
71
+ // <Element canvas> - some (eg Library.js's addSelected) create it bare
72
+ isCanvas: true,
73
+ props: {
74
+ library_id: undefined,
75
+ library_name: "",
76
+ },
77
+ rules: {
78
+ canDrag: () => true,
79
+ },
80
+ related: {
81
+ settings: LibraryInstanceSettings,
82
+ },
83
+ };
@@ -0,0 +1,214 @@
1
+ /**
2
+ * @category saltcorn-builder
3
+ * @module components/elements/LibrarySlotInstance
4
+ * @subcategory components / elements
5
+ */
6
+
7
+ import React, { useContext } from "react";
8
+ import { useNode } from "@craftjs/core";
9
+ import optionsCtx from "../context";
10
+ import useTranslation from "../../hooks/useTranslation";
11
+
12
+ export /**
13
+ * A placeholder inside a shared Library component - each placement fills it
14
+ * independently (picks a field to render, or drops its own content in),
15
+ * without touching the shared component itself.
16
+ * @param {object} props
17
+ * @param {string} props.kind
18
+ * @param {string} props.slot_name
19
+ * @param {string} props.field
20
+ * @param {string} props.fieldview
21
+ * @param {*} props.children
22
+ * @returns {div}
23
+ * @category saltcorn-builder
24
+ * @subcategory components
25
+ * @namespace
26
+ */
27
+ const LibrarySlotInstance = ({ kind, slot_name, field, fieldview, children }) => {
28
+ const {
29
+ selected,
30
+ connectors: { connect, drag },
31
+ } = useNode((node) => ({ selected: node.events.selected }));
32
+ const { t } = useTranslation();
33
+ const isEmpty = Array.isArray(children) ? children.length === 0 : !children;
34
+
35
+ return (
36
+ <div
37
+ ref={(dom) => connect(drag(dom))}
38
+ className={`library-slot-instance ${
39
+ kind === "field" ? "d-inline-block" : ""
40
+ } ${selected ? "selected-node" : ""}`}
41
+ >
42
+ {kind === "field" ? (
43
+ field ? (
44
+ `[slot: ${fieldview || ""} ${field}]`
45
+ ) : (
46
+ `[slot: ${slot_name || "unfilled"}]`
47
+ )
48
+ ) : isEmpty ? (
49
+ // an empty container slot has no size of its own to drop onto - a
50
+ // placeholder gives it one, for both real users and drag targeting
51
+ <div
52
+ className="text-muted border border-dashed p-2"
53
+ style={{ minHeight: "2em" }}
54
+ >
55
+ {t("Drop content here")}
56
+ </div>
57
+ ) : (
58
+ children
59
+ )}
60
+ </div>
61
+ );
62
+ };
63
+
64
+ export /**
65
+ * @returns {div}
66
+ * @category saltcorn-builder
67
+ * @subcategory components
68
+ * @namespace
69
+ */
70
+ const LibrarySlotInstanceSettings = () => {
71
+ const {
72
+ actions: { setProp },
73
+ slot_name,
74
+ kind,
75
+ field,
76
+ fieldview,
77
+ } = useNode((node) => ({
78
+ slot_name: node.data.props.slot_name,
79
+ kind: node.data.props.kind,
80
+ field: node.data.props.field,
81
+ fieldview: node.data.props.fieldview,
82
+ }));
83
+ const { t } = useTranslation();
84
+ const options = useContext(optionsCtx);
85
+ const canPickField = !!options.fields;
86
+ const fvs = (options.field_view_options || {})[field];
87
+
88
+ return (
89
+ <div>
90
+ <label>{t("Slot name")}</label>
91
+ <input
92
+ type="text"
93
+ className="slot-name form-control"
94
+ value={slot_name || ""}
95
+ onChange={(e) => {
96
+ if (!e.target) return;
97
+ const value = e.target.value;
98
+ setProp((prop) => (prop.slot_name = value));
99
+ }}
100
+ />
101
+ <label className="mt-2">{t("Kind")}</label>
102
+ <select
103
+ className="slot-kind form-control form-select"
104
+ value={kind}
105
+ onChange={(e) => {
106
+ if (!e.target) return;
107
+ const value = e.target.value;
108
+ setProp((prop) => (prop.kind = value));
109
+ }}
110
+ >
111
+ <option value="field" disabled={!canPickField}>
112
+ {t("Field")}
113
+ </option>
114
+ <option value="container">{t("Container")}</option>
115
+ </select>
116
+ {!canPickField && kind === "field" && (
117
+ <small className="text-muted d-block mt-1">
118
+ {t(
119
+ "Field slots aren't available here - this builder isn't bound to one table."
120
+ )}
121
+ </small>
122
+ )}
123
+ {kind === "field" && canPickField ? (
124
+ <table className="w-100 mt-2">
125
+ <tbody>
126
+ <tr>
127
+ <td>
128
+ <label>{t("Field")}</label>
129
+ </td>
130
+ <td>
131
+ <select
132
+ className="field form-control form-select"
133
+ value={field || ""}
134
+ onChange={(e) => {
135
+ if (!e.target) return;
136
+ const value = e.target.value;
137
+ setProp((prop) => (prop.field = value));
138
+ const newfvs = options.field_view_options[value];
139
+ if (newfvs && newfvs.length > 0)
140
+ setProp((prop) => (prop.fieldview = newfvs[0]));
141
+ }}
142
+ >
143
+ <option value="">{t("Choose a field")}</option>
144
+ {options.fields.map((f, ix) => (
145
+ <option key={ix} value={f.name}>
146
+ {f.label}
147
+ </option>
148
+ ))}
149
+ </select>
150
+ </td>
151
+ </tr>
152
+ {fvs && (
153
+ <tr>
154
+ <td>
155
+ <label>{t("Field view")}</label>
156
+ </td>
157
+ <td>
158
+ <select
159
+ className="fieldview form-control form-select"
160
+ value={fieldview || ""}
161
+ onChange={(e) => {
162
+ if (!e.target) return;
163
+ const value = e.target.value;
164
+ setProp((prop) => (prop.fieldview = value));
165
+ }}
166
+ >
167
+ {(fvs || []).map((fvnm, ix) => (
168
+ <option key={ix} value={fvnm}>
169
+ {fvnm}
170
+ </option>
171
+ ))}
172
+ </select>
173
+ </td>
174
+ </tr>
175
+ )}
176
+ </tbody>
177
+ </table>
178
+ ) : null}
179
+ {kind === "container" && (
180
+ <small className="text-muted d-block mt-2">
181
+ {t(
182
+ "Drop elements here - each placement of this component fills this area independently."
183
+ )}
184
+ </small>
185
+ )}
186
+ </div>
187
+ );
188
+ };
189
+
190
+ /**
191
+ * @type {object}
192
+ */
193
+ LibrarySlotInstance.craft = {
194
+ displayName: "LibrarySlotInstance",
195
+ // declared here rather than relying on every caller to wrap it in
196
+ // <Element canvas> - Library.js's onNodesChange creates it bare
197
+ isCanvas: true,
198
+ props: {
199
+ slot_name: "",
200
+ kind: "field",
201
+ field: undefined,
202
+ fieldview: undefined,
203
+ },
204
+ rules: {
205
+ canDrag: () => true,
206
+ // a field slot has no children - it's filled via the settings panel,
207
+ // not by dropping elements into it
208
+ canMoveIn: (incoming, currentNode) =>
209
+ currentNode?.data?.props?.kind !== "field",
210
+ },
211
+ related: {
212
+ settings: LibrarySlotInstanceSettings,
213
+ },
214
+ };
@@ -0,0 +1,101 @@
1
+ /**
2
+ * @category saltcorn-builder
3
+ * @module components/elements/MessageForm
4
+ * @subcategory components / elements
5
+ */
6
+
7
+ import React from "react";
8
+ import useTranslation from "../../hooks/useTranslation";
9
+ import { useNode } from "@craftjs/core";
10
+ import { setAPropGen } from "./utils";
11
+
12
+ export /**
13
+ * The form for sending a new message. Its fields come from the room's message
14
+ * form view, so this stands in for it on the canvas.
15
+ *
16
+ * @param {object} props
17
+ * @param {boolean} props.pinned
18
+ * @returns {div}
19
+ * @namespace
20
+ * @category saltcorn-builder
21
+ * @subcategory components
22
+ */
23
+ const MessageForm = ({ pinned }) => {
24
+ const { t } = useTranslation();
25
+ const {
26
+ selected,
27
+ connectors: { connect, drag },
28
+ } = useNode((node) => ({ selected: node.events.selected }));
29
+
30
+ return (
31
+ <div
32
+ className={`sc-room-msgform-preview ${selected ? "selected-node" : ""}`}
33
+ ref={(dom) => connect(drag(dom))}
34
+ >
35
+ <div className="input-group">
36
+ <input
37
+ type="text"
38
+ className="form-control bg-light"
39
+ placeholder={t("New message...")}
40
+ readOnly={true}
41
+ />
42
+ <button className="btn btn-primary" disabled>
43
+ {t("Send")}
44
+ </button>
45
+ </div>
46
+ {pinned && (
47
+ <div className="text-muted small">{t("Pinned below the messages")}</div>
48
+ )}
49
+ </div>
50
+ );
51
+ };
52
+
53
+ export /**
54
+ * @returns {div}
55
+ * @namespace
56
+ * @category saltcorn-builder
57
+ * @subcategory components
58
+ */
59
+ const MessageFormSettings = () => {
60
+ const { t } = useTranslation();
61
+ const {
62
+ actions: { setProp },
63
+ pinned,
64
+ } = useNode((node) => ({ pinned: node.data.props.pinned }));
65
+ const setAProp = setAPropGen(setProp);
66
+
67
+ return (
68
+ <div>
69
+ <div className="form-check">
70
+ <input
71
+ className="form-check-input"
72
+ name="pinned"
73
+ type="checkbox"
74
+ checked={!!pinned}
75
+ onChange={setAProp("pinned", { checked: true })}
76
+ />
77
+ <label className="form-check-label">{t("Pin to bottom")}</label>
78
+ </div>
79
+ <small className="text-muted">
80
+ {t(
81
+ "Fix the form below the message list, which then scrolls within the room. The room takes the message list height, or fills the window down to the bottom if that is blank"
82
+ )}
83
+ </small>
84
+ </div>
85
+ );
86
+ };
87
+
88
+ /**
89
+ * @type {object}
90
+ */
91
+ MessageForm.craft = {
92
+ displayName: "MessageForm",
93
+ props: {
94
+ pinned: false,
95
+ },
96
+ related: {
97
+ settings: MessageFormSettings,
98
+ segment_type: "message_form",
99
+ fields: [{ name: "pinned" }],
100
+ },
101
+ };