Merge branch 't9-menus' into integration2

This commit is contained in:
2026-09-05 02:40:31 +08:00
2 changed files with 626 additions and 23 deletions
+136 -10
View File
@@ -1,21 +1,102 @@
<script setup lang="ts"> <script setup lang="ts">
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
import { CANVAS_CONTEXT_MENU, getCommand, runCommand } from "@/editor/commands";
import { keyHintOf } from "@/editor/useShortcuts";
import { useContextMenu } from "@/editor/useContextMenu";
/** /**
* 【T9 待实现 — 右键上下文菜单】 * 右键上下文菜单。开合与位置来自 `@/editor/useContextMenu`(坐标相对画布区左上角)。
* *
* 开合与位置在 `@/editor/useContextMenu`(坐标相对画布区左上角)。 * 菜单内容读 `CANVAS_CONTEXT_MENU`,每一项指向命令表里的一条命令:
* 菜单内容读 `CANVAS_CONTEXT_MENU`(`@/editor/commands`),每项指向一条命令: * 标题 `command.label`、右侧灰字 `keyHintOf(id)`、`command.enabled()` 为 false 置灰,
* 标题用 `command.label`,右侧灰字用 `keyHintOf(id)`(`@/editor/useShortcuts`), * 点击走 `runCommand(id)`——删除/层序/缩放到选中都复用命令表,和快捷键同一条路。
* `command.enabled()` 为 false 时置灰不可点,点击走 `runCommand(id)`。
* *
* **不要在这里自己写删除/层序的逻辑**——那些都在命令表里, * 需要处理:点菜单外或按 Esc 关闭;贴近画布区右/下边缘时翻转,别被裁掉。
* 快捷键和工具栏走的是同一条,抄一份必然走偏。
*
* 要处理:点菜单外或按 Esc 关闭;贴近视口边缘时翻转,不要被裁掉。
*/ */
const { open, x, y, closeContextMenu } = useContextMenu();
const menuEl = ref<HTMLElement | null>(null);
const left = ref(0);
const top = ref(0);
function labelOf(commandId: string): string {
return getCommand(commandId)?.label ?? commandId;
}
function hintOf(commandId: string): string | undefined {
return keyHintOf(commandId);
}
function enabledOf(commandId: string): boolean {
return getCommand(commandId)?.enabled() ?? false;
}
function onClick(commandId: string) {
runCommand(commandId);
closeContextMenu();
}
/**
* 打开后量一次菜单尺寸,贴到画布区右/下边缘就翻到光标另一侧,避免被 overflow:hidden 裁掉。
* 画布区的尺寸取菜单的父元素(`.area-stage`,菜单绝对定位在它里面)。
*/
watch(open, async (val) => {
if (!val) return;
left.value = x.value;
top.value = y.value;
await nextTick();
const el = menuEl.value;
const stage = el?.parentElement;
if (!el || !stage) return;
const mw = el.offsetWidth;
const mh = el.offsetHeight;
const sw = stage.clientWidth;
const sh = stage.clientHeight;
if (left.value + mw > sw) left.value = Math.max(0, x.value - mw);
if (top.value + mh > sh) top.value = Math.max(0, y.value - mh);
});
function onDocPointerDown(e: PointerEvent) {
if (!open.value) return;
if (menuEl.value && !menuEl.value.contains(e.target as Node)) closeContextMenu();
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && open.value) {
e.preventDefault();
closeContextMenu();
}
}
onMounted(() => {
document.addEventListener("pointerdown", onDocPointerDown);
window.addEventListener("keydown", onKeyDown);
});
onBeforeUnmount(() => {
document.removeEventListener("pointerdown", onDocPointerDown);
window.removeEventListener("keydown", onKeyDown);
});
</script> </script>
<template> <template>
<div v-if="false" class="context-menu">T9 待实现</div> <div
v-if="open"
ref="menuEl"
class="context-menu"
:style="{ left: left + 'px', top: top + 'px' }"
@contextmenu.prevent
>
<template v-for="(item, i) in CANVAS_CONTEXT_MENU" :key="i">
<div v-if="item.type === 'separator'" class="sep"></div>
<button
v-else
class="item"
:disabled="!enabledOf(item.commandId)"
@click="onClick(item.commandId)"
>
<span class="label">{{ labelOf(item.commandId) }}</span>
<span v-if="hintOf(item.commandId)" class="hint">{{ hintOf(item.commandId) }}</span>
</button>
</template>
</div>
</template> </template>
<style scoped> <style scoped>
@@ -29,4 +110,49 @@
box-shadow: 0 8px 28px rgb(0 0 0 / 0.5); box-shadow: 0 8px 28px rgb(0 0 0 / 0.5);
z-index: 10; z-index: 10;
} }
.item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 24px;
width: 100%;
padding: 5px 10px;
border: none;
background: transparent;
border-radius: 5px;
text-align: left;
cursor: pointer;
}
.item:hover:not(:disabled) {
background: var(--accent);
color: #fff;
}
.item:disabled {
color: var(--fg-dim);
opacity: 0.5;
cursor: default;
}
.item .label {
white-space: nowrap;
}
.item .hint {
color: var(--fg-dim);
font-size: 11px;
white-space: nowrap;
}
.item:hover:not(:disabled) .hint {
color: #fff;
}
.sep {
height: 1px;
margin: 4px 6px;
background: var(--line);
}
</style> </style>
+490 -13
View File
@@ -1,25 +1,429 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, ref, watch } from "vue";
import { ActiveSelection, FabricImage, Rect, Textbox } from "fabric";
import { useEditor } from "@/editor/useEditor";
import { runCommand } from "@/editor/commands";
import { commit } from "@/editor/useHistory";
/** /**
* 【T9 待实现 — 选中对象的浮动编辑条】 * 选中对象的圆角浮动编辑条,贴在对象包围盒上方,顶到画布区上缘时翻到下方。
* **没有选中就不渲染。**
* *
* 一条圆角矩形的浮动菜单,跟着选中对象走(贴在它包围盒的上方, * 内容随选中类型变(任务书表格):
* 顶到画布上缘时翻到下方)。**没有选中就不渲染。** * - 图片:圆角、置顶/置底、创建副本、删除
* - 文字:字号、颜色、对齐、粗细
* - 矩形:填色、圆角、置顶/置底、删除
* - 多选:对齐与分布、创建副本、删除
* *
* 内容随选中对象的类型变: * 能走命令的一律走命令(bringToFront / sendToBack / duplicate / delete),
* - 图片:滤镜入口、替换图片、圆角、置顶/置底、删除 * 文字、填色、圆角、对齐分布没有对应命令,直接 `obj.set()` + `requestRenderAll()` + `commit()`。
* - 文字:字体、字号、颜色、对齐、粗细
* - 矩形:填色、圆角、描边
* - 多选:对齐与分布、成组、删除
* *
* 位置换算:对象的 `getBoundingRect()` 给的是**场景坐标**,要经 viewportTransform * 位置换算是这条线唯一的技术难点:`getBoundingRect()` 给的是场景坐标,要经
* 换算成屏幕坐标才能定位这个 DOM 浮层。参考 * `viewportTransform` 换算成屏幕坐标才能当 CSS 值用——别把场景坐标直接当 CSS 像素。
* `canvas.getViewportPoint` / `viewportTransform`,别用场景坐标直接当 CSS 值。 * 跟随用 rAF 循环:拖拽、缩放、平移时画布每一帧都在变,循环每帧重算一次位置。
* 跟随要 watch `useEditor().revision`(拖拽过程中它每帧都会变)。
*/ */
const ed = useEditor();
/** 场景坐标 → 屏幕坐标(纯函数)。vpt 是 fabric 的 viewportTransform `[a,b,c,d,e,f]`。 */
function sceneToScreen(vpt: number[], sx: number, sy: number): { x: number; y: number } {
return { x: sx * vpt[0] + vpt[4], y: sy * vpt[3] + vpt[5] };
}
type MenuKind = "image" | "text" | "rect" | "multi" | "none";
/**
* `selectionKind` 把矩形和多选都归成 "other",所以这里用 instanceof 再分一层。
* 读 revision 是为了让 drag/增删改都能触发重算。
*/
const kind = computed<MenuKind>(() => {
void ed.revision.value;
const sk = ed.selectionKind.value;
if (sk === "image") return "image";
if (sk === "text") return "text";
const obj = ed.activeObject.value;
if (obj instanceof Rect) return "rect";
if (obj instanceof ActiveSelection || ed.selectedObjects().length > 1) return "multi";
return "none";
});
const barEl = ref<HTMLElement | null>(null);
const left = ref(0);
const top = ref(0);
const below = ref(false);
const barW = ref(180);
const barH = ref(34);
/** 选中的对象集合在场景坐标里的并集包围盒。 */
function selectionSceneBounds(): { left: number; top: number; width: number; height: number } | null {
const objs = ed.selectedObjects();
if (objs.length === 0) return null;
let l = Infinity;
let t = Infinity;
let r = -Infinity;
let b = -Infinity;
for (const o of objs) {
const box = o.getBoundingRect();
l = Math.min(l, box.left);
t = Math.min(t, box.top);
r = Math.max(r, box.left + box.width);
b = Math.max(b, box.top + box.height);
}
return { left: l, top: t, width: r - l, height: b - t };
}
const GAP = 8;
function updatePosition() {
const c = ed.canvas.value;
const bounds = selectionSceneBounds();
if (!c || !bounds) return;
const vpt = c.viewportTransform ?? [1, 0, 0, 1, 0, 0];
const stage = barEl.value?.parentElement;
const stageW = stage?.clientWidth ?? 0;
const cx = sceneToScreen(vpt, bounds.left + bounds.width / 2, bounds.top).x;
const topY = sceneToScreen(vpt, bounds.left, bounds.top).y;
const bottomY = sceneToScreen(vpt, bounds.left, bounds.top + bounds.height).y;
left.value = cx;
// 水平方向夹在画布区内,别让半根条伸出视口。
if (stageW > 0) {
const half = barW.value / 2;
if (left.value - half < 0) left.value = half;
else if (left.value + half > stageW) left.value = stageW - half;
}
// 默认贴上方,顶到画布区上缘就翻到下方。
if (topY - GAP - barH.value >= 0) {
below.value = false;
top.value = topY - GAP;
} else {
below.value = true;
top.value = bottomY + GAP;
}
}
// 选中期间跑一个 rAF 循环:拖拽、缩放、平移每帧都跟着走。
let rafId = 0;
function loop() {
updatePosition();
rafId = requestAnimationFrame(loop);
}
function startLoop() {
if (!rafId) rafId = requestAnimationFrame(loop);
}
function stopLoop() {
if (rafId) {
cancelAnimationFrame(rafId);
rafId = 0;
}
}
watch(
() => ed.activeObject.value,
(obj) => (obj ? startLoop() : stopLoop()),
{ immediate: true },
);
// 内容随类型变化后,量一次真实尺寸再定位(避免用估算值在翻折边界上差几像素)。
watch(kind, async () => {
await nextTick();
const el = barEl.value;
if (el) {
barW.value = el.offsetWidth;
barH.value = el.offsetHeight;
}
updatePosition();
});
onBeforeUnmount(stopLoop);
/* ---------------------------------------------------------------- 文字 */
const textFontSize = ref(40);
const textFill = ref("#ffffff");
const textAlign = ref<"left" | "center" | "right">("center");
const textBold = ref(false);
/* ---------------------------------------------------------------- 矩形 */
const rectFill = ref("#6ea8fe");
const rectRadius = ref(0);
/* ---------------------------------------------------------------- 图片 */
const imageRadius = ref(0);
/** 选中对象变化时,把对象上的当前值同步进本地 UI 状态。 */
function syncFromObject() {
const obj = ed.activeObject.value;
if (obj instanceof Textbox) {
textFontSize.value = typeof obj.fontSize === "number" ? obj.fontSize : 40;
textFill.value = typeof obj.fill === "string" ? obj.fill : "#ffffff";
const a = obj.textAlign;
textAlign.value = a === "left" || a === "right" ? a : "center";
const w = obj.fontWeight;
textBold.value = w === "bold" || w === 700 || w === "700";
} else if (obj instanceof Rect) {
rectFill.value = typeof obj.fill === "string" ? obj.fill : "#6ea8fe";
rectRadius.value = typeof obj.rx === "number" ? obj.rx : 0;
} else if (obj instanceof FabricImage) {
const cp = obj.clipPath;
imageRadius.value = cp instanceof Rect ? (typeof cp.rx === "number" ? cp.rx : 0) : 0;
}
}
watch(
() => ed.activeObject.value,
() => syncFromObject(),
{ immediate: true },
);
function onFontSize(e: Event) {
const obj = ed.activeObject.value;
if (!(obj instanceof Textbox)) return;
const n = Number((e.target as HTMLInputElement).value);
if (!Number.isFinite(n) || n <= 0) return;
textFontSize.value = n;
obj.set({ fontSize: n });
obj.setCoords();
ed.getCanvas().requestRenderAll();
ed.bumpRevision();
commit();
}
function onTextFill(e: Event) {
const obj = ed.activeObject.value;
if (!(obj instanceof Textbox)) return;
const v = (e.target as HTMLInputElement).value;
textFill.value = v;
obj.set({ fill: v });
ed.getCanvas().requestRenderAll();
ed.bumpRevision();
}
function onTextFillDone() {
commit();
}
function setTextAlign(a: "left" | "center" | "right") {
const obj = ed.activeObject.value;
if (!(obj instanceof Textbox)) return;
textAlign.value = a;
obj.set({ textAlign: a });
obj.setCoords();
ed.getCanvas().requestRenderAll();
ed.bumpRevision();
commit();
}
function toggleBold() {
const obj = ed.activeObject.value;
if (!(obj instanceof Textbox)) return;
textBold.value = !textBold.value;
obj.set({ fontWeight: textBold.value ? "bold" : "normal" });
obj.setCoords();
ed.getCanvas().requestRenderAll();
ed.bumpRevision();
commit();
}
/* ---------------------------------------------------------------- 矩形 / 图片 */
function onRectFill(e: Event) {
const obj = ed.activeObject.value;
if (!(obj instanceof Rect)) return;
const v = (e.target as HTMLInputElement).value;
rectFill.value = v;
obj.set({ fill: v });
ed.getCanvas().requestRenderAll();
ed.bumpRevision();
}
function onRectFillDone() {
commit();
}
function onRectRadius(e: Event) {
const obj = ed.activeObject.value;
if (!(obj instanceof Rect)) return;
const n = Number((e.target as HTMLInputElement).value);
if (!Number.isFinite(n) || n < 0) return;
rectRadius.value = n;
obj.set({ rx: n, ry: n });
obj.setCoords();
ed.getCanvas().requestRenderAll();
ed.bumpRevision();
commit();
}
/** 图片没有原生圆角,靠 clipPath 挂一个同尺寸圆角矩形。原点显式写 center,避开 Rect 默认中心原点那个坑。 */
function setImageCorner(img: FabricImage, radius: number) {
if (radius <= 0) {
img.clipPath = undefined;
} else {
img.clipPath = new Rect({
originX: "center",
originY: "center",
width: img.width,
height: img.height,
rx: radius,
ry: radius,
});
}
img.dirty = true;
}
function onImageRadius(e: Event) {
const obj = ed.activeObject.value;
if (!(obj instanceof FabricImage)) return;
const n = Number((e.target as HTMLInputElement).value);
if (!Number.isFinite(n) || n < 0) return;
imageRadius.value = n;
setImageCorner(obj, n);
ed.getCanvas().requestRenderAll();
ed.bumpRevision();
commit();
}
/* ---------------------------------------------------------------- 多选:对齐与分布 */
type AlignEdge = "left" | "center" | "right" | "top" | "middle" | "bottom";
const selectedCount = computed(() => {
void ed.revision.value;
return ed.selectedObjects().length;
});
const canDistribute = computed(() => selectedCount.value >= 3);
function alignSelected(edge: AlignEdge) {
const objs = ed.selectedObjects();
if (objs.length < 2) return;
const bounds = selectionSceneBounds();
if (!bounds) return;
for (const o of objs) {
const b = o.getBoundingRect();
const cx = b.left + b.width / 2;
const cy = b.top + b.height / 2;
if (edge === "left") o.set({ left: (o.left ?? 0) + (bounds.left - b.left) });
else if (edge === "center") o.set({ left: (o.left ?? 0) + (bounds.left + bounds.width / 2 - cx) });
else if (edge === "right") o.set({ left: (o.left ?? 0) + (bounds.left + bounds.width - (b.left + b.width)) });
else if (edge === "top") o.set({ top: (o.top ?? 0) + (bounds.top - b.top) });
else if (edge === "middle") o.set({ top: (o.top ?? 0) + (bounds.top + bounds.height / 2 - cy) });
else if (edge === "bottom") o.set({ top: (o.top ?? 0) + (bounds.top + bounds.height - (b.top + b.height)) });
o.setCoords();
}
ed.getCanvas().requestRenderAll();
ed.bumpRevision();
commit();
}
function distributeSelected(axis: "x" | "y") {
const objs = ed.selectedObjects();
if (objs.length < 3) return;
const sorted = [...objs].sort((a, b) => {
const ba = a.getBoundingRect();
const bb = b.getBoundingRect();
return axis === "x"
? ba.left + ba.width / 2 - (bb.left + bb.width / 2)
: ba.top + ba.height / 2 - (bb.top + bb.height / 2);
});
const first = sorted[0].getBoundingRect();
const last = sorted[sorted.length - 1].getBoundingRect();
if (axis === "x") {
const total = sorted.reduce((s, o) => s + o.getBoundingRect().width, 0);
const span = last.left + last.width - first.left;
const gap = (span - total) / (sorted.length - 1);
let cursor = first.left;
for (const o of sorted) {
const b = o.getBoundingRect();
o.set({ left: (o.left ?? 0) + (cursor - b.left) });
o.setCoords();
cursor += b.width + gap;
}
} else {
const total = sorted.reduce((s, o) => s + o.getBoundingRect().height, 0);
const span = last.top + last.height - first.top;
const gap = (span - total) / (sorted.length - 1);
let cursor = first.top;
for (const o of sorted) {
const b = o.getBoundingRect();
o.set({ top: (o.top ?? 0) + (cursor - b.top) });
o.setCoords();
cursor += b.height + gap;
}
}
ed.getCanvas().requestRenderAll();
ed.bumpRevision();
commit();
}
</script> </script>
<template> <template>
<div v-if="false" class="selection-menu">T9 待实现</div> <div
v-if="kind !== 'none'"
ref="barEl"
class="selection-menu"
:class="{ below }"
:style="{ left: left + 'px', top: top + 'px' }"
>
<!-- 图片 -->
<template v-if="kind === 'image'">
<label class="num" title="圆角半径(像素)">
<span class="lbl">圆角</span>
<input type="number" :value="imageRadius" min="0" step="1" @change="onImageRadius" />
</label>
<button title="置于顶层" @click="runCommand('bringToFront')">置顶</button>
<button title="置于底层" @click="runCommand('sendToBack')">置底</button>
<button title="创建副本" @click="runCommand('duplicate')">副本</button>
<button class="danger" title="删除" @click="runCommand('delete')">删除</button>
</template>
<!-- 文字 -->
<template v-else-if="kind === 'text'">
<label class="num">
<input type="number" :value="textFontSize" min="1" step="1" title="字号" @change="onFontSize" />
</label>
<input type="color" :value="textFill" title="文字颜色" @input="onTextFill" @change="onTextFillDone" />
<div class="seg">
<button :class="{ on: textAlign === 'left' }" title="左对齐" @click="setTextAlign('left')">左</button>
<button :class="{ on: textAlign === 'center' }" title="居中" @click="setTextAlign('center')">中</button>
<button :class="{ on: textAlign === 'right' }" title="右对齐" @click="setTextAlign('right')">右</button>
</div>
<button class="bold" :class="{ on: textBold }" title="加粗" @click="toggleBold">B</button>
</template>
<!-- 矩形 -->
<template v-else-if="kind === 'rect'">
<input type="color" :value="rectFill" title="填充色" @input="onRectFill" @change="onRectFillDone" />
<label class="num" title="圆角半径(像素)">
<span class="lbl">圆角</span>
<input type="number" :value="rectRadius" min="0" step="1" @change="onRectRadius" />
</label>
<button title="置于顶层" @click="runCommand('bringToFront')">置顶</button>
<button title="置于底层" @click="runCommand('sendToBack')">置底</button>
<button class="danger" title="删除" @click="runCommand('delete')">删除</button>
</template>
<!-- 多选 -->
<template v-else>
<div class="seg">
<button title="左对齐" @click="alignSelected('left')">左</button>
<button title="水平居中" @click="alignSelected('center')">中</button>
<button title="右对齐" @click="alignSelected('right')">右</button>
</div>
<div class="seg">
<button title="顶对齐" @click="alignSelected('top')">顶</button>
<button title="垂直居中" @click="alignSelected('middle')">中</button>
<button title="底对齐" @click="alignSelected('bottom')">底</button>
</div>
<div class="seg">
<button title="水平分布(等间距)" :disabled="!canDistribute" @click="distributeSelected('x')">横分</button>
<button title="垂直分布(等间距)" :disabled="!canDistribute" @click="distributeSelected('y')">纵分</button>
</div>
<button title="创建副本" @click="runCommand('duplicate')">副本</button>
<button class="danger" title="删除" @click="runCommand('delete')">删除</button>
</template>
</div>
</template> </template>
<style scoped> <style scoped>
@@ -34,5 +438,78 @@
background: var(--bg3); background: var(--bg3);
box-shadow: 0 6px 24px rgb(0 0 0 / 0.45); box-shadow: 0 6px 24px rgb(0 0 0 / 0.45);
pointer-events: auto; pointer-events: auto;
z-index: 9;
/* 默认贴在对象上方:水平居中,垂直向上让出自身高度。 */
transform: translate(-50%, -100%);
white-space: nowrap;
}
/* 翻到对象下方。 */
.selection-menu.below {
transform: translate(-50%, 0);
}
.selection-menu button {
padding: 3px 8px;
font-size: 12px;
line-height: 1.4;
border-radius: 6px;
}
.selection-menu button.on {
border-color: var(--accent);
background: var(--accent);
color: #fff;
}
.selection-menu button.danger:hover {
border-color: var(--danger);
color: var(--danger);
}
.selection-menu button:disabled {
opacity: 0.4;
cursor: default;
}
.selection-menu .bold {
font-weight: 700;
}
.seg {
display: flex;
gap: 2px;
padding: 0 2px;
}
.seg button {
padding: 3px 6px;
}
.num {
display: flex;
align-items: center;
gap: 4px;
margin: 0;
}
.num .lbl {
color: var(--fg-dim);
font-size: 12px;
}
.num input {
width: 52px;
padding: 2px 4px;
font-size: 12px;
}
.selection-menu input[type="color"] {
width: 24px;
height: 24px;
padding: 2px;
border-radius: 6px;
background: var(--bg);
cursor: pointer;
} }
</style> </style>