Add zoom dropdown

This commit is contained in:
Danila Poyarkov 2026-03-29 19:43:18 +03:00
parent 581abf3b93
commit 25e656d68c
11 changed files with 222 additions and 19 deletions

View file

@ -51,17 +51,21 @@ export function createViewportActions(ctx: EditorContext) {
zoomToBounds(b.x, b.y, b.x + b.width, b.y + b.height)
}
function zoomTo100() {
function zoomToLevel(level: number) {
const { width: viewW, height: viewH } = ctx.getViewportSize()
const centerX = (-ctx.state.panX + viewW / 2) / ctx.state.zoom
const centerY = (-ctx.state.panY + viewH / 2) / ctx.state.zoom
ctx.state.zoom = 1
ctx.state.zoom = Math.max(0.02, Math.min(256, level))
ctx.state.panX = viewW / 2 - centerX
ctx.state.panY = viewH / 2 - centerY
ctx.requestRepaint()
}
function zoomTo100() {
zoomToLevel(1)
}
function zoomToSelection() {
if (ctx.state.selectedIds.size === 0) return
@ -74,5 +78,14 @@ export function createViewportActions(ctx: EditorContext) {
zoomToBounds(b.x, b.y, b.x + b.width, b.y + b.height)
}
return { screenToCanvas, applyZoom, pan, zoomToBounds, zoomToFit, zoomTo100, zoomToSelection }
return {
screenToCanvas,
applyZoom,
pan,
zoomToBounds,
zoomToFit,
zoomTo100,
zoomToLevel,
zoomToSelection
}
}

View file

@ -157,7 +157,9 @@ export const panelMessages = i18n('panels', {
colorHintHsl: 'H hue · S saturation · L lightness',
colorHintHsb: 'H hue · S saturation · B brightness',
colorHintOkhcl: 'H hue · C chroma · L lightness · A alpha',
colorPreviewClipped: params('Clipped to {space} preview gamut')
colorPreviewClipped: params('Clipped to {space} preview gamut'),
rulers: 'Rulers',
multiplayerCursors: 'Multiplayer cursors'
})
export const pageMessages = i18n('pages', {

View file

@ -131,7 +131,9 @@
"mixedFillsHelp": "Klicke auf +, um gemischte Füllungen zu ersetzen",
"mixedStrokesHelp": "Klicke auf +, um gemischte Konturen zu ersetzen",
"mixedEffectsHelp": "Klicke auf +, um gemischte Effekte zu ersetzen",
"strokeSides": "Konturseiten"
"strokeSides": "Konturseiten",
"rulers": "Lineale",
"multiplayerCursors": "Cursor anderer Teilnehmer"
},
"pages": {
"newPage": "Neue Seite",

View file

@ -131,7 +131,9 @@
"mixedFillsHelp": "Haz clic en + para reemplazar rellenos mixtos",
"mixedStrokesHelp": "Haz clic en + para reemplazar trazos mixtos",
"mixedEffectsHelp": "Haz clic en + para reemplazar efectos mixtos",
"strokeSides": "Lados del trazo"
"strokeSides": "Lados del trazo",
"rulers": "Reglas",
"multiplayerCursors": "Cursores de otros participantes"
},
"pages": {
"newPage": "Nueva página",

View file

@ -131,7 +131,9 @@
"mixedFillsHelp": "Cliquez sur + pour remplacer les remplissages mixtes",
"mixedStrokesHelp": "Cliquez sur + pour remplacer les contours mixtes",
"mixedEffectsHelp": "Cliquez sur + pour remplacer les effets mixtes",
"strokeSides": "Côtés du contour"
"strokeSides": "Côtés du contour",
"rulers": "Règles",
"multiplayerCursors": "Curseurs des participants"
},
"pages": {
"newPage": "Nouvelle page",

View file

@ -131,7 +131,9 @@
"mixedFillsHelp": "Fai clic su + per sostituire i riempimenti misti",
"mixedStrokesHelp": "Fai clic su + per sostituire i contorni misti",
"mixedEffectsHelp": "Fai clic su + per sostituire gli effetti misti",
"strokeSides": "Lati del contorno"
"strokeSides": "Lati del contorno",
"rulers": "Righelli",
"multiplayerCursors": "Cursori degli altri partecipanti"
},
"pages": {
"newPage": "Nuova pagina",

View file

@ -131,7 +131,9 @@
"mixedFillsHelp": "Kliknij +, aby zastąpić mieszane wypełnienia",
"mixedStrokesHelp": "Kliknij +, aby zastąpić mieszane obrysy",
"mixedEffectsHelp": "Kliknij +, aby zastąpić mieszane efekty",
"strokeSides": "Boki obrysu"
"strokeSides": "Boki obrysu",
"rulers": "Linijki",
"multiplayerCursors": "Kursory innych uczestników"
},
"pages": {
"newPage": "Nowa strona",

View file

@ -144,7 +144,9 @@
"colorHintHsl": "H тон · S насыщенность · L светлота",
"colorHintHsb": "H тон · S насыщенность · B яркость",
"colorHintOkhcl": "H тон · C хрома · L светлота · A альфа",
"colorPreviewClipped": "Обрезано до гаммы предпросмотра {space}"
"colorPreviewClipped": "Обрезано до гаммы предпросмотра {space}",
"rulers": "Линейки",
"multiplayerCursors": "Курсоры участников"
},
"pages": {
"newPage": "Новая страница",

View file

@ -3,13 +3,12 @@ import { TabsContent, TabsList, TabsRoot, TabsTrigger } from 'reka-ui'
import { useI18n } from '@open-pencil/vue'
import { useAIChat } from '@/composables/use-chat'
import { useEditorStore } from '@/stores/editor'
import ChatPanel from './ChatPanel.vue'
import CodePanel from './CodePanel.vue'
import DesignPanel from './DesignPanel.vue'
import ZoomDropdown from './ZoomDropdown.vue'
const store = useEditorStore()
const { activeTab } = useAIChat()
const { panels } = useI18n()
</script>
@ -45,13 +44,7 @@ const { panels } = useI18n()
<icon-lucide-sparkles class="size-3" />
{{ panels.ai }}
</TabsTrigger>
<span
v-if="activeTab === 'design'"
data-test-id="properties-zoom"
class="ml-auto cursor-pointer rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover"
>
{{ Math.round(store.state.zoom * 100) }}%
</span>
<ZoomDropdown v-if="activeTab === 'design'" />
</TabsList>
<TabsContent

View file

@ -0,0 +1,169 @@
<script setup lang="ts">
import {
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuPortal,
DropdownMenuRoot,
DropdownMenuSeparator,
DropdownMenuTrigger
} from 'reka-ui'
import { nextTick, ref, watch } from 'vue'
import { useEditorCommands, useI18n } from '@open-pencil/vue'
import { menuItem, useMenuUI } from '@/components/ui/menu'
import { useEditorStore } from '@/stores/editor'
const store = useEditorStore()
const { getCommand } = useEditorCommands()
const { menu: menuText, commands, panels } = useI18n()
const open = ref(false)
const editing = ref(false)
const inputRef = ref<HTMLInputElement | null>()
const inputValue = ref('')
const menuCls = useMenuUI({ content: 'min-w-52' })
const itemCls = menuItem({ justify: 'start', class: 'relative pl-7' })
function zoomPercent() {
return Math.round(store.state.zoom * 100)
}
function startEditing() {
editing.value = true
inputValue.value = String(zoomPercent())
void nextTick(() => inputRef.value?.select())
}
function commitInput() {
const parsed = Number.parseInt(inputValue.value, 10)
if (!Number.isNaN(parsed) && parsed > 0) {
store.zoomToLevel(parsed / 100)
}
editing.value = false
}
function cancelInput() {
editing.value = false
}
function toggleRulers() {
store.state.showRulers = !store.state.showRulers
store.requestRepaint()
}
function toggleRemoteCursors() {
store.state.showRemoteCursors = !store.state.showRemoteCursors
store.requestRepaint()
}
function zoomIn() {
const center = store.viewportScreenCenter()
store.applyZoom(-100, center.x, center.y)
}
function zoomOut() {
const center = store.viewportScreenCenter()
store.applyZoom(100, center.x, center.y)
}
const ZOOM_PRESETS: ReadonlyArray<{ label: string; level: number; shortcut?: string }> = [
{ label: '50%', level: 0.5 },
{ label: '100%', level: 1, shortcut: '⌘0' },
{ label: '200%', level: 2 }
]
function isActivePreset(level: number) {
return Math.abs(store.state.zoom - level) < 0.005
}
watch(open, (v) => {
if (!v) editing.value = false
})
</script>
<template>
<DropdownMenuRoot v-model:open="open">
<DropdownMenuTrigger as-child>
<button
data-test-id="zoom-dropdown-trigger"
class="ml-auto cursor-pointer rounded px-1.5 py-0.5 text-[11px] text-muted hover:bg-hover"
>
{{ zoomPercent() }}%
</button>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
side="bottom"
:side-offset="4"
align="end"
:class="menuCls.content"
@escape-key-down="cancelInput"
>
<div class="px-1 py-1">
<input
v-if="editing"
ref="inputRef"
v-model="inputValue"
data-test-id="zoom-input"
class="w-full rounded border border-accent bg-input px-2 py-1 text-xs text-surface outline-none"
@blur="commitInput"
@keydown.enter="commitInput"
@keydown.escape.stop="cancelInput"
/>
<button
v-else
data-test-id="zoom-input-trigger"
class="w-full cursor-pointer rounded border border-border bg-input px-2 py-1 text-left text-xs text-surface hover:border-muted"
@click="startEditing"
>
{{ zoomPercent() }}%
</button>
</div>
<DropdownMenuSeparator :class="menuCls.separator" />
<DropdownMenuItem :class="itemCls" @select="zoomIn">
<span class="flex-1">{{ menuText.zoomIn }}</span>
<span class="text-[11px] text-muted">+</span>
</DropdownMenuItem>
<DropdownMenuItem :class="itemCls" @select="zoomOut">
<span class="flex-1">{{ menuText.zoomOut }}</span>
<span class="text-[11px] text-muted"></span>
</DropdownMenuItem>
<DropdownMenuItem :class="itemCls" @select="getCommand('view.zoomFit').run()">
<span class="flex-1">{{ commands.zoomToFit }}</span>
<span class="text-[11px] text-muted">1</span>
</DropdownMenuItem>
<DropdownMenuItem
v-for="preset in ZOOM_PRESETS"
:key="preset.level"
:class="itemCls"
@select="store.zoomToLevel(preset.level)"
>
<icon-lucide-check
v-if="isActivePreset(preset.level)"
class="absolute left-2 size-3.5"
/>
<span class="flex-1">{{ preset.label }}</span>
<span v-if="preset.shortcut" class="text-[11px] text-muted">{{ preset.shortcut }}</span>
</DropdownMenuItem>
<DropdownMenuSeparator :class="menuCls.separator" />
<DropdownMenuItem :class="itemCls" @select.prevent="toggleRulers">
<icon-lucide-check v-if="store.state.showRulers" class="absolute left-2 size-3.5" />
<span class="flex-1">{{ panels.rulers }}</span>
</DropdownMenuItem>
<DropdownMenuItem :class="itemCls" @select.prevent="toggleRemoteCursors">
<icon-lucide-check
v-if="store.state.showRemoteCursors"
class="absolute left-2 size-3.5"
/>
<span class="flex-1">{{ panels.multiplayerCursors }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</template>

View file

@ -63,6 +63,8 @@ export function createEditorStore(initialGraph?: SceneGraph) {
resumedStrokes?: SceneNode['strokes']
} | null
showUI: boolean
showRulers: boolean
showRemoteCursors: boolean
activeRibbonTab: 'panels' | 'code' | 'ai'
panelMode: 'layers' | 'design'
actionToast: string | null
@ -95,6 +97,8 @@ export function createEditorStore(initialGraph?: SceneGraph) {
>({
...createDefaultEditorState(graph.getPages()[0].id),
showUI: true,
showRulers: true,
showRemoteCursors: true,
activeRibbonTab: 'panels',
panelMode: 'design',
actionToast: null,
@ -1183,6 +1187,15 @@ export function createEditorStore(initialGraph?: SceneGraph) {
// ─── Profiler toggle ─────────────────────────────────────────
function viewportScreenCenter() {
const canvas = document.querySelector<HTMLCanvasElement>('[data-test-id="canvas-element"]')
if (canvas) {
const rect = canvas.getBoundingClientRect()
return { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 }
}
return { x: window.innerWidth / 2, y: window.innerHeight / 2 }
}
function toggleProfiler() {
editor.renderer?.profiler.toggle()
editor.requestRepaint()
@ -1229,6 +1242,7 @@ export function createEditorStore(initialGraph?: SceneGraph) {
mobileCopy,
mobileCut,
mobilePaste,
viewportScreenCenter,
toggleProfiler
}