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>
+300 -11
View File
@@ -1,32 +1,321 @@
<script setup lang="ts">
/**
* 【T7 待实现 — 顶部工具栏】
* 【T7 — 顶部工具栏】
*
* 左:工具组(选择 V / 文字 T / 矩形 R / 图片 Shift+K),当前工具高亮。
* 工具状态是 `useEditor().tool`,切换走 `runCommand("toolSelect")` 等命令——
* **不要直接写 tool.value**,否则工具栏和快捷键会走成两条路。
* 三段式(grid 1fr auto 1fr,中间严格居中):
*
* 中:文件名 / 画布尺寸(尺寸预设与自定义从旧的 CanvasPanel 搬过来,那是 T10 的活)。
* 右:撤销 / 重做(`useHistory` 的 canUndo / canRedo 控制禁用态)、导出入口。
* 左 —— 工具组。选择 V / 文字 T / 矩形 R / 图片 Shift+K。
* 当前工具高亮读 `useEditor().tool`;切换一律 `runCommand(...)`,不直接写
* `tool.value`——工具栏与快捷键共用命令表,抄一份必然走偏。每个按钮的 tooltip
* 用 `keyHintOf(commandId)` 显示键位,保证键位只在一处(commands.ts)维护。
*
* 中 —— 画布尺寸。逻辑搬自旧的 CanvasPanel.vue(T10 的活,只读不复写):
* 预设下拉、比例锁下拉、自定义宽高。预设表直接用 @/editor/canvasSize 的
* SIZE_PRESETS / RATIOS / applyRatio。
*
* 右 —— 撤销 / 重做(useHistory 的 canUndo / canRedo 控制禁用态)+ 导出入口。
* 导出面板本体是 T10 的事,这里只把右栏下半的滚动容器(.right-bottom,T10 的
* ToolPanels 就在里面)滚到底——导出与模板组在 ToolPanels 里是常驻组。
*/
import { computed, ref, watch } from "vue";
import { useEditor } from "@/editor/useEditor";
import { useHistory } from "@/editor/useHistory";
import { RATIOS, SIZE_PRESETS, applyRatio } from "@/editor/canvasSize";
import { runCommand } from "@/editor/commands";
import { keyHintOf } from "@/editor/useShortcuts";
import type { ToolId } from "@/types";
const { tool, size, setSize } = useEditor();
const { canUndo, canRedo } = useHistory();
/* ================================================================ 图标
* 仓里不引图标库,用内联 SVG 手画(都是简单形状)。stroke 用 currentColor,
* 颜色跟着按钮的 CSS 走。
*/
const ICON_ATTRS = 'width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"';
const icons: Record<string, string> = {
select: `<svg ${ICON_ATTRS}><path d="M3 3l7.07 16.97 2.51-7.39 7.39-2.51L3 3z"/><path d="M13 13l6 6"/></svg>`,
text: `<svg ${ICON_ATTRS}><polyline points="4 7 4 4 20 4 20 7"/><line x1="9" y1="20" x2="15" y2="20"/><line x1="12" y1="4" x2="12" y2="20"/></svg>`,
rect: `<svg ${ICON_ATTRS}><rect x="4.5" y="4.5" width="15" height="15" rx="1.5"/></svg>`,
image: `<svg ${ICON_ATTRS}><rect x="3.5" y="3.5" width="17" height="17" rx="2"/><circle cx="9" cy="9" r="1.6"/><path d="M21 15l-4.6-4.6L6 21"/></svg>`,
undo: `<svg ${ICON_ATTRS}><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5 0 0 1-5.5 5.5H11"/></svg>`,
redo: `<svg ${ICON_ATTRS}><path d="m15 14 5-5-5-5"/><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"/></svg>`,
export: `<svg ${ICON_ATTRS}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`,
};
/* ================================================================ 工具组 */
/**
* `cmd` 是命令表里的 id(字符串**原样**写在文件里,scripts 侧的 Node 断言会
* 核对每个 id 都存在于 COMMANDS——拼错一个就是「按钮点了没反应」)。active 态
* 由 `toolOf(cmd)` 映射回 useEditor().tool 的值来判定。
*/
const TOOLS = [
{ cmd: "toolSelect", label: "选择工具", icon: icons.select },
{ cmd: "toolText", label: "文字工具", icon: icons.text },
{ cmd: "toolRect", label: "矩形工具", icon: icons.rect },
{ cmd: "toolImage", label: "图片工具", icon: icons.image },
] as const;
const TOOL_OF: Record<string, ToolId> = {
toolSelect: "select",
toolText: "text",
toolRect: "rect",
toolImage: "image",
};
function toolOf(cmd: string): ToolId {
return TOOL_OF[cmd] ?? "select";
}
function tip(cmd: string, label: string): string {
const k = keyHintOf(cmd);
return k ? `${label}(${k})` : label;
}
/* ================================================================ 画布尺寸(中) */
/** 选中预设后,任何一边宽高的改动都会把下拉弹回「自定义」。 */
const presetId = ref<string>("custom");
/** 比例锁。改宽高时另一侧按这个比例联动;"free"(0)表示不锁。 */
const ratioId = ref<string>("free");
const ratio = computed(() => RATIOS.find((r) => r.id === ratioId.value)?.value ?? 0);
const currentPreset = computed(() => SIZE_PRESETS.find((p) => p.id === presetId.value));
/** 尺寸可能被别处改掉(模板加载、别的面板),下拉常驻,要跟着真值走。 */
watch(
() => size.value,
(s) => {
const p = SIZE_PRESETS.find((x) => x.width === s.width && x.height === s.height);
presetId.value = p ? p.id : "custom";
},
{ immediate: true },
);
function pickPreset(id: string) {
presetId.value = id;
const p = SIZE_PRESETS.find((s) => s.id === id);
if (p) setSize({ width: p.width, height: p.height });
}
/** 手动改宽/高。带比例锁时另一侧按 applyRatio 联动(和旧 CanvasPanel 一致)。 */
function edit(dimension: "width" | "height", raw: string) {
const n = Number(raw);
if (!Number.isFinite(n) || n < 16) return;
presetId.value = "custom";
setSize(applyRatio({ ...size.value, [dimension]: n }, ratio.value, dimension));
}
/** 预设下拉里只放短名,完整「名字(宽×高)」放 title,随鼠标悬停可见。 */
function shortPresetLabel(label: string): string {
return label.split("(")[0].trim();
}
const presetTitle = computed(() => {
const p = currentPreset.value;
return p ? `${p.label}(${p.width}×${p.height})` : "自定义尺寸";
});
/* ================================================================ 导出入口(右) */
/** 右栏下半是滚动容器,导出与模板组常驻在最底。滚到底露出导出区。
* T10 落地前那里是空的,滚动是幂等操作,先这么挂着。 */
function scrollToExport() {
const scroller = document.querySelector<HTMLElement>(".right-bottom");
if (!scroller) return;
scroller.scrollTo({ top: scroller.scrollHeight, behavior: "smooth" });
}
</script>
<template>
<header class="topbar">
<span class="todo">T7 待实现:工具组 / 画布尺寸 / 撤销重做 / 导出</span>
<!-- 左:工具组 -->
<div class="zone left">
<button
v-for="t in TOOLS"
:key="t.cmd"
class="tbtn"
:class="{ active: toolOf(t.cmd) === tool }"
:title="tip(t.cmd, t.label)"
:aria-label="t.label"
@click="runCommand(t.cmd)"
v-html="t.icon"
/>
</div>
<!-- 中:画布尺寸(预设 + 比例锁 + 自定义宽高) -->
<div class="zone mid">
<select
class="preset"
:value="presetId"
:title="presetTitle"
aria-label="画布尺寸预设"
@change="pickPreset(($event.target as HTMLSelectElement).value)"
>
<option v-for="p in SIZE_PRESETS" :key="p.id" :value="p.id" :title="`${p.width}×${p.height}px`">
{{ shortPresetLabel(p.label) }}
</option>
<option value="custom">自定义</option>
</select>
<div class="dims">
<input
type="number"
min="16"
class="num"
:value="size.width"
title="宽度(px)"
aria-label="画布宽度"
@change="edit('width', ($event.target as HTMLInputElement).value)"
/>
<span class="times" aria-hidden="true">×</span>
<input
type="number"
min="16"
class="num"
:value="size.height"
title="高度(px)"
aria-label="画布高度"
@change="edit('height', ($event.target as HTMLInputElement).value)"
/>
</div>
<select
v-model="ratioId"
class="ratio"
title="比例锁:编辑宽高时另一侧按此比例联动"
aria-label="锁定比例"
>
<option v-for="r in RATIOS" :key="r.id" :value="r.id" :title="r.label">
{{ shortPresetLabel(r.label) }}
</option>
</select>
</div>
<!-- 右:撤销 / 重做 / 导出 -->
<div class="zone right">
<button
class="tbtn"
:disabled="!canUndo"
:title="tip('undo', '撤销')"
aria-label="撤销"
@click="runCommand('undo')"
v-html="icons.undo"
/>
<button
class="tbtn"
:disabled="!canRedo"
:title="tip('redo', '重做')"
aria-label="重做"
@click="runCommand('redo')"
v-html="icons.redo"
/>
<span class="vr" aria-hidden="true"></span>
<button
class="tbtn"
title="导出(面板在右侧,点了滚过去)"
aria-label="导出"
@click="scrollToExport"
v-html="icons.export"
/>
</div>
</header>
</template>
<style scoped>
.topbar {
display: flex;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 8px;
padding: 0 12px;
height: var(--topbar-h);
padding: 0 8px;
border-bottom: 1px solid var(--line);
background: var(--bg2);
overflow: hidden;
}
.todo {
.zone {
display: flex;
align-items: center;
gap: 4px;
min-width: 0;
}
.left {
justify-content: flex-start;
}
.mid {
justify-content: center;
}
.right {
justify-content: flex-end;
gap: 2px;
}
/* ---- 图标按钮(工具 / 撤销重做 / 导出共用) ---- */
.tbtn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
border: none;
border-radius: var(--radius);
background: transparent;
color: var(--fg-dim);
}
.tbtn:hover {
background: var(--bg3);
color: var(--fg);
}
.tbtn:active {
background: var(--bg);
}
.tbtn.active {
background: var(--bg3);
color: var(--accent);
}
.tbtn:disabled {
opacity: 0.35;
cursor: default;
background: transparent;
color: var(--fg-dim);
}
.vr {
width: 1px;
height: 20px;
background: var(--line);
margin: 0 6px;
}
/* ---- 画布尺寸 ---- */
.preset {
width: 112px;
}
.ratio {
width: 84px;
}
.dims {
display: flex;
align-items: center;
gap: 4px;
margin: 0 8px;
}
/* 覆盖全局 input/select 的 width:100%,顶栏里宽度要收敛。 */
.num {
width: 58px;
text-align: center;
}
.times {
color: var(--fg-dim);
font-size: 12px;
}
</style>