feat: 顶部工具栏与底部缩放胶囊

- TopBar: 左工具组(选择/文字/矩形/图片,切换走命令表,键位 tooltip)、
  中画布尺寸(预设/比例锁/自定义宽高,逻辑从 CanvasPanel 搬来)、
  右撤销重做(禁用态随 canUndo/canRedo)+ 导出入口(滚到右栏导出区)
- BottomDock: 悬浮缩放胶囊,±/百分比菜单(50/100/200、适应、缩放到选中)
  与适应画布按钮;有命令的一律走命令表,任意数值档走 zoomAt 锚点缩放
- 图标全部内联 SVG,不引依赖
This commit is contained in:
2026-09-05 02:33:49 +08:00
parent 0c0cc86fa8
commit 89dd7d8181
2 changed files with 530 additions and 22 deletions
+230 -11
View File
@@ -1,19 +1,131 @@
<script setup lang="ts">
/**
* 【T7 待实现 — 底部悬浮胶囊】
* 【T7 — 底部悬浮胶囊】
*
* 浮在画布下缘居中的圆角胶囊,装高频快捷操作:
* 缩放百分比(可点开选 50/100/200%、适应画布、缩放到选中)、减号/加号、
* 适应画布按钮。全部走 `runCommand("zoomFit")` 等命令,不要自己调 viewport。
* 浮在画布区下缘居中的圆角胶囊,装高频快捷操作:
* − | 缩放百分比(点开选 50% / 100% / 200% / 适应画布 / 缩放到选中)| +
* ⤢ 适应画布
*
* 缩放数值读 `useViewport().zoom`。
* 它是浮层,绝对定位在 .area-stage 里,不占布局空间——不要给它加 margin 去挤画布。
* 缩放数值读 `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">
<span class="todo">T7 待实现:缩放胶囊</span>
<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>
@@ -23,16 +135,123 @@
left: 50%;
bottom: 16px;
transform: translateX(-50%);
z-index: 20;
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
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);
}
.todo {
/* ---- 缩放 ± 与适应画布按钮 ---- */
.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>