diff --git a/frontend/src/stores/studioStore.ts b/frontend/src/stores/studioStore.ts index 2ff46b9e..e53382c7 100644 --- a/frontend/src/stores/studioStore.ts +++ b/frontend/src/stores/studioStore.ts @@ -23,7 +23,7 @@ import { registerStudioPageScripts, unregisterStudioPageScripts, } from "@/data/studioPageScripts" -import { setCustomComponentFilePaths } from "@/utils/components" +import { registerCustomComponentPaths } from "@/utils/components" import type { CustomVueComponentMeta } from "@/types/vue" import type { StudioApp } from "@/types/Studio/StudioApp" @@ -443,7 +443,7 @@ const useStudioStore = defineStore("store", () => { // custom components async function setCustomComponents() { await loadCustomVueComponents() - setCustomComponentFilePaths(customVueComponents.value) + await registerCustomComponentPaths(customVueComponents.value) } async function loadCustomVueComponents() { diff --git a/frontend/src/utils/block.ts b/frontend/src/utils/block.ts index 67055916..3e2d098b 100644 --- a/frontend/src/utils/block.ts +++ b/frontend/src/utils/block.ts @@ -11,6 +11,7 @@ import LucideCode from "~icons/lucide/code" import { generateId, isObjectEmpty, kebabToCamelCase, numberToPx } from "./helpers"; import { copyObject, getBlockCopy, getComponentBlock } from "@/utils/serializer" +import { componentHasDefaultSlot, getComponentSlots } from "@/utils/components" import type { StyleValue, FrappeUIComponents } from "@/types" import type { ComponentEvent } from "@/types/ComponentEvent" @@ -72,6 +73,8 @@ class Block implements BlockOptions { } if (options.isCustomVueComponent) { this.isCustomVueComponent = options.isCustomVueComponent + // Warm the slot cache so canHaveChildren() knows this component's default slot + void getComponentSlots(this.componentName, true) } // get component props @@ -219,10 +222,21 @@ class Block implements BlockOptions { } canHaveChildren() { - if (this.isRoot() || this.isContainer() || this.hasComponentSlots() || this.hasChildren()) return true + if ( + this.isRoot() || + this.isContainer() || + this.hasComponentSlots() || + this.hasChildren() || + this.hasDefaultSlot() + ) + return true return false } + hasDefaultSlot() { + return componentHasDefaultSlot(this.componentName) + } + isRoot() { return this.componentId === "root" || this.originalElement === "body"; } diff --git a/frontend/src/utils/components.ts b/frontend/src/utils/components.ts index a94c0264..0eb527b8 100644 --- a/frontend/src/utils/components.ts +++ b/frontend/src/utils/components.ts @@ -4,7 +4,7 @@ import type { VueProp, VuePropType } from "@/types/vue" import * as jsonTypes from "@/json_types" import { isObjectEmpty } from "@/utils/helpers" -import { ConcreteComponent } from "vue" +import { ConcreteComponent, reactive } from "vue" import type { CustomVueComponentMeta } from "@/types/vue" interface ComponentTypes { @@ -219,11 +219,13 @@ const frameworkUIComponentPaths: Record = { const templateCache = new Map() const customComponentFilePaths = new Map() -function setCustomComponentFilePaths(components: CustomVueComponentMeta[]) { + +async function registerCustomComponentPaths(components: CustomVueComponentMeta[]) { customComponentFilePaths.clear() for (const comp of components) { customComponentFilePaths.set(comp.component_name, comp.file_path) } + await Promise.all(components.map((comp) => getComponentSlots(comp.component_name, true))) } function getComponentTemplate(componentName: string): string { @@ -281,8 +283,11 @@ async function fetchCustomComponentTemplate(componentName: string): Promise|\s*>(.*?)<\/slot>)?/gi - const slots = [] + const slots = new Map() let match while ((match = slotRegex.exec(template)) !== null) { - // Named slot with name attribute - const namedSlot = match[1] - // Default/unnamed slot or slot content - const defaultSlotContent = match[2] - - if (namedSlot) { - slots.push({ - name: namedSlot, - type: "named", - hasDefaultContent: !!defaultSlotContent, - }) - } else if (defaultSlotContent || match[0].includes(">()) + async function getComponentSlots(componentName: string, isCustomVueComponent?: boolean) { - const template = isCustomVueComponent ? await fetchCustomComponentTemplate(componentName) : getComponentTemplate(componentName) - return parseSlotsFromTemplate(template) + const cached = slotsCache.get(componentName) + if (cached) return cached + const template = isCustomVueComponent + ? await fetchCustomComponentTemplate(componentName) + : getComponentTemplate(componentName) + const slots = parseSlotsFromTemplate(template) + if (template) slotsCache.set(componentName, slots) + return slots +} + +function componentHasDefaultSlot(componentName: string): boolean { + if (!slotsCache.has(componentName)) { + const template = getComponentTemplate(componentName) + if (template) slotsCache.set(componentName, parseSlotsFromTemplate(template)) + } + return (slotsCache.get(componentName) ?? []).some((slot) => slot.type === "default") +} + +function invalidateComponentCache(componentName: string) { + templateCache.delete(componentName) + slotsCache.delete(componentName) } function resolveProperty( @@ -369,4 +381,11 @@ function resolveProperty( return { type: type as string, inputType, options } } -export { getComponentProps, getComponentTemplate, getComponentSlots, setCustomComponentFilePaths } +export { + getComponentProps, + getComponentTemplate, + getComponentSlots, + componentHasDefaultSlot, + invalidateComponentCache, + registerCustomComponentPaths, +} diff --git a/frontend/src/utils/useLiveEditor.ts b/frontend/src/utils/useLiveEditor.ts index f58ff066..89ebf71c 100644 --- a/frontend/src/utils/useLiveEditor.ts +++ b/frontend/src/utils/useLiveEditor.ts @@ -1,6 +1,7 @@ import { inject, onMounted, onBeforeUnmount } from "vue" import { fetchApp } from "@/utils/helpers" +import { invalidateComponentCache, getComponentSlots } from "@/utils/components" import useStudioStore from "@/stores/studioStore" import useComponentStore from "@/stores/componentStore" @@ -40,13 +41,27 @@ export function useLiveEditor() { // dev; content edits hot-reload on their own). Reload the app's component set. const onCustomComponentsChanged = () => store.loadCustomVueComponents() + // 3. A custom .vue component's content changed on disk. Vite hot-reloads its rendered module, + // but our template/slot caches keep the stale copy — so a newly added/removed wouldn't + // affect droppability. Drop the caches for the edited component and re-warm its slots. + const onCustomComponentFileChanged = ({ path }: { path: string }) => { + const component = store.customVueComponents.find( + (comp) => `${comp.studio_app}/${comp.studio_file_path}` === path, + ) + if (!component) return + invalidateComponentCache(component.component_name) + void getComponentSlots(component.component_name, true) + } + onMounted(() => { socket?.on("studio_doc_update", onDiskSync) import.meta.hot?.on("studio:custom-components-changed", onCustomComponentsChanged) + import.meta.hot?.on("studio:file-changed", onCustomComponentFileChanged) }) onBeforeUnmount(() => { socket?.off("studio_doc_update", onDiskSync) import.meta.hot?.off("studio:custom-components-changed", onCustomComponentsChanged) + import.meta.hot?.off("studio:file-changed", onCustomComponentFileChanged) }) }