176 lines
5.3 KiB
Vue
176 lines
5.3 KiB
Vue
<script setup lang="ts">
|
|
import { onBeforeUnmount, onMounted, ref } from "vue";
|
|
import { useEditor } from "@/editor/useEditor";
|
|
import { useViewport } from "@/editor/useViewport";
|
|
import { initHistory, commit } from "@/editor/useHistory";
|
|
import { openContextMenu } from "@/editor/useContextMenu";
|
|
import { attachShapeTool } from "@/editor/useShapeTool";
|
|
|
|
/**
|
|
* 画布区。**画布元素的像素尺寸跟着容器走**(它是「视口」),
|
|
* 画布内容的逻辑尺寸由 useEditor 的 size 决定,两者靠 viewportTransform 换算。
|
|
*
|
|
* 这和旧版不同:旧版把画布元素本身缩放到容器里,所以看到多大就是多大;
|
|
* 现在画布元素铺满容器,内容在里面缩放平移,才能做出 Figma 那种无限画布的感觉。
|
|
*
|
|
* 交互:滚轮缩放(以光标为锚)、空格+拖 或 中键拖 平移、右键出上下文菜单。
|
|
*
|
|
* **这个文件由主控独占。**
|
|
*/
|
|
const { mount, unmount, getCanvas, tool, addText } = useEditor();
|
|
const { zoomAt, panBy, zoomToFit } = useViewport();
|
|
|
|
const canvasEl = ref<HTMLCanvasElement | null>(null);
|
|
const stage = ref<HTMLDivElement | null>(null);
|
|
let observer: ResizeObserver | null = null;
|
|
let spaceDown = false;
|
|
let panning = false;
|
|
let lastX = 0;
|
|
let lastY = 0;
|
|
let detachShapeTool: (() => void) | null = null;
|
|
|
|
/**
|
|
* 视口尺寸跟着容器走。
|
|
*
|
|
* **第一次拿到真实尺寸时必须重新适应画布。** `onMounted` 里 stage 的
|
|
* clientWidth/Height 可能还是 0 或过渡值,那时算出来的 zoomToFit 是按错误视口算的,
|
|
* 之后 setDimensions 换了视口却不重算,画板就会跑偏(2026-09-05 踩过)。
|
|
*/
|
|
let fitted = false;
|
|
|
|
function resize() {
|
|
const box = stage.value;
|
|
const c = canvasEl.value ? getCanvas() : null;
|
|
if (!box || !c) return;
|
|
const w = box.clientWidth;
|
|
const h = box.clientHeight;
|
|
if (w === 0 || h === 0) return;
|
|
c.setDimensions({ width: w, height: h });
|
|
if (!fitted) {
|
|
fitted = true;
|
|
zoomToFit();
|
|
}
|
|
c.requestRenderAll();
|
|
}
|
|
|
|
function onWheel(e: WheelEvent) {
|
|
// Ctrl+滚轮 = 缩放(和 Penpot / Figma 一致);不带修饰键 = 平移。
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
if (e.ctrlKey || e.metaKey) {
|
|
e.preventDefault();
|
|
zoomAt(e.clientX - rect.left, e.clientY - rect.top, e.deltaY < 0 ? 1.1 : 1 / 1.1);
|
|
} else {
|
|
e.preventDefault();
|
|
panBy(-e.deltaX, -e.deltaY);
|
|
}
|
|
}
|
|
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.code === "Space" && !spaceDown) {
|
|
spaceDown = true;
|
|
if (stage.value) stage.value.style.cursor = "grab";
|
|
}
|
|
}
|
|
function onKeyUp(e: KeyboardEvent) {
|
|
if (e.code === "Space") {
|
|
spaceDown = false;
|
|
if (stage.value) stage.value.style.cursor = "";
|
|
}
|
|
}
|
|
|
|
function onPointerDown(e: PointerEvent) {
|
|
// 中键或空格+左键 = 平移
|
|
if (e.button === 1 || (spaceDown && e.button === 0)) {
|
|
e.preventDefault();
|
|
panning = true;
|
|
lastX = e.clientX;
|
|
lastY = e.clientY;
|
|
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
|
|
return;
|
|
}
|
|
// 形状工具:拖拽创建由 useShapeTool 的 attachShapeTool 接管,这里完全让开。
|
|
if (e.button === 0 && tool.value === "shape") return;
|
|
|
|
// 非选择工具:在点击处放一个新对象,然后回到选择工具(和 Figma 一致)
|
|
if (e.button === 0 && tool.value !== "select") {
|
|
if (tool.value === "text") addText();
|
|
if (tool.value !== "background") {
|
|
tool.value = "select";
|
|
commit();
|
|
}
|
|
}
|
|
}
|
|
|
|
function onPointerMove(e: PointerEvent) {
|
|
if (!panning) return;
|
|
panBy(e.clientX - lastX, e.clientY - lastY);
|
|
lastX = e.clientX;
|
|
lastY = e.clientY;
|
|
}
|
|
|
|
function onPointerUp(e: PointerEvent) {
|
|
if (!panning) return;
|
|
panning = false;
|
|
(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId);
|
|
}
|
|
|
|
function onContextMenu(e: MouseEvent) {
|
|
e.preventDefault();
|
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
|
openContextMenu(e.clientX - rect.left, e.clientY - rect.top);
|
|
}
|
|
|
|
onMounted(() => {
|
|
if (!canvasEl.value || !stage.value) return;
|
|
const c = mount(canvasEl.value);
|
|
// 尺寸与首次适应交给 resize(),它会等到 stage 真的有尺寸了再做。
|
|
resize();
|
|
initHistory();
|
|
// 形状工具的指针交互单独挂在画布上,这里只负责装/卸。
|
|
detachShapeTool = attachShapeTool(c);
|
|
// 拖拽 / 缩放 / 旋转结束都算一步操作。
|
|
c.on("object:modified", () => commit());
|
|
observer = new ResizeObserver(resize);
|
|
observer.observe(stage.value);
|
|
window.addEventListener("keydown", onKeyDown);
|
|
window.addEventListener("keyup", onKeyUp);
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
observer?.disconnect();
|
|
detachShapeTool?.();
|
|
window.removeEventListener("keydown", onKeyDown);
|
|
window.removeEventListener("keyup", onKeyUp);
|
|
unmount();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="stage"
|
|
ref="stage"
|
|
@wheel="onWheel"
|
|
@pointerdown="onPointerDown"
|
|
@pointermove="onPointerMove"
|
|
@pointerup="onPointerUp"
|
|
@contextmenu="onContextMenu"
|
|
>
|
|
<canvas ref="canvasEl"></canvas>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.stage {
|
|
position: absolute;
|
|
inset: 0;
|
|
background: var(--stage-bg);
|
|
overflow: hidden;
|
|
}
|
|
|
|
/* 画布铺满视口,内容靠 viewportTransform 缩放平移。 */
|
|
.stage :deep(.canvas-container) {
|
|
position: absolute !important;
|
|
inset: 0;
|
|
}
|
|
</style>
|