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,391 @@
1
+ <template>
2
+ <div class="wswg-json-editor">
3
+ <div class="wswg-json-editor-header">
4
+ <!-- header slot for custom control elements -->
5
+ <slot name="header">
6
+ <!-- no default header content -->
7
+ </slot>
8
+ <!-- wswg toolbar-->
9
+ <PageBuilderToolbar
10
+ v-model:editorViewport="editorViewport"
11
+ v-model:showPageSettings="showPageSettings"
12
+ v-model:activeBlock="activeBlock"
13
+ :hasPageSettings="hasPageSettings"
14
+ >
15
+ <slot name="toolbar">
16
+ <!-- no default toolbar content -->
17
+ </slot>
18
+ </PageBuilderToolbar>
19
+ </div>
20
+ <slot v-if="loading" name="loading">
21
+ <div class="wswg-json-editor-loading">
22
+ <span>Loading...</span>
23
+ </div>
24
+ </slot>
25
+ <!-- WYSIWYG editor -->
26
+ <div v-else class="wswg-json-editor-canvas">
27
+ <!-- Page preview -->
28
+ <div class="wswg-json-editor-canvas-preview">
29
+ <div
30
+ class="mx-auto h-full overflow-hidden rounded-lg bg-white transition-all duration-300"
31
+ :class="{ 'w-full': editorViewport === 'desktop', 'w-96': editorViewport === 'mobile' }"
32
+ >
33
+ <BrowserNavigation v-if="showBrowserBar" :url="url" />
34
+ <div v-if="pageLayout" id="page-preview-viewport" class="h-full overflow-y-auto">
35
+ <component :is="pageLayout">
36
+ <template #default>
37
+ <!-- No blocks found -->
38
+ <EmptyState
39
+ v-if="!pageData[blocksKey]?.length"
40
+ v-model:showAddBlockMenu="showAddBlockMenu"
41
+ :editable="editable"
42
+ @block-added="handleAddBlock"
43
+ />
44
+ <!-- Blocks found -->
45
+ <div v-else id="page-blocks-wrapper">
46
+ <div v-for="(block, blockIndex) in pageData[blocksKey]" :key="block.id">
47
+ <BlockComponent
48
+ :block="block"
49
+ :blockIndex="blockIndex"
50
+ :activeBlock="activeBlock"
51
+ :editable="editable"
52
+ :hoveredBlockId="hoveredBlockId"
53
+ @hover-block="setHoveredBlockId"
54
+ @click-block="handleBlockClick"
55
+ />
56
+ </div>
57
+ </div>
58
+ </template>
59
+ </component>
60
+ </div>
61
+ <p v-else>No layout component found</p>
62
+ </div>
63
+ </div>
64
+
65
+ <!-- Resizable divider -->
66
+ <ResizeHandle @sidebar-width="handleSidebarWidth" />
67
+
68
+ <!-- Sidebar -->
69
+ <PageBuilderSidebar
70
+ v-model="pageData"
71
+ v-model:activeBlock="activeBlock"
72
+ v-model:hoveredBlockId="hoveredBlockId"
73
+ v-model:showPageSettings="showPageSettings"
74
+ v-model:showAddBlockMenu="showAddBlockMenu"
75
+ :editable="editable"
76
+ :blocksKey="blocksKey"
77
+ :settingsKey="settingsKey"
78
+ :style="{ width: sidebarWidth + 'px' }"
79
+ />
80
+ </div>
81
+ </div>
82
+ </template>
83
+
84
+ <script setup lang="ts">
85
+ import { ref, shallowRef, withDefaults, watch, type Component, onBeforeMount, nextTick, computed } from "vue";
86
+ import PageBuilderToolbar from "../PageBuilderToolbar/PageBuilderToolbar.vue";
87
+ import ResizeHandle from "../ResizeHandle/ResizeHandle.vue";
88
+ import PageBuilderSidebar from "../PageBuilderSidebar/PageBuilderSidebar.vue";
89
+ import BrowserNavigation from "../BrowserNavigation/BrowserNavigation.vue";
90
+ import BlockComponent from "../BlockComponent/BlockComponent.vue";
91
+ import EmptyState from "../EmptyState/EmptyState.vue";
92
+ import { getBlockComponent, getLayouts } from "../../util/registry";
93
+ import type { Block } from "../../types/Block";
94
+ import Sortable from "sortablejs";
95
+
96
+ const props = withDefaults(
97
+ defineProps<{
98
+ editable?: boolean;
99
+ loading?: boolean;
100
+ url?: string;
101
+ showBrowserBar?: boolean;
102
+ blocksKey?: string;
103
+ settingsKey?: string;
104
+ defaultBlockMargin?: "none" | "small" | "medium" | "large";
105
+ }>(),
106
+ {
107
+ editable: false,
108
+ loading: false,
109
+ url: "",
110
+ showBrowserBar: false,
111
+ blocksKey: "blocks",
112
+ settingsKey: "settings",
113
+ defaultBlockMargin: "none",
114
+ }
115
+ );
116
+
117
+ const editorViewport = ref<"desktop" | "mobile">("desktop");
118
+ const showPageSettings = ref(false);
119
+ const showAddBlockMenu = ref(false);
120
+ const activeBlock = ref<any>(null);
121
+ const isSorting = ref(false);
122
+ const hoveredBlockId = ref<string | null>(null);
123
+ const sidebarWidth = ref(380); // Default sidebar width (380px)
124
+
125
+ // Model value for the JSON page data
126
+ const pageData = defineModel<any>();
127
+
128
+ // Layout component - dynamically imported from page-builder directory
129
+ // Using shallowRef to avoid making the component reactive (performance optimization)
130
+ const pageLayout = shallowRef<Component | undefined>(undefined);
131
+
132
+ // Apply the sidebar width from the resize handle
133
+ function handleSidebarWidth(width: number) {
134
+ sidebarWidth.value = width;
135
+ }
136
+
137
+ // Load layout component dynamically from @page-builder/layout/
138
+ async function loadLayout(layoutName: string | undefined) {
139
+ // Use "default" layout if no layout is provided
140
+ const layout = layoutName || "default";
141
+
142
+ try {
143
+ // Try to import the layout from @page-builder/layout/
144
+ // This alias is configured in the consuming app (admin)
145
+ const layoutModule = await import(`@page-builder/layout/${layout}.vue`);
146
+ pageLayout.value = layoutModule.default;
147
+ await nextTick();
148
+ initSortable();
149
+ } catch (error) {
150
+ // Layout doesn't exist, return undefined
151
+ console.warn(`Layout "${layout}" not found in @page-builder/layout/`, error);
152
+ pageLayout.value = undefined;
153
+ }
154
+ }
155
+
156
+ // Check if the page has settings
157
+ const hasPageSettings = computed(() => {
158
+ // Show page settings if there are multiple layouts to choose from
159
+ const layouts = getLayouts();
160
+ if (Object.keys(layouts).length > 1) {
161
+ return true;
162
+ }
163
+
164
+ // If the layout has settings
165
+ if (pageData.value?.[props.settingsKey]) {
166
+ if (Object.keys(pageData.value?.[props.settingsKey]).length > 0) {
167
+ return true;
168
+ }
169
+ }
170
+
171
+ return false;
172
+ });
173
+
174
+ function handleBlockClick(block: Block | null) {
175
+ activeBlock.value = block;
176
+ showPageSettings.value = false;
177
+ hoveredBlockId.value = null;
178
+ showAddBlockMenu.value = false;
179
+ }
180
+
181
+ function handleAddBlock(blockType: string, insertIndex?: number) {
182
+ // Ensure blocks array exists
183
+ if (!pageData.value[props.blocksKey]) {
184
+ pageData.value[props.blocksKey] = [];
185
+ }
186
+
187
+ // Record if this is an add from the EmptyState
188
+ const isAddFromEmptyState = insertIndex === undefined;
189
+
190
+ // Create a new block object
191
+ const newBlock = {
192
+ id: `block-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
193
+ type: blockType,
194
+ margin: props.defaultBlockMargin
195
+ ? { top: props.defaultBlockMargin, bottom: props.defaultBlockMargin }
196
+ : undefined,
197
+ };
198
+ // Get the default prop values from the block component
199
+ const blockComponent = getBlockComponent(blockType);
200
+ if (blockComponent?.props) {
201
+ // loop props and set their default value
202
+ Object.entries(blockComponent.props).forEach(([key, value]: [string, any]) => {
203
+ if (value.default) {
204
+ if (typeof value.default === "function") {
205
+ newBlock[key as keyof typeof newBlock] = value.default();
206
+ } else {
207
+ newBlock[key as keyof typeof newBlock] = value.default;
208
+ }
209
+ }
210
+ });
211
+ }
212
+
213
+ // Add the new block at the specified index or at the end
214
+ if (insertIndex !== undefined) {
215
+ pageData.value[props.blocksKey].splice(insertIndex, 0, newBlock);
216
+ } else {
217
+ pageData.value[props.blocksKey].push(newBlock);
218
+ // Set the new block as active only when adding to the end (from EmptyState)
219
+ activeBlock.value = newBlock;
220
+ showAddBlockMenu.value = false;
221
+ }
222
+
223
+ // finally, if this is an add from the EmptyState, we need to trigger a re-render and initialise the sortable
224
+ if (isAddFromEmptyState) {
225
+ nextTick(() => {
226
+ // The component will re-render with the new block data
227
+ initSortable();
228
+ });
229
+ }
230
+
231
+ return newBlock;
232
+ }
233
+
234
+ function setHoveredBlockId(id: string | null) {
235
+ if (isSorting.value) return;
236
+ hoveredBlockId.value = id;
237
+ }
238
+
239
+ // Watch for changes in pageData layout and load the corresponding component
240
+ watch(
241
+ () => pageData.value?.[props.settingsKey]?.layout,
242
+ (layoutName) => {
243
+ loadLayout(layoutName);
244
+ },
245
+ { immediate: true }
246
+ );
247
+
248
+ // Watch for activeBlock changes and scroll to the corresponding block in the preview
249
+ watch(
250
+ () => activeBlock.value?.id,
251
+ async (blockId) => {
252
+ if (!blockId) return;
253
+
254
+ // Wait for DOM to update
255
+ await nextTick();
256
+
257
+ // Find the block element by data-block-id
258
+ const blockElement = document.querySelector(`[data-block-id="${blockId}"]`) as HTMLElement;
259
+ if (!blockElement) return;
260
+
261
+ // Scroll the block into view, centered in the scrollable container
262
+ blockElement.scrollIntoView({
263
+ behavior: "smooth",
264
+ block: "center",
265
+ inline: "nearest",
266
+ });
267
+ }
268
+ );
269
+
270
+ function initSortable() {
271
+ const sortableBlocksWrapper = document.getElementById("page-blocks-wrapper");
272
+ if (!sortableBlocksWrapper) return;
273
+ new Sortable(sortableBlocksWrapper, {
274
+ animation: 150,
275
+ ghostClass: "sortable-ghost",
276
+ chosenClass: "sortable-chosen",
277
+ dragClass: "sortable-drag",
278
+ group: "page-blocks",
279
+ onStart: () => {
280
+ isSorting.value = true;
281
+ },
282
+ onAdd: (event: any) => {
283
+ // This fires when an item is added from another list (drag from sidebar)
284
+ const { item: draggedElement, newIndex } = event;
285
+ const blockType = draggedElement.getAttribute("data-block-type");
286
+
287
+ if (blockType) {
288
+ // Use the consolidated handleAddBlock function
289
+ handleAddBlock(blockType, newIndex);
290
+
291
+ // Remove the cloned HTML element that SortableJS added
292
+ draggedElement.remove();
293
+
294
+ // Force Vue to re-render by triggering reactivity
295
+ nextTick(() => {
296
+ // The component will re-render with the new block data
297
+ });
298
+ }
299
+ },
300
+ onEnd: (event: any) => {
301
+ isSorting.value = false;
302
+ const { oldIndex, newIndex } = event;
303
+
304
+ // Only handle reordering if this wasn't an add operation (oldIndex will be null for adds)
305
+ if (oldIndex !== null && oldIndex !== newIndex) {
306
+ const movedBlock = pageData.value?.[props.blocksKey]?.splice(oldIndex, 1)[0];
307
+ pageData.value?.[props.blocksKey]?.splice(newIndex, 0, movedBlock);
308
+ }
309
+ },
310
+ });
311
+ }
312
+
313
+ onBeforeMount(() => {
314
+ if (!pageData.value?.[props.settingsKey]) {
315
+ if (pageData.value) {
316
+ pageData.value[props.settingsKey] = {};
317
+ }
318
+ }
319
+
320
+ if (!pageData.value?.[props.settingsKey]?.layout) {
321
+ if (pageData.value && pageData.value[props.settingsKey]) {
322
+ pageData.value[props.settingsKey].layout = "default";
323
+ }
324
+ }
325
+
326
+ // Sanitise the layout data (must be a valid layout name string)
327
+ if (pageData.value?.[props.settingsKey]?.layout) {
328
+ const layouts = getLayouts();
329
+ const settings = pageData.value?.[props.settingsKey];
330
+ if (settings) {
331
+ if (typeof settings.layout !== "string") {
332
+ settings.layout = "default";
333
+ } else if (!layouts[settings.layout]) {
334
+ settings.layout = "default";
335
+ }
336
+ }
337
+ }
338
+
339
+ if (!pageData.value?.[props.blocksKey]) {
340
+ if (pageData.value) {
341
+ pageData.value[props.blocksKey] = [];
342
+ }
343
+ }
344
+ });
345
+ </script>
346
+
347
+ <style lang="scss">
348
+ .wswg-json-editor {
349
+ display: flex;
350
+ flex-direction: column;
351
+ width: 100%;
352
+ max-width: 100%;
353
+ height: 100vh;
354
+
355
+ &-header {
356
+ position: sticky;
357
+ top: 0;
358
+ z-index: 20;
359
+ background-color: #fff;
360
+ }
361
+
362
+ &-loading {
363
+ display: flex;
364
+ align-items: center;
365
+ justify-content: center;
366
+ }
367
+
368
+ &-canvas {
369
+ display: flex;
370
+ flex: 1;
371
+ flex-grow: 1;
372
+ flex-shrink: 0;
373
+ height: 100%;
374
+ overflow-y: auto;
375
+ background-color: #6a6a6a;
376
+
377
+ &-preview {
378
+ flex: 1;
379
+ flex-grow: 1;
380
+ flex-shrink: 0;
381
+ padding: 2rem;
382
+ }
383
+
384
+ &-sidebar {
385
+ min-width: 300px;
386
+ padding: 2rem;
387
+ background: #fff;
388
+ }
389
+ }
390
+ }
391
+ </style>
package/src/index.ts ADDED
@@ -0,0 +1,15 @@
1
+ // Export field configuration utilities first (before any side effects)
2
+ // This ensures createField is available immediately without waiting for CSS or component imports
3
+ export { createField } from "./util/fieldConfig";
4
+ export type { EditorFieldConfig, ValidatorFunction } from "./util/fieldConfig";
5
+ export { getLayouts } from "./util/registry";
6
+ export { validateField, validateAllFields } from "./util/validation";
7
+
8
+ // Export components (component exports don't cause side effects until used)
9
+ export { default as WswgJsonEditor } from "./components/WswgJsonEditor/WswgJsonEditor.vue";
10
+ // Export PageRenderer separately - it doesn't use the registry, so it won't trigger field loading
11
+ export { default as PageRenderer } from "./components/PageRenderer/PageRenderer.vue";
12
+
13
+ // Import CSS - Vite will extract this to dist/style.css during build
14
+ // Consuming apps should import "vue-wswg-editor/style.css"
15
+ import "./style.css";
package/src/shims.d.ts ADDED
@@ -0,0 +1,72 @@
1
+ declare module "*.vue" {
2
+ import type { DefineComponent } from "vue";
3
+
4
+ const component: DefineComponent<object, object, any>;
5
+ export default component;
6
+ }
7
+
8
+ // Image imports - Vite returns the URL as a string
9
+ declare module "*.jpg" {
10
+ const src: string;
11
+ export default src;
12
+ }
13
+
14
+ declare module "*.jpeg" {
15
+ const src: string;
16
+ export default src;
17
+ }
18
+
19
+ declare module "*.png" {
20
+ const src: string;
21
+ export default src;
22
+ }
23
+
24
+ declare module "*.gif" {
25
+ const src: string;
26
+ export default src;
27
+ }
28
+
29
+ declare module "*.svg" {
30
+ const src: string;
31
+ export default src;
32
+ }
33
+
34
+ declare module "*.webp" {
35
+ const src: string;
36
+ export default src;
37
+ }
38
+
39
+ // Vite import.meta.glob type definitions
40
+ interface ImportMeta {
41
+ glob<T = any>(
42
+ pattern: string | string[],
43
+ options?: {
44
+ eager?: boolean;
45
+ import?: string;
46
+ query?: string | Record<string, string | number | boolean>;
47
+ exclude?: string | string[];
48
+ as?: "url" | "raw";
49
+ }
50
+ ): Record<string, () => Promise<T>> | Record<string, T> | Record<string, string>;
51
+ }
52
+
53
+ // SortableJS type declaration - reference @types/sortablejs and re-export as ESM default
54
+ /// <reference types="sortablejs" />
55
+ declare module "sortablejs" {
56
+ // Reference the Sortable class from @types/sortablejs via triple-slash directive
57
+ // This allows default import with esModuleInterop
58
+ const Sortable: {
59
+ new (element: HTMLElement, options?: any): any;
60
+ active: any;
61
+ utils: any;
62
+ mount(...plugins: any[]): void;
63
+ create(element: HTMLElement, options?: any): any;
64
+ dragged: HTMLElement | null;
65
+ ghost: HTMLElement | null;
66
+ clone: HTMLElement | null;
67
+ get(element: HTMLElement): any;
68
+ readonly version: string;
69
+ };
70
+ export = Sortable;
71
+ export default Sortable;
72
+ }
package/src/style.css ADDED
@@ -0,0 +1,3 @@
1
+ @tailwind base;
2
+ @tailwind components;
3
+ @tailwind utilities;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Custom component type with additional block-specific properties
3
+ * These properties are set via defineOptions() in Vue components
4
+ */
5
+ export type Block = Component & {
6
+ __name: string; // The autogenerated name of the block
7
+ props: Record<string, any>;
8
+ id: string;
9
+ type: string;
10
+ // Defined in template
11
+ label?: string;
12
+ icon?: string;
13
+ // fields file
14
+ fields?: Record<string, any>;
15
+ // Auto generated from the component path
16
+ directory?: string; // Where the block component is located (e.g., "@page-builder/blocks/hero-section")
17
+ // Margin
18
+ margin?: { top?: string; bottom?: string };
19
+ };
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Custom component type with additional layout-specific properties
3
+ * These properties are set via defineOptions() in Vue components
4
+ */
5
+ export type Layout = Component & {
6
+ __name: string; // The autogenerated name of the layout
7
+ label: string; // The custom name of the layout
8
+ fields?: Record<string, EditorFieldConfig>;
9
+ };
@@ -0,0 +1,173 @@
1
+ import type { Component } from "vue";
2
+
3
+ /**
4
+ * Validator function that returns:
5
+ * - `true` if the value is valid
6
+ * - `false` if the value is invalid (generic error)
7
+ * - `string` if the value is invalid (specific error message)
8
+ */
9
+ export type ValidatorFunction = (value: any) => Promise<boolean | string>;
10
+
11
+ /**
12
+ * Editor field types for the page builder sidebar
13
+ */
14
+ export type EditorFieldType =
15
+ | "text" // ✅
16
+ | "textarea" // ✅
17
+ | "number" // ✅
18
+ | "boolean" // ✅
19
+ | "email" // ✅
20
+ | "url" // ✅
21
+ | "select" // ✅
22
+ | "checkbox" // ✅
23
+ | "radio" // ✅
24
+ | "color" // ✅
25
+ | "range" // ✅
26
+ | "repeater" // ✅
27
+ | "margin" // ✅
28
+ | "custom"; // 🔌 (image, json, video, richtext, etc)
29
+
30
+ /**
31
+ * Simple utility for defining page builder props with editor field metadata
32
+ * This approach separates Vue props from editor field configuration
33
+ */
34
+
35
+ export interface EditorFieldConfig {
36
+ type: EditorFieldType;
37
+ component?: Component; // if providing a custom editor field component
38
+ required?: boolean;
39
+ default?: any;
40
+ label?: string;
41
+ description?: string;
42
+ placeholder?: string;
43
+ rows?: number;
44
+ options?: Array<{ label: string; value: any; id: string }>;
45
+ step?: number;
46
+ hidden?: boolean;
47
+ group?: string;
48
+ clearable?: boolean;
49
+ /**
50
+ * Validator function that returns:
51
+ * - `true` if the value is valid
52
+ * - `false` if the value is invalid (generic error)
53
+ * - `string` if the value is invalid (specific error message)
54
+ */
55
+ validator?: ValidatorFunction;
56
+ // Repeater-specific properties
57
+ repeaterFields?: Record<string, EditorFieldConfig>;
58
+ repeaterFieldLabel?: string; // attribute key for the repeater field label
59
+ // String length validation
60
+ minLength?: number;
61
+ maxLength?: number;
62
+ // Number validation
63
+ min?: number;
64
+ max?: number;
65
+ // Repeater validation
66
+ minItems?: number;
67
+ maxItems?: number;
68
+ // Image specific
69
+ // Whether the image is responsive eg: xs, sm, md, lg, xl, primary
70
+ responsive?: boolean;
71
+ }
72
+
73
+ /**
74
+ * Helper function to create editor field configurations
75
+ */
76
+ export const createField = {
77
+ custom: (config: Partial<EditorFieldConfig> = {}): EditorFieldConfig => ({
78
+ type: "custom",
79
+ ...config,
80
+ }),
81
+
82
+ text: (config: Partial<EditorFieldConfig> = {}): EditorFieldConfig => ({
83
+ type: "text",
84
+ ...config,
85
+ }),
86
+
87
+ textarea: (config: Partial<EditorFieldConfig> = {}): EditorFieldConfig => ({
88
+ type: "textarea",
89
+ ...config,
90
+ }),
91
+
92
+ number: (config: Partial<EditorFieldConfig> = {}): EditorFieldConfig => ({
93
+ type: "number",
94
+ ...config,
95
+ }),
96
+
97
+ boolean: (config: Partial<EditorFieldConfig> = {}): EditorFieldConfig => ({
98
+ type: "boolean",
99
+ ...config,
100
+ }),
101
+
102
+ email: (config: Partial<EditorFieldConfig> = {}): EditorFieldConfig => ({
103
+ type: "email",
104
+ ...config,
105
+ }),
106
+
107
+ url: (config: Partial<EditorFieldConfig> = {}): EditorFieldConfig => ({
108
+ type: "url",
109
+ ...config,
110
+ }),
111
+
112
+ select: <T>(
113
+ options: Array<{ label: string; value: T; id: string }>,
114
+ config: Partial<Omit<EditorFieldConfig, "options">> = {}
115
+ ): EditorFieldConfig => ({
116
+ type: "select",
117
+ options,
118
+ ...config,
119
+ }),
120
+
121
+ radio: <T>(
122
+ options: Array<{ label: string; value: T; id: string }>,
123
+ config: Partial<Omit<EditorFieldConfig, "options">> = {}
124
+ ): EditorFieldConfig => ({
125
+ type: "radio",
126
+ options,
127
+ ...config,
128
+ }),
129
+
130
+ checkbox: <T>(
131
+ options: Array<{ label: string; value: T; id: string }>,
132
+ config: Partial<Omit<EditorFieldConfig, "options">> = {}
133
+ ): EditorFieldConfig => ({
134
+ type: "checkbox",
135
+ options,
136
+ ...config,
137
+ }),
138
+
139
+ color: (config: Partial<EditorFieldConfig> = {}): EditorFieldConfig => ({
140
+ type: "color",
141
+ ...config,
142
+ }),
143
+
144
+ range: (config: Partial<EditorFieldConfig> = {}): EditorFieldConfig => ({
145
+ type: "range",
146
+ ...config,
147
+ }),
148
+
149
+ repeater: (
150
+ repeaterFields: Record<string, EditorFieldConfig>,
151
+ config: Partial<EditorFieldConfig> = {}
152
+ ): EditorFieldConfig => ({
153
+ type: "repeater",
154
+ repeaterFields,
155
+ ...config,
156
+ }),
157
+
158
+ margin: (config: Partial<EditorFieldConfig> = {}): EditorFieldConfig => ({
159
+ type: "margin",
160
+ options: [
161
+ { label: "None", value: "none", id: "margin-none" },
162
+ { label: "Small", value: "small", id: "margin-small" },
163
+ { label: "Medium", value: "medium", id: "margin-medium" },
164
+ { label: "Large", value: "large", id: "margin-large" },
165
+ ],
166
+ default: "none",
167
+ description: "Vertical margin spacing",
168
+ group: "settings",
169
+ placeholder: "Select a margin...",
170
+ label: "Margin",
171
+ ...config,
172
+ }),
173
+ };