feat: 左侧图层面板——选中/多选、重命名、显隐/锁定、行内删除、拖拽排序带插入线

- 数据与操作全部走 useLayers,面板只渲染不另存状态
- 高亮集合由画布 getActiveObjects() 实时派生:selection:* 事件不 bump
  revision,吃 layers[].selected 会在画布纯点选后保持陈旧
- 拖拽把被拖行收起、其余行顶上,插入线与跟手浮层指示目标缝隙,
  slot 语义经 Node 脚本对 moveLayerToRow→fabric moveObjectTo 全组合断言
This commit is contained in:
2026-09-05 02:38:41 +08:00
parent 0c0cc86fa8
commit eab82102b7
+575 -13
View File
@@ -1,40 +1,602 @@
<script setup lang="ts"> <script setup lang="ts">
/** /**
* 【T6 待实现 — 左侧图层面板】 * 左侧图层面板(T6)。
* *
* 数据来自 `@/editor/useLayers` 的 `layers`(从上到下 = 从前到后,和 Figma 一致)。 * 数据与操作全部来自 `@/editor/useLayers`,本文件只管界面:
* 那个模块已经把所有操作都写好了:selectLayer / renameLayer / toggleVisible / * 行序 = `layers`(从上到下 = 从前到后,和 Figma 一致),
* toggleLocked / deleteLayer / moveLayerToRow。**面板只管界面,不要另写一套状态。** * 选中 / 重命名 / 显隐 / 锁定 / 删除 / 排序一律调它暴露的方法,不另写状态。
* *
* 要有:每行图标(按 kind)+ 名字 + 显隐眼睛 + 锁;点击选中,Ctrl/Shift 点多选; * 行内交互:
* 双击重命名;拖拽排序(放到第 N 行调 moveLayerToRow);行内或右键删除。 * - 单击选中;Ctrl/Shift 单击 = 多选(additive)。按 pointerdown 选中(设计工具惯例)。
* - 双击名字重命名:Enter / 失焦提交,Esc 取消。
* - 拖拽排序:行被拖起后列表收起,跟手显示一行浮层 + 一条插入线,
* 松开时把「目标行号」交给 `moveLayerToRow(id, slot)`。
* - 行尾眼睛 / 锁 / 删除按钮。
*
* 两个值得注意的点:
*
* 1. 高亮不直接吃 `layers[].selected`。useEditor 只对
* object:added/removed/modified/moving/... 这些事件 bumpRevision,
* 而 selection:* 事件只更新 activeObject;fabric 的 `object:modified`
* 又只在 transform 真正发生动作时触发。于是「在画布上纯点选另一个对象」
* 不会 bump revision,内核推导的 `selected` 会保持陈旧,面板高亮就不跟画布走。
* 这里的高亮集合直接由画布 `getActiveObjects()` 派生,并让 computed 同时
* 依赖 `revision` 和 `activeObject`——两者任一变化都会重算。这是只读投影,
* 不是另一份图层状态。
*
* 2. 拖拽的 slot 语义。`moveLayerToRow(id, toRow)` 的 toRow 是「拖完这一行
* 在面板里最终占第几行(0 = 最上层)」。拖拽时把被拖行从 DOM 里收起,
* 其余行顶上,插入线画在「其余行」的第 slot 条缝隙上——drop 的 slot
* 正好等于最终行号,直接传给 moveLayerToRow 即可,不需要自己换算 fabric 的
* 绘制序号(那一层换算在 useLayers 内部)。
*/ */
import {
computed,
defineComponent,
h,
nextTick,
onBeforeUnmount,
ref,
type PropType,
type VNode,
} from "vue";
import type { LayerInfo, LayerKind } from "@/types";
import { useEditor } from "@/editor/useEditor";
import { useLayers } from "@/editor/useLayers";
const { canvas, revision, activeObject } = useEditor();
const { layers, selectLayer, renameLayer, toggleVisible, toggleLocked, deleteLayer, moveLayerToRow } =
useLayers();
/* ------------------------------------------------ 常量 */
/** 单行高度 px。必须与样式里的 .layer-row/.drag-ghost 的 height 保持一致。 */
const ROW_H = 30;
/** 判定为拖拽的最小位移。 */
const DRAG_PX = 4;
/** 拖拽到列表上下边缘多少 px 内开始自动滚动。 */
const EDGE_PX = 24;
const SCROLL_STEP = 12;
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
/* ------------------------------------------------ 图标(本地小部件) */
function iconSvg(children: VNode[]): VNode {
return h(
"svg",
{
width: 14,
height: 14,
viewBox: "0 0 16 16",
fill: "none",
stroke: "currentColor",
"stroke-width": 1.25,
"stroke-linecap": "round",
"stroke-linejoin": "round",
"aria-hidden": "true",
},
children,
);
}
function kindNodes(kind: LayerKind): VNode[] {
switch (kind) {
case "image":
return [
h("rect", { x: 2.3, y: 3.5, width: 11.4, height: 9, rx: 1.5 }),
h("circle", { cx: 5.6, cy: 6.5, r: 1.1 }),
h("path", { d: "M3.1 11.4l2.9-3.1 2.5 2.5 1.8-1.9 2.6 2.5" }),
];
case "text":
return [h("path", { d: "M3.4 4.5h9.2M8 4.5v7" })];
case "rect":
return [h("rect", { x: 3.6, y: 3.6, width: 8.8, height: 8.8, rx: 1.6 })];
case "other":
return [h("path", { d: "M8 2.7l1.7 3.6 3.6 1.7-3.6 1.7-1.7 3.6-1.7-3.6-3.6-1.7 3.6-1.7Z" })];
}
}
const KindIcon = defineComponent({
name: "KindIcon",
props: { kind: { type: String as PropType<LayerKind>, required: true } },
setup(p) {
return () => iconSvg(kindNodes(p.kind));
},
});
/** 显隐眼睛:off = 眼睛加一道斜杠(隐藏态)。 */
const EyeIcon = defineComponent({
name: "EyeIcon",
props: { off: { type: Boolean, default: false } },
setup(p) {
const eye = "M2.6 8C4.3 5.6 6.3 4.3 8 4.3s3.7 1.3 5.4 3.7c-1.7 2.4-3.7 3.7-5.4 3.7S4.3 10.4 2.6 8Z";
return () =>
iconSvg(
p.off
? [h("path", { d: eye }), h("line", { x1: 3, y1: 3.2, x2: 13, y2: 12.8 })]
: [h("path", { d: eye }), h("circle", { cx: 8, cy: 8, r: 1.6 })],
);
},
});
/** 锁:open = 已解锁(锁梁翘起、没扣进锁体)。 */
const LockIcon = defineComponent({
name: "LockIcon",
props: { open: { type: Boolean, default: false } },
setup(p) {
return () =>
iconSvg([
h("rect", { x: 3.9, y: 7.2, width: 8.2, height: 5.6, rx: 1.4 }),
h(
"path",
p.open
? { d: "M5.4 7.2v-1.8a2.6 2.6 0 0 1 4.8-1.2" }
: { d: "M5.4 7.2V5.4a2.6 2.6 0 0 1 5.2 0V7.2" },
),
]);
},
});
const TrashIcon = defineComponent({
name: "TrashIcon",
setup() {
return () =>
iconSvg([
h("path", { d: "M4.6 5.4h6.8" }),
h("path", { d: "M6 5.4l.4 6.1a1.1 1.1 0 0 0 1.1 1h1a1.1 1.1 0 0 0 1.1-1l.4-6.1" }),
h("path", { d: "M6.5 3.7h3" }),
]);
},
});
/* ------------------------------------------------ 状态 */
const scrollEl = ref<HTMLElement | null>(null);
const renameInputEl = ref<HTMLInputElement | null>(null);
/** 正在重命名的行 id(同时只有一个)。 */
const editingId = ref<string | null>(null);
const draftName = ref("");
/**
* 拖拽中。slot 是「其余行」的第几条缝隙(0..n-1,n = 总行数),
* drop 时原样传给 moveLayerToRow。originalRow 用来判空拖(没动就不调,避免空历史步)。
*/
const drag = ref<{ id: string; originalRow: number; slot: number; ghostTop: number } | null>(null);
/** 从 pointerdown 到判定为拖拽之间的候选(还没超过位移阈值)。 */
let candidate: { id: string; x: number; y: number } | null = null;
function byId(id: string): LayerInfo | undefined {
return layers.value.find((l) => l.id === id);
}
/** 高亮集合:画布当前选中的对象 id。见文件头注释第 1 点。 */
const selectedIds = computed<Set<string>>(() => {
void revision.value;
void activeObject.value;
const c = canvas.value;
const set = new Set<string>();
if (!c) return set;
for (const o of c.getActiveObjects()) if (o.layerId) set.add(o.layerId);
return set;
});
/** 拖拽时把被拖行收起,其余行保持原序顶上。 */
const shownLayers = computed(() => {
const d = drag.value;
return d ? layers.value.filter((l) => l.id !== d.id) : layers.value;
});
/** 被拖的那行(浮层要用它的名字与图标)。 */
const dragInfo = computed(() => {
const d = drag.value;
return d ? byId(d.id) ?? null : null;
});
/* ------------------------------------------------ 手势:选中 / 拖拽 */
/** pointerdown 一次完成两件事:按设计工具惯例立即选中,并登记拖拽候选。 */
function onScrollPointerDown(e: PointerEvent) {
if (e.button !== 0 || !e.isPrimary) return;
// 上次手势的 pointerup 可能丢在浏览器窗口外(没有 pointer capture,收不到),
// 残留的 candidate/drag 会挡住后续所有点击。新的 primary pointerdown 一定意味着
// 上次手势已经结束,先收掉再走正常流程。
if (candidate || drag.value) cancelGesture();
const t = e.target as HTMLElement;
if (t.closest("button, input")) return;
const rowEl = t.closest<HTMLElement>(".layer-row");
const id = rowEl?.dataset.id;
if (!id) return;
const layer = byId(id);
if (!layer || layer.locked) return; // 锁定行不可选中也不可拖
selectLayer(id, e.ctrlKey || e.metaKey || e.shiftKey);
if (layers.value.length < 2) return;
candidate = { id, x: e.clientX, y: e.clientY };
window.addEventListener("pointermove", onWindowMove);
window.addEventListener("pointerup", onWindowUp);
window.addEventListener("pointercancel", cancelGesture);
window.addEventListener("blur", cancelGesture);
}
function beginDrag(e: PointerEvent) {
const cand = candidate;
if (!cand) return;
const idx = layers.value.findIndex((l) => l.id === cand.id);
if (idx < 0) {
cancelGesture();
return;
}
drag.value = { id: cand.id, originalRow: idx, slot: idx, ghostTop: 0 };
window.addEventListener("keydown", onWindowKey);
updateDrag(e);
}
function onWindowMove(e: PointerEvent) {
if (drag.value) {
updateDrag(e);
return;
}
if (!candidate) return;
if (Math.hypot(e.clientX - candidate.x, e.clientY - candidate.y) < DRAG_PX) return;
beginDrag(e);
}
function updateDrag(e: PointerEvent) {
const sc = scrollEl.value;
const d = drag.value;
if (!sc || !d) return;
const rect = sc.getBoundingClientRect();
// 靠近上下边缘时自动滚动,让长列表也能拖到看不见的位置。
if (e.clientY < rect.top + EDGE_PX) sc.scrollTop -= SCROLL_STEP;
else if (e.clientY > rect.bottom - EDGE_PX) sc.scrollTop += SCROLL_STEP;
const n = layers.value.length;
const contentY = e.clientY - rect.top + sc.scrollTop;
const slot = clamp(Math.floor(contentY / ROW_H), 0, Math.max(n - 1, 0));
const ghostTop = clamp(contentY - ROW_H / 2, 0, Math.max(0, sc.scrollHeight - ROW_H));
drag.value = { ...d, slot, ghostTop };
}
function onWindowUp() {
const d = drag.value;
cancelGesture();
if (!d) return;
const layer = byId(d.id);
// slot == originalRow 说明拖回了原位,不调(否则 moveLayerToRow 会记一个空历史步)。
if (layer && d.slot !== d.originalRow) moveLayerToRow(d.id, d.slot);
}
function onWindowKey(e: KeyboardEvent) {
if (e.key === "Escape") cancelGesture();
}
function cancelGesture() {
candidate = null;
drag.value = null;
window.removeEventListener("pointermove", onWindowMove);
window.removeEventListener("pointerup", onWindowUp);
window.removeEventListener("pointercancel", cancelGesture);
window.removeEventListener("blur", cancelGesture);
window.removeEventListener("keydown", onWindowKey);
}
onBeforeUnmount(cancelGesture);
/* ------------------------------------------------ 行内操作 */
function onRowDblclick(e: MouseEvent, layer: LayerInfo) {
const t = e.target as HTMLElement;
if (t.closest("button, input")) return;
if (layer.locked) return;
startRename(layer);
}
function startRename(layer: LayerInfo) {
editingId.value = layer.id;
draftName.value = layer.name;
void nextTick(() => {
const el = renameInputEl.value;
if (el) {
el.focus();
el.select();
}
});
}
function commitRename() {
const id = editingId.value;
if (id === null) return;
editingId.value = null;
const name = draftName.value.trim();
const cur = byId(id);
if (!cur) return;
// 空名与原名都跳过:不改就不记历史步;清空视为放弃这次改名。
if (name !== "" && name !== cur.name) renameLayer(id, name);
}
function cancelRename() {
editingId.value = null;
}
function onInputKeydown(e: KeyboardEvent) {
if (e.isComposing) return; // 中文输入法组词中的回车不算提交
if (e.key === "Enter") commitRename();
else if (e.key === "Escape") cancelRename();
}
function onToggleVisible(layer: LayerInfo) {
toggleVisible(layer.id);
}
function onToggleLocked(layer: LayerInfo) {
toggleLocked(layer.id);
}
function removeLayer(layer: LayerInfo) {
if (editingId.value === layer.id) editingId.value = null;
deleteLayer(layer.id);
}
</script> </script>
<template> <template>
<aside class="layers"> <aside class="layers">
<div class="layers-head">
<h2>图层</h2> <h2>图层</h2>
<p class="todo">T6 待实现</p> <span v-if="layers.length" class="count">{{ layers.length }}</span>
</div>
<div
ref="scrollEl"
class="layers-scroll"
:class="{ dragging: !!drag }"
@pointerdown="onScrollPointerDown"
>
<p v-if="layers.length === 0" class="empty">
暂无图层。往画布上添加图片、文字或形状后,会按前后顺序列在这里,可以点选、拖拽排序。
</p>
<template v-for="layer in shownLayers" :key="layer.id">
<div
class="layer-row"
:data-id="layer.id"
:class="{ selected: selectedIds.has(layer.id), locked: layer.locked, hidden: !layer.visible }"
:title="layer.locked ? `${layer.name}(已锁定)` : layer.name"
@dblclick="onRowDblclick($event, layer)"
>
<KindIcon class="kind-icon" :kind="layer.kind" />
<input
v-if="editingId === layer.id"
ref="renameInputEl"
v-model="draftName"
class="rename-input"
type="text"
spellcheck="false"
@pointerdown.stop
@keydown="onInputKeydown"
@blur="commitRename"
/>
<span v-else class="name">{{ layer.name }}</span>
<span class="row-actions">
<button
class="row-btn"
type="button"
:title="layer.visible ? '隐藏图层' : '显示图层'"
@click="onToggleVisible(layer)"
>
<EyeIcon :off="!layer.visible" />
</button>
<button
class="row-btn"
type="button"
:title="layer.locked ? '解锁图层' : '锁定图层'"
@click="onToggleLocked(layer)"
>
<LockIcon :open="!layer.locked" />
</button>
<button class="row-btn btn-del" type="button" title="删除图层" @click="removeLayer(layer)">
<TrashIcon />
</button>
</span>
</div>
</template>
<div v-if="drag" class="drop-line" :style="{ top: drag.slot * ROW_H + 'px' }"></div>
<div v-if="drag && dragInfo" class="drag-ghost" :style="{ top: drag.ghostTop + 'px' }">
<KindIcon class="kind-icon" :kind="dragInfo.kind" />
<span class="name">{{ dragInfo.name }}</span>
</div>
</div>
</aside> </aside>
</template> </template>
<style scoped> <style scoped>
.layers { .layers {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
border-right: 1px solid var(--line); border-right: 1px solid var(--line);
background: var(--bg2); background: var(--bg2);
overflow-y: auto;
padding: 10px 0;
} }
.layers h2 {
margin: 0 12px 8px; .layers-head {
flex: none;
display: flex;
align-items: baseline;
gap: 6px;
padding: 10px 12px 8px;
}
.layers-head h2 {
margin: 0;
font-size: 11px; font-size: 11px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.08em; letter-spacing: 0.08em;
color: var(--fg-dim); color: var(--fg-dim);
text-transform: uppercase; text-transform: uppercase;
} }
.todo {
.count {
font-size: 10px;
color: var(--fg-dim); color: var(--fg-dim);
margin: 0 12px; }
/* 滚动区也是插入线 / 拖拽浮层的定位上下文。 */
.layers-scroll {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 0 6px 14px;
}
.empty {
margin: 0;
padding: 14px 10px;
font-size: 12px;
line-height: 1.7;
color: var(--fg-dim);
}
.layer-row {
display: flex;
align-items: center;
gap: 6px;
height: 30px; /* 与脚本里 ROW_H 保持一致 */
padding: 0 4px;
border-radius: 5px;
user-select: none;
touch-action: none;
}
.layer-row:hover {
background: var(--bg3);
}
.layer-row.selected {
background: color-mix(in srgb, var(--accent) 16%, transparent);
}
.layer-row.selected:hover {
background: color-mix(in srgb, var(--accent) 24%, transparent);
}
/* 锁定:整行置灰,不可选中也不可拖(操作按钮仍可用)。 */
.layer-row.locked {
opacity: 0.45;
}
/* 隐藏的图层:名字与图标压暗一档,仍列在面板里。 */
.layer-row.hidden .name {
color: var(--fg-dim);
}
.kind-icon {
flex: none;
color: var(--fg-dim);
}
.name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 12px;
color: var(--fg);
}
.rename-input {
flex: 1 1 auto;
min-width: 0;
width: auto; /* 全局 input { width: 100% } 会撑破 flex 行,宽度交给 flex 分配 */
height: 20px;
padding: 0 4px;
font-size: 12px;
border-color: var(--accent);
}
.row-actions {
flex: none;
display: flex;
align-items: center;
gap: 1px;
}
.row-btn {
display: grid;
place-items: center;
width: 22px;
height: 22px;
padding: 0;
border: none;
border-radius: 4px;
background: transparent;
color: var(--fg-dim);
cursor: pointer;
}
.row-btn:hover {
color: var(--fg);
background: color-mix(in srgb, var(--fg) 12%, transparent);
}
.row-btn:focus-visible {
outline: 1px solid var(--accent);
}
/* 删除按钮平时藏起来,悬停行 / 该行被选中时才出现。 */
.btn-del {
opacity: 0;
transition: opacity 0.08s ease;
}
.layer-row:hover .btn-del,
.layer-row.selected .btn-del {
opacity: 1;
}
/* 拖拽中禁止行再响应指针,避免悬停闪烁和误触;行本身已被收起。 */
.layers-scroll.dragging .layer-row {
pointer-events: none;
}
.drop-line {
position: absolute;
left: 8px;
right: 8px;
height: 2px;
margin-top: -1px;
border-radius: 1px;
background: var(--accent);
box-shadow: 0 0 6px color-mix(in srgb, var(--accent) 70%, transparent);
pointer-events: none;
z-index: 6;
}
.drag-ghost {
position: absolute;
left: 6px;
right: 6px;
display: flex;
align-items: center;
gap: 6px;
height: 30px; /* 与脚本里 ROW_H 保持一致 */
padding: 0 8px;
border: 1px solid var(--accent);
border-radius: 5px;
background: var(--bg3);
box-shadow: 0 6px 18px rgb(0 0 0 / 0.45);
pointer-events: none;
z-index: 7;
} }
</style> </style>