- TopBar: 左工具组(选择/文字/矩形/图片,切换走命令表,键位 tooltip)、 中画布尺寸(预设/比例锁/自定义宽高,逻辑从 CanvasPanel 搬来)、 右撤销重做(禁用态随 canUndo/canRedo)+ 导出入口(滚到右栏导出区) - BottomDock: 悬浮缩放胶囊,±/百分比菜单(50/100/200、适应、缩放到选中) 与适应画布按钮;有命令的一律走命令表,任意数值档走 zoomAt 锚点缩放 - 图标全部内联 SVG,不引依赖
258 lines
7.8 KiB
Vue
258 lines
7.8 KiB
Vue
<script setup lang="ts">
|
||
/**
|
||
* 【T7 — 底部悬浮胶囊】
|
||
*
|
||
* 浮在画布区下缘居中的圆角胶囊,装高频快捷操作:
|
||
* − | 缩放百分比(点开选 50% / 100% / 200% / 适应画布 / 缩放到选中)| +
|
||
* ⤢ 适应画布
|
||
*
|
||
* 缩放数值读 `useViewport().zoom`(revision 无关,zoom 是 vue ref,会跟着
|
||
* 视口变化自动更新)。它是**浮层**,绝对定位在 .area-stage 里,不占布局空间。
|
||
*
|
||
* 命令路由:有对应命令的一律 `runCommand(...)`(zoomIn / zoomOut / zoomReset /
|
||
* zoomFit / zoomSelection),和快捷键共用一张表。只有 50% / 200% 这两个任意数值
|
||
* 档在命令表里没有对应条目(命令表只覆盖「相对缩放 + 复位 + 适应」这些离散动作),
|
||
* 所以它们走 useViewport().zoomAt 以画布中心为锚做绝对缩放——不重复实现任何
|
||
* 变换逻辑,只是把「目标百分比 ÷ 当前 zoom」这个相对因子喂给现成的锚点缩放。
|
||
*/
|
||
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||
import { useEditor } from "@/editor/useEditor";
|
||
import { useViewport } from "@/editor/useViewport";
|
||
import { runCommand } from "@/editor/commands";
|
||
import { keyHintOf } from "@/editor/useShortcuts";
|
||
|
||
const { zoom, zoomAt, MIN_ZOOM, MAX_ZOOM } = useViewport();
|
||
const { canvas } = useEditor();
|
||
|
||
const root = ref<HTMLElement | null>(null);
|
||
const menuOpen = ref(false);
|
||
|
||
const pct = computed(() => Math.round(zoom.value * 100));
|
||
const canZoomOut = computed(() => zoom.value > MIN_ZOOM * 1.001);
|
||
const canZoomIn = computed(() => zoom.value < MAX_ZOOM * 0.999);
|
||
|
||
/** 以画布视口中心为锚,缩放到指定的绝对百分比。 */
|
||
function zoomToPercent(target: number) {
|
||
const c = canvas.value;
|
||
if (!c) return;
|
||
zoomAt(c.getWidth() / 2, c.getHeight() / 2, target / 100 / zoom.value);
|
||
}
|
||
|
||
function close() {
|
||
menuOpen.value = false;
|
||
}
|
||
|
||
/**
|
||
* 点胶囊/菜单以外任意处(含画布)关闭。**不拦截事件本身**——画布交互照常发生,
|
||
* 只是先把菜单收掉(和 Figma 一致)。不能靠全屏 backdrop:
|
||
* .dock 带 transform,会把 position:fixed 的含块变成胶囊自身,盖不住全屏。
|
||
*/
|
||
function onPointerDown(e: PointerEvent) {
|
||
if (menuOpen.value && root.value && !root.value.contains(e.target as Node)) close();
|
||
}
|
||
|
||
function onKeydown(e: KeyboardEvent) {
|
||
if (e.key === "Escape") close();
|
||
}
|
||
|
||
onMounted(() => {
|
||
window.addEventListener("pointerdown", onPointerDown);
|
||
window.addEventListener("keydown", onKeydown);
|
||
});
|
||
onBeforeUnmount(() => {
|
||
window.removeEventListener("pointerdown", onPointerDown);
|
||
window.removeEventListener("keydown", onKeydown);
|
||
});
|
||
|
||
/* ================================================================ 图标 */
|
||
const ICON_ATTRS = 'width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"';
|
||
|
||
const icons: Record<string, string> = {
|
||
minus: `<svg ${ICON_ATTRS}><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
|
||
plus: `<svg ${ICON_ATTRS}><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>`,
|
||
chevron: `<svg ${ICON_ATTRS}><path d="m6 9 6 6 6-6"/></svg>`,
|
||
fit: `<svg ${ICON_ATTRS}><path d="M8 3H5a2 2 0 0 0-2 2v3"/><path d="M21 8V5a2 2 0 0 0-2-2h-3"/><path d="M3 16v3a2 2 0 0 0 2 2h3"/><path d="M16 21h3a2 2 0 0 0 2-2v-3"/></svg>`,
|
||
};
|
||
|
||
/** 当前 zoom 是否正好是某个菜单数值档(±0.05% 容差),用于打勾。 */
|
||
function atLevel(target: number): boolean {
|
||
return Math.abs(zoom.value - target / 100) < 0.0005;
|
||
}
|
||
</script>
|
||
|
||
<template>
|
||
<div class="dock" ref="root">
|
||
<!-- 缩放 − / + -->
|
||
<button class="zbtn" title="缩小" aria-label="缩小" :disabled="!canZoomOut" @click="runCommand('zoomOut')" v-html="icons.minus" />
|
||
|
||
<!-- 缩放百分比 → 小菜单 -->
|
||
<button class="pct" :class="{ open: menuOpen }" title="缩放级别" aria-label="缩放级别" @click="menuOpen = !menuOpen">
|
||
<span class="pct-num">{{ pct }}%</span>
|
||
<span class="pct-caret" v-html="icons.chevron"></span>
|
||
</button>
|
||
|
||
<div v-if="menuOpen" class="menu" role="menu">
|
||
<button role="menuitem" class="mrow" @click="close(); zoomToPercent(50)">
|
||
<span class="mark" aria-hidden="true">{{ atLevel(50) ? "✓" : "" }}</span>
|
||
<span>50%</span>
|
||
</button>
|
||
<button role="menuitem" class="mrow" @click="close(); runCommand('zoomReset')">
|
||
<span class="mark" aria-hidden="true">{{ atLevel(100) ? "✓" : "" }}</span>
|
||
<span>100%</span>
|
||
<span class="hint">{{ keyHintOf('zoomReset') }}</span>
|
||
</button>
|
||
<button role="menuitem" class="mrow" @click="close(); zoomToPercent(200)">
|
||
<span class="mark" aria-hidden="true">{{ atLevel(200) ? "✓" : "" }}</span>
|
||
<span>200%</span>
|
||
</button>
|
||
|
||
<div class="msep" role="separator"></div>
|
||
|
||
<button role="menuitem" class="mrow" @click="close(); runCommand('zoomFit')">
|
||
<span class="mark" aria-hidden="true"></span>
|
||
<span>适应画布</span>
|
||
<span class="hint">{{ keyHintOf('zoomFit') }}</span>
|
||
</button>
|
||
<button role="menuitem" class="mrow" @click="close(); runCommand('zoomSelection')">
|
||
<span class="mark" aria-hidden="true"></span>
|
||
<span>缩放到选中</span>
|
||
<span class="hint">{{ keyHintOf('zoomSelection') }}</span>
|
||
</button>
|
||
</div>
|
||
|
||
<button class="zbtn" title="放大" aria-label="放大" :disabled="!canZoomIn" @click="runCommand('zoomIn')" v-html="icons.plus" />
|
||
|
||
<span class="vsep" aria-hidden="true"></span>
|
||
|
||
<!-- 适应画布 -->
|
||
<button class="zbtn fit" title="适应画布(Shift+1)" aria-label="适应画布" @click="runCommand('zoomFit')" v-html="icons.fit" />
|
||
</div>
|
||
</template>
|
||
|
||
<style scoped>
|
||
.dock {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 16px;
|
||
transform: translateX(-50%);
|
||
z-index: 20;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
padding: 4px 6px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 999px;
|
||
background: var(--bg3);
|
||
box-shadow: 0 6px 24px rgb(0 0 0 / 0.45);
|
||
}
|
||
|
||
/* ---- 缩放 ± 与适应画布按钮 ---- */
|
||
.zbtn {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 26px;
|
||
height: 26px;
|
||
padding: 0;
|
||
border: none;
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: var(--fg-dim);
|
||
}
|
||
.zbtn:hover:not(:disabled) {
|
||
background: var(--bg2);
|
||
color: var(--fg);
|
||
}
|
||
.zbtn:disabled {
|
||
opacity: 0.35;
|
||
cursor: default;
|
||
background: transparent;
|
||
}
|
||
|
||
.vsep {
|
||
width: 1px;
|
||
height: 16px;
|
||
background: var(--line);
|
||
margin: 0 4px;
|
||
}
|
||
|
||
/* ---- 百分比按钮 ---- */
|
||
.pct {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 2px;
|
||
height: 26px;
|
||
min-width: 64px;
|
||
padding: 0 6px;
|
||
border: none;
|
||
border-radius: 999px;
|
||
background: transparent;
|
||
color: var(--fg);
|
||
}
|
||
.pct:hover,
|
||
.pct.open {
|
||
background: var(--bg2);
|
||
}
|
||
.pct-num {
|
||
font-variant-numeric: tabular-nums;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.pct-caret {
|
||
display: inline-flex;
|
||
color: var(--fg-dim);
|
||
}
|
||
|
||
/* ---- 缩放菜单 ---- */
|
||
.menu {
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: calc(100% + 10px);
|
||
transform: translateX(-50%);
|
||
z-index: 2;
|
||
min-width: 172px;
|
||
padding: 4px;
|
||
border: 1px solid var(--line);
|
||
border-radius: 10px;
|
||
background: var(--bg3);
|
||
box-shadow: 0 8px 28px rgb(0 0 0 / 0.5);
|
||
text-align: left;
|
||
}
|
||
|
||
.mrow {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
width: 100%;
|
||
height: 30px;
|
||
padding: 0 8px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
background: transparent;
|
||
color: var(--fg);
|
||
font-size: 12px;
|
||
text-align: left;
|
||
}
|
||
.mrow:hover {
|
||
background: var(--bg2);
|
||
color: var(--fg);
|
||
}
|
||
.mrow .mark {
|
||
width: 14px;
|
||
text-align: center;
|
||
color: var(--accent);
|
||
font-size: 11px;
|
||
}
|
||
.mrow .hint {
|
||
margin-left: auto;
|
||
color: var(--fg-dim);
|
||
font-size: 11px;
|
||
}
|
||
|
||
.msep {
|
||
height: 1px;
|
||
background: var(--line);
|
||
margin: 4px 6px;
|
||
}
|
||
</style>
|