diff --git a/frontend/components.d.ts b/frontend/components.d.ts index 4f34374a..86d33c2b 100644 --- a/frontend/components.d.ts +++ b/frontend/components.d.ts @@ -77,6 +77,7 @@ declare module 'vue' { RouterLink: typeof import('vue-router')['RouterLink'] RouterView: typeof import('vue-router')['RouterView'] ScriptSection: typeof import('./src/components/ScriptSection.vue')['default'] + SearchBlock: typeof import('./src/components/SearchBlock.vue')['default'] SectionContainer: typeof import('./src/components/SectionContainer.vue')['default'] SettingItem: typeof import('./src/components/SettingItem.vue')['default'] Sidebar: typeof import('./src/components/AppLayout/Sidebar.vue')['default'] diff --git a/frontend/src/components/DraggablePopup.vue b/frontend/src/components/DraggablePopup.vue index 48e637dc..63926efb 100644 --- a/frontend/src/components/DraggablePopup.vue +++ b/frontend/src/components/DraggablePopup.vue @@ -1,8 +1,7 @@ - @@ -92,6 +105,8 @@ import { Ref, ref, watch, reactive, computed, onMounted, provide } from "vue" import { LoadingIndicator } from "frappe-ui" import StudioComponent from "@/components/StudioComponent.vue" import FitScreenIcon from "@/components/Icons/FitScreenIcon.vue" +import DraggablePopup from "@/components/DraggablePopup.vue" +import SearchBlock from "@/components/SearchBlock.vue" import useStudioStore from "@/stores/studioStore" import useCanvasStore from "@/stores/canvasStore" @@ -254,8 +269,16 @@ const activeSlotIds = computed(() => { return slotIds }) -const { setScaleAndTranslate, setupHistory, getRootBlock, setRootBlock, findBlock, removeBlock, toggleMode } = - useCanvasUtils(canvasProps, canvasContainer, canvas, rootComponent, selectedBlockIds, history) +const { + setScaleAndTranslate, + setupHistory, + getRootBlock, + setRootBlock, + findBlock, + removeBlock, + toggleMode, + scrollBlockIntoView, +} = useCanvasUtils(canvasProps, canvasContainer, canvas, rootComponent, selectedBlockIds, history) watch( () => activeSlotIds.value, @@ -318,6 +341,7 @@ defineExpose({ selectedBlockIds, selectedBlocks, selectBlock, + scrollBlockIntoView, selectBlockById, clearSelection, isRootSelected, diff --git a/frontend/src/stores/studioStore.ts b/frontend/src/stores/studioStore.ts index e4dd8b3f..d31fe2e6 100644 --- a/frontend/src/stores/studioStore.ts +++ b/frontend/src/stores/studioStore.ts @@ -40,6 +40,7 @@ const useStudioStore = defineStore("store", () => { // dialogs const showSlotEditorDialog = ref(false) + const showSearchBlock = ref(false) // studio apps const activeApp = ref(null) @@ -350,6 +351,7 @@ const useStudioStore = defineStore("store", () => { componentContextMenu, // dialogs showSlotEditorDialog, + showSearchBlock, // studio app activeApp, setApp, diff --git a/frontend/src/utils/useCanvasUtils.ts b/frontend/src/utils/useCanvasUtils.ts index 9924ff5b..71f7c19c 100644 --- a/frontend/src/utils/useCanvasUtils.ts +++ b/frontend/src/utils/useCanvasUtils.ts @@ -129,6 +129,103 @@ export function useCanvasUtils( } } + async function scrollIntoView( + blockToFocus: Block, + canvasProps: CanvasProps, + canvasContainer: Ref, + canvas: Ref, + ) { + // wait for editor to render + await new Promise((resolve) => setTimeout(resolve, 100)); + if (!selectedBlockIds.value.has(blockToFocus.componentId)) { + blockToFocus.selectBlock(); + } + await nextTick(); + // single nextTick is not enough, adding this to ensure the DOM is updated after selection + await nextTick(); + + if ( + !canvasContainer.value || + !canvas.value || + blockToFocus.isRoot() || + !blockToFocus.isVisible() || + blockToFocus.getParentBlock()?.isSVG() + ) { + return; + } + const container = canvasContainer.value as HTMLElement; + const containerRect = container.getBoundingClientRect(); + await nextTick(); + const selectedBlock = canvasContainer.value.querySelector( + `.editor[data-component-id="${blockToFocus.componentId}"][selected=true]`, + ) as HTMLElement; + if (!selectedBlock) { + return; + } + const blockRect = reactive(useElementBounding(selectedBlock)); + // check if block is in view + if ( + blockRect.top >= containerRect.top && + blockRect.bottom <= containerRect.bottom && + blockRect.left >= containerRect.left && + blockRect.right <= containerRect.right + ) { + return; + } + + let padding = 80; + let paddingBottom = 200; + const blockWidth = blockRect.width + padding * 2; + const containerBound = container.getBoundingClientRect(); + const blockHeight = blockRect.height + padding + paddingBottom; + + const scaleX = containerBound.width / blockWidth; + const scaleY = containerBound.height / blockHeight; + const newScale = Math.min(scaleX, scaleY); + + const scaleDiff = canvasProps.scale - canvasProps.scale * newScale; + if (scaleDiff > 0.2) { + return; + } + + if (newScale < 1) { + canvasProps.scale = canvasProps.scale * newScale; + await new Promise((resolve) => setTimeout(resolve, 100)); + await nextTick(); + blockRect.update(); + } + + padding = padding * canvasProps.scale; + paddingBottom = paddingBottom * canvasProps.scale; + + // slide in block from the closest edge of the container + const diffTop = containerRect.top - blockRect.top + padding; + const diffBottom = blockRect.bottom - containerRect.bottom + paddingBottom; + const diffLeft = containerRect.left - blockRect.left + padding; + const diffRight = blockRect.right - containerRect.right + padding; + + if (diffTop > 0) { + canvasProps.translateY += diffTop / canvasProps.scale; + } else if (diffBottom > 0) { + canvasProps.translateY -= diffBottom / canvasProps.scale; + } + + if (diffLeft > 0) { + canvasProps.translateX += diffLeft / canvasProps.scale; + } else if (diffRight > 0) { + canvasProps.translateX -= diffRight / canvasProps.scale; + } + } + + async function scrollBlockIntoView(blockToFocus: Block) { + return scrollIntoView( + blockToFocus, + canvasProps, + canvasContainer as unknown as Ref, + canvas as unknown as Ref, + ); + } + return { setScaleAndTranslate, setupHistory, @@ -137,5 +234,6 @@ export function useCanvasUtils( findBlock, removeBlock, toggleMode, + scrollBlockIntoView, }; } diff --git a/frontend/src/utils/useDomAttr.ts b/frontend/src/utils/useDomAttr.ts new file mode 100644 index 00000000..93560ac0 --- /dev/null +++ b/frontend/src/utils/useDomAttr.ts @@ -0,0 +1,50 @@ +import { ref, watch, onMounted, onBeforeUnmount, unref } from "vue" + +type MaybeRef = T | { value: T } + +export function useDomAttr( + target: MaybeRef, + attrName: string, + options: { + immediate?: boolean + transform?: (value: string | null) => any + } = {}, +) { + const { immediate = true, transform = (v) => v } = options + + const value = ref(null) + let observer: MutationObserver | null = null + + const read = () => { + const el = unref(target as HTMLElement) + if (!el) return + value.value = transform(el.getAttribute(attrName)) + } + + onMounted(() => { + const el = unref(target as HTMLElement) + if (!el) return + + if (immediate) read() + + observer = new MutationObserver((mutations) => { + for (const m of mutations) { + if (m.type === "attributes" && m.attributeName === attrName) { + read() + } + } + }) + + observer.observe(el, { + attributes: true, + attributeFilter: [attrName], + }) + }) + + onBeforeUnmount(() => { + observer?.disconnect() + observer = null + }) + + return value +} diff --git a/frontend/src/utils/useStudioEvents.ts b/frontend/src/utils/useStudioEvents.ts index 128f140d..b631ccfb 100644 --- a/frontend/src/utils/useStudioEvents.ts +++ b/frontend/src/utils/useStudioEvents.ts @@ -130,6 +130,12 @@ export function useStudioEvents() { return } + // search block + if (e.key === "f" && isCtrlOrCmd(e) && e.shiftKey) { + e.preventDefault(); + store.showSearchBlock = true; + } + if (isCtrlOrCmd(e) || e.shiftKey) { return }