Skip to content
139 changes: 114 additions & 25 deletions frontend/src/components/StudioCanvas.vue
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,14 @@
<div
class="fixed flex gap-40"
ref="canvas"
@mouseenter="isCanvasActive = true"
@mouseleave="isCanvasActive = false"
:style="{
transformOrigin: 'top center',
transform: `scale(${canvasProps.scale}) translate(${canvasProps.translateX}px, ${canvasProps.translateY}px)`,
}"
>
<div class="dark:bg-zinc-900 absolute right-0 top-[-60px] flex rounded-md bg-white px-3">
<div
v-show="!canvasProps.scaling && !canvasProps.panning"
class="w-auto cursor-pointer p-2"
v-for="breakpoint in canvasProps.breakpoints"
:key="breakpoint.device"
@click.stop="breakpoint.visible = !breakpoint.visible"
>
<FeatherIcon
:name="breakpoint.icon"
class="h-8 w-6"
:class="{
'dark:text-zinc-50 text-gray-700': breakpoint.visible,
'dark:text-zinc-500 text-gray-300': !breakpoint.visible,
}"
/>
</div>
</div>

<div
class="canvas relative flex h-full bg-white shadow-2xl contain-layout"
:style="{
Expand Down Expand Up @@ -74,22 +59,70 @@
</div>

<div
class="fixed bottom-12 left-[50%] z-40 flex translate-x-[-50%] cursor-default items-center justify-center gap-2 rounded-lg bg-white px-3 py-2 text-center text-sm font-semibold text-gray-600 shadow-md"
class="fixed bottom-12 left-[50%] z-40 flex translate-x-[-50%] items-center gap-4 rounded-lg bg-white px-5 py-2 text-sm font-medium text-gray-600 shadow-md"
v-show="!canvasProps.panning"
>
{{ Math.round(canvasProps.scale * 100) + "%" }}
<div class="ml-2 cursor-pointer" @click="setScaleAndTranslate">
<FitScreenIcon />
<!-- Breakpoint Toggles -->
<div class="flex items-center gap-2 border-r border-gray-300 pr-3">
<button
v-for="breakpoint in canvasProps.breakpoints"
:key="breakpoint.device"
@click.stop="breakpoint.visible = !breakpoint.visible"
class="rounded p-1 transition-transform hover:scale-110 hover:bg-gray-100"
:title="breakpoint.displayName"
aria-label="Toggle Breakpoint"
>
<FeatherIcon
:name="breakpoint.icon"
class="h-5 w-5 cursor-pointer"
:class="{
'dark:text-zinc-50 text-gray-700': breakpoint.visible,
'dark:text-zinc-500 text-gray-300': !breakpoint.visible,
}"
/>
</button>
</div>

<!-- Zoom Controls -->
<div class="flex items-center gap-3 pl-2">
<button
title="Zoom Out (-)"
aria-label="Zoom Out"
@click="zoomOut"
class="rounded p-1 transition-transform hover:scale-110 hover:bg-gray-100"
>
<FeatherIcon name="minus" class="h-5 w-5" />
</button>

<span class="min-w-[3ch] text-center">{{ Math.round(canvasProps.scale * 100) + "%" }}</span>


<button
title="Zoom In (+)"
aria-label="Zoom In"
@click="zoomIn"
class="rounded p-1 transition-transform hover:scale-110 hover:bg-gray-100"
>
<FeatherIcon name="plus" class="h-5 w-5" />
</button>

<button
title="Fit to Screen (0)"
aria-label="Fit to Screen"
@click="() => setScaleAndTranslate()"
class="rounded p-1 transition-transform hover:scale-110 hover:bg-gray-100"
>
<FeatherIcon name="maximize" class="h-5 w-5" />
</button>
</div>
</div>
</div>
</template>

<script setup lang="ts">
import { Ref, ref, watch, reactive, computed, onMounted, provide } from "vue"
import { Ref, ref, watch, reactive, computed, onMounted, provide, onBeforeUnmount } from "vue"
import { LoadingIndicator } from "frappe-ui"
import StudioComponent from "@/components/StudioComponent.vue"
import FitScreenIcon from "@/components/Icons/FitScreenIcon.vue"

import useStudioStore from "@/stores/studioStore"
import { getBlockCopy, getBlockInfo } from "@/utils/helpers"
Expand Down Expand Up @@ -117,6 +150,7 @@ const canvasContainer = ref(null)
const canvas = ref<HTMLElement | null>(null)
const overlay = ref(null)
const showBlocks = ref(false)
const isCanvasActive = ref(false)

const canvasProps = reactive({
overlayElement: null,
Expand Down Expand Up @@ -265,7 +299,15 @@ onMounted(() => {
const canvasContainerEl = canvasContainer.value as unknown as HTMLElement
const canvasEl = canvas.value as unknown as HTMLElement
canvasProps.overlayElement = overlay.value
setScaleAndTranslate()

// Restore saved zoom level
const savedZoom = parseFloat(localStorage.getItem("studioCanvasZoom") || "1")
const isZoomRestored = !isNaN(savedZoom) && savedZoom >= 0.2 && savedZoom <= 2
if (isZoomRestored) {
canvasProps.scale = savedZoom
}

setScaleAndTranslate(false, !isZoomRestored)
showBlocks.value = true
setupHistory()
useCanvasEvents(
Expand All @@ -278,6 +320,53 @@ onMounted(() => {
setPanAndZoom(canvasProps, canvasEl, canvasContainerEl)
})

function zoomIn() {
Copy link
Member

@ruchamahabal ruchamahabal May 22, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Builder uses cmd + & cmd - to do so. Those are the standard zoom in & out shortcuts and we should stick to it: https://github.com/frappe/builder/blob/8d45e36f04e8a78ab2a74e85d81dfbab54d0e1de/frontend/src/utils/useBuilderEvents.ts#L400
  2. Code for keyboard shortcuts is in useStudioEvents composable. So it should be there. Copy builder's logic instead of redoing it here.

if (canvasProps.scale < 2) {
canvasProps.scale = +(canvasProps.scale + 0.1).toFixed(2)
localStorage.setItem("studioCanvasZoom", canvasProps.scale.toString())
}
}

function zoomOut() {
if (canvasProps.scale > 0.2) {
canvasProps.scale = +(canvasProps.scale - 0.1).toFixed(2)
localStorage.setItem("studioCanvasZoom", canvasProps.scale.toString())
}
}


function handleKeyDown(e: KeyboardEvent) {
const tag = (e.target as HTMLElement).tagName

if (["INPUT", "TEXTAREA"].includes(tag) || (e.target as HTMLElement).isContentEditable) return

if (!isCanvasActive.value) return
switch (e.key) {
case "+":
case "=":
e.preventDefault()
zoomIn()
break
case "-":
case "_":
e.preventDefault()
zoomOut()
break
case "0":
e.preventDefault()
setScaleAndTranslate()
break
}
}

onMounted(() => {
window.addEventListener("keydown", handleKeyDown)
})

onBeforeUnmount(() => {
window.removeEventListener("keydown", handleKeyDown)
})

defineExpose({
history,
rootComponent,
Expand Down
9 changes: 7 additions & 2 deletions frontend/src/utils/useCanvasUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export function useCanvasUtils(
// canvas positioning
const containerBound = reactive(useElementBounding(canvasContainer));
const canvasBound = reactive(useElementBounding(canvas));
const setScaleAndTranslate = async () => {
const setScaleAndTranslate = async (persist = true, overrideScale = true) => {
if (document.readyState !== "complete") {
await new Promise((resolve) => {
window.addEventListener("load", resolve);
Expand All @@ -35,7 +35,12 @@ export function useCanvasUtils(
const containerWidth = containerBound.width;
const canvasWidth = canvasBound.width / canvasProps.scale;

canvasProps.scale = containerWidth / (canvasWidth + paddingX * 2);
if (overrideScale) {
canvasProps.scale = containerWidth / (canvasWidth + paddingX * 2);
}
if (persist) {
localStorage.setItem("studioCanvasZoom", canvasProps.scale.toString())
}

canvasProps.translateX = 0;
canvasProps.translateY = 0;
Expand Down