vue-wswg-editor 0.0.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.
- package/README.md +91 -0
- package/dist/style.css +1 -0
- package/dist/types/components/AddBlockItem/AddBlockItem.vue.d.ts +6 -0
- package/dist/types/components/BlockBrowser/BlockBrowser.vue.d.ts +2 -0
- package/dist/types/components/BlockComponent/BlockComponent.vue.d.ts +15 -0
- package/dist/types/components/BlockEditorFieldNode/BlockEditorFieldNode.vue.d.ts +15 -0
- package/dist/types/components/BlockEditorFields/BlockEditorFields.vue.d.ts +15 -0
- package/dist/types/components/BlockMarginFieldNode/BlockMarginNode.vue.d.ts +23 -0
- package/dist/types/components/BlockRepeaterFieldNode/BlockRepeaterNode.vue.d.ts +15 -0
- package/dist/types/components/BrowserNavigation/BrowserNavigation.vue.d.ts +5 -0
- package/dist/types/components/EmptyState/EmptyState.vue.d.ts +15 -0
- package/dist/types/components/PageBlockList/PageBlockList.vue.d.ts +19 -0
- package/dist/types/components/PageBuilderSidebar/PageBuilderSidebar.vue.d.ts +30 -0
- package/dist/types/components/PageBuilderToolbar/PageBuilderToolbar.vue.d.ts +28 -0
- package/dist/types/components/PageRenderer/PageRenderer.vue.d.ts +6 -0
- package/dist/types/components/PageRenderer/blockModules.d.ts +1 -0
- package/dist/types/components/PageSettings/PageSettings.vue.d.ts +15 -0
- package/dist/types/components/ResizeHandle/ResizeHandle.vue.d.ts +6 -0
- package/dist/types/components/WswgJsonEditor/WswgJsonEditor.test.d.ts +1 -0
- package/dist/types/components/WswgJsonEditor/WswgJsonEditor.vue.d.ts +40 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/dist/types/util/fieldConfig.d.ts +82 -0
- package/dist/types/util/helpers.d.ts +28 -0
- package/dist/types/util/registry.d.ts +21 -0
- package/dist/types/util/validation.d.ts +15 -0
- package/dist/vue-wswg-editor.es.js +3377 -0
- package/package.json +85 -0
- package/src/assets/images/empty-state.jpg +0 -0
- package/src/assets/styles/_mixins.scss +73 -0
- package/src/assets/styles/main.css +3 -0
- package/src/components/AddBlockItem/AddBlockItem.vue +50 -0
- package/src/components/BlockBrowser/BlockBrowser.vue +69 -0
- package/src/components/BlockComponent/BlockComponent.vue +186 -0
- package/src/components/BlockEditorFieldNode/BlockEditorFieldNode.vue +378 -0
- package/src/components/BlockEditorFields/BlockEditorFields.vue +91 -0
- package/src/components/BlockMarginFieldNode/BlockMarginNode.vue +132 -0
- package/src/components/BlockRepeaterFieldNode/BlockRepeaterNode.vue +217 -0
- package/src/components/BrowserNavigation/BrowserNavigation.vue +27 -0
- package/src/components/EmptyState/EmptyState.vue +94 -0
- package/src/components/PageBlockList/PageBlockList.vue +103 -0
- package/src/components/PageBuilderSidebar/PageBuilderSidebar.vue +241 -0
- package/src/components/PageBuilderToolbar/PageBuilderToolbar.vue +63 -0
- package/src/components/PageRenderer/PageRenderer.vue +65 -0
- package/src/components/PageRenderer/blockModules-alternative.ts.example +9 -0
- package/src/components/PageRenderer/blockModules-manual.ts.example +19 -0
- package/src/components/PageRenderer/blockModules-runtime.ts.example +23 -0
- package/src/components/PageRenderer/blockModules.ts +3 -0
- package/src/components/PageSettings/PageSettings.vue +86 -0
- package/src/components/ResizeHandle/ResizeHandle.vue +105 -0
- package/src/components/WswgJsonEditor/WswgJsonEditor.test.ts +43 -0
- package/src/components/WswgJsonEditor/WswgJsonEditor.vue +391 -0
- package/src/index.ts +15 -0
- package/src/shims.d.ts +72 -0
- package/src/style.css +3 -0
- package/src/types/Block.d.ts +19 -0
- package/src/types/Layout.d.ts +9 -0
- package/src/util/fieldConfig.ts +173 -0
- package/src/util/helpers.ts +176 -0
- package/src/util/registry.ts +149 -0
- package/src/util/validation.ts +110 -0
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<!-- Field -->
|
|
3
|
+
<div class="editor-field-node" :class="{ 'border-red-500': validationErrorMessage }">
|
|
4
|
+
<div class="mb-1 flex items-center gap-1.5">
|
|
5
|
+
<!-- Label -->
|
|
6
|
+
<label class="mr-auto text-sm font-medium first-letter:uppercase">
|
|
7
|
+
{{ fieldConfig.label || fieldName }}
|
|
8
|
+
<span v-if="fieldConfig.required" class="prop-required ml-1 text-red-600">*</span>
|
|
9
|
+
</label>
|
|
10
|
+
<!-- Clearable -->
|
|
11
|
+
<div
|
|
12
|
+
v-if="canClearFieldValue"
|
|
13
|
+
class="cursor-pointer rounded-full bg-zinc-100 px-2 py-0.5 text-xs text-zinc-600 hover:bg-zinc-200 hover:text-zinc-600"
|
|
14
|
+
@click="clearFieldValue"
|
|
15
|
+
>
|
|
16
|
+
<span>Clear</span>
|
|
17
|
+
</div>
|
|
18
|
+
<!-- Description -->
|
|
19
|
+
<div v-if="fieldConfig.description" :title="fieldConfig.description" class="cursor-default">
|
|
20
|
+
<InformationCircleIcon class="size-4 text-zinc-500" />
|
|
21
|
+
</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<!-- Custom field component-->
|
|
25
|
+
<template v-if="fieldConfig.component">
|
|
26
|
+
<component :is="fieldConfig.component" v-model="fieldValue" :editable="editable" v-bind="fieldConfig" />
|
|
27
|
+
</template>
|
|
28
|
+
|
|
29
|
+
<!-- Generic input (text, number, email, url) -->
|
|
30
|
+
<template v-else-if="['text', 'number', 'email', 'url'].includes(fieldConfig.type)">
|
|
31
|
+
<input v-model="textFieldValue" class="form-control mb-1" :disabled="!editable" v-bind="fieldConfig" />
|
|
32
|
+
</template>
|
|
33
|
+
|
|
34
|
+
<!-- Text area -->
|
|
35
|
+
<template v-else-if="fieldConfig.type === 'textarea'">
|
|
36
|
+
<textarea v-model="textFieldValue" class="form-control" :disabled="!editable" v-bind="fieldConfig"></textarea>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<!-- Select -->
|
|
40
|
+
<template v-else-if="fieldConfig.type === 'select'">
|
|
41
|
+
<select
|
|
42
|
+
v-model="textFieldValue"
|
|
43
|
+
class="form-control"
|
|
44
|
+
:placeholder="fieldConfig.placeholder"
|
|
45
|
+
:disabled="!editable"
|
|
46
|
+
>
|
|
47
|
+
<option v-for="option in fieldConfig.options" :key="option.value" :value="option.value">
|
|
48
|
+
{{ option.label }}
|
|
49
|
+
</option>
|
|
50
|
+
</select>
|
|
51
|
+
</template>
|
|
52
|
+
|
|
53
|
+
<!-- Color picker -->
|
|
54
|
+
<div v-else-if="fieldConfig.type === 'color'" class="flex gap-2">
|
|
55
|
+
<input v-model="fieldValue" type="color" class="form-control size-10 shrink-0" :disabled="!editable" />
|
|
56
|
+
<input v-model="fieldValue" type="text" class="form-control" :disabled="!editable" />
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<!-- Range input -->
|
|
60
|
+
<div v-else-if="fieldConfig.type === 'range'" class="flex items-center gap-2">
|
|
61
|
+
<span class="rounded-full bg-zinc-100 px-2 py-1 text-sm font-bold text-zinc-600">{{ fieldValue }}</span>
|
|
62
|
+
<input
|
|
63
|
+
v-model="fieldValue"
|
|
64
|
+
type="range"
|
|
65
|
+
class="form-control"
|
|
66
|
+
:min="fieldConfig.min || 0"
|
|
67
|
+
:max="fieldConfig.max || 100"
|
|
68
|
+
:step="fieldConfig.step || 1"
|
|
69
|
+
:disabled="!editable"
|
|
70
|
+
/>
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<!-- Checkbox -->
|
|
74
|
+
<div v-else-if="fieldConfig.type === 'checkbox'" class="form-control flex flex-col gap-2">
|
|
75
|
+
<label
|
|
76
|
+
v-for="option in fieldConfig.options"
|
|
77
|
+
:key="`${fieldName}_${option.value}`"
|
|
78
|
+
class="flex cursor-pointer items-center gap-2 rounded-md border p-2"
|
|
79
|
+
>
|
|
80
|
+
<input
|
|
81
|
+
:id="`${fieldName}_${option.value}`"
|
|
82
|
+
v-model="checkboxValues"
|
|
83
|
+
:value="option.value"
|
|
84
|
+
type="checkbox"
|
|
85
|
+
class="form-control"
|
|
86
|
+
:disabled="!editable"
|
|
87
|
+
/>
|
|
88
|
+
<span class="text-sm">{{ option.label }}</span>
|
|
89
|
+
</label>
|
|
90
|
+
</div>
|
|
91
|
+
|
|
92
|
+
<!-- Radio -->
|
|
93
|
+
<div v-else-if="fieldConfig.type === 'radio'" class="form-control flex flex-col gap-2">
|
|
94
|
+
<label
|
|
95
|
+
v-for="option in fieldConfig.options"
|
|
96
|
+
:key="`${fieldName}_${option.value}`"
|
|
97
|
+
class="flex cursor-pointer items-center gap-2 rounded-md border p-2"
|
|
98
|
+
>
|
|
99
|
+
<input
|
|
100
|
+
:id="`${fieldName}_${option.value}`"
|
|
101
|
+
v-model="fieldValue"
|
|
102
|
+
:value="option.value"
|
|
103
|
+
type="radio"
|
|
104
|
+
class="form-control"
|
|
105
|
+
:disabled="!editable"
|
|
106
|
+
/>
|
|
107
|
+
<span class="text-sm">{{ option.label }}</span>
|
|
108
|
+
</label>
|
|
109
|
+
</div>
|
|
110
|
+
|
|
111
|
+
<!-- Boolean toggle -->
|
|
112
|
+
<template v-else-if="fieldConfig.type === 'boolean'">
|
|
113
|
+
<label
|
|
114
|
+
tabindex="0"
|
|
115
|
+
class="group inline-flex h-7 w-14 cursor-pointer items-center gap-2 rounded-full p-1 transition-all duration-200"
|
|
116
|
+
:class="!!fieldValue ? 'bg-emerald-700 hover:bg-emerald-800' : 'bg-zinc-200 hover:bg-zinc-300'"
|
|
117
|
+
>
|
|
118
|
+
<input
|
|
119
|
+
:id="fieldName"
|
|
120
|
+
v-model="fieldValue"
|
|
121
|
+
tabindex="-1"
|
|
122
|
+
type="checkbox"
|
|
123
|
+
class="ml-0 size-5 cursor-pointer rounded-full !border-none !bg-white !outline-none !ring-0 !ring-offset-0 transition-all duration-200 checked:ml-7"
|
|
124
|
+
:disabled="!editable"
|
|
125
|
+
/>
|
|
126
|
+
<span class="hidden text-sm">{{ fieldConfig.label }}</span>
|
|
127
|
+
</label>
|
|
128
|
+
</template>
|
|
129
|
+
|
|
130
|
+
<!-- Repeater Input -->
|
|
131
|
+
<div v-else-if="fieldConfig.type === 'repeater'">
|
|
132
|
+
<BlockRepeaterNode
|
|
133
|
+
v-model="fieldValue"
|
|
134
|
+
:fieldConfig="fieldConfig"
|
|
135
|
+
:fieldName="fieldName"
|
|
136
|
+
:editable="editable"
|
|
137
|
+
/>
|
|
138
|
+
</div>
|
|
139
|
+
|
|
140
|
+
<!-- Margin -->
|
|
141
|
+
<div v-else-if="fieldConfig.type === 'margin'">
|
|
142
|
+
<BlockMarginNode v-model="fieldValue" :fieldConfig="fieldConfig" :fieldName="fieldName" :editable="editable" />
|
|
143
|
+
</div>
|
|
144
|
+
|
|
145
|
+
<!-- Default fallback -->
|
|
146
|
+
<template v-else>
|
|
147
|
+
<input v-model="textFieldValue" type="text" class="form-control" :disabled="!editable" />
|
|
148
|
+
</template>
|
|
149
|
+
|
|
150
|
+
<!-- Validation error message -->
|
|
151
|
+
<div v-if="validationErrorMessage" class="rounded-sm bg-red-50 p-2 px-3 text-xs text-red-600">
|
|
152
|
+
{{ validationErrorMessage }}
|
|
153
|
+
</div>
|
|
154
|
+
</div>
|
|
155
|
+
</template>
|
|
156
|
+
|
|
157
|
+
<script setup lang="ts">
|
|
158
|
+
import { ref, computed, watch } from "vue";
|
|
159
|
+
import type { EditorFieldConfig } from "../../util/fieldConfig";
|
|
160
|
+
import BlockRepeaterNode from "../BlockRepeaterFieldNode/BlockRepeaterNode.vue";
|
|
161
|
+
import BlockMarginNode from "../BlockMarginFieldNode/BlockMarginNode.vue";
|
|
162
|
+
import { InformationCircleIcon } from "@heroicons/vue/24/outline";
|
|
163
|
+
import * as yup from "yup";
|
|
164
|
+
|
|
165
|
+
const fieldValue = defineModel<any>();
|
|
166
|
+
|
|
167
|
+
const props = defineProps<{
|
|
168
|
+
fieldConfig: EditorFieldConfig;
|
|
169
|
+
fieldName: string;
|
|
170
|
+
editable: boolean;
|
|
171
|
+
}>();
|
|
172
|
+
|
|
173
|
+
const validationErrorMessage = ref<string | null>(null);
|
|
174
|
+
|
|
175
|
+
// Computed property for checkbox values (array of selected values)
|
|
176
|
+
const checkboxValues = computed({
|
|
177
|
+
get: () => {
|
|
178
|
+
// Ensure fieldValue is always an array for checkboxes
|
|
179
|
+
if (!Array.isArray(fieldValue.value)) {
|
|
180
|
+
// If it's a single value, convert to array
|
|
181
|
+
if (fieldValue.value !== null && fieldValue.value !== undefined) {
|
|
182
|
+
return [fieldValue.value];
|
|
183
|
+
}
|
|
184
|
+
// If it's null/undefined, return empty array
|
|
185
|
+
return [];
|
|
186
|
+
}
|
|
187
|
+
return fieldValue.value;
|
|
188
|
+
},
|
|
189
|
+
set: (newValues: any[]) => {
|
|
190
|
+
// Update fieldValue with the array of selected values
|
|
191
|
+
fieldValue.value = newValues.length > 0 ? newValues : [];
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// Computed property to handle object/array conversion for text inputs
|
|
196
|
+
const textFieldValue = computed({
|
|
197
|
+
get: () => {
|
|
198
|
+
if (typeof fieldValue.value === "string") {
|
|
199
|
+
return fieldValue.value;
|
|
200
|
+
}
|
|
201
|
+
if (typeof fieldValue.value === "number" || typeof fieldValue.value === "boolean") {
|
|
202
|
+
return String(fieldValue.value);
|
|
203
|
+
}
|
|
204
|
+
if (fieldValue.value === null || fieldValue.value === undefined) {
|
|
205
|
+
return "";
|
|
206
|
+
}
|
|
207
|
+
// For objects/arrays, convert to JSON string for editing
|
|
208
|
+
if (typeof fieldValue.value === "object") {
|
|
209
|
+
return JSON.stringify(fieldValue.value, null, 2);
|
|
210
|
+
}
|
|
211
|
+
return String(fieldValue.value);
|
|
212
|
+
},
|
|
213
|
+
set: (newValue: string) => {
|
|
214
|
+
// Try to parse as JSON if it looks like JSON, otherwise keep as string
|
|
215
|
+
if (
|
|
216
|
+
newValue &&
|
|
217
|
+
((typeof newValue === "string" && (newValue.trim().startsWith("{") || newValue.trim().startsWith("["))) ||
|
|
218
|
+
(typeof newValue === "object" && Object.keys(newValue).length > 0))
|
|
219
|
+
) {
|
|
220
|
+
try {
|
|
221
|
+
fieldValue.value = JSON.parse(newValue);
|
|
222
|
+
} catch {
|
|
223
|
+
fieldValue.value = newValue;
|
|
224
|
+
}
|
|
225
|
+
} else {
|
|
226
|
+
fieldValue.value = newValue;
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
const canClearFieldValue = computed(() => {
|
|
232
|
+
if (!props.fieldConfig.clearable) return false;
|
|
233
|
+
if (props.fieldConfig.type === "checkbox" || props.fieldConfig.type === "radio") {
|
|
234
|
+
return checkboxValues.value.length > 0;
|
|
235
|
+
}
|
|
236
|
+
return fieldValue.value !== undefined;
|
|
237
|
+
});
|
|
238
|
+
/**
|
|
239
|
+
* Validation
|
|
240
|
+
* Rules are defined in the fields.ts file
|
|
241
|
+
* -----------------------------------------
|
|
242
|
+
* Supported validation rules:
|
|
243
|
+
* maxLength // For text fields
|
|
244
|
+
* minLength // For text fields
|
|
245
|
+
* max // For number fields
|
|
246
|
+
* min // For number fields
|
|
247
|
+
* minItems // For repeater fields
|
|
248
|
+
* maxItems // For repeater fields
|
|
249
|
+
* required // For all fields
|
|
250
|
+
*/
|
|
251
|
+
|
|
252
|
+
async function validateField(): Promise<void> {
|
|
253
|
+
if (!props.fieldConfig.validator) return;
|
|
254
|
+
try {
|
|
255
|
+
const result = await props.fieldConfig.validator(fieldValue.value);
|
|
256
|
+
// True = valid, false = invalid, string = error message
|
|
257
|
+
if (result === true) {
|
|
258
|
+
validationErrorMessage.value = null;
|
|
259
|
+
} else {
|
|
260
|
+
// result is either false or a string error message
|
|
261
|
+
// TODO: i18n
|
|
262
|
+
validationErrorMessage.value = result === false ? "Field is invalid" : result;
|
|
263
|
+
}
|
|
264
|
+
} catch (error) {
|
|
265
|
+
const validationErrorsList = error as yup.ValidationError;
|
|
266
|
+
validationErrorMessage.value = validationErrorsList.errors[0] || "Field is invalid";
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function clearFieldValue(): void {
|
|
271
|
+
fieldValue.value = null;
|
|
272
|
+
validationErrorMessage.value = null;
|
|
273
|
+
// validateField();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Watch the field value and validate it
|
|
277
|
+
watch(
|
|
278
|
+
fieldValue,
|
|
279
|
+
async () => {
|
|
280
|
+
if (!props.editable) return;
|
|
281
|
+
await validateField();
|
|
282
|
+
},
|
|
283
|
+
{
|
|
284
|
+
immediate: true,
|
|
285
|
+
}
|
|
286
|
+
);
|
|
287
|
+
</script>
|
|
288
|
+
|
|
289
|
+
<style scoped lang="scss">
|
|
290
|
+
.editor-field-node {
|
|
291
|
+
:deep(.form-control) {
|
|
292
|
+
// Text based inputs
|
|
293
|
+
&:is(input:not([type="checkbox"], [type="radio"]), textarea, select) {
|
|
294
|
+
width: 100%;
|
|
295
|
+
padding: 8px;
|
|
296
|
+
font-size: 14px;
|
|
297
|
+
color: #333;
|
|
298
|
+
outline: none;
|
|
299
|
+
background-color: #fff;
|
|
300
|
+
border: 1px solid #e0e0e0;
|
|
301
|
+
border-radius: 5px;
|
|
302
|
+
box-shadow: none;
|
|
303
|
+
transition: border-color 0.2s;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// checkbox input
|
|
307
|
+
label {
|
|
308
|
+
input[type="checkbox"],
|
|
309
|
+
input[type="radio"] {
|
|
310
|
+
width: 16px;
|
|
311
|
+
height: 16px;
|
|
312
|
+
padding: 8px;
|
|
313
|
+
font-size: 14px;
|
|
314
|
+
appearance: none;
|
|
315
|
+
cursor: pointer;
|
|
316
|
+
border: 1px solid #e0e0e0;
|
|
317
|
+
border-radius: 5px;
|
|
318
|
+
|
|
319
|
+
&:checked {
|
|
320
|
+
@apply bg-blue-700 border-blue-700;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
&:has(input[type="checkbox"]:checked, input[type="radio"]:checked) {
|
|
325
|
+
@apply bg-blue-50;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
&:hover {
|
|
329
|
+
input[type="checkbox"]:not(:checked),
|
|
330
|
+
input[type="radio"]:not(:checked) {
|
|
331
|
+
@apply border-blue-600;
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
&:has(input[type="checkbox"]:disabled, input[type="radio"]:disabled) {
|
|
336
|
+
cursor: not-allowed;
|
|
337
|
+
opacity: 0.75;
|
|
338
|
+
|
|
339
|
+
input {
|
|
340
|
+
cursor: not-allowed;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Select input chevron icon
|
|
346
|
+
&:is(select) {
|
|
347
|
+
padding-right: 32px;
|
|
348
|
+
appearance: none;
|
|
349
|
+
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); // down chevron
|
|
350
|
+
background-repeat: no-repeat;
|
|
351
|
+
background-position: right 8px center;
|
|
352
|
+
background-size: 20px;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
// Color picker input
|
|
356
|
+
&:is(input[type="color"]) {
|
|
357
|
+
width: auto;
|
|
358
|
+
aspect-ratio: 1/1;
|
|
359
|
+
padding: 3px;
|
|
360
|
+
cursor: pointer;
|
|
361
|
+
background-color: #fff;
|
|
362
|
+
border: none;
|
|
363
|
+
border: 1px solid #e0e0e0;
|
|
364
|
+
border-radius: 5px;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
&:hover {
|
|
368
|
+
border-color: #555;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
&:disabled {
|
|
372
|
+
cursor: not-allowed;
|
|
373
|
+
background-color: #fff;
|
|
374
|
+
opacity: 0.75;
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
</style>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="section-editor-fields">
|
|
3
|
+
<!-- Field group tabs-->
|
|
4
|
+
<div v-if="editorFieldGroups.length" class="field-group-tabs flex gap-2 border-b px-5 pt-3">
|
|
5
|
+
<button
|
|
6
|
+
v-for="fieldGroupName in editorFieldGroups"
|
|
7
|
+
:key="`fg_${fieldGroupName}`"
|
|
8
|
+
class="field-group-tab rounded-t-md border border-zinc-200 bg-zinc-100 px-4 py-2 text-sm first-letter:uppercase"
|
|
9
|
+
:class="{ 'bg-zinc-00 border-b-transparent !bg-white': activeFieldGroup === fieldGroupName }"
|
|
10
|
+
@click="activeFieldGroup = fieldGroupName"
|
|
11
|
+
>
|
|
12
|
+
{{ fieldGroupName }}
|
|
13
|
+
</button>
|
|
14
|
+
</div>
|
|
15
|
+
|
|
16
|
+
<!-- Fields -->
|
|
17
|
+
<div v-if="blockData && Object.keys(editorFields).length > 0" class="flex flex-col gap-3 p-5">
|
|
18
|
+
<div v-for="(fieldConfig, fieldName) in editorFields" :key="fieldName" class="prop-field">
|
|
19
|
+
<BlockEditorFieldNode
|
|
20
|
+
v-model="blockData[fieldName]"
|
|
21
|
+
:fieldConfig="fieldConfig"
|
|
22
|
+
:fieldName="fieldName"
|
|
23
|
+
:editable="editable"
|
|
24
|
+
/>
|
|
25
|
+
</div>
|
|
26
|
+
</div>
|
|
27
|
+
|
|
28
|
+
<!-- No fields -->
|
|
29
|
+
<div v-else class="p-5">
|
|
30
|
+
<div class="rounded-lg bg-zinc-100 px-4 py-3 text-sm font-bold text-zinc-500">
|
|
31
|
+
<p>
|
|
32
|
+
{{ isLayoutBlock ? "No settings available for this layout." : "No options available for this block." }}
|
|
33
|
+
</p>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</template>
|
|
38
|
+
|
|
39
|
+
<script setup lang="ts">
|
|
40
|
+
import { ref, computed, onBeforeMount, watch } from "vue";
|
|
41
|
+
import BlockEditorFieldNode from "../BlockEditorFieldNode/BlockEditorFieldNode.vue";
|
|
42
|
+
import type { EditorFieldConfig } from "../../util/fieldConfig";
|
|
43
|
+
|
|
44
|
+
const blockData = defineModel<any>();
|
|
45
|
+
const activeFieldGroup = ref<string>("");
|
|
46
|
+
const { fields, editable } = defineProps<{
|
|
47
|
+
fields?: Record<string, EditorFieldConfig>;
|
|
48
|
+
editable: boolean;
|
|
49
|
+
isLayoutBlock?: boolean;
|
|
50
|
+
}>();
|
|
51
|
+
|
|
52
|
+
const editorFields = computed(() => {
|
|
53
|
+
// If no groups, return all fields
|
|
54
|
+
if (!editorFieldGroups.value.length) return fields || {};
|
|
55
|
+
// If an active field group is set, return only the fields in that group
|
|
56
|
+
if (fields && Object.keys(fields).length > 0) {
|
|
57
|
+
return Object.fromEntries(Object.entries(fields).filter(([_, field]) => field?.group === activeFieldGroup.value));
|
|
58
|
+
}
|
|
59
|
+
// If no active field group is set, return all fields
|
|
60
|
+
return fields || {};
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// Return unique string[] of unique field groups
|
|
64
|
+
const editorFieldGroups = computed(() => {
|
|
65
|
+
// If there are no fields, return an empty array
|
|
66
|
+
if (!fields || Object.keys(fields).length === 0) return [];
|
|
67
|
+
|
|
68
|
+
// If the fields do not have any groups, return empty array
|
|
69
|
+
if (!Object.keys(fields).some((field) => fields[field]?.group)) return [];
|
|
70
|
+
|
|
71
|
+
return Object.keys(fields).reduce((acc, field) => {
|
|
72
|
+
const fieldGroup = fields[field]?.group;
|
|
73
|
+
if (fieldGroup && !acc.includes(fieldGroup)) {
|
|
74
|
+
acc.push(fieldGroup);
|
|
75
|
+
}
|
|
76
|
+
return acc;
|
|
77
|
+
}, [] as string[]);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
watch(editorFieldGroups, () => {
|
|
81
|
+
if (editorFieldGroups.value.length > 0) {
|
|
82
|
+
activeFieldGroup.value = editorFieldGroups.value[0];
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
onBeforeMount(() => {
|
|
87
|
+
if (editorFieldGroups.value.length > 0) {
|
|
88
|
+
activeFieldGroup.value = editorFieldGroups.value[0];
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
</script>
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="margin-field flex gap-2" :class="linkedMargin ? '' : 'items-start'">
|
|
3
|
+
<div v-if="linkedMargin" class="field-wrapper relative flex-1" title="Top & Bottom Margin">
|
|
4
|
+
<span class="pointer-events-none absolute left-0 top-0 inline-flex h-full items-center border-r px-3 text-xs">
|
|
5
|
+
<ArrowsUpDownIcon class="size-3" />
|
|
6
|
+
</span>
|
|
7
|
+
<select
|
|
8
|
+
v-model="linkedMarginValue"
|
|
9
|
+
class="form-control"
|
|
10
|
+
style="padding-left: 3rem"
|
|
11
|
+
:placeholder="fieldConfig.placeholder"
|
|
12
|
+
:disabled="!editable"
|
|
13
|
+
@change="handleLinkedMarginChange"
|
|
14
|
+
>
|
|
15
|
+
<option v-for="option in fieldConfig.options" :key="option.value" :value="option.value">
|
|
16
|
+
{{ option.label }}
|
|
17
|
+
</option>
|
|
18
|
+
</select>
|
|
19
|
+
</div>
|
|
20
|
+
<template v-else-if="fieldValue">
|
|
21
|
+
<div class="flex flex-1 gap-2">
|
|
22
|
+
<div class="field-wrapper relative flex-1" title="Top Margin">
|
|
23
|
+
<span
|
|
24
|
+
class="pointer-events-none absolute left-0 top-0 inline-flex h-full items-center justify-center border-r px-3 text-xs"
|
|
25
|
+
><ArrowUpIcon class="size-3"
|
|
26
|
+
/></span>
|
|
27
|
+
<select
|
|
28
|
+
v-model="fieldValue.top"
|
|
29
|
+
class="form-control"
|
|
30
|
+
style="padding-left: 3rem"
|
|
31
|
+
:placeholder="fieldConfig.placeholder"
|
|
32
|
+
:disabled="!editable"
|
|
33
|
+
>
|
|
34
|
+
<option v-for="option in fieldConfig.options" :key="option.value" :value="option.value">
|
|
35
|
+
{{ option.label }}
|
|
36
|
+
</option>
|
|
37
|
+
</select>
|
|
38
|
+
</div>
|
|
39
|
+
<div class="field-wrapper relative flex-1" title="Bottom Margin">
|
|
40
|
+
<span
|
|
41
|
+
class="pointer-events-none absolute left-0 top-0 inline-flex h-full items-center justify-center border-r px-3 text-xs"
|
|
42
|
+
>
|
|
43
|
+
<ArrowDownIcon class="size-3" />
|
|
44
|
+
</span>
|
|
45
|
+
<select
|
|
46
|
+
v-model="fieldValue.bottom"
|
|
47
|
+
class="form-control"
|
|
48
|
+
style="padding-left: 3rem"
|
|
49
|
+
:placeholder="fieldConfig.placeholder"
|
|
50
|
+
:disabled="!editable"
|
|
51
|
+
>
|
|
52
|
+
<option v-for="option in fieldConfig.options" :key="option.value" :value="option.value">
|
|
53
|
+
{{ option.label }}
|
|
54
|
+
</option>
|
|
55
|
+
</select>
|
|
56
|
+
</div>
|
|
57
|
+
</div>
|
|
58
|
+
</template>
|
|
59
|
+
<button
|
|
60
|
+
v-if="editable"
|
|
61
|
+
title="Link Margin (Top & Bottom)"
|
|
62
|
+
class="inline-flex size-10 shrink-0 items-center justify-center rounded-md border p-2 text-center"
|
|
63
|
+
:class="
|
|
64
|
+
linkedMargin
|
|
65
|
+
? 'bg-blue-50 border-blue-200 hover:bg-blue-100'
|
|
66
|
+
: 'bg-zinc-50 border-zinc-200 hover:border-zinc-300 hover:bg-zinc-100'
|
|
67
|
+
"
|
|
68
|
+
@click="toggleLinkedMargin"
|
|
69
|
+
>
|
|
70
|
+
<LinkIcon v-if="linkedMargin" class="size-4" />
|
|
71
|
+
<LinkSlashIcon v-else class="size-4" />
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
</template>
|
|
75
|
+
|
|
76
|
+
<script setup lang="ts">
|
|
77
|
+
import { defineProps, ref, onMounted } from "vue";
|
|
78
|
+
import type { EditorFieldConfig } from "../../util/fieldConfig";
|
|
79
|
+
import { LinkIcon, LinkSlashIcon, ArrowUpIcon, ArrowDownIcon, ArrowsUpDownIcon } from "@heroicons/vue/24/outline";
|
|
80
|
+
|
|
81
|
+
const props = defineProps<{
|
|
82
|
+
fieldConfig: EditorFieldConfig;
|
|
83
|
+
editable: boolean;
|
|
84
|
+
}>();
|
|
85
|
+
|
|
86
|
+
const linkedMargin = ref(false);
|
|
87
|
+
const linkedMarginValue = ref<string>(props.fieldConfig.default);
|
|
88
|
+
|
|
89
|
+
const fieldValue = defineModel<{ top: string; bottom: string } | undefined>();
|
|
90
|
+
|
|
91
|
+
const toggleLinkedMargin = () => {
|
|
92
|
+
linkedMargin.value = !linkedMargin.value;
|
|
93
|
+
if (linkedMargin.value) {
|
|
94
|
+
linkedMarginValue.value = fieldValue.value?.top || fieldValue.value?.bottom || props.fieldConfig.default || "";
|
|
95
|
+
fieldValue.value = { top: linkedMarginValue.value || "", bottom: linkedMarginValue.value || "" };
|
|
96
|
+
} else {
|
|
97
|
+
fieldValue.value = {
|
|
98
|
+
top: linkedMarginValue.value || props.fieldConfig.default || "",
|
|
99
|
+
bottom: linkedMarginValue.value || props.fieldConfig.default || "",
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const handleLinkedMarginChange = () => {
|
|
105
|
+
if (!linkedMarginValue.value) return;
|
|
106
|
+
fieldValue.value = { top: linkedMarginValue.value, bottom: linkedMarginValue.value };
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
onMounted(() => {
|
|
110
|
+
// If top and bottom margin are the same, set linked margin to true
|
|
111
|
+
if (fieldValue.value?.top === fieldValue.value?.bottom) {
|
|
112
|
+
linkedMargin.value = true;
|
|
113
|
+
linkedMarginValue.value = fieldValue.value?.top || props.fieldConfig.default || "";
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// If top and bottom margin are not set, set them to the default value
|
|
117
|
+
if (!fieldValue.value || !fieldValue.value.top || !fieldValue.value.bottom) {
|
|
118
|
+
fieldValue.value = { top: props.fieldConfig.default || "", bottom: props.fieldConfig.default || "" };
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
</script>
|
|
122
|
+
|
|
123
|
+
<style scoped lang="scss">
|
|
124
|
+
select.form-control {
|
|
125
|
+
padding-right: 32px;
|
|
126
|
+
appearance: none;
|
|
127
|
+
background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M7 10l5 5 5-5z'/%3E%3C/svg%3E"); // down chevron
|
|
128
|
+
background-repeat: no-repeat;
|
|
129
|
+
background-position: right 8px center;
|
|
130
|
+
background-size: 20px;
|
|
131
|
+
}
|
|
132
|
+
</style>
|