diff --git a/packages/core/src/editor/viewport.ts b/packages/core/src/editor/viewport.ts index 188481a23..194a4234b 100644 --- a/packages/core/src/editor/viewport.ts +++ b/packages/core/src/editor/viewport.ts @@ -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 + } } diff --git a/src/components/PropertiesPanel.vue b/src/components/PropertiesPanel.vue index 9e33f24a1..bf5a15aea 100644 --- a/src/components/PropertiesPanel.vue +++ b/src/components/PropertiesPanel.vue @@ -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() @@ -45,13 +44,7 @@ const { panels } = useI18n() {{ panels.ai }} - - {{ Math.round(store.state.zoom * 100) }}% - + +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() +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 +}) + + + + + + + {{ zoomPercent() }}% + + + + + + + + + {{ zoomPercent() }}% + + + + + + + {{ menuText.zoomIn }} + ⌘+ + + + {{ menuText.zoomOut }} + ⌘− + + + {{ commands.zoomToFit }} + ⇧1 + + + + {{ preset.label }} + {{ preset.shortcut }} + + + + + + + {{ panels.rulers }} + + + + {{ panels.multiplayerCursors }} + + + + + diff --git a/src/stores/editor.ts b/src/stores/editor.ts index e9f64c740..773dd4a12 100644 --- a/src/stores/editor.ts +++ b/src/stores/editor.ts @@ -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('[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 }