Files
cover-studio/src/components/BottomDock.vue
T
im0o 89dd7d8181 feat: 顶部工具栏与底部缩放胶囊
- TopBar: 左工具组(选择/文字/矩形/图片,切换走命令表,键位 tooltip)、
  中画布尺寸(预设/比例锁/自定义宽高,逻辑从 CanvasPanel 搬来)、
  右撤销重做(禁用态随 canUndo/canRedo)+ 导出入口(滚到右栏导出区)
- BottomDock: 悬浮缩放胶囊,±/百分比菜单(50/100/200、适应、缩放到选中)
  与适应画布按钮;有命令的一律走命令表,任意数值档走 zoomAt 锚点缩放
- 图标全部内联 SVG,不引依赖
2026-09-05 02:33:49 +08:00

258 lines
7.8 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>