chore: 本轮改造的共享契约与占位——ToolId/ShapeKind/LayerKind 扩充、Dropdown 壳、形状表、三个菜单与两个 composable 的桩

This commit is contained in:
2026-09-05 17:56:10 +08:00
parent 3a3798e37b
commit 19c8c604cb
17 changed files with 512 additions and 28 deletions
+9 -3
View File
@@ -35,10 +35,11 @@ import { computed, nextTick, onBeforeUnmount, ref } from "vue";
import type { LayerInfo, LayerKind } from "@/types";
import { useEditor } from "@/editor/useEditor";
import { useLayers } from "@/editor/useLayers";
import { shapeMeta } from "@/editor/shapes";
import {
IconImage,
IconText,
IconRect,
IconBackgroundTool,
IconEye,
IconEyeOff,
IconLocked,
@@ -65,11 +66,16 @@ const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v
/* ------------------------------------------------ 图标 */
/** 行首的类型图标。`other` 兜底用「多层」那个,表示不属于以上三类的对象。 */
/** 行首的类型图标。形状五种直接借形状表里的那份,和工具栏图标保持一致。 */
const KIND_ICON: Record<LayerKind, unknown> = {
background: IconBackgroundTool,
image: IconImage,
text: IconText,
rect: IconRect,
rect: shapeMeta("rect").icon,
line: shapeMeta("line").icon,
arrow: shapeMeta("arrow").icon,
ellipse: shapeMeta("ellipse").icon,
polygon: shapeMeta("polygon").icon,
other: IconTemplate,
};
+4 -4
View File
@@ -48,15 +48,15 @@ const { canUndo, canRedo } = useHistory();
const TOOLS = [
{ cmd: "toolSelect", label: "选择工具", icon: IconSelect },
{ cmd: "toolText", label: "文字工具", icon: IconText },
{ cmd: "toolRect", label: "矩形工具", icon: IconRect },
{ cmd: "toolImage", label: "图片工具", icon: IconImage },
{ cmd: "toolShape", label: "形状工具", icon: IconRect },
{ cmd: "toolBackground", label: "背景工具", icon: IconImage },
] as const;
const TOOL_OF: Record<string, ToolId> = {
toolSelect: "select",
toolText: "text",
toolRect: "rect",
toolImage: "image",
toolShape: "shape",
toolBackground: "background",
};
function toolOf(cmd: string): ToolId {
+2 -2
View File
@@ -93,8 +93,8 @@ function onPointerDown(e: PointerEvent) {
const scene = c.getScenePoint(e);
void p;
if (tool.value === "text") addText();
else if (tool.value === "rect") addRect({ x: scene.x, y: scene.y });
if (tool.value !== "image") {
else if (tool.value === "shape") addRect({ x: scene.x, y: scene.y });
if (tool.value !== "background") {
tool.value = "select";
commit();
}
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
/** 【占位】BackgroundMenu 的内容由对应开发线实现。见任务书。 */
</script>
<template>
<div class="menu-block">(BackgroundMenu 待实现)</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
/** 【占位】CanvasMenu 的内容由对应开发线实现。见任务书。 */
</script>
<template>
<div class="menu-block">(CanvasMenu 待实现)</div>
</template>
+7
View File
@@ -0,0 +1,7 @@
<script setup lang="ts">
/** 【占位】ShapeMenu 的内容由对应开发线实现。见任务书。 */
</script>
<template>
<div class="menu-block">(ShapeMenu 待实现)</div>
</template>
+122
View File
@@ -0,0 +1,122 @@
<script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
/**
* 顶栏三个下拉(画布编辑 / 形状 / 背景)共用的浮层壳。
*
* 它只管四件事:开合、点外面关掉、Esc 关掉、贴着触发器定位并在撞到窗口右边缘时
* 翻到左对齐。菜单**内容**由使用方通过 `panel` 插槽给,行的样式用 style.css 里的
* 全局 `.menu-*` 类(作用域样式够不到插槽内容,所以那几个类必须是全局的)。
*
* 触发器也由使用方给(`trigger` 插槽),插槽参数带 `open` 和 `toggle`,
* 这样按钮的高亮态和点击行为都由使用方决定,壳不猜。
*/
const props = withDefaults(
defineProps<{
/** 面板最小宽度 px。 */
minWidth?: number;
/** 面板相对触发器的水平对齐。撞到窗口边缘时会自动翻到另一边。 */
align?: "left" | "center";
}>(),
{ minWidth: 200, align: "left" },
);
const open = ref(false);
const rootEl = ref<HTMLElement | null>(null);
const panelEl = ref<HTMLElement | null>(null);
/** 面板相对 root(触发器)的左偏移 px。 */
const offsetX = ref(0);
function toggle() {
open.value = !open.value;
}
function close() {
open.value = false;
}
defineExpose({ close });
/**
* 打开后量一次,撞到窗口右边缘就往左挪。
* 用 window 而不是父容器做边界:顶栏是 `overflow: hidden` 的,但面板挂在
* 顶栏里靠 `position: absolute` 浮出去,真正会裁掉它的是浏览器窗口。
*/
watch(open, async (val) => {
if (!val) return;
offsetX.value = 0;
await nextTick();
const root = rootEl.value;
const panel = panelEl.value;
if (!root || !panel) return;
const r = root.getBoundingClientRect();
const pw = panel.offsetWidth;
let x = props.align === "center" ? (r.width - pw) / 2 : 0;
const MARGIN = 8;
const leftAbs = r.left + x;
if (leftAbs + pw > window.innerWidth - MARGIN) x -= leftAbs + pw - (window.innerWidth - MARGIN);
if (r.left + x < MARGIN) x = MARGIN - r.left;
offsetX.value = x;
});
function onDocPointerDown(e: PointerEvent) {
if (!open.value) return;
if (rootEl.value && !rootEl.value.contains(e.target as Node)) close();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && open.value) {
e.preventDefault();
e.stopPropagation();
close();
}
}
onMounted(() => {
document.addEventListener("pointerdown", onDocPointerDown);
// 捕获阶段:面板里有输入框时,Esc 要先关面板,不让全局快捷键抢走。
window.addEventListener("keydown", onKeyDown, true);
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", onDocPointerDown);
window.removeEventListener("keydown", onKeyDown, true);
});
</script>
<template>
<div ref="rootEl" class="dd">
<slot name="trigger" :open="open" :toggle="toggle" />
<div
v-if="open"
ref="panelEl"
class="dd-panel"
:style="{ left: offsetX + 'px', minWidth: minWidth + 'px' }"
@contextmenu.prevent
>
<slot name="panel" :close="close" />
</div>
</div>
</template>
<style scoped>
.dd {
position: relative;
display: flex;
align-items: center;
}
.dd-panel {
position: absolute;
top: calc(100% + 6px);
z-index: 40;
padding: 5px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--bg3);
box-shadow: 0 10px 34px rgb(0 0 0 / 0.55);
/* 顶栏很矮,面板可能比它长得多,超出就自己滚。 */
max-height: calc(100vh - var(--topbar-h) - 24px);
overflow-y: auto;
}
</style>