Merge branch 't11-topbar'

This commit is contained in:
2026-09-05 18:19:52 +08:00
2 changed files with 329 additions and 169 deletions
+186 -166
View File
@@ -1,66 +1,64 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 【T7 — 顶部工具栏】 * 【T11 — 顶部工具栏】工具组居中,尺寸设置收进「画布编辑」下拉。
* *
* 三段式(grid 1fr auto 1fr,中间严格居中): * 三段式(grid 1fr auto 1fr,中间严格居中):
* *
* 左 —— 工具组。选择 V / 文字 T / 矩形 R / 图片 Shift+K。 * 左 —— 留空(以后放文件名之类)。
* 中 —— 工具组五个按钮,这是顶栏的视觉主体:
* - 画布编辑:纯下拉(CanvasMenu),点开改预设 / 宽高 / 比例。
* - 选择 / 文字:普通按钮,切工具。
* - 形状 / 背景:本体切工具 + 右侧小角标开下拉(Figma 的形状工具样式)。
* 当前工具高亮读 `useEditor().tool`;切换一律 `runCommand(...)`,不直接写 * 当前工具高亮读 `useEditor().tool`;切换一律 `runCommand(...)`,不直接写
* `tool.value`——工具栏与快捷键共用命令表,抄一份必然走偏。每个按钮的 tooltip * `tool.value`——工具栏与快捷键共用命令表,抄一份必然走偏。tooltip 用
* 用 `keyHintOf(commandId)` 显示键位,保证键位只在一处(commands.ts)维护。 * `keyHintOf(commandId)` 拼键位,保证键位只在 commands.ts 里维护。
* 图标从 `@/ui/icons` 取(hugeicons,构建期内联,见那个文件的说明)。 * 右 —— 撤销 / 重做 + 导出入口(导出按钮滚到右栏的导出面板)。
* *
* 中 —— 画布尺寸:预设下拉、比例锁下拉、自定义宽高。预设表直接用 * 文字工具的光标:这里只往 `document.body.dataset.tool` 写当前工具,
* @/editor/canvasSize 的 SIZE_PRESETS / RATIOS / applyRatio。 * 真正的 `body[data-tool="text"] .stage { cursor: text; }` 规则在 style.css
* * 里(body 不在本组件的 scoped 作用域内,够不到)。卸载时清掉。
* 右 —— 撤销 / 重做(useHistory 的 canUndo / canRedo 控制禁用态)+ 导出入口。
* 导出按钮只负责把右栏下半的滚动容器(.right-bottom,即 ToolPanels)滚到底,
* 面板本体在那里。
*/ */
import { computed, nextTick, ref, watch } from "vue"; import { computed, nextTick, onBeforeUnmount, watch } from "vue";
import { useEditor } from "@/editor/useEditor"; import { useEditor } from "@/editor/useEditor";
import { useHistory } from "@/editor/useHistory"; import { useHistory } from "@/editor/useHistory";
import { RATIOS, SIZE_PRESETS, applyRatio } from "@/editor/canvasSize";
import { runCommand } from "@/editor/commands"; import { runCommand } from "@/editor/commands";
import { keyHintOf } from "@/editor/useShortcuts"; import { keyHintOf } from "@/editor/useShortcuts";
import type { ToolId } from "@/types"; import { shapeMeta } from "@/editor/shapes";
import { import {
IconSelect, IconBackgroundTool,
IconText, IconCanvasEdit,
IconRect, IconChevronDown,
IconImage,
IconUndo,
IconRedo,
IconExport, IconExport,
IconRedo,
IconSelect,
IconTextTool,
IconUndo,
} from "@/ui/icons"; } from "@/ui/icons";
import { openToolGroup } from "@/ui/toolGroups"; import { openToolGroup } from "@/ui/toolGroups";
import Dropdown from "@/components/ui/Dropdown.vue";
import CanvasMenu from "@/components/topbar/CanvasMenu.vue";
import ShapeMenu from "@/components/topbar/ShapeMenu.vue";
import BackgroundMenu from "@/components/topbar/BackgroundMenu.vue";
const { tool, size, setSize } = useEditor(); const { tool, shapeKind } = useEditor();
const { canUndo, canRedo } = useHistory(); const { canUndo, canRedo } = useHistory();
/* ================================================================ 工具组 */ /** 形状按钮的图标跟着当前形状走(和 Figma 一致)。 */
const shapeIcon = computed(() => shapeMeta(shapeKind.value).icon);
/** /**
* `cmd` 是命令表里的 id(字符串**原样**写在文件里,scripts 侧的 Node 断言会 * 形状 / 背景是「本体 + 角标」的组合:点本体切工具,点角标开下拉。
* 核对每个 id 都存在于 COMMANDS——拼错一个就是「按钮点了没反应」)。active 态 * 本体点击时若下拉正开着,顺手关掉——本体在 Dropdown 的 root 里,外壳的
* 由 `toolOf(cmd)` 映射回 useEditor().tool 的值来判定。 * 「点外面关」管不到它,这里用 slot 给的 `toggle` 在 open 时再翻一次。
*/ */
const TOOLS = [ function pickShapeTool(open: boolean, toggle: () => void) {
{ cmd: "toolSelect", label: "选择工具", icon: IconSelect }, runCommand("toolShape");
{ cmd: "toolText", label: "文字工具", icon: IconText }, if (open) toggle();
{ cmd: "toolShape", label: "形状工具", icon: IconRect }, }
{ cmd: "toolBackground", label: "背景工具", icon: IconImage },
] as const;
const TOOL_OF: Record<string, ToolId> = { function pickBackgroundTool(open: boolean, toggle: () => void) {
toolSelect: "select", runCommand("toolBackground");
toolText: "text", if (open) toggle();
toolShape: "shape",
toolBackground: "background",
};
function toolOf(cmd: string): ToolId {
return TOOL_OF[cmd] ?? "select";
} }
function tip(cmd: string, label: string): string { function tip(cmd: string, label: string): string {
@@ -68,57 +66,28 @@ function tip(cmd: string, label: string): string {
return k ? `${label}(${k})` : label; return k ? `${label}(${k})` : label;
} }
/* ================================================================ 画布尺寸(中) */ /* ---------------------------------------------------------------- 光标 */
/** 选中预设后,任何一边宽高的改动都会把下拉弹回「自定义」。 */ /**
const presetId = ref<string>("custom"); * 文字 / 形状工具的鼠标指针。规则在 style.css(body 够不到 scoped),
/** 比例锁。改宽高时另一侧按这个比例联动;"free"(0)表示不锁。 */ * 这里只负责同步 data 属性。`immediate` 让初始工具也写一次。
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( watch(
() => size.value, tool,
(s) => { (t) => {
const p = SIZE_PRESETS.find((x) => x.width === s.width && x.height === s.height); document.body.dataset.tool = t;
presetId.value = p ? p.id : "custom";
}, },
{ immediate: true }, { immediate: true },
); );
onBeforeUnmount(() => {
function pickPreset(id: string) { delete document.body.dataset.tool;
presetId.value = id;
const p = SIZE_PRESETS.find((s) => s.id === id);
if (p) setSize({ width: p.width, height: p.height });
}
/** 手动改宽/高。带比例锁时另一侧按 applyRatio 联动。 */
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})` : "自定义尺寸";
}); });
/* ================================================================ 导出入口(右) */ /* ---------------------------------------------------------------- 导出 */
/** /**
* 右栏下半是滚动容器,「导出与模板」是最后一组。 * 右栏下半是滚动容器,「导出与模板」是最后一组。先展开再滚,否则滚到底
* 先展开那一组再滚——四个组默认全收,只滚不展开的话滚到底看到的还是一行标题。 * 看到的还是一行标题。`nextTick` 等展开内容渲染进 DOM,`scrollHeight` 才是最终高度。
* `nextTick` 是等展开后的内容渲染进 DOM,`scrollHeight` 才是最终高度。
*/ */
async function scrollToExport() { async function scrollToExport() {
openToolGroup("export"); openToolGroup("export");
@@ -131,68 +100,107 @@ async function scrollToExport() {
<template> <template>
<header class="topbar"> <header class="topbar">
<!-- 左:工具组 --> <!-- 左:留空(以后放文件名之类) -->
<div class="zone left"> <div class="zone left"></div>
<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)"
>
<component :is="t.icon" />
</button>
</div>
<!-- 中:画布尺寸(预设 + 比例锁 + 自定义宽高) --> <!-- 中:工具组 -->
<div class="zone mid"> <div class="zone mid">
<select <Dropdown :min-width="240">
class="preset" <template #trigger="{ open, toggle }">
:value="presetId" <button
:title="presetTitle" class="tbtn"
aria-label="画布尺寸预设" :class="{ active: open }"
@change="pickPreset(($event.target as HTMLSelectElement).value)" title="画布编辑"
aria-label="画布编辑"
:aria-expanded="open"
@click="toggle"
> >
<option v-for="p in SIZE_PRESETS" :key="p.id" :value="p.id" :title="`${p.width}×${p.height}px`"> <IconCanvasEdit />
{{ shortPresetLabel(p.label) }} <IconChevronDown class="caret" />
</option> </button>
<option value="custom">自定义</option> </template>
</select> <template #panel="{ close }">
<CanvasMenu :close="close" />
</template>
</Dropdown>
<div class="dims"> <button
<input class="tbtn"
type="number" :class="{ active: tool === 'select' }"
min="16" :title="tip('toolSelect', '选择工具')"
class="num" aria-label="选择工具"
:value="size.width" @click="runCommand('toolSelect')"
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"> <IconSelect />
{{ shortPresetLabel(r.label) }} </button>
</option>
</select> <button
class="tbtn"
:class="{ active: tool === 'text' }"
:title="tip('toolText', '文字工具')"
aria-label="文字工具"
@click="runCommand('toolText')"
>
<IconTextTool />
</button>
<Dropdown :min-width="200">
<template #trigger="{ open, toggle }">
<span class="combo">
<button
class="tbtn combo-body"
:class="{ active: tool === 'shape' || open }"
:title="tip('toolShape', '形状工具')"
aria-label="形状工具"
@click="pickShapeTool(open, toggle)"
>
<component :is="shapeIcon" />
</button>
<button
class="tbtn combo-caret"
:class="{ active: tool === 'shape' || open }"
title="形状类型"
aria-label="选择形状类型"
:aria-expanded="open"
@click="toggle"
>
<IconChevronDown />
</button>
</span>
</template>
<template #panel>
<ShapeMenu />
</template>
</Dropdown>
<Dropdown :min-width="200">
<template #trigger="{ open, toggle }">
<span class="combo">
<button
class="tbtn combo-body"
:class="{ active: tool === 'background' || open }"
:title="tip('toolBackground', '背景工具')"
aria-label="背景工具"
@click="pickBackgroundTool(open, toggle)"
>
<IconBackgroundTool />
</button>
<button
class="tbtn combo-caret"
:class="{ active: tool === 'background' || open }"
title="背景来源"
aria-label="选择背景来源"
:aria-expanded="open"
@click="toggle"
>
<IconChevronDown />
</button>
</span>
</template>
<template #panel>
<BackgroundMenu />
</template>
</Dropdown>
</div> </div>
<!-- 右:撤销 / 重做 / 导出 --> <!-- 右:撤销 / 重做 / 导出 -->
@@ -218,7 +226,7 @@ async function scrollToExport() {
<span class="vr" aria-hidden="true"></span> <span class="vr" aria-hidden="true"></span>
<button <button
class="tbtn" class="tbtn"
title="导出(面板在右侧,点了滚过去)" :title="tip('export', '导出')"
aria-label="导出" aria-label="导出"
@click="scrollToExport" @click="scrollToExport"
> >
@@ -263,6 +271,7 @@ async function scrollToExport() {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
gap: 2px;
width: 32px; width: 32px;
height: 32px; height: 32px;
padding: 0; padding: 0;
@@ -289,36 +298,47 @@ async function scrollToExport() {
color: var(--fg-dim); color: var(--fg-dim);
} }
/* 画布编辑按钮里的下角标,比工具图标小一档。 */
.tbtn .caret {
font-size: 12px;
}
/* ---- 形状 / 背景的「本体 + 角标」组合 ---- */
.combo {
display: inline-flex;
align-items: center;
}
.combo .tbtn {
border-radius: 0;
}
.combo .combo-body {
border-radius: var(--radius) 0 0 var(--radius);
}
.combo .combo-caret {
width: 18px;
border-radius: 0 var(--radius) var(--radius) 0;
}
.combo-caret .icon {
font-size: 12px;
}
/* 组合按钮整块一起亮:hover 与 active 都作用到两个按钮,不分家。
* `.combo:hover .tbtn:not(.active)` 管非激活时的悬停(光靠单个 .tbtn:hover
* 只会亮鼠标底下那一半);`.combo .tbtn.active` 保证激活态在悬停时也不被
* .tbtn:hover 的 color 盖回 fg。 */
.combo:hover .tbtn:not(.active) {
background: var(--bg3);
color: var(--fg);
}
.combo .tbtn.active {
background: var(--bg3);
color: var(--accent);
}
.vr { .vr {
width: 1px; width: 1px;
height: 20px; height: 20px;
background: var(--line); background: var(--line);
margin: 0 6px; 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> </style>
+142 -2
View File
@@ -1,7 +1,147 @@
<script setup lang="ts"> <script setup lang="ts">
/** 【占位】CanvasMenu 的内容由对应开发线实现。见任务书。 */ /**
* 画布编辑下拉:预设 / 分辨率 / 比例。
*
* 逻辑照搬旧 TopBar 中间那组,不另起一套:尺寸写回一律 `useEditor().setSize()`,
* 预设表来自 `@/editor/canvasSize` 的 `SIZE_PRESETS`,比例锁用 `applyRatio`。
* `size` 是响应式真值——模板加载、别的面板改尺寸都会反映到这里,所以下拉里的
* 显示和命中预设都 watch 真值,而不是只在打开时读一次。
*/
import { computed, ref, watch } from "vue";
import { useEditor } from "@/editor/useEditor";
import { RATIOS, SIZE_PRESETS, applyRatio } from "@/editor/canvasSize";
import { IconCheck } from "@/ui/icons";
/** 选中一行后要顺手关面板,由 TopBar 通过插槽的 `close` 传进来。 */
const props = defineProps<{ close: () => void }>();
const { size, setSize } = useEditor();
/** 选中预设后,任何一边宽高的改动都会把它弹回「自定义」。 */
const presetId = ref<string>("custom");
/** 比例锁。改宽高时另一侧按这个比例联动;"free"(0)表示不锁。 */
const ratioId = ref<string>("free");
const ratio = computed(() => RATIOS.find((r) => r.id === ratioId.value)?.value ?? 0);
/** 尺寸可能被别处改掉(模板加载、别的面板),要跟着真值走。 */
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 });
props.close();
}
/** 比例锁只记当前锁定,不立刻改尺寸——改宽高时才用 applyRatio 联动。 */
function pickRatio(id: string) {
ratioId.value = id;
}
/** 手动改宽/高。带比例锁时另一侧按 applyRatio 联动。 */
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));
}
</script> </script>
<template> <template>
<div class="menu-block">(CanvasMenu 待实现)</div> <div class="canvas-menu">
<div class="menu-title">预设</div>
<button
v-for="p in SIZE_PRESETS"
:key="p.id"
class="menu-row"
:class="{ on: presetId === p.id }"
@click="pickPreset(p.id)"
>
<span class="name">{{ p.label }}</span>
<span class="menu-aside">{{ p.width }}×{{ p.height }}</span>
<IconCheck v-if="presetId === p.id" class="check" />
</button>
<div class="menu-sep"></div>
<div class="menu-title">分辨率</div>
<div class="menu-block">
<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>
</div>
<div class="menu-sep"></div>
<div class="menu-title">比例</div>
<button
v-for="r in RATIOS"
:key="r.id"
class="menu-row"
:class="{ on: ratioId === r.id }"
title="锁定比例:改宽高时另一侧按此联动"
@click="pickRatio(r.id)"
>
<span class="name">{{ r.label }}</span>
<IconCheck v-if="ratioId === r.id" class="check" />
</button>
</div>
</template> </template>
<style scoped>
/* 名字占满剩余空间,把右侧的尺寸 / 勾推到行尾。 */
.name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* 选中行的勾。图标走 currentColor,`.menu-row.on` 已把颜色置成 accent。 */
.check {
flex: none;
font-size: 12px;
}
.dims {
display: flex;
align-items: center;
gap: 6px;
}
.num {
text-align: center;
}
.times {
color: var(--fg-dim);
font-size: 12px;
}
</style>