feat: 右键菜单与选中浮动编辑条(T9)
This commit is contained in:
+136
-10
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user