Merge branch 't6-layers' into integration2
This commit is contained in:
+576
-14
@@ -1,40 +1,602 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 【T6 待实现 — 左侧图层面板】
|
||||
* 左侧图层面板(T6)。
|
||||
*
|
||||
* 数据来自 `@/editor/useLayers` 的 `layers`(从上到下 = 从前到后,和 Figma 一致)。
|
||||
* 那个模块已经把所有操作都写好了:selectLayer / renameLayer / toggleVisible /
|
||||
* toggleLocked / deleteLayer / moveLayerToRow。**面板只管界面,不要另写一套状态。**
|
||||
* 数据与操作全部来自 `@/editor/useLayers`,本文件只管界面:
|
||||
* 行序 = `layers`(从上到下 = 从前到后,和 Figma 一致),
|
||||
* 选中 / 重命名 / 显隐 / 锁定 / 删除 / 排序一律调它暴露的方法,不另写状态。
|
||||
*
|
||||
* 要有:每行图标(按 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>
|
||||
|
||||
<template>
|
||||
<aside class="layers">
|
||||
<h2>图层</h2>
|
||||
<p class="todo">T6 待实现</p>
|
||||
<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);
|
||||
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-weight: 600;
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--fg-dim);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.todo {
|
||||
|
||||
.count {
|
||||
font-size: 10px;
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user