Add zoom dropdown
This commit is contained in:
parent
581abf3b93
commit
25e656d68c
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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', {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": "Новая страница",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
169
src/components/ZoomDropdown.vue
Normal file
169
src/components/ZoomDropdown.vue
Normal 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>
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue