Skip to content

Commit 2636a94

Browse files
committed
feat: enhance theme editor preview updates
- Allow root-relative paths for images and links - Support function-based live update handlers in Visual.handleLiveUpdate - Add script patching logic to execute new inline/external scripts - Improve DOM diffing and insertion logic for added sections - Fix Emmet parser to support colons and underscores in class names
1 parent d007452 commit 2636a94

File tree

13 files changed

+137
-25
lines changed

13 files changed

+137
-25
lines changed

resources/assets/editor/App.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
store.setTemplates(data.templates);
3131
store.setPreloadedModels(data.preloadedModels);
3232
store.setAvailableSections(window.ThemeEditor.availableSections());
33-
store.setPreviewIframeReady()
33+
store.setPreviewIframeReady();
3434
3535
if (templateChanged) {
3636
router.replace('/');
@@ -157,4 +157,4 @@
157157
</div>
158158
</div>
159159
</div>
160-
</template>
160+
</template>

resources/assets/editor/components/ImagePicker.vue

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
'(\\#[-a-z\\d_]*)?$',
2323
'i');
2424
25-
return !!pattern.test(str);
25+
return str.startsWith('/') || !!pattern.test(str);
2626
}
2727
2828
const store = useStore();

resources/assets/editor/injected.ts

Lines changed: 90 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -105,7 +105,7 @@ class VisualObject {
105105
return;
106106
}
107107

108-
let config: LiveUpdateOptions | undefined;
108+
let config: LiveUpdateOptions | ((value: any) => void) | undefined;
109109

110110
if (block && mappings.blocks?.[block.type]) {
111111
config = mappings.blocks[block.type][settingId];
@@ -117,6 +117,12 @@ class VisualObject {
117117
return;
118118
}
119119

120+
if (typeof config === 'function') {
121+
// then use it as a custom handler, no target
122+
config(settingValue);
123+
return skipRefresh();
124+
}
125+
120126
const targetEl = config.target ? (container.querySelector(config.target) as HTMLElement) : null;
121127

122128
if (!targetEl) {
@@ -161,6 +167,8 @@ class ThemeEditor {
161167
private hoverDebounce = 0;
162168
private reorderingSectionId: string | null = null;
163169

170+
private sectionContainers = new Map<string, HTMLElement>();
171+
164172
private discardLivewireComponentNotFoundError = false;
165173

166174
private messageHandlers: Record<string, (data: any, messageId?: string) => void> = {
@@ -191,6 +199,7 @@ class ThemeEditor {
191199
});
192200

193201
this.sectionsOrder = window.themeData.sectionsOrder;
202+
this.buildSectionContainers();
194203

195204
window.addEventListener('message', ({ data }) => this.handleMessage(data));
196205
window.addEventListener('resize', () => this.handleWindowResize());
@@ -219,6 +228,23 @@ class ThemeEditor {
219228
this.removeBtn = this.sectionOverlay.querySelector('#remove') as HTMLButtonElement;
220229
}
221230

231+
private buildSectionContainers() {
232+
document.querySelectorAll(`[${ATTRS.SectionId}]`).forEach((el) => {
233+
this.sectionContainers.set((el as HTMLElement).dataset.sectionId!, el.parentNode as HTMLElement);
234+
});
235+
}
236+
237+
private findCommentParent(text: string): Node | null {
238+
const iterator = document.createNodeIterator(document.body, NodeFilter.SHOW_COMMENT, {
239+
acceptNode(node) {
240+
return node.nodeValue?.trim() === text ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_SKIP;
241+
},
242+
});
243+
244+
const commentNode = iterator.nextNode();
245+
return commentNode?.parentNode ?? null;
246+
}
247+
222248
private attachButtonEvents() {
223249
this.moveUpBtn.onclick = () => this.postMessage(ACTIONS.MOVE_SECTION_UP, this.activeSectionId);
224250
this.moveDownBtn.onclick = () => this.postMessage(ACTIONS.MOVE_SECTION_DOWN, this.activeSectionId);
@@ -568,13 +594,59 @@ class ThemeEditor {
568594
});
569595
}
570596

597+
private patchScripts(existingContainer: Element, newContainer: Element) {
598+
const existingScripts = Array.from(existingContainer.querySelectorAll('script'));
599+
const newScripts = Array.from(newContainer.querySelectorAll('script'));
600+
601+
newScripts.forEach((newScript) => {
602+
const isInline = !newScript.src;
603+
const matchesExisting = existingScripts.some((existingScript) => {
604+
// Match external scripts by src
605+
if (!isInline && existingScript.src === newScript.src) return true;
606+
607+
// Match inline scripts by content and attributes
608+
if (isInline && existingScript.textContent === newScript.textContent) {
609+
return Array.from(newScript.attributes).every(
610+
(attr) => existingScript.getAttribute(attr.name) === attr.value
611+
);
612+
}
613+
614+
return false;
615+
});
616+
617+
if (!matchesExisting) {
618+
const executableScript = document.createElement('script');
619+
620+
// Copy attributes
621+
Array.from(newScript.attributes).forEach((attr) => {
622+
executableScript.setAttribute(attr.name, attr.value);
623+
});
624+
625+
// Copy inline content
626+
if (isInline) {
627+
executableScript.textContent = newScript.textContent;
628+
}
629+
630+
// Append to existing container to execute
631+
existingContainer.appendChild(executableScript);
632+
}
633+
});
634+
}
635+
571636
private refreshPreviewer({ html, updatedSections }: { html: string; updatedSections: Map<string, any> }) {
572637
const newDoc = new DOMParser().parseFromString(html, 'text/html');
638+
const sectionContainers = this.sectionContainers;
639+
640+
morphdom(document.head, newDoc.head);
573641

574642
if (updatedSections.size === 0) {
575-
this.patchNode(document.documentElement, newDoc.documentElement);
643+
this.patchNode(document.body, newDoc.body);
644+
window.Visual._dispatch('page:load', {});
576645
// document.documentElement.innerHTML = newDoc.documentElement.innerHTML;
577646
} else {
647+
const templateContainer = this.findCommentParent('BEGIN: template') as HTMLElement;
648+
const sections = document.querySelectorAll(`[${ATTRS.SectionType}]`);
649+
578650
updatedSections.forEach((context: any, sectionId: string) => {
579651
const oldEl = document.querySelector(`[data-section-id="${sectionId}"]`);
580652
const newEl = newDoc.querySelector(`[data-section-id="${sectionId}"]`);
@@ -583,20 +655,26 @@ class ThemeEditor {
583655
window.Visual._dispatch(EVENTS.SECTION_UNLOAD, context);
584656
this.patchNode(oldEl, newEl);
585657
} else if (!oldEl && newEl) {
586-
const sections = document.querySelectorAll(`[${ATTRS.SectionType}]`);
587658
const position = context.position ?? sections.length;
588-
const parent = sections[0]?.parentNode;
589-
590-
if (!parent) {
591-
return;
592-
}
659+
const sectionId = (newEl as HTMLElement).dataset.sectionId as string;
593660

594661
if (position <= 0) {
595-
parent.insertBefore(newEl, sections[0]);
662+
let parent = sectionContainers.get(sectionId) ?? templateContainer;
663+
parent.insertBefore(newEl, parent.firstChild);
664+
665+
if (!sectionContainers.has(sectionId)) {
666+
sectionContainers.set(sectionId, parent);
667+
}
596668
} else if (position >= sections.length) {
669+
let parent = sectionContainers.get(sectionId) ?? templateContainer;
597670
parent.appendChild(newEl);
671+
672+
if (!sectionContainers.has(sectionId)) {
673+
sectionContainers.set(sectionId, parent);
674+
}
598675
} else {
599-
parent.insertBefore(newEl, sections[position]);
676+
const nextSection = sections[position];
677+
nextSection.parentNode?.insertBefore(newEl, nextSection);
600678
}
601679
} else {
602680
return;
@@ -606,6 +684,8 @@ class ThemeEditor {
606684
});
607685
}
608686

687+
this.patchScripts(document.body, newDoc.body);
688+
609689
if (this.activeSectionId) {
610690
const el = document.querySelector(`[${ATTRS.SectionId}="${this.activeSectionId}"]`) as HTMLElement;
611691
if (el) {

resources/assets/editor/store.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -346,7 +346,9 @@ export const useStore = defineStore('main', () => {
346346

347347
switch (setting.type) {
348348
case 'image': {
349-
return String(value).startsWith('http') ? value : `${window.ThemeEditor.imagesBaseUrl()}/${value}`;
349+
return String(value).startsWith('http') || String(value).startsWith('/')
350+
? value
351+
: `${window.ThemeEditor.imagesBaseUrl()}/${value}`;
350352
}
351353

352354
case 'product': {
@@ -513,7 +515,6 @@ export const useStore = defineStore('main', () => {
513515
};
514516

515517
themeData.sectionsData[id] = sectionData;
516-
517518
themeData.sectionsOrder.push(id);
518519

519520
// set default blocks
@@ -587,6 +588,7 @@ export const useStore = defineStore('main', () => {
587588
delete section.blocks[blockId];
588589
section.blocks_order = section.blocks_order.filter((id) => id !== blockId);
589590

591+
dirtySections.set(sectionId, { section: toRaw(section), block: null, settingId: null });
590592
await persistThemeData();
591593
}
592594

resources/assets/editor/views/index.vue

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
const sectionsDialogOpened = ref(false);
99
const search = ref('');
1010
const sections = sortBy(window.ThemeEditor.availableSections(), ['name'], ['asc']);
11+
const isJsonTemplate = computed(() => !!store.themeData.source)
1112
1213
const availableSections = computed(() => {
1314
return sections.filter((section) => {
@@ -127,6 +128,7 @@
127128
:title="$t('Template Sections')"
128129
:order="store.contentSectionsOrder"
129130
:sections="store.contentSections"
131+
:static="!isJsonTemplate"
130132
@reorder="onContentSectionsReorder"
131133
@reordering="store.reorderingContentSections"
132134
@addSection="toggleSectionsDialog"
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
@php
2+
if (ThemeEditor::inDesignMode()) {
3+
ThemeEditor::startRenderingTemplate();
4+
}
5+
@endphp
6+
<!--BEGIN: template-->
7+
{{ $slot }}
8+
<!--END: template-->
9+
@php
10+
if (ThemeEditor::inDesignMode()) {
11+
ThemeEditor::stopRenderingTemplate();
12+
}
13+
@endphp

src/Middlewares/InjectThemeEditorScript.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,11 @@ public function handle(Request $request, Closure $next)
7474
->merge($renderedSections->where('group', 'afterTemplate'))
7575
->pluck('id'),
7676

77-
'sectionsData' => $this->themeDataCollector->getSectionsData()->all(),
77+
'sectionsData' => (object) $this->themeDataCollector->getSectionsData()->all(),
7878

7979
'settings' => $this->themeDataCollector->getThemeSettings(),
8080

81-
'source' => encrypt($this->themeEditor->renderingJsonView()),
81+
'source' => $this->themeEditor->renderingJsonView() ? encrypt($this->themeEditor->renderingJsonView()) : null,
8282

8383
'haveEdits' => $this->checkIfHaveEdits(),
8484
];

src/Sections/Support/SectionData.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ public function __construct(
3131

3232
public static function make(string $id, array $data, Section $section, ?string $sourceFile = null): self
3333
{
34+
3435
$blocks = self::prepareBlocks($data['blocks'] ?? [], $section->blocks, $id);
3536

3637
return new self(
@@ -60,6 +61,7 @@ protected static function prepareSettings(array $settings, array $settingsSchema
6061

6162
protected static function prepareBlocks(array $blocks, array $blocksSchemas, string $sectionId): array
6263
{
64+
6365
return collect($blocks)->map(function ($block, $id) use ($blocksSchemas, $sectionId) {
6466
$blockSchema = collect($blocksSchemas)->firstWhere('type', $block['type']);
6567

src/Settings/Support/ImageTransformer.php

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,18 @@ public function __invoke(?string $path = null)
2626
// @see https://stackoverflow.com/questions/41194159/how-to-catch-hex2bin-warning
2727
if (ctype_xdigit($encodedName) && strlen($encodedName) % 2 == 0) {
2828
$originalName = hex2bin($encodedName);
29-
} else {
30-
$originalName = $encodedName;
29+
30+
return new ImageValue(
31+
name: $originalName,
32+
path: $path,
33+
url: Storage::disk(config('bagisto_visual.images_storage'))->url($path)
34+
);
3135
}
3236

3337
return new ImageValue(
34-
name: $originalName,
38+
name: $encodedName,
3539
path: $path,
36-
url: Storage::disk(config('bagisto_visual.images_storage'))->url($path)
40+
url: url($path)
3741
);
3842
}
3943
}

src/Settings/Support/LinkTransformer.php

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,14 @@ public function __invoke(?string $url = null)
1010
return null;
1111
}
1212

13-
if (strpos($url, 'visual://') !== 0) {
13+
if (str_starts_with($url, 'http://') || str_starts_with($url, 'https://')) {
1414
return $url;
1515
}
1616

17+
if (! str_starts_with($url, 'visual://')) {
18+
return url($url);
19+
}
20+
1721
if (preg_match('/^visual:\/\/([^:]+):([^\/]+)\/(.*)?$/', $url, $matches)) {
1822
return match ($matches[1]) {
1923
'categories' => url($matches[3]),

0 commit comments

Comments
 (0)