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/packages/vue/src/i18n/messages.ts b/packages/vue/src/i18n/messages.ts index 4444b3f3f..9d152bc56 100644 --- a/packages/vue/src/i18n/messages.ts +++ b/packages/vue/src/i18n/messages.ts @@ -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', { diff --git a/packages/vue/src/locales/de.json b/packages/vue/src/locales/de.json index dd55f7f98..d1d293b6b 100644 --- a/packages/vue/src/locales/de.json +++ b/packages/vue/src/locales/de.json @@ -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", diff --git a/packages/vue/src/locales/es.json b/packages/vue/src/locales/es.json index 2de58b9ad..2d9bfc969 100644 --- a/packages/vue/src/locales/es.json +++ b/packages/vue/src/locales/es.json @@ -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", diff --git a/packages/vue/src/locales/fr.json b/packages/vue/src/locales/fr.json index 78a37165f..ae7318454 100644 --- a/packages/vue/src/locales/fr.json +++ b/packages/vue/src/locales/fr.json @@ -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", diff --git a/packages/vue/src/locales/it.json b/packages/vue/src/locales/it.json index e00f86ff4..03ce5abd9 100644 --- a/packages/vue/src/locales/it.json +++ b/packages/vue/src/locales/it.json @@ -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", diff --git a/packages/vue/src/locales/pl.json b/packages/vue/src/locales/pl.json index ec9bebf7d..dd81dc3c1 100644 --- a/packages/vue/src/locales/pl.json +++ b/packages/vue/src/locales/pl.json @@ -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", diff --git a/packages/vue/src/locales/ru.json b/packages/vue/src/locales/ru.json index deb44071e..855bb7cee 100644 --- a/packages/vue/src/locales/ru.json +++ b/packages/vue/src/locales/ru.json @@ -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": "Новая страница", 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 +}) + + + 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 }