feat: 接入 iconify 图标库,右栏改成 Figma 式收起分组

图标统一走 unplugin-icons + hugeicons,构建期内联,出口收在 src/ui/icons.ts。
顺带修掉 TopBar 那个 undo 图标缺弧命令的 path。

右栏下半四组默认全收,收起态只有一行 32px;顶栏导出按钮改成先展开
「导出与模板」再滚,否则滚到底只看得到一行标题。

属性区去掉分组标题,改成 Figma 那种「字母/图标 + 无边框输入框」两列密排,
新增 W/H 宽高比锁(比例在开锁那一刻定格,不每次编辑现算)。

删掉 Sidebar.vue 和 panels/CanvasPanel.vue:上一轮改 Figma 布局之后
它们已无人 import,--sidebar-w 同批遗留。
This commit is contained in:
2026-09-05 17:51:43 +08:00
parent ee303bcd44
commit 3a3798e37b
16 changed files with 722 additions and 619 deletions
+102 -135
View File
@@ -5,126 +5,96 @@ import SourcePanel from "./panels/SourcePanel.vue";
import FilterPanel from "./panels/FilterPanel.vue";
import TextPanel from "./panels/TextPanel.vue";
import ExportPanel from "./panels/ExportPanel.vue";
import {
IconImage,
IconFilter,
IconText,
IconExport,
IconPlus,
IconChevronDown,
} from "@/ui/icons";
import { toolGroupRequest, type ToolGroupId } from "@/ui/toolGroups";
/**
* 【T10 — 工具面板的重新安置】
* 右栏下半:背景 / 滤镜 / 文字 / 导出与模板 四个工具面板的容器。
*
* 旧版把所有东西塞在一个右侧栏里。改成 Figma 式布局之后,那四个面板
* (背景 / 滤镜 / 文字 / 导出与模板)没有任何挂载点——功能不可达。这里
* 把它们收成**可折叠的分组**,占右栏下半层(App.vue 已挂好,不动布局):
* 版式照 Figma 的设计面板:每组平时只是**一行 32px 的标题**(图标 + 名字 + 右侧
* 加号),点开才展开内容。**默认四组全收**——这四个面板加起来一千多行控件,
* 常驻展开的话右栏一屏塞不下,找任何一个都得先滚。
*
* | 组 | 来源 | 内容何时可用 |
* |----------|---------------|---------------------------|
* | 背景 | SourcePanel | 常驻 |
* | 滤镜 | FilterPanel | 选中图片时 |
* | 文字 | TextPanel | 选中文字时 |
* | 导出与模板| ExportPanel | 常驻 |
* 自动展开只发生在一种情况:选中的对象类型正好对应某一组(选图片 → 滤镜,
* 选文字 → 文字)。这是「我要改这个」的明确信号,替用户省一次点击;反过来
* 不自动收起,收起是用户自己的事。
*
* 规则:
* - 分组标题可手动折叠(展开与否只是 UI 状态,不销毁面板)。
* - **选中对象自动展开相关的那组**:选中图片自动展开「滤镜」,
* 选中文字自动展开「文字」。
* - 内容是否可用看 `selectionKind`(filter 要图片、text 要文字)。
* 面板组件**保持挂载**(用 v-show 而不是 v-if 隐藏),这样它们内部
* watch activeObject 的状态能一直跟随画布;条件不满足时组内显示一行提示。
* 面板组件用 v-show 而不是 v-if 隐藏,保持挂载,它们内部 watch activeObject
* 的状态才能一直跟着画布走。滤镜和文字组在条件不满足时显示一行提示。
*
* 别处要把用户送到某个面板,走 `@/ui/toolGroups` 的 `openToolGroup(id)`。
*/
const { selectionKind } = useEditor();
type GroupId = "bg" | "filter" | "text" | "export";
const GROUPS = [
{ id: "bg", label: "背景", icon: IconImage, scope: "" },
{ id: "filter", label: "滤镜", icon: IconFilter, scope: "图片" },
{ id: "text", label: "文字", icon: IconText, scope: "文字" },
{ id: "export", label: "导出与模板", icon: IconExport, scope: "" },
] as const;
/** 每组是否展开。展开与否是纯 UI 状态;组的内容是否可用看 selectionKind。 */
const open = reactive<Record<GroupId, boolean>>({
bg: true,
/** 每组是否展开。纯 UI 状态;组的内容是否可用看 selectionKind。 */
const open = reactive<Record<ToolGroupId, boolean>>({
bg: false,
filter: false,
text: false,
export: true,
export: false,
});
const isImage = computed(() => selectionKind.value === "image");
const isText = computed(() => selectionKind.value === "text");
/** 选中对象类型变化 → 自动展开相关的那组。只展开不收起,收起是用户自己的事。 */
watch(selectionKind, (kind) => {
if (kind === "image") open.filter = true;
else if (kind === "text") open.text = true;
});
function toggle(id: GroupId) {
/** 别处(顶栏的导出按钮)点名要展开某一组。 */
watch(toolGroupRequest, (req) => {
if (req) open[req.id] = true;
});
function toggle(id: ToolGroupId) {
open[id] = !open[id];
}
</script>
<template>
<div class="tool-panels">
<!-- ================= 背景 ================= -->
<section class="tp-sec" :class="{ open: open.bg }">
<button
type="button"
class="tp-head"
:aria-expanded="open.bg"
@click="toggle('bg')"
>
<span class="chev" aria-hidden="true"></span>
<span class="tp-title">背景</span>
<section v-for="g in GROUPS" :key="g.id" class="sec" :class="{ open: open[g.id] }">
<button type="button" class="head" :aria-expanded="open[g.id]" @click="toggle(g.id)">
<component :is="g.icon" class="head-icon" />
<span class="title">{{ g.label }}</span>
<span v-if="g.scope && !open[g.id]" class="scope">{{ g.scope }}</span>
<IconChevronDown v-if="open[g.id]" class="mark" />
<IconPlus v-else class="mark" />
</button>
<div v-show="open.bg" class="tp-body">
<SourcePanel />
</div>
</section>
<!-- ================= 滤镜(选中图片时可用) ================= -->
<section class="tp-sec" :class="{ open: open.filter }">
<button
type="button"
class="tp-head"
:aria-expanded="open.filter"
@click="toggle('filter')"
>
<span class="chev" aria-hidden="true"></span>
<span class="tp-title">滤镜</span>
<span class="tp-scope">作用于图片</span>
</button>
<div v-show="open.filter" class="tp-body">
<FilterPanel v-show="isImage" />
<p v-if="!isImage" class="tp-hint">
在画布上选中一张图片后,这一组会自动展开,列出可叠加的滤镜。
</p>
</div>
</section>
<div v-show="open[g.id]" class="body">
<template v-if="g.id === 'bg'">
<SourcePanel />
</template>
<!-- ================= 文字(选中文字时可用) ================= -->
<section class="tp-sec" :class="{ open: open.text }">
<button
type="button"
class="tp-head"
:aria-expanded="open.text"
@click="toggle('text')"
>
<span class="chev" aria-hidden="true"></span>
<span class="tp-title">文字</span>
<span class="tp-scope">作用于文字</span>
</button>
<div v-show="open.text" class="tp-body">
<TextPanel v-show="isText" />
<p v-if="!isText" class="tp-hint">
用顶栏文字工具(T)添加文字,或选中画布上已有的文字,再到这里调字体样式。
</p>
</div>
</section>
<template v-else-if="g.id === 'filter'">
<FilterPanel v-show="isImage" />
<p v-if="!isImage" class="hint">选中画布上的一张图片,这里会列出可叠加的滤镜。</p>
</template>
<!-- ================= 导出与模板 ================= -->
<section class="tp-sec" :class="{ open: open.export }">
<button
type="button"
class="tp-head"
:aria-expanded="open.export"
@click="toggle('export')"
>
<span class="chev" aria-hidden="true"></span>
<span class="tp-title">导出与模板</span>
</button>
<div v-show="open.export" class="tp-body">
<ExportPanel />
<template v-else-if="g.id === 'text'">
<TextPanel v-show="isText" />
<p v-if="!isText" class="hint">用文字工具(T)新建,或选中已有文字,再到这里调字体样式。</p>
</template>
<template v-else>
<ExportPanel />
</template>
</div>
</section>
</div>
@@ -132,82 +102,79 @@ function toggle(id: GroupId) {
<style scoped>
.tool-panels {
padding: 2px 0 18px;
padding-bottom: 18px;
}
/* 分组之间一条细分隔线,整体顶到右栏上边缘 */
.tp-sec {
.sec {
border-top: 1px solid var(--line);
}
.tp-sec:first-child {
border-top: none;
}
/* 分组标题:整行可点,覆盖全局 button 外观 */
.tp-head {
/* 标题行:整行可点,覆盖全局 button 外观。收起态就是这一行的全部高度。 */
.head {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 7px 10px;
height: 32px;
padding: 0 10px;
border: none;
border-radius: 0;
background: transparent;
color: var(--fg);
color: var(--fg-dim);
cursor: pointer;
text-align: left;
}
.tp-head:hover {
.head:hover {
background: var(--bg3);
border-color: transparent;
color: var(--fg);
}
.tp-head:focus-visible {
.head:focus-visible {
outline: 1px solid var(--accent);
outline-offset: -1px;
}
/* 展开/收起的三角,收拢朝右、展开朝下 */
.chev {
flex: none;
width: 0;
height: 0;
border-left: 4px solid var(--fg-dim);
border-top: 4px solid transparent;
border-bottom: 4px solid transparent;
transition: transform 0.12s ease;
}
.tp-sec.open .chev {
transform: rotate(90deg);
}
.tp-title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
color: var(--fg-dim);
}
.tp-sec.open .tp-title {
.sec.open .head {
color: var(--fg);
}
/* 组的适用范围提示,靠右 */
.tp-scope {
.head-icon {
font-size: 15px;
}
.title {
font-size: 12px;
font-weight: 500;
}
/* 收起时在右边提示这组作用在什么上;展开后收掉,让位给内容。 */
.scope {
margin-left: auto;
font-size: 11px;
font-size: 10px;
color: var(--fg-dim);
opacity: 0.85;
opacity: 0.7;
white-space: nowrap;
}
.tp-body {
padding: 0 12px 12px;
/* 展开/收起的记号:收起是加号,展开是朝下的角标(和 Figma 一致)。 */
.mark {
margin-left: auto;
font-size: 14px;
color: var(--fg-dim);
}
.scope + .mark {
margin-left: 6px;
}
.head:hover .mark {
color: var(--fg);
}
/* 组展开了但条件不满足(没选图片/文字)时给一行提示,不渲染控制项 */
.tp-hint {
color: var(--fg-dim);
font-size: 12px;
line-height: 1.7;
.body {
padding: 2px 10px 12px;
}
.hint {
margin: 2px 0 0;
color: var(--fg-dim);
font-size: 11px;
line-height: 1.7;
}
</style>