实现右侧属性检查器 Inspector(位置/尺寸、变换、外观)
This commit is contained in:
+375
-12
@@ -1,32 +1,352 @@
|
||||
<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(W/H 是缩放后的实际占位尺寸,见 types.ts 的 TransformValues)
|
||||
* - 变换:缩放比例 X/Y、旋转角度
|
||||
* 只编辑**选中对象**的几何与外观,分组:
|
||||
* - 位置与尺寸:X / Y / W / H
|
||||
* - 变换:缩放 X / Y、旋转角度
|
||||
* - 外观:不透明度、圆角(矩形走 rx/ry;图片靠 clipPath 挂圆角矩形;文字没有)
|
||||
*
|
||||
* 全部双向绑定:画布上拖动要让数字跟着变(watch `useEditor().revision`),
|
||||
* 改数字要立刻反映到画布(`obj.set` + `setCoords` + `requestRenderAll`)。
|
||||
* 每次编辑结束调 `commit()` 记一步历史;拖拽过程中不要每帧 commit。
|
||||
* 数据流:画布是唯一真源,fabric 不是响应式的,这里靠 `revision` 这个信号
|
||||
* 重新推导显示值(拖拽过程中 object:moving 每帧都 bump,数字跟着走)。
|
||||
* 反向写回走 `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>
|
||||
|
||||
<template>
|
||||
<aside class="inspector">
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.inspector {
|
||||
padding: 10px 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.inspector h2 {
|
||||
margin: 0 12px 8px;
|
||||
font-size: 11px;
|
||||
@@ -35,9 +355,52 @@
|
||||
color: var(--fg-dim);
|
||||
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);
|
||||
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;
|
||||
}
|
||||
|
||||
.inspector input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user