Files
cover-studio/src/components/Inspector.vue
T

407 lines
13 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">
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】右侧属性检查器。
*
* 只编辑**选中对象**的几何与外观,分组:
* - 位置与尺寸:X / Y / W / H
* - 变换:缩放 X / Y、旋转角度
* - 外观:不透明度、圆角(矩形走 rx/ry;图片靠 clipPath 挂圆角矩形;文字没有)
*
* 数据流:画布是唯一真源,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>
<!-- 选中单个对象:可编辑的几何 / 外观 -->
<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;
font-weight: 600;
letter-spacing: 0.08em;
color: var(--fg-dim);
text-transform: uppercase;
}
.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);
}
.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>