Files
cover-studio/src/components/LayersPanel.vue
T
im0o eab82102b7 feat: 左侧图层面板——选中/多选、重命名、显隐/锁定、行内删除、拖拽排序带插入线
- 数据与操作全部走 useLayers,面板只渲染不另存状态
- 高亮集合由画布 getActiveObjects() 实时派生:selection:* 事件不 bump
  revision,吃 layers[].selected 会在画布纯点选后保持陈旧
- 拖拽把被拖行收起、其余行顶上,插入线与跟手浮层指示目标缝隙,
  slot 语义经 Node 脚本对 moveLayerToRow→fabric moveObjectTo 全组合断言
2026-09-05 02:38:41 +08:00

603 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
/**
* 左侧图层面板(T6)。
*
* 数据与操作全部来自 `@/editor/useLayers`,本文件只管界面:
* 行序 = `layers`(从上到下 = 从前到后,和 Figma 一致),
* 选中 / 重命名 / 显隐 / 锁定 / 删除 / 排序一律调它暴露的方法,不另写状态。
*
* 行内交互:
* - 单击选中;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>
<template>
<aside class="layers">
<div class="layers-head">
<h2>图层</h2>
<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>
</template>
<style scoped>
.layers {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
border-right: 1px solid var(--line);
background: var(--bg2);
}
.layers-head {
flex: none;
display: flex;
align-items: baseline;
gap: 6px;
padding: 10px 12px 8px;
}
.layers-head h2 {
margin: 0;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--fg-dim);
text-transform: uppercase;
}
.count {
font-size: 10px;
color: var(--fg-dim);
}
/* 滚动区也是插入线 / 拖拽浮层的定位上下文。 */
.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>