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.
Files changed (61) hide show
  1. package/README.md +91 -0
  2. package/dist/style.css +1 -0
  3. package/dist/types/components/AddBlockItem/AddBlockItem.vue.d.ts +6 -0
  4. package/dist/types/components/BlockBrowser/BlockBrowser.vue.d.ts +2 -0
  5. package/dist/types/components/BlockComponent/BlockComponent.vue.d.ts +15 -0
  6. package/dist/types/components/BlockEditorFieldNode/BlockEditorFieldNode.vue.d.ts +15 -0
  7. package/dist/types/components/BlockEditorFields/BlockEditorFields.vue.d.ts +15 -0
  8. package/dist/types/components/BlockMarginFieldNode/BlockMarginNode.vue.d.ts +23 -0
  9. package/dist/types/components/BlockRepeaterFieldNode/BlockRepeaterNode.vue.d.ts +15 -0
  10. package/dist/types/components/BrowserNavigation/BrowserNavigation.vue.d.ts +5 -0
  11. package/dist/types/components/EmptyState/EmptyState.vue.d.ts +15 -0
  12. package/dist/types/components/PageBlockList/PageBlockList.vue.d.ts +19 -0
  13. package/dist/types/components/PageBuilderSidebar/PageBuilderSidebar.vue.d.ts +30 -0
  14. package/dist/types/components/PageBuilderToolbar/PageBuilderToolbar.vue.d.ts +28 -0
  15. package/dist/types/components/PageRenderer/PageRenderer.vue.d.ts +6 -0
  16. package/dist/types/components/PageRenderer/blockModules.d.ts +1 -0
  17. package/dist/types/components/PageSettings/PageSettings.vue.d.ts +15 -0
  18. package/dist/types/components/ResizeHandle/ResizeHandle.vue.d.ts +6 -0
  19. package/dist/types/components/WswgJsonEditor/WswgJsonEditor.test.d.ts +1 -0
  20. package/dist/types/components/WswgJsonEditor/WswgJsonEditor.vue.d.ts +40 -0
  21. package/dist/types/index.d.ts +7 -0
  22. package/dist/types/tsconfig.tsbuildinfo +1 -0
  23. package/dist/types/util/fieldConfig.d.ts +82 -0
  24. package/dist/types/util/helpers.d.ts +28 -0
  25. package/dist/types/util/registry.d.ts +21 -0
  26. package/dist/types/util/validation.d.ts +15 -0
  27. package/dist/vue-wswg-editor.es.js +3377 -0
  28. package/package.json +85 -0
  29. package/src/assets/images/empty-state.jpg +0 -0
  30. package/src/assets/styles/_mixins.scss +73 -0
  31. package/src/assets/styles/main.css +3 -0
  32. package/src/components/AddBlockItem/AddBlockItem.vue +50 -0
  33. package/src/components/BlockBrowser/BlockBrowser.vue +69 -0
  34. package/src/components/BlockComponent/BlockComponent.vue +186 -0
  35. package/src/components/BlockEditorFieldNode/BlockEditorFieldNode.vue +378 -0
  36. package/src/components/BlockEditorFields/BlockEditorFields.vue +91 -0
  37. package/src/components/BlockMarginFieldNode/BlockMarginNode.vue +132 -0
  38. package/src/components/BlockRepeaterFieldNode/BlockRepeaterNode.vue +217 -0
  39. package/src/components/BrowserNavigation/BrowserNavigation.vue +27 -0
  40. package/src/components/EmptyState/EmptyState.vue +94 -0
  41. package/src/components/PageBlockList/PageBlockList.vue +103 -0
  42. package/src/components/PageBuilderSidebar/PageBuilderSidebar.vue +241 -0
  43. package/src/components/PageBuilderToolbar/PageBuilderToolbar.vue +63 -0
  44. package/src/components/PageRenderer/PageRenderer.vue +65 -0
  45. package/src/components/PageRenderer/blockModules-alternative.ts.example +9 -0
  46. package/src/components/PageRenderer/blockModules-manual.ts.example +19 -0
  47. package/src/components/PageRenderer/blockModules-runtime.ts.example +23 -0
  48. package/src/components/PageRenderer/blockModules.ts +3 -0
  49. package/src/components/PageSettings/PageSettings.vue +86 -0
  50. package/src/components/ResizeHandle/ResizeHandle.vue +105 -0
  51. package/src/components/WswgJsonEditor/WswgJsonEditor.test.ts +43 -0
  52. package/src/components/WswgJsonEditor/WswgJsonEditor.vue +391 -0
  53. package/src/index.ts +15 -0
  54. package/src/shims.d.ts +72 -0
  55. package/src/style.css +3 -0
  56. package/src/types/Block.d.ts +19 -0
  57. package/src/types/Layout.d.ts +9 -0
  58. package/src/util/fieldConfig.ts +173 -0
  59. package/src/util/helpers.ts +176 -0
  60. package/src/util/registry.ts +149 -0
  61. 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>