Merge branch 't8-inspector' into integration2
This commit is contained in:
+375
-12
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user