vue-wswg-editor 0.0.12 → 0.0.13
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 +23 -8
- package/dist/style.css +1 -1
- package/dist/types/components/BlockEditorFields/BlockEditorFields.vue.d.ts +1 -0
- package/dist/types/components/EditorPageRenderer/EditorPageRenderer.vue.d.ts +21 -0
- package/dist/types/components/EmptyState/EmptyState.vue.d.ts +2 -8
- package/dist/types/components/IframePreview/IframePreview.vue.d.ts +26 -0
- package/dist/types/components/IframePreview/iframeContent.d.ts +9 -0
- package/dist/types/components/IframePreview/iframePreviewApp.d.ts +36 -0
- package/dist/types/components/IframePreview/messageHandler.d.ts +55 -0
- package/dist/types/components/IframePreview/types.d.ts +77 -0
- package/dist/types/components/PageBuilderSidebar/PageBuilderSidebar.vue.d.ts +2 -0
- package/dist/types/components/PageRenderer/PageRenderer.vue.d.ts +2 -0
- package/dist/types/components/PageSettings/PageSettings.vue.d.ts +2 -0
- package/dist/types/components/{WswgJsonEditor/WswgJsonEditor.vue.d.ts → WswgPageBuilder/WswgPageBuilder.vue.d.ts} +2 -0
- package/dist/types/index.d.ts +8 -2
- package/dist/types/util/registry.d.ts +2 -0
- package/dist/types/util/theme-registry.d.ts +42 -0
- package/dist/types/util/validation.d.ts +2 -2
- package/dist/vite-plugin.js +33 -29
- package/dist/vue-wswg-editor.es.js +2723 -1897
- package/package.json +1 -2
- package/src/assets/styles/_mixins.scss +15 -0
- package/src/components/AddBlockItem/AddBlockItem.vue +13 -4
- package/src/components/BlockBrowser/BlockBrowser.vue +5 -5
- package/src/components/BlockComponent/BlockComponent.vue +23 -50
- package/src/components/BlockEditorFieldNode/BlockEditorFieldNode.vue +12 -10
- package/src/components/BlockEditorFields/BlockEditorFields.vue +24 -4
- package/src/components/BlockRepeaterFieldNode/BlockRepeaterNode.vue +9 -4
- package/src/components/BrowserNavigation/BrowserNavigation.vue +1 -1
- package/src/components/EditorPageRenderer/EditorPageRenderer.vue +641 -0
- package/src/components/EmptyState/EmptyState.vue +3 -12
- package/src/components/IframePreview/IframePreview.vue +211 -0
- package/src/components/IframePreview/iframeContent.ts +210 -0
- package/src/components/IframePreview/iframePreviewApp.ts +221 -0
- package/src/components/IframePreview/messageHandler.ts +219 -0
- package/src/components/IframePreview/types.ts +126 -0
- package/src/components/PageBlockList/PageBlockList.vue +8 -6
- package/src/components/PageBuilderSidebar/PageBuilderSidebar.vue +5 -3
- package/src/components/PageRenderer/PageRenderer.vue +9 -33
- package/src/components/PageSettings/PageSettings.vue +10 -6
- package/src/components/ResizeHandle/ResizeHandle.vue +68 -10
- package/src/components/{WswgJsonEditor/WswgJsonEditor.test.ts → WswgPageBuilder/WswgPageBuilder.test.ts} +8 -8
- package/src/components/WswgPageBuilder/WswgPageBuilder.vue +375 -0
- package/src/index.ts +10 -2
- package/src/shims.d.ts +4 -0
- package/src/types/Theme.d.ts +15 -0
- package/src/util/registry.ts +2 -2
- package/src/util/theme-registry.ts +397 -0
- package/src/util/validation.ts +102 -11
- package/src/vite-plugin.ts +8 -4
- package/types/vue-wswg-editor.d.ts +4 -0
- package/dist/types/components/PageRenderer/blockModules.d.ts +0 -3
- package/dist/types/components/PageRenderer/layoutModules.d.ts +0 -3
- package/src/components/PageRenderer/blockModules-alternative.ts.example +0 -9
- package/src/components/PageRenderer/blockModules-manual.ts.example +0 -19
- package/src/components/PageRenderer/blockModules-runtime.ts.example +0 -23
- package/src/components/PageRenderer/blockModules.ts +0 -32
- package/src/components/PageRenderer/layoutModules.ts +0 -32
- package/src/components/WswgJsonEditor/WswgJsonEditor.vue +0 -595
- /package/dist/types/components/{WswgJsonEditor/WswgJsonEditor.test.d.ts → WswgPageBuilder/WswgPageBuilder.test.d.ts} +0 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
id="page-viewport"
|
|
4
|
+
ref="editorRef"
|
|
5
|
+
class="page-renderer-wrapper relative"
|
|
6
|
+
:class="{ 'settings-open': settingsOpen }"
|
|
7
|
+
>
|
|
8
|
+
<template v-if="isReady">
|
|
9
|
+
<component :is="layoutComponent" v-if="layoutComponent" v-bind="settings" :blocks="blocks">
|
|
10
|
+
<template #default>
|
|
11
|
+
<!-- No blocks found -->
|
|
12
|
+
<EmptyState v-if="!blocks?.length" :editable="editable" @block-added="handleBlockAdded" />
|
|
13
|
+
<!-- Blocks found -->
|
|
14
|
+
<div
|
|
15
|
+
v-else
|
|
16
|
+
id="page-blocks-wrapper"
|
|
17
|
+
ref="pageBlocksWrapperRef"
|
|
18
|
+
class="relative"
|
|
19
|
+
:class="{ 'drag-over': isDraggingOver }"
|
|
20
|
+
@dragenter="handleDragEnter"
|
|
21
|
+
@dragleave="handleDragLeave"
|
|
22
|
+
@dragover="handleDragOver"
|
|
23
|
+
@drop="handleDrop"
|
|
24
|
+
>
|
|
25
|
+
<!-- Drop indicator - positioned absolutely based on calculated position -->
|
|
26
|
+
<div
|
|
27
|
+
v-if="isDraggingOver && dropInsertIndex !== null && dropIndicatorTop !== null"
|
|
28
|
+
class="drop-indicator"
|
|
29
|
+
:style="{ top: dropIndicatorTop + 'px' }"
|
|
30
|
+
>
|
|
31
|
+
<div class="drop-indicator-line"></div>
|
|
32
|
+
<div class="drop-indicator-label">Drop here</div>
|
|
33
|
+
<div class="drop-indicator-line"></div>
|
|
34
|
+
</div>
|
|
35
|
+
|
|
36
|
+
<template v-for="(block, blockIndex) in blocks" :key="block.id">
|
|
37
|
+
<div>
|
|
38
|
+
<BlockComponent
|
|
39
|
+
:block="block"
|
|
40
|
+
:blockIndex="blockIndex"
|
|
41
|
+
:activeBlock="activeBlock"
|
|
42
|
+
:editable="editable"
|
|
43
|
+
:hoveredBlockId="hoveredBlockId"
|
|
44
|
+
@hover-block="setHoveredBlockId"
|
|
45
|
+
@click-block="handleBlockClick"
|
|
46
|
+
/>
|
|
47
|
+
</div>
|
|
48
|
+
</template>
|
|
49
|
+
</div>
|
|
50
|
+
</template>
|
|
51
|
+
</component>
|
|
52
|
+
<!-- No layout found -->
|
|
53
|
+
<div v-else class="rounded-b-lg bg-white px-5 py-12 md:py-20">
|
|
54
|
+
<div class="mx-auto max-w-md pb-7 text-center">
|
|
55
|
+
<svg
|
|
56
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
57
|
+
fill="none"
|
|
58
|
+
viewBox="0 0 24 24"
|
|
59
|
+
stroke-width="1.5"
|
|
60
|
+
stroke="currentColor"
|
|
61
|
+
class="mx-auto size-20 text-gray-400"
|
|
62
|
+
>
|
|
63
|
+
<path
|
|
64
|
+
stroke-linecap="round"
|
|
65
|
+
stroke-linejoin="round"
|
|
66
|
+
d="M12 9v3.75m9-.75a9 9 0 1 1-18 0 9 9 0 0 1 18 0Zm-9 3.75h.008v.008H12v-.008Z"
|
|
67
|
+
></path>
|
|
68
|
+
</svg>
|
|
69
|
+
|
|
70
|
+
<h2 class="text-2xl font-bold text-gray-900">No layout found</h2>
|
|
71
|
+
|
|
72
|
+
<p class="mt-4 text-pretty text-gray-700">
|
|
73
|
+
Get started by creating your first layout. It only takes a few seconds.
|
|
74
|
+
</p>
|
|
75
|
+
|
|
76
|
+
<p class="mt-6 text-sm text-gray-700">
|
|
77
|
+
<a href="#" class="underline hover:text-gray-900">Learn how</a> or
|
|
78
|
+
<a href="#" class="underline hover:text-gray-900">view examples</a>
|
|
79
|
+
</p>
|
|
80
|
+
</div>
|
|
81
|
+
</div>
|
|
82
|
+
</template>
|
|
83
|
+
</div>
|
|
84
|
+
</template>
|
|
85
|
+
|
|
86
|
+
<script setup lang="ts">
|
|
87
|
+
import { computed, withDefaults, onBeforeMount, ref, onBeforeUnmount, onMounted, watch, nextTick } from "vue";
|
|
88
|
+
import { initialiseRegistry, getLayout } from "../../util/theme-registry";
|
|
89
|
+
import BlockComponent from "../BlockComponent/BlockComponent.vue";
|
|
90
|
+
import EmptyState from "../EmptyState/EmptyState.vue";
|
|
91
|
+
import type { Block } from "../../types/Block";
|
|
92
|
+
import type { PartialClickMessage } from "../IframePreview/types";
|
|
93
|
+
import Sortable from "sortablejs";
|
|
94
|
+
|
|
95
|
+
const props = withDefaults(
|
|
96
|
+
defineProps<{
|
|
97
|
+
blocks: Block[];
|
|
98
|
+
layout?: string;
|
|
99
|
+
settings?: Record<string, any>;
|
|
100
|
+
activeBlock?: Block | null;
|
|
101
|
+
hoveredBlockId?: string | null;
|
|
102
|
+
editable?: boolean;
|
|
103
|
+
settingsOpen?: boolean;
|
|
104
|
+
theme?: string;
|
|
105
|
+
}>(),
|
|
106
|
+
{
|
|
107
|
+
layout: "default",
|
|
108
|
+
settings: () => ({}),
|
|
109
|
+
activeBlock: null,
|
|
110
|
+
hoveredBlockId: null,
|
|
111
|
+
editable: false,
|
|
112
|
+
settingsOpen: false,
|
|
113
|
+
theme: "default",
|
|
114
|
+
}
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const isReady = ref(false);
|
|
118
|
+
const editorRef = ref<HTMLElement | null>(null);
|
|
119
|
+
const isSorting = ref(false);
|
|
120
|
+
const sortableInstance = ref<InstanceType<typeof Sortable> | null>(null);
|
|
121
|
+
const isInitializingSortable = ref(false);
|
|
122
|
+
|
|
123
|
+
// Get the layout component based on the layout prop
|
|
124
|
+
// Only compute when isReady is true (registry is initialized)
|
|
125
|
+
const layoutComponent = computed(() => {
|
|
126
|
+
if (!isReady.value) {
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
|
129
|
+
return getLayout(props.layout);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
function handleBlockClick(block: Block | null) {
|
|
133
|
+
sendToParent({
|
|
134
|
+
type: "BLOCK_CLICK",
|
|
135
|
+
block: block,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function setHoveredBlockId(blockId: string | null) {
|
|
140
|
+
sendToParent({
|
|
141
|
+
type: "BLOCK_HOVER",
|
|
142
|
+
blockId: blockId,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function handleBlockAdded(blockType: string) {
|
|
147
|
+
sendToParent({
|
|
148
|
+
type: "BLOCK_ADD",
|
|
149
|
+
blockType: blockType,
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Serialize data for postMessage (handles Vue reactive proxies)
|
|
154
|
+
function serializeForPostMessage(data: any): any {
|
|
155
|
+
try {
|
|
156
|
+
return JSON.parse(JSON.stringify(data));
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.warn("[iframe] Failed to serialize data for postMessage:", error);
|
|
159
|
+
return undefined;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Message handler
|
|
164
|
+
function sendToParent(message: any) {
|
|
165
|
+
if (window.parent) {
|
|
166
|
+
// Serialize message to handle Vue reactive proxies
|
|
167
|
+
const serializedMessage = serializeForPostMessage(message);
|
|
168
|
+
if (!serializedMessage) {
|
|
169
|
+
console.error("[iframe] Failed to serialize message for postMessage");
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
window.parent.postMessage(serializedMessage, "*");
|
|
173
|
+
} else {
|
|
174
|
+
console.error("[iframe] No parent window found");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** PARTIALS HANDLING */
|
|
179
|
+
// Handle clicks on elements with data-partial attribute
|
|
180
|
+
let dataPartialClickHandler: ((event: Event) => void) | null = null;
|
|
181
|
+
|
|
182
|
+
function setupDataPartialClickHandler() {
|
|
183
|
+
if (!editorRef.value) {
|
|
184
|
+
console.error("[iframe] EditorPageRenderer: No editor reference found");
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
dataPartialClickHandler = (event: Event) => {
|
|
189
|
+
const target = event.target as HTMLElement;
|
|
190
|
+
const partialElement = target.closest("[data-partial]") as HTMLElement | null;
|
|
191
|
+
|
|
192
|
+
if (partialElement) {
|
|
193
|
+
sendToParent({
|
|
194
|
+
type: "CLICK_PARTIAL",
|
|
195
|
+
partial: partialElement.getAttribute("data-partial"),
|
|
196
|
+
} as PartialClickMessage);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
editorRef.value.addEventListener("click", dataPartialClickHandler);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** SORTABLE HANDLING */
|
|
204
|
+
const pageBlocksWrapperRef = ref<HTMLElement | null>(null);
|
|
205
|
+
|
|
206
|
+
/** DRAG AND DROP HANDLING - Cross-iframe drag support */
|
|
207
|
+
const isDraggingOver = ref(false);
|
|
208
|
+
const dropInsertIndex = ref<number | null>(null);
|
|
209
|
+
const dropIndicatorTop = ref<number | null>(null);
|
|
210
|
+
|
|
211
|
+
function handleDragEnter(event: DragEvent) {
|
|
212
|
+
// Skip if SortableJS is handling the drag (reordering existing blocks)
|
|
213
|
+
if (isSorting.value) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Check if SortableJS is dragging (has sortable drag element)
|
|
218
|
+
const dragSource = document.querySelector(".sortable-drag, .sortable-ghost");
|
|
219
|
+
if (dragSource) {
|
|
220
|
+
// SortableJS is handling this drag, don't interfere
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Check if this is a new block drag from sidebar (has block-type data)
|
|
225
|
+
if (event.dataTransfer?.types.includes("block-type")) {
|
|
226
|
+
isDraggingOver.value = true;
|
|
227
|
+
event.preventDefault();
|
|
228
|
+
event.stopPropagation();
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function handleDragLeave(event: DragEvent) {
|
|
233
|
+
// Skip if SortableJS is handling the drag
|
|
234
|
+
if (isSorting.value) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const relatedTarget = event.relatedTarget as HTMLElement | null;
|
|
239
|
+
const currentTarget = event.currentTarget as HTMLElement | null;
|
|
240
|
+
|
|
241
|
+
// Only hide if we're actually leaving the drop zone
|
|
242
|
+
if (!currentTarget?.contains(relatedTarget)) {
|
|
243
|
+
isDraggingOver.value = false;
|
|
244
|
+
dropInsertIndex.value = null;
|
|
245
|
+
dropIndicatorTop.value = null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function handleDragOver(event: DragEvent) {
|
|
250
|
+
// Skip if SortableJS is handling the drag (reordering existing blocks)
|
|
251
|
+
if (isSorting.value) {
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// Check if drag source is from within blocks wrapper (existing block being reordered)
|
|
256
|
+
const dragSource = document.querySelector(".sortable-drag, .sortable-ghost");
|
|
257
|
+
if (dragSource) {
|
|
258
|
+
// SortableJS is handling this drag, don't interfere
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Check if this is a new block drag from sidebar (has block-type data)
|
|
263
|
+
if (!event.dataTransfer?.types.includes("block-type")) {
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Allow drop
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
event.stopPropagation();
|
|
270
|
+
if (event.dataTransfer) {
|
|
271
|
+
event.dataTransfer.dropEffect = "move";
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// Calculate insertion position
|
|
275
|
+
if (!props.blocks || props.blocks.length === 0) {
|
|
276
|
+
dropInsertIndex.value = 0;
|
|
277
|
+
dropIndicatorTop.value = 0;
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const dropTarget = event.target as HTMLElement;
|
|
282
|
+
const blockElement = dropTarget.closest("[data-block-id]");
|
|
283
|
+
const wrapperElement = pageBlocksWrapperRef.value;
|
|
284
|
+
|
|
285
|
+
if (!wrapperElement) return;
|
|
286
|
+
|
|
287
|
+
if (blockElement) {
|
|
288
|
+
const blockId = blockElement.getAttribute("data-block-id");
|
|
289
|
+
if (blockId) {
|
|
290
|
+
const currentIndex = props.blocks.findIndex((b) => b.id === blockId);
|
|
291
|
+
if (currentIndex !== -1) {
|
|
292
|
+
// Determine if we should insert before or after based on mouse position
|
|
293
|
+
const blockRect = blockElement.getBoundingClientRect();
|
|
294
|
+
const wrapperRect = wrapperElement.getBoundingClientRect();
|
|
295
|
+
const mouseY = event.clientY;
|
|
296
|
+
const blockCenterY = blockRect.top + blockRect.height / 2;
|
|
297
|
+
|
|
298
|
+
if (mouseY < blockCenterY) {
|
|
299
|
+
// Insert before this block
|
|
300
|
+
dropInsertIndex.value = currentIndex;
|
|
301
|
+
dropIndicatorTop.value = blockRect.top - wrapperRect.top - 2;
|
|
302
|
+
} else {
|
|
303
|
+
// Insert after this block
|
|
304
|
+
dropInsertIndex.value = currentIndex + 1;
|
|
305
|
+
dropIndicatorTop.value = blockRect.bottom - wrapperRect.top - 2;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} else {
|
|
310
|
+
// Dragging over empty area - append to end
|
|
311
|
+
dropInsertIndex.value = props.blocks.length;
|
|
312
|
+
// Calculate position at the bottom of the wrapper
|
|
313
|
+
const wrapperRect = wrapperElement.getBoundingClientRect();
|
|
314
|
+
dropIndicatorTop.value = wrapperRect.height - 2;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function handleDrop(event: DragEvent) {
|
|
319
|
+
// Skip if SortableJS is handling the drop (reordering existing blocks)
|
|
320
|
+
if (isSorting.value) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Check if this is a SortableJS drop (has sortable drag element)
|
|
325
|
+
const dragSource = document.querySelector(".sortable-drag, .sortable-ghost");
|
|
326
|
+
if (dragSource) {
|
|
327
|
+
// SortableJS is handling this drop, don't interfere
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Check if this is a new block drag from sidebar (has block-type data)
|
|
332
|
+
if (!event.dataTransfer?.types.includes("block-type")) {
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
event.preventDefault();
|
|
337
|
+
event.stopPropagation();
|
|
338
|
+
|
|
339
|
+
if (!event.dataTransfer) {
|
|
340
|
+
// Reset drag state
|
|
341
|
+
isDraggingOver.value = false;
|
|
342
|
+
dropInsertIndex.value = null;
|
|
343
|
+
dropIndicatorTop.value = null;
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// Get block type from dataTransfer (set by AddBlockItem)
|
|
348
|
+
const blockType = event.dataTransfer.getData("block-type") || event.dataTransfer.getData("text/plain");
|
|
349
|
+
|
|
350
|
+
if (!blockType) {
|
|
351
|
+
// Reset drag state
|
|
352
|
+
isDraggingOver.value = false;
|
|
353
|
+
dropInsertIndex.value = null;
|
|
354
|
+
dropIndicatorTop.value = null;
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Calculate insertion position based on drop event (don't rely on stale dropInsertIndex)
|
|
359
|
+
let insertIndex: number;
|
|
360
|
+
|
|
361
|
+
if (!props.blocks || props.blocks.length === 0) {
|
|
362
|
+
insertIndex = 0;
|
|
363
|
+
} else {
|
|
364
|
+
const dropTarget = event.target as HTMLElement;
|
|
365
|
+
const blockElement = dropTarget.closest("[data-block-id]");
|
|
366
|
+
|
|
367
|
+
if (blockElement) {
|
|
368
|
+
const blockId = blockElement.getAttribute("data-block-id");
|
|
369
|
+
if (blockId) {
|
|
370
|
+
const currentIndex = props.blocks.findIndex((b) => b.id === blockId);
|
|
371
|
+
if (currentIndex !== -1) {
|
|
372
|
+
// Determine if we should insert before or after based on mouse position
|
|
373
|
+
const blockRect = blockElement.getBoundingClientRect();
|
|
374
|
+
const mouseY = event.clientY;
|
|
375
|
+
const blockCenterY = blockRect.top + blockRect.height / 2;
|
|
376
|
+
insertIndex = mouseY < blockCenterY ? currentIndex : currentIndex + 1;
|
|
377
|
+
} else {
|
|
378
|
+
insertIndex = props.blocks.length;
|
|
379
|
+
}
|
|
380
|
+
} else {
|
|
381
|
+
insertIndex = props.blocks.length;
|
|
382
|
+
}
|
|
383
|
+
} else {
|
|
384
|
+
// Dropped in empty area - append to end
|
|
385
|
+
insertIndex = props.blocks.length;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Reset drag state
|
|
390
|
+
isDraggingOver.value = false;
|
|
391
|
+
dropInsertIndex.value = null;
|
|
392
|
+
dropIndicatorTop.value = null;
|
|
393
|
+
|
|
394
|
+
// Send message to parent to add the block
|
|
395
|
+
sendToParent({
|
|
396
|
+
type: "BLOCK_ADD",
|
|
397
|
+
blockType: blockType,
|
|
398
|
+
index: insertIndex,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
async function initSortable() {
|
|
403
|
+
// Don't re-initialize if already sorting or initializing (prevents conflicts)
|
|
404
|
+
if (isSorting.value || isInitializingSortable.value) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (!layoutComponent.value) {
|
|
409
|
+
console.warn("Cannot initialize Sortable: layout component is not set");
|
|
410
|
+
return;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
const blockCount = props.blocks?.length;
|
|
414
|
+
if (!blockCount || blockCount === 0) {
|
|
415
|
+
console.warn("Cannot initialize Sortable: no blocks exist");
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Use the template ref first, fallback to getElementById
|
|
420
|
+
const sortableBlocksWrapper = pageBlocksWrapperRef.value || document.getElementById("page-blocks-wrapper");
|
|
421
|
+
|
|
422
|
+
if (!sortableBlocksWrapper) {
|
|
423
|
+
console.warn("page-blocks-wrapper element not found. Conditions:", {
|
|
424
|
+
hasLayout: !!layoutComponent.value,
|
|
425
|
+
blockCount: props.blocks?.length,
|
|
426
|
+
hasRef: !!pageBlocksWrapperRef.value,
|
|
427
|
+
pageViewportExists: !!document.getElementById("page-viewport"),
|
|
428
|
+
});
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// If Sortable is already initialized and working, don't re-initialize
|
|
433
|
+
if (sortableInstance.value) {
|
|
434
|
+
// Check if the instance is still valid by checking if the element is still attached
|
|
435
|
+
const { el } = sortableInstance.value;
|
|
436
|
+
if (el && el.isConnected && el === sortableBlocksWrapper) {
|
|
437
|
+
// Sortable is already initialized and valid, no need to re-initialize
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
// Instance exists but element is disconnected or different, destroy it
|
|
441
|
+
try {
|
|
442
|
+
sortableInstance.value.destroy();
|
|
443
|
+
} catch {
|
|
444
|
+
// Ignore errors during destruction
|
|
445
|
+
}
|
|
446
|
+
sortableInstance.value = null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
isInitializingSortable.value = true;
|
|
450
|
+
try {
|
|
451
|
+
sortableInstance.value = new Sortable(sortableBlocksWrapper, {
|
|
452
|
+
animation: 150,
|
|
453
|
+
ghostClass: "sortable-ghost",
|
|
454
|
+
chosenClass: "sortable-chosen",
|
|
455
|
+
dragClass: "sortable-drag",
|
|
456
|
+
group: "page-blocks",
|
|
457
|
+
forceFallback: false, // Use native HTML5 drag if available
|
|
458
|
+
fallbackOnBody: true, // Append fallback element to body
|
|
459
|
+
swapThreshold: 0.7, // Threshold for swap
|
|
460
|
+
onStart: () => {
|
|
461
|
+
isSorting.value = true;
|
|
462
|
+
},
|
|
463
|
+
onAdd: (event: any) => {
|
|
464
|
+
// This fires when an item is added from another list (drag from sidebar)
|
|
465
|
+
const { item: draggedElement, newIndex } = event;
|
|
466
|
+
const blockType = draggedElement.getAttribute("data-block-type");
|
|
467
|
+
|
|
468
|
+
if (blockType) {
|
|
469
|
+
// Send message to parent to handle block addition
|
|
470
|
+
sendToParent({
|
|
471
|
+
type: "BLOCK_ADD",
|
|
472
|
+
blockType: blockType,
|
|
473
|
+
index: newIndex,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// Remove the cloned HTML element that SortableJS added
|
|
477
|
+
draggedElement.remove();
|
|
478
|
+
}
|
|
479
|
+
},
|
|
480
|
+
onEnd: async (event: any) => {
|
|
481
|
+
const { oldIndex, newIndex } = event;
|
|
482
|
+
|
|
483
|
+
// Only handle reordering if this wasn't an add operation (oldIndex will be null for adds)
|
|
484
|
+
if (
|
|
485
|
+
oldIndex !== null &&
|
|
486
|
+
oldIndex !== undefined &&
|
|
487
|
+
newIndex !== null &&
|
|
488
|
+
newIndex !== undefined &&
|
|
489
|
+
oldIndex !== newIndex
|
|
490
|
+
) {
|
|
491
|
+
// Send message to parent to handle block reordering
|
|
492
|
+
sendToParent({
|
|
493
|
+
type: "BLOCK_REORDER",
|
|
494
|
+
oldIndex: oldIndex,
|
|
495
|
+
newIndex: newIndex,
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
// Reset sorting state after a small delay to ensure DOM is stable
|
|
500
|
+
await nextTick();
|
|
501
|
+
isSorting.value = false;
|
|
502
|
+
},
|
|
503
|
+
});
|
|
504
|
+
} finally {
|
|
505
|
+
isInitializingSortable.value = false;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
onBeforeMount(async () => {
|
|
510
|
+
isReady.value = false;
|
|
511
|
+
|
|
512
|
+
// Initialise registries for editor preview
|
|
513
|
+
// Exclude the editing registry to load only the theme and blocks
|
|
514
|
+
await initialiseRegistry(props.theme, false);
|
|
515
|
+
|
|
516
|
+
isReady.value = true;
|
|
517
|
+
|
|
518
|
+
setupDataPartialClickHandler();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
// Watch for blocks and layout changes to initialize Sortable
|
|
522
|
+
watch(
|
|
523
|
+
[() => props.blocks, () => layoutComponent.value, () => isReady.value],
|
|
524
|
+
async ([blocks, layout, ready]) => {
|
|
525
|
+
if (ready && layout && blocks && blocks.length > 0) {
|
|
526
|
+
await nextTick();
|
|
527
|
+
await initSortable();
|
|
528
|
+
}
|
|
529
|
+
},
|
|
530
|
+
{ immediate: true }
|
|
531
|
+
);
|
|
532
|
+
|
|
533
|
+
onMounted(async () => {
|
|
534
|
+
// Initialize Sortable after component is mounted
|
|
535
|
+
if (props.blocks && props.blocks.length > 0 && layoutComponent.value && isReady.value) {
|
|
536
|
+
await nextTick();
|
|
537
|
+
await initSortable();
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// Cleanup event listener and Sortable instance on unmount
|
|
542
|
+
onBeforeUnmount(() => {
|
|
543
|
+
if (dataPartialClickHandler && editorRef.value) {
|
|
544
|
+
editorRef.value.removeEventListener("click", dataPartialClickHandler);
|
|
545
|
+
dataPartialClickHandler = null;
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
// Destroy Sortable instance
|
|
549
|
+
if (sortableInstance.value) {
|
|
550
|
+
try {
|
|
551
|
+
sortableInstance.value.destroy();
|
|
552
|
+
} catch {
|
|
553
|
+
// Ignore errors during destruction
|
|
554
|
+
}
|
|
555
|
+
sortableInstance.value = null;
|
|
556
|
+
}
|
|
557
|
+
});
|
|
558
|
+
</script>
|
|
559
|
+
|
|
560
|
+
<style scoped lang="scss">
|
|
561
|
+
@use "../../assets/styles/mixins" as *;
|
|
562
|
+
|
|
563
|
+
.block-wrapper {
|
|
564
|
+
@include block-margin-classes;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
// target elements any data-partial="header" or empty data-partial attribute
|
|
568
|
+
:deep([data-partial]) {
|
|
569
|
+
&:hover {
|
|
570
|
+
@include overlay-apply(
|
|
571
|
+
var(--partial-backdrop-color),
|
|
572
|
+
var(--partial-border-color),
|
|
573
|
+
var(--partial-border-width),
|
|
574
|
+
var(--partial-border-style)
|
|
575
|
+
);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
cursor: pointer;
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
// When settings sidebar is open, show overlay on all partial elements using active color
|
|
582
|
+
.settings-open {
|
|
583
|
+
:deep([data-partial]) {
|
|
584
|
+
@include overlay-apply(
|
|
585
|
+
var(--partial-backdrop-color),
|
|
586
|
+
var(--partial-border-color),
|
|
587
|
+
var(--partial-border-width),
|
|
588
|
+
var(--partial-border-style)
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
#page-blocks-wrapper {
|
|
594
|
+
position: relative;
|
|
595
|
+
|
|
596
|
+
&.drag-over {
|
|
597
|
+
min-height: 100px;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
.drop-indicator {
|
|
602
|
+
position: absolute;
|
|
603
|
+
right: 0;
|
|
604
|
+
left: 0;
|
|
605
|
+
z-index: 1000;
|
|
606
|
+
display: flex;
|
|
607
|
+
gap: 0.2rem;
|
|
608
|
+
align-items: center;
|
|
609
|
+
width: 100%;
|
|
610
|
+
pointer-events: none;
|
|
611
|
+
transform: translateY(calc(var(--block-border-width, 4px) * -2));
|
|
612
|
+
animation: drop-indicator-pulse 1.5s ease-in-out infinite;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
.drop-indicator-line {
|
|
616
|
+
flex: 1;
|
|
617
|
+
height: 3px;
|
|
618
|
+
background: var(--block-border-color, #638ef1);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
.drop-indicator-label {
|
|
622
|
+
padding: 0.25rem 0.75rem;
|
|
623
|
+
font-size: 0.75rem;
|
|
624
|
+
font-weight: 600;
|
|
625
|
+
color: white;
|
|
626
|
+
white-space: nowrap;
|
|
627
|
+
background: var(--block-border-color, #638ef1);
|
|
628
|
+
border-radius: 0.375rem;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
@keyframes drop-indicator-pulse {
|
|
632
|
+
0%,
|
|
633
|
+
100% {
|
|
634
|
+
opacity: 1;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
50% {
|
|
638
|
+
opacity: 0.7;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
</style>
|
|
@@ -21,19 +21,11 @@
|
|
|
21
21
|
|
|
22
22
|
<h2 class="mb-3 text-xl font-bold">No blocks found</h2>
|
|
23
23
|
<template v-if="editable">
|
|
24
|
-
<
|
|
25
|
-
|
|
26
|
-
class="mb-9 inline-flex items-center gap-1.5 rounded-md border border-gray-300 bg-zinc-50 px-3 py-2 text-sm text-zinc-500 hover:border-zinc-400 hover:text-zinc-900 active:border-blue-600 active:bg-blue-50 active:text-blue-600"
|
|
27
|
-
@click="showAddBlockMenu = true"
|
|
24
|
+
<p
|
|
25
|
+
class="mb-9 inline-flex items-center gap-1.5 rounded-md border border-gray-300 bg-zinc-50 px-3 py-2 text-sm text-zinc-500"
|
|
28
26
|
>
|
|
29
27
|
Add a block to get started
|
|
30
|
-
</
|
|
31
|
-
<span
|
|
32
|
-
v-else
|
|
33
|
-
class="mb-9 inline-flex items-center gap-1.5 border border-transparent px-3 py-2 text-sm text-zinc-500"
|
|
34
|
-
>
|
|
35
|
-
Select a block from the sidebar
|
|
36
|
-
</span>
|
|
28
|
+
</p>
|
|
37
29
|
</template>
|
|
38
30
|
<!-- empty state image from assets -->
|
|
39
31
|
<img :src="emptyStateImage" alt="Empty state" class="mx-auto h-auto w-full max-w-xs" />
|
|
@@ -49,7 +41,6 @@
|
|
|
49
41
|
import { ref } from "vue";
|
|
50
42
|
import emptyStateImage from "../../assets/images/empty-state.jpg";
|
|
51
43
|
|
|
52
|
-
const showAddBlockMenu = defineModel<boolean>("showAddBlockMenu");
|
|
53
44
|
const isDraggingOver = ref(false);
|
|
54
45
|
const emit = defineEmits<{
|
|
55
46
|
(e: "blockAdded", blockType: string): void;
|