实现右侧属性检查器 Inspector(位置/尺寸、变换、外观)

This commit is contained in:
2026-09-05 02:36:12 +08:00
parent 0c0cc86fa8
commit ceae8eee24
+375 -12
View File
@@ -1,32 +1,352 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from "vue";
import { ActiveSelection, Rect } from "fabric";
import type { FabricObject } from "fabric";
import { useEditor, layerKindOf } from "@/editor/useEditor";
import { commit } from "@/editor/useHistory";
import type { TransformValues } from "@/types";
/** /**
* 【T8 待实现 — 右侧属性检查器】 * 【T8】右侧属性检查器。
* *
* 只放**选中对象的属性**,不再是原来那个什么都装的侧栏。分组: * 只编辑**选中对象**的几何与外观,分组:
* * - 位置与尺寸:X / Y / W / H
* - 位置与尺寸:X / Y / W / H(W/H 是缩放后的实际占位尺寸,见 types.ts 的 TransformValues) * - 变换:缩放 X / Y、旋转角度
* - 变换:缩放比例 X/Y、旋转角度
* - 外观:不透明度、圆角(矩形走 rx/ry;图片靠 clipPath 挂圆角矩形;文字没有) * - 外观:不透明度、圆角(矩形走 rx/ry;图片靠 clipPath 挂圆角矩形;文字没有)
* *
* 全部双向绑定:画布上拖动要让数字跟着变(watch `useEditor().revision`), * 数据流:画布是唯一真源,fabric 不是响应式的,这里靠 `revision` 这个信号
* 改数字要立刻反映到画布(`obj.set` + `setCoords` + `requestRenderAll`)。 * 重新推导显示值(拖拽过程中 object:moving 每帧都 bump,数字跟着走)。
* 每次编辑结束调 `commit()` 记一步历史;拖拽过程中不要每帧 commit。 * 反向写回走 `obj.set` + `setCoords` + `requestRenderAll`,编辑结束才 `commit()`。
* *
* 没选中对象时显示画布本身的属性(尺寸、背景色)。 * 两条容易做错的语义(任务书点名,见文件末尾的纯函数区):
* 1. W/H 是缩放后的**实际占位尺寸** = 原始尺寸 × scale,不是 `getScaledWidth()`——
* 后者会把 `strokeWidth`(默认 1,即使 stroke 为 null)也算进去,导致
* width=400、scaleX=1 的对象读出来是 401 而不是 400(实测)。
* 2. 对象是中心原点(originX/originY: "center"),`obj.left` 是**中心点**坐标。
* 显示给用户的 X/Y 是**未旋转的左上角**(Figma 语义:旋转不改 X/Y/W/H),
* 要 `left - width*scale/2` 换算,不能直接拿 `obj.left`。
*/ */
const { activeObject, revision, size, getCanvas, bumpRevision } = useEditor();
/** 当前选中的单个对象。多选(ActiveSelection)或没选时返回 null。 */
function singleObject(): FabricObject | null {
const obj = activeObject.value;
if (!obj || obj instanceof ActiveSelection) return null;
return obj;
}
/** 圆角半径:矩形读 rx,图片读 clipPath 圆角矩形的 rx,文字/其他不适用返回 undefined。 */
function readCornerRadius(obj: FabricObject): number | undefined {
const kind = layerKindOf(obj);
if (kind === "text") return undefined;
if (kind === "rect") return round((obj as Rect).rx ?? 0);
if (kind === "image") {
const cp = obj.clipPath;
return cp instanceof Rect ? round(cp.rx ?? 0) : 0;
}
return undefined;
}
/** 从画布上的对象推导出面板显示值。依赖 `revision`,画布一变就重算。 */
const values = computed<TransformValues | null>(() => {
void revision.value;
const obj = singleObject();
if (!obj) return null;
const naturalW = obj.width ?? 0;
const naturalH = obj.height ?? 0;
const sx = obj.scaleX ?? 1;
const sy = obj.scaleY ?? 1;
return {
x: round(topLeftFromCenter(obj.left ?? 0, naturalW, sx)),
y: round(topLeftFromCenter(obj.top ?? 0, naturalH, sy)),
width: round(readScaledSize(naturalW, sx)),
height: round(readScaledSize(naturalH, sy)),
scaleX: round(sx),
scaleY: round(sy),
angle: round(normalizeAngle(obj.angle ?? 0)),
opacity: round(obj.opacity ?? 1),
cornerRadius: readCornerRadius(obj),
};
});
/** 写回后统一收尾:更新包围盒、重绘、通知其它视图。**不**记历史。 */
function finish(obj: FabricObject) {
obj.setCoords();
getCanvas().requestRenderAll();
bumpRevision();
}
/** 一次编辑结束(输入框失焦 / 回车 / 滑杆松手)才记一步历史。 */
function commitEdit() {
commit();
}
/* ---------------- 各字段的写回 ---------------- */
function editX(raw: string) {
const obj = singleObject();
if (!obj) return;
const n = num(raw);
if (n == null) return;
obj.set({ left: centerFromTopLeft(n, obj.width ?? 0, obj.scaleX ?? 1) });
finish(obj);
}
function editY(raw: string) {
const obj = singleObject();
if (!obj) return;
const n = num(raw);
if (n == null) return;
obj.set({ top: centerFromTopLeft(n, obj.height ?? 0, obj.scaleY ?? 1) });
finish(obj);
}
function editWidth(raw: string) {
const obj = singleObject();
if (!obj) return;
const target = positive(raw);
const natural = obj.width ?? 0;
if (target == null || natural <= 0) return;
obj.set({ scaleX: scaleForSize(natural, target) });
finish(obj);
}
function editHeight(raw: string) {
const obj = singleObject();
if (!obj) return;
const target = positive(raw);
const natural = obj.height ?? 0;
if (target == null || natural <= 0) return;
obj.set({ scaleY: scaleForSize(natural, target) });
finish(obj);
}
function editScaleX(raw: string) {
const obj = singleObject();
if (!obj) return;
const s = positive(raw);
if (s == null) return;
obj.set({ scaleX: s });
finish(obj);
}
function editScaleY(raw: string) {
const obj = singleObject();
if (!obj) return;
const s = positive(raw);
if (s == null) return;
obj.set({ scaleY: s });
finish(obj);
}
function editAngle(raw: string) {
const obj = singleObject();
if (!obj) return;
const a = num(raw);
if (a == null) return;
obj.set({ angle: normalizeAngle(a) });
finish(obj);
}
function onOpacityInput(e: Event) {
const obj = singleObject();
if (!obj) return;
const p = num((e.target as HTMLInputElement).value);
if (p == null) return;
obj.set({ opacity: clamp(p, 0, 100) / 100 });
finish(obj);
}
function editRadius(raw: string) {
const obj = singleObject();
if (!obj) return;
const r = num(raw);
if (r == null) return;
const v = clamp(r, 0, Infinity);
const kind = layerKindOf(obj);
if (kind === "rect") {
obj.set({ rx: v, ry: v });
} else if (kind === "image") {
if (v <= 0) {
// 圆角归零就摘掉 clipPath,省掉一次裁剪开销。
obj.clipPath = undefined;
} else {
// 对象级 clipPath 是正常工作的(不吃 viewportTransform 的是**画布级**那个)。
// 非 absolutePositioned 的 clipPath 在对象的**局部坐标**里,原点即对象原点(中心),
// 所以这里用默认中心原点、left/top 为 0、尺寸等于图片自然尺寸的圆角矩形,
// 正好盖住图片本体,并跟随图片一起缩放平移旋转。
const cp = obj.clipPath instanceof Rect ? obj.clipPath : new Rect();
cp.set({
left: 0,
top: 0,
width: obj.width ?? 0,
height: obj.height ?? 0,
rx: v,
ry: v,
});
obj.set({ clipPath: cp });
}
} else {
return;
}
finish(obj);
}
/* ---------------- 通用数值小工具(组件侧,非纯函数区) ---------------- */
function num(raw: string): number | null {
const n = Number(raw);
return Number.isFinite(n) ? n : null;
}
function positive(raw: string): number | null {
const n = num(raw);
return n != null && n > 0 ? n : null;
}
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n));
}
function round(n: number): number {
const p = 10 ** 2;
return Math.round(n * p) / p;
}
// ==PURE-START== Node 侧自验从这里**原样**抽取(勿改动这两行标记)
/**
* 缩放后的实际占位尺寸 = 原始尺寸 × 缩放。
* 刻意不用 fabric 的 getScaledWidth():它会把 strokeWidth(默认 1,即便 stroke 为 null)
* 也算进去,导致 width=800、scaleX=1.5 读成 1201.5 而不是 1200。
*/
function readScaledSize(raw: number, scale: number): number {
return raw * scale;
}
/** 由目标占位尺寸反推缩放。`scaleForSize(800, 600) === 0.75`。 */
function scaleForSize(raw: number, target: number): number {
return target / raw;
}
/** 中心原点的对象:中心坐标 → 未旋转时的左上角坐标。 */
function topLeftFromCenter(center: number, raw: number, scale: number): number {
return center - (raw * scale) / 2;
}
/** 左上角坐标 → 中心原点的中心坐标(topLeftFromCenter 的逆)。 */
function centerFromTopLeft(topLeft: number, raw: number, scale: number): number {
return topLeft + (raw * scale) / 2;
}
/** 角度归一到 [0, 360)。 */
function normalizeAngle(angle: number): number {
return ((angle % 360) + 360) % 360;
}
// ==PURE-END==
</script> </script>
<template> <template>
<aside class="inspector"> <aside class="inspector">
<h2>属性</h2> <h2>属性</h2>
<p class="todo">T8 待实现:X / Y / W / H、缩放、旋转、不透明度、圆角</p>
<!-- 选中单个对象:可编辑的几何 / 外观 -->
<template v-if="values">
<section class="group">
<h3>位置与尺寸</h3>
<div class="grid">
<label class="field">
<span class="field-label">X</span>
<input type="number" :value="values.x" step="1" @input="editX(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label class="field">
<span class="field-label">Y</span>
<input type="number" :value="values.y" step="1" @input="editY(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label class="field">
<span class="field-label">W</span>
<input type="number" :value="values.width" min="0" step="1" @input="editWidth(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label class="field">
<span class="field-label">H</span>
<input type="number" :value="values.height" min="0" step="1" @input="editHeight(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
</div>
</section>
<section class="group">
<h3>变换</h3>
<div class="grid">
<label class="field">
<span class="field-label">缩放 X</span>
<input type="number" :value="values.scaleX" min="0" step="0.01" @input="editScaleX(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label class="field">
<span class="field-label">缩放 Y</span>
<input type="number" :value="values.scaleY" min="0" step="0.01" @input="editScaleY(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
<label class="field">
<span class="field-label">旋转</span>
<input type="number" :value="values.angle" step="1" @input="editAngle(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
</div>
</section>
<section class="group">
<h3>外观</h3>
<div class="field">
<span class="field-label">不透明度</span>
<div class="row opacity-row">
<input
type="range"
min="0"
max="100"
step="1"
:value="Math.round(values.opacity * 100)"
@input="onOpacityInput"
@change="commitEdit"
/>
<span class="value">{{ Math.round(values.opacity * 100) }}%</span>
</div>
</div>
<label v-if="values.cornerRadius !== undefined" class="field">
<span class="field-label">圆角</span>
<input type="number" :value="values.cornerRadius" min="0" step="1" @input="editRadius(($event.target as HTMLInputElement).value)" @change="commitEdit" />
</label>
</section>
</template>
<!-- 多选:不做几何编辑 -->
<template v-else-if="activeObject">
<p class="meta">已选中多个对象。几何属性只在单选时编辑。</p>
</template>
<!-- 没选中:画布属性(只读,改尺寸是 T7 的活) -->
<template v-else>
<section class="group">
<h3>画布</h3>
<div class="grid">
<label class="field">
<span class="field-label">宽</span>
<input type="number" disabled :value="size.width" />
</label>
<label class="field">
<span class="field-label">高</span>
<input type="number" disabled :value="size.height" />
</label>
</div>
<p class="meta">画布尺寸由顶部工具栏修改。</p>
</section>
</template>
</aside> </aside>
</template> </template>
<style scoped> <style scoped>
.inspector { .inspector {
padding: 10px 0; padding: 10px 0;
overflow-y: auto;
} }
.inspector h2 { .inspector h2 {
margin: 0 12px 8px; margin: 0 12px 8px;
font-size: 11px; font-size: 11px;
@@ -35,9 +355,52 @@
color: var(--fg-dim); color: var(--fg-dim);
text-transform: uppercase; text-transform: uppercase;
} }
.todo {
.group {
padding: 0 12px;
margin-bottom: 14px;
}
.group h3 {
margin: 0 0 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
color: var(--fg-dim); color: var(--fg-dim);
margin: 0 12px; }
.grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
}
.field-label {
display: block;
margin-bottom: 4px;
font-size: 11px;
color: var(--fg-dim);
}
.value {
color: var(--fg-dim);
font-variant-numeric: tabular-nums;
white-space: nowrap;
}
.opacity-row input[type="range"] {
flex: 1;
}
.meta {
margin: 0;
color: var(--fg-dim);
font-size: 12px;
line-height: 1.6; line-height: 1.6;
} }
.inspector input:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style> </style>