@zzalai/leafer-multi-roi 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +293 -0
- package/README_EN.md +293 -0
- package/docs/assets/index-B2aZIWia.css +1 -0
- package/docs/assets/index-BrSsc-mD.js +1 -0
- package/docs/assets/vite-CMPW0ETM.svg +130 -0
- package/docs/index.html +14 -0
- package/index.html +13 -0
- package/package.json +61 -0
- package/project-docs/ARCHITECTURE.md +129 -0
- package/project-docs/REQUIREMENTS.md +113 -0
- package/src/App.vue +284 -0
- package/src/components/RoiEditor.vue +1544 -0
- package/src/index.ts +10 -0
- package/src/main.ts +4 -0
- package/src/types/index.ts +49 -0
- package/src/utils/coordinates.ts +46 -0
- package/src/utils/icons.ts +41 -0
- package/src/utils/uuid.ts +4 -0
- package/src/vite-env.d.ts +7 -0
- package/tsconfig.json +25 -0
- package/tsconfig.node.json +11 -0
- package/vite.config.ts +39 -0
- package/vite.docs.config.ts +29 -0
- package/vite.svg +130 -0
|
@@ -0,0 +1,1544 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div
|
|
3
|
+
class="roi-editor"
|
|
4
|
+
@focus="isCanvasFocused = true"
|
|
5
|
+
@blur="isCanvasFocused = false"
|
|
6
|
+
@mouseenter="isMouseOverCanvas = true"
|
|
7
|
+
@mouseleave="isMouseOverCanvas = false"
|
|
8
|
+
>
|
|
9
|
+
<!-- 画布容器 -->
|
|
10
|
+
<div ref="canvasContainer" class="canvas-container" tabindex="0">
|
|
11
|
+
<!-- 加载占位 -->
|
|
12
|
+
<div
|
|
13
|
+
v-if="loadStatus === 'loading'"
|
|
14
|
+
class="loading-overlay"
|
|
15
|
+
:style="{
|
|
16
|
+
// width: imageWidth ? `${imageWidth}px` : '100%',
|
|
17
|
+
// height: imageHeight ? `${imageHeight}px` : '100%',
|
|
18
|
+
}"
|
|
19
|
+
>
|
|
20
|
+
<div class="gradient-animation"></div>
|
|
21
|
+
<div class="loading-text">图片加载中</div>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<!-- 错误状态 -->
|
|
25
|
+
<div v-if="loadStatus === 'error'" class="error-overlay">
|
|
26
|
+
<p>加载失败</p>
|
|
27
|
+
<button @click="loadImage()">重试</button>
|
|
28
|
+
</div>
|
|
29
|
+
|
|
30
|
+
<!-- 缩放控制器 -->
|
|
31
|
+
<div class="zoom-controller">
|
|
32
|
+
<button class="zoom-button" title="缩小 (Ctrl+-)" @click="zoomOut">
|
|
33
|
+
<svg
|
|
34
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
35
|
+
width="18"
|
|
36
|
+
height="18"
|
|
37
|
+
viewBox="0 0 24 24"
|
|
38
|
+
fill="none"
|
|
39
|
+
stroke="currentColor"
|
|
40
|
+
stroke-width="2"
|
|
41
|
+
stroke-linecap="round"
|
|
42
|
+
stroke-linejoin="round"
|
|
43
|
+
>
|
|
44
|
+
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
45
|
+
</svg>
|
|
46
|
+
<span class="hotkey-hint" v-if="showHotkeys">Ctrl+-</span>
|
|
47
|
+
</button>
|
|
48
|
+
<div
|
|
49
|
+
class="zoom-value"
|
|
50
|
+
@click="resetZoom"
|
|
51
|
+
title="点击重置为100% (Ctrl+0)"
|
|
52
|
+
>
|
|
53
|
+
{{ zoomLevel }}%
|
|
54
|
+
<span class="hotkey-hint" v-if="showHotkeys">Ctrl+0</span>
|
|
55
|
+
</div>
|
|
56
|
+
<button class="zoom-button" title="放大 (Ctrl++)" @click="zoomIn">
|
|
57
|
+
<svg
|
|
58
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
59
|
+
width="18"
|
|
60
|
+
height="18"
|
|
61
|
+
viewBox="0 0 24 24"
|
|
62
|
+
fill="none"
|
|
63
|
+
stroke="currentColor"
|
|
64
|
+
stroke-width="2"
|
|
65
|
+
stroke-linecap="round"
|
|
66
|
+
stroke-linejoin="round"
|
|
67
|
+
>
|
|
68
|
+
<line x1="12" y1="5" x2="12" y2="19"></line>
|
|
69
|
+
<line x1="5" y1="12" x2="19" y2="12"></line>
|
|
70
|
+
</svg>
|
|
71
|
+
<span class="hotkey-hint" v-if="showHotkeys">Ctrl++</span>
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<!-- 工具栏 -->
|
|
77
|
+
<div class="toolbar">
|
|
78
|
+
<button
|
|
79
|
+
class="tool-button"
|
|
80
|
+
:class="{ active: currentTool === 'select' }"
|
|
81
|
+
title="选择工具 (V)"
|
|
82
|
+
@click="selectTool"
|
|
83
|
+
>
|
|
84
|
+
<svg
|
|
85
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
86
|
+
width="24"
|
|
87
|
+
height="24"
|
|
88
|
+
viewBox="0 0 24 24"
|
|
89
|
+
fill="none"
|
|
90
|
+
stroke="currentColor"
|
|
91
|
+
stroke-width="2"
|
|
92
|
+
stroke-linecap="round"
|
|
93
|
+
stroke-linejoin="round"
|
|
94
|
+
class="lucide lucide-mouse-pointer2-icon lucide-mouse-pointer-2"
|
|
95
|
+
>
|
|
96
|
+
<path
|
|
97
|
+
d="M4.037 4.688a.495.495 0 0 1 .651-.651l16 6.5a.5.5 0 0 1-.063.947l-6.124 1.58a2 2 0 0 0-1.438 1.435l-1.579 6.126a.5.5 0 0 1-.947.063z"
|
|
98
|
+
/>
|
|
99
|
+
</svg>
|
|
100
|
+
<span class="hotkey-hint" v-if="showHotkeys">V</span>
|
|
101
|
+
</button>
|
|
102
|
+
<button
|
|
103
|
+
class="tool-button"
|
|
104
|
+
:class="{ active: currentTool === 'rectangle' }"
|
|
105
|
+
title="框选工具 (M)"
|
|
106
|
+
@click="rectangleTool"
|
|
107
|
+
>
|
|
108
|
+
<svg
|
|
109
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
110
|
+
width="20"
|
|
111
|
+
height="20"
|
|
112
|
+
viewBox="0 0 24 24"
|
|
113
|
+
fill="none"
|
|
114
|
+
stroke="currentColor"
|
|
115
|
+
stroke-width="2"
|
|
116
|
+
stroke-linecap="round"
|
|
117
|
+
stroke-linejoin="round"
|
|
118
|
+
>
|
|
119
|
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
|
|
120
|
+
</svg>
|
|
121
|
+
<span class="hotkey-hint" v-if="showHotkeys">M</span>
|
|
122
|
+
</button>
|
|
123
|
+
<button class="tool-button" title="撤销 (Ctrl+Z)" @click="undo">
|
|
124
|
+
<svg
|
|
125
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
126
|
+
width="20"
|
|
127
|
+
height="20"
|
|
128
|
+
viewBox="0 0 24 24"
|
|
129
|
+
fill="none"
|
|
130
|
+
stroke="currentColor"
|
|
131
|
+
stroke-width="2"
|
|
132
|
+
stroke-linecap="round"
|
|
133
|
+
stroke-linejoin="round"
|
|
134
|
+
>
|
|
135
|
+
<path d="M3 7v6h6"></path>
|
|
136
|
+
<path d="M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13"></path>
|
|
137
|
+
</svg>
|
|
138
|
+
<span class="hotkey-hint" v-if="showHotkeys">Ctrl+Z</span>
|
|
139
|
+
</button>
|
|
140
|
+
<button class="tool-button" title="重做 (Ctrl+Y)" @click="redo">
|
|
141
|
+
<svg
|
|
142
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
143
|
+
width="20"
|
|
144
|
+
height="20"
|
|
145
|
+
viewBox="0 0 24 24"
|
|
146
|
+
fill="none"
|
|
147
|
+
stroke="currentColor"
|
|
148
|
+
stroke-width="2"
|
|
149
|
+
stroke-linecap="round"
|
|
150
|
+
stroke-linejoin="round"
|
|
151
|
+
>
|
|
152
|
+
<path d="M21 7v6h-6"></path>
|
|
153
|
+
<path d="M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7"></path>
|
|
154
|
+
</svg>
|
|
155
|
+
<span class="hotkey-hint" v-if="showHotkeys">Ctrl+Y</span>
|
|
156
|
+
</button>
|
|
157
|
+
<button class="tool-button" title="删除 (Delete)" @click="deleteSelected">
|
|
158
|
+
<svg
|
|
159
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
160
|
+
width="20"
|
|
161
|
+
height="20"
|
|
162
|
+
viewBox="0 0 24 24"
|
|
163
|
+
fill="none"
|
|
164
|
+
stroke="currentColor"
|
|
165
|
+
stroke-width="2"
|
|
166
|
+
stroke-linecap="round"
|
|
167
|
+
stroke-linejoin="round"
|
|
168
|
+
>
|
|
169
|
+
<path d="M3 6h18"></path>
|
|
170
|
+
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path>
|
|
171
|
+
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path>
|
|
172
|
+
</svg>
|
|
173
|
+
<span class="hotkey-hint" v-if="showHotkeys">Del</span>
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</template>
|
|
178
|
+
|
|
179
|
+
<script setup lang="ts">
|
|
180
|
+
import { ref, onMounted, onUnmounted, nextTick } from "vue";
|
|
181
|
+
import {
|
|
182
|
+
App,
|
|
183
|
+
ImageEvent,
|
|
184
|
+
PointerEvent,
|
|
185
|
+
ZoomEvent,
|
|
186
|
+
Image,
|
|
187
|
+
Rect,
|
|
188
|
+
Group,
|
|
189
|
+
DragEvent,
|
|
190
|
+
} from "leafer-ui";
|
|
191
|
+
import { EditorScaleEvent } from "@leafer-in/editor";
|
|
192
|
+
import "@leafer-in/editor";
|
|
193
|
+
import "@leafer-in/resize";
|
|
194
|
+
import "@leafer-in/viewport";
|
|
195
|
+
import "@leafer-in/view";
|
|
196
|
+
import {
|
|
197
|
+
CommandManager,
|
|
198
|
+
AddElementCommand,
|
|
199
|
+
RemoveElementCommand,
|
|
200
|
+
BatchCommand,
|
|
201
|
+
ICommand,
|
|
202
|
+
MoveCommand,
|
|
203
|
+
ResizeCommand,
|
|
204
|
+
} from "@zzalai/leafer-undo-redo";
|
|
205
|
+
// 扩展 Window 接口,添加热键取消订阅函数
|
|
206
|
+
declare global {
|
|
207
|
+
interface Window {
|
|
208
|
+
__roiEditorHotkeysUnsubscribe?: () => void;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// @ts-ignore - tinykeys 类型声明问题
|
|
213
|
+
import { tinykeys } from "tinykeys";
|
|
214
|
+
|
|
215
|
+
// Props
|
|
216
|
+
export interface ImageSource {
|
|
217
|
+
id?: string; // 图片ID,非必填
|
|
218
|
+
url: string; // 图片URL或Base64,必填
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface OptionsSource {
|
|
222
|
+
regionStyle?: {
|
|
223
|
+
fill: string;
|
|
224
|
+
stroke: string;
|
|
225
|
+
strokeWidth: number;
|
|
226
|
+
};
|
|
227
|
+
selectedRegionStyle?: {
|
|
228
|
+
fill: string;
|
|
229
|
+
stroke: string;
|
|
230
|
+
strokeWidth?: number;
|
|
231
|
+
};
|
|
232
|
+
maxRegions?: number;
|
|
233
|
+
maxUndoSteps?: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const props = defineProps({
|
|
237
|
+
// 图片源(URL 或 Base64)
|
|
238
|
+
imageSource: {
|
|
239
|
+
type: Object as () => ImageSource,
|
|
240
|
+
required: true,
|
|
241
|
+
},
|
|
242
|
+
// 配置选项
|
|
243
|
+
options: {
|
|
244
|
+
type: Object as () => OptionsSource,
|
|
245
|
+
default: () => ({}),
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
// Emits
|
|
249
|
+
const emit = defineEmits([
|
|
250
|
+
"roiChange",
|
|
251
|
+
"loadStart",
|
|
252
|
+
"loadSuccess",
|
|
253
|
+
"loadError",
|
|
254
|
+
"undoStateChange",
|
|
255
|
+
"redoStateChange",
|
|
256
|
+
]);
|
|
257
|
+
|
|
258
|
+
// 画布容器引用
|
|
259
|
+
const canvasContainer = ref<HTMLElement | undefined>(undefined);
|
|
260
|
+
const loadStatus = ref<"idle" | "loading" | "success" | "error">("idle");
|
|
261
|
+
const imageWidth = ref<number | null>(null);
|
|
262
|
+
const imageHeight = ref<number | null>(null);
|
|
263
|
+
let app: App | null = null;
|
|
264
|
+
let imageBox: Image | null = null;
|
|
265
|
+
// const bottomLayer = new Group({ name: "bottom" });
|
|
266
|
+
// const roiLayer = new Group({ name: "roi" });
|
|
267
|
+
const contentLayer = new Group({ name: "contentLayer" });
|
|
268
|
+
|
|
269
|
+
// 鼠标位置
|
|
270
|
+
const mousePosition = ref({ x: 0, y: 0 });
|
|
271
|
+
|
|
272
|
+
// 画布是否获得焦点
|
|
273
|
+
const isCanvasFocused = ref(false);
|
|
274
|
+
|
|
275
|
+
// 鼠标是否在编辑器区域内
|
|
276
|
+
const isMouseOverCanvas = ref(false);
|
|
277
|
+
|
|
278
|
+
// 是否显示热键提示
|
|
279
|
+
const showHotkeys = ref(false);
|
|
280
|
+
|
|
281
|
+
// 当前工具模式
|
|
282
|
+
const currentTool = ref<"select" | "rectangle">("rectangle");
|
|
283
|
+
|
|
284
|
+
// 缩放相关状态
|
|
285
|
+
const zoomLevel = ref<number>(100);
|
|
286
|
+
|
|
287
|
+
// 拖拽相关状态
|
|
288
|
+
const isDragging = ref(false);
|
|
289
|
+
const startX = ref(0);
|
|
290
|
+
const startY = ref(0);
|
|
291
|
+
let tempRect: Rect | null = null;
|
|
292
|
+
|
|
293
|
+
// 移动撤销/重做相关状态
|
|
294
|
+
interface MoveData {
|
|
295
|
+
target: Rect;
|
|
296
|
+
fromX: number;
|
|
297
|
+
fromY: number;
|
|
298
|
+
toX: number;
|
|
299
|
+
toY: number;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
let pendingMoveData: MoveData | null = null;
|
|
303
|
+
|
|
304
|
+
// 缩放撤销/重做相关状态
|
|
305
|
+
interface ResizeData {
|
|
306
|
+
target: Rect;
|
|
307
|
+
fromWidth: number;
|
|
308
|
+
fromHeight: number;
|
|
309
|
+
toWidth: number;
|
|
310
|
+
toHeight: number;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let pendingResizeData: ResizeData | null = null;
|
|
314
|
+
|
|
315
|
+
// 命令管理器实例
|
|
316
|
+
let commandManager: CommandManager;
|
|
317
|
+
|
|
318
|
+
// 初始化命令管理器
|
|
319
|
+
const initCommandManager = () => {
|
|
320
|
+
// 从配置中获取最大撤销步数(默认100)
|
|
321
|
+
const maxUndoSteps = props.options.maxUndoSteps ?? 100;
|
|
322
|
+
commandManager = new CommandManager(maxUndoSteps);
|
|
323
|
+
};
|
|
324
|
+
|
|
325
|
+
// 执行命令
|
|
326
|
+
const executeCommand = (command: ICommand) => {
|
|
327
|
+
commandManager.executeCommand(command);
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
const initCanvas = () => {
|
|
331
|
+
// 初始化命令管理器
|
|
332
|
+
initCommandManager();
|
|
333
|
+
|
|
334
|
+
// 创建 Leafer 实例
|
|
335
|
+
app = new App({
|
|
336
|
+
view: canvasContainer.value,
|
|
337
|
+
width: canvasContainer.value?.clientWidth || 800,
|
|
338
|
+
height: canvasContainer.value?.clientHeight || 600,
|
|
339
|
+
fill: "#e3e3e3",
|
|
340
|
+
zoom: { min: 0.2, max: 4 },
|
|
341
|
+
// tree: { usePartLayout: false },
|
|
342
|
+
editor: {
|
|
343
|
+
rotateable: false, // 禁止旋转
|
|
344
|
+
middlePoint: {}, // 展示中间手柄
|
|
345
|
+
selectedStyle: {
|
|
346
|
+
...props.options.selectedRegionStyle,
|
|
347
|
+
// strokeColor: "rgba(200, 149, 237, 0.8)",
|
|
348
|
+
// fill: "rgba(200, 149, 237, 1)",
|
|
349
|
+
}
|
|
350
|
+
},
|
|
351
|
+
tree: {
|
|
352
|
+
type: "design",
|
|
353
|
+
},
|
|
354
|
+
// wheel: { zoomMode: true }
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
app?.tree.add(contentLayer);
|
|
358
|
+
|
|
359
|
+
// 监听鼠标滚轮事件,处理缩放,暂时注释掉,按Ctrl+wheel也挺方便,直接滚轮缩放有点太轻易触发到了
|
|
360
|
+
// if (app) {
|
|
361
|
+
// canvasContainer.value?.addEventListener("wheel", (e) => {
|
|
362
|
+
// e.preventDefault();
|
|
363
|
+
// if (app) {
|
|
364
|
+
// if (e.deltaY < 0) {
|
|
365
|
+
// // 向上滚动,放大
|
|
366
|
+
// app.tree.zoom("in");
|
|
367
|
+
// } else {
|
|
368
|
+
// // 向下滚动,缩小
|
|
369
|
+
// app.tree.zoom("out");
|
|
370
|
+
// }
|
|
371
|
+
// // 更新缩放级别显示
|
|
372
|
+
// updateZoomLevel();
|
|
373
|
+
// }
|
|
374
|
+
// });
|
|
375
|
+
// }
|
|
376
|
+
|
|
377
|
+
// 添加指针事件监听器
|
|
378
|
+
if (app) {
|
|
379
|
+
app.on(PointerEvent.DOWN, handlePointerDown);
|
|
380
|
+
app.on(PointerEvent.MOVE, handlePointerMove);
|
|
381
|
+
app.on(PointerEvent.UP, handlePointerUp);
|
|
382
|
+
|
|
383
|
+
// 监听拖动开始事件(用于移动撤销/重做)
|
|
384
|
+
app.on(DragEvent.START, (e: DragEvent) => {
|
|
385
|
+
const target = e.target as Rect;
|
|
386
|
+
if (target instanceof Rect && target.parent === contentLayer) {
|
|
387
|
+
// 记录移动前的位置
|
|
388
|
+
pendingMoveData = {
|
|
389
|
+
target,
|
|
390
|
+
fromX: target.x || 0,
|
|
391
|
+
fromY: target.y || 0,
|
|
392
|
+
toX: target.x || 0,
|
|
393
|
+
toY: target.y || 0,
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// 监听拖动事件(更新目标位置)
|
|
399
|
+
app.on(DragEvent.DRAG, (e: DragEvent) => {
|
|
400
|
+
if (pendingMoveData) {
|
|
401
|
+
pendingMoveData.toX = (e.target as Rect).x || 0;
|
|
402
|
+
pendingMoveData.toY = (e.target as Rect).y || 0;
|
|
403
|
+
}
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
// 监听拖动结束事件(创建移动命令)
|
|
407
|
+
app.on(DragEvent.END, () => {
|
|
408
|
+
if (pendingMoveData) {
|
|
409
|
+
const target = pendingMoveData.target;
|
|
410
|
+
// 只有位置发生变化时才创建命令
|
|
411
|
+
if (
|
|
412
|
+
pendingMoveData.fromX !== pendingMoveData.toX ||
|
|
413
|
+
pendingMoveData.fromY !== pendingMoveData.toY
|
|
414
|
+
) {
|
|
415
|
+
const moveCommand = new MoveCommand(
|
|
416
|
+
target,
|
|
417
|
+
pendingMoveData.fromX,
|
|
418
|
+
pendingMoveData.fromY,
|
|
419
|
+
pendingMoveData.toX,
|
|
420
|
+
pendingMoveData.toY,
|
|
421
|
+
);
|
|
422
|
+
executeCommand(moveCommand);
|
|
423
|
+
// 触发ROI变化事件
|
|
424
|
+
emit("roiChange", getROIAnnotations());
|
|
425
|
+
}
|
|
426
|
+
pendingMoveData = null;
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// 监听画布缩放事件(更新缩放级别显示)
|
|
431
|
+
app.on(ZoomEvent.ZOOM, (e: ZoomEvent) => {
|
|
432
|
+
console.log("ZoomEvent.ZOOM event:", e);
|
|
433
|
+
updateZoomLevel();
|
|
434
|
+
});
|
|
435
|
+
|
|
436
|
+
// 监听缩放开始事件(用于缩放撤销/重做)
|
|
437
|
+
app.editor?.on(EditorScaleEvent.BEFORE_SCALE, (e: EditorScaleEvent) => {
|
|
438
|
+
console.log("EditorScaleEvent.BEFORE_SCALE event:", e);
|
|
439
|
+
const target = e.target as Rect;
|
|
440
|
+
if (target instanceof Rect && target.parent === contentLayer) {
|
|
441
|
+
// 只有当没有待处理的缩放数据时才初始化(确保只记录一次初始尺寸)
|
|
442
|
+
if (!pendingResizeData) {
|
|
443
|
+
// 记录缩放前的尺寸
|
|
444
|
+
pendingResizeData = {
|
|
445
|
+
target,
|
|
446
|
+
fromWidth: target.width || 0,
|
|
447
|
+
fromHeight: target.height || 0,
|
|
448
|
+
toWidth: target.width || 0,
|
|
449
|
+
toHeight: target.height || 0,
|
|
450
|
+
};
|
|
451
|
+
console.log("pendingResizeData initialized:", pendingResizeData);
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
// 监听缩放事件(更新目标尺寸)
|
|
457
|
+
app.editor?.on(EditorScaleEvent.SCALE, (e: EditorScaleEvent) => {
|
|
458
|
+
if (pendingResizeData) {
|
|
459
|
+
pendingResizeData.toWidth = (e.target as Rect).width || 0;
|
|
460
|
+
pendingResizeData.toHeight = (e.target as Rect).height || 0;
|
|
461
|
+
console.log("Scale updated, pendingResizeData:", pendingResizeData);
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
|
|
465
|
+
// 监听指针释放事件(缩放完成时创建命令)
|
|
466
|
+
app.on(PointerEvent.UP, () => {
|
|
467
|
+
// 只有当存在待处理的缩放数据时才创建命令
|
|
468
|
+
if (pendingResizeData) {
|
|
469
|
+
processPendingResize();
|
|
470
|
+
}
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
// 处理待处理的缩放数据
|
|
476
|
+
function processPendingResize() {
|
|
477
|
+
if (pendingResizeData) {
|
|
478
|
+
const target = pendingResizeData.target;
|
|
479
|
+
// 只有尺寸发生变化时才创建命令
|
|
480
|
+
if (
|
|
481
|
+
pendingResizeData.fromWidth !== pendingResizeData.toWidth ||
|
|
482
|
+
pendingResizeData.fromHeight !== pendingResizeData.toHeight
|
|
483
|
+
) {
|
|
484
|
+
console.log("Creating ResizeCommand:", pendingResizeData);
|
|
485
|
+
const resizeCommand = new ResizeCommand(
|
|
486
|
+
target,
|
|
487
|
+
pendingResizeData.fromWidth,
|
|
488
|
+
pendingResizeData.fromHeight,
|
|
489
|
+
pendingResizeData.toWidth,
|
|
490
|
+
pendingResizeData.toHeight,
|
|
491
|
+
);
|
|
492
|
+
executeCommand(resizeCommand);
|
|
493
|
+
// 触发ROI变化事件
|
|
494
|
+
emit("roiChange", getROIAnnotations());
|
|
495
|
+
}
|
|
496
|
+
pendingResizeData = null;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* 加载图片
|
|
501
|
+
*/
|
|
502
|
+
// 预获取图片尺寸
|
|
503
|
+
const preloadImageSize = (
|
|
504
|
+
url: string,
|
|
505
|
+
): Promise<{ width: number; height: number }> => {
|
|
506
|
+
return new Promise((resolve, reject) => {
|
|
507
|
+
const tempImage = new window.Image();
|
|
508
|
+
tempImage.onload = () => {
|
|
509
|
+
resolve({
|
|
510
|
+
width: tempImage.width,
|
|
511
|
+
height: tempImage.height,
|
|
512
|
+
});
|
|
513
|
+
};
|
|
514
|
+
tempImage.onerror = reject;
|
|
515
|
+
tempImage.src = url;
|
|
516
|
+
});
|
|
517
|
+
};
|
|
518
|
+
const loadImage = async (imageSrc?: string | undefined) => {
|
|
519
|
+
const _imageSrc = imageSrc ? imageSrc : props.imageSource.url;
|
|
520
|
+
if (!app || !_imageSrc) return;
|
|
521
|
+
|
|
522
|
+
// 清除旧图片
|
|
523
|
+
if (imageBox) {
|
|
524
|
+
contentLayer.clear();
|
|
525
|
+
imageBox.destroy();
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// 设置加载状态
|
|
529
|
+
loadStatus.value = "loading";
|
|
530
|
+
emit("loadStart");
|
|
531
|
+
imageWidth.value = null;
|
|
532
|
+
imageHeight.value = null;
|
|
533
|
+
|
|
534
|
+
try {
|
|
535
|
+
// 预获取图片尺寸
|
|
536
|
+
const size = await preloadImageSize(_imageSrc);
|
|
537
|
+
imageWidth.value = size.width;
|
|
538
|
+
imageHeight.value = size.height;
|
|
539
|
+
|
|
540
|
+
// 调整画布大小
|
|
541
|
+
// app.resize(size.width, size.height);
|
|
542
|
+
|
|
543
|
+
// 创建新图片
|
|
544
|
+
imageBox = new Image({
|
|
545
|
+
url: _imageSrc,
|
|
546
|
+
draggable: false,
|
|
547
|
+
editable: false,
|
|
548
|
+
lazy: true,
|
|
549
|
+
zIndex: -1,
|
|
550
|
+
// around: 'top-left',
|
|
551
|
+
// smooth: true,
|
|
552
|
+
placeholderColor: "transparent", // 不使用默认占位符,使用我们自定义的
|
|
553
|
+
});
|
|
554
|
+
|
|
555
|
+
// 监听加载成功
|
|
556
|
+
imageBox.on(ImageEvent.LOADED, function () {
|
|
557
|
+
loadStatus.value = "success";
|
|
558
|
+
emit("loadSuccess");
|
|
559
|
+
|
|
560
|
+
// 调整图片在画布中的显示
|
|
561
|
+
fitImageToCanvas();
|
|
562
|
+
// app?.tree.zoom('fit');
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
// 监听加载失败
|
|
566
|
+
imageBox.on(ImageEvent.ERROR, function (e: ImageEvent) {
|
|
567
|
+
loadStatus.value = "error";
|
|
568
|
+
emit("loadError", e);
|
|
569
|
+
console.error("Failed to load image:", e);
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
// 添加到画布
|
|
573
|
+
contentLayer.add(imageBox);
|
|
574
|
+
} catch (error) {
|
|
575
|
+
loadStatus.value = "error";
|
|
576
|
+
emit("loadError", error);
|
|
577
|
+
console.error("Failed to preload image size:", error);
|
|
578
|
+
}
|
|
579
|
+
};
|
|
580
|
+
/**
|
|
581
|
+
* 获取 ROI 坐标
|
|
582
|
+
*/
|
|
583
|
+
interface ROIAnnotation {
|
|
584
|
+
id: string; // 唯一标识符
|
|
585
|
+
x: number; // 左上角 x 坐标
|
|
586
|
+
y: number; // 左上角 y 坐标
|
|
587
|
+
width: number; // 宽度
|
|
588
|
+
height: number; // 高度
|
|
589
|
+
points: { x: number; y: number }[]; // 四个角的坐标
|
|
590
|
+
// 归一化数据
|
|
591
|
+
normalized: {
|
|
592
|
+
x: number;
|
|
593
|
+
y: number;
|
|
594
|
+
width: number;
|
|
595
|
+
height: number;
|
|
596
|
+
points: { x: number; y: number }[];
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
const getROIAnnotations = (): ROIAnnotation[] => {
|
|
600
|
+
if (!contentLayer || !imageWidth.value || !imageHeight.value) return [];
|
|
601
|
+
|
|
602
|
+
const annotations: ROIAnnotation[] = [];
|
|
603
|
+
const width = imageWidth.value;
|
|
604
|
+
const height = imageHeight.value;
|
|
605
|
+
|
|
606
|
+
// 遍历 ROI 图层中的所有矩形元素
|
|
607
|
+
const children = Array.from(contentLayer.children) as Array<any>;
|
|
608
|
+
children.forEach((child) => {
|
|
609
|
+
if (!child.url) {
|
|
610
|
+
// 把【图片】排除
|
|
611
|
+
const rect = child as Rect;
|
|
612
|
+
const id = rect.id || `roi-${annotations.length}`;
|
|
613
|
+
const x = rect.x || 0;
|
|
614
|
+
const y = rect.y || 0;
|
|
615
|
+
const rectWidth = rect.width || 0;
|
|
616
|
+
const rectHeight = rect.height || 0;
|
|
617
|
+
|
|
618
|
+
// 计算四个角的坐标
|
|
619
|
+
const points = [
|
|
620
|
+
{ x, y },
|
|
621
|
+
{ x: x + rectWidth, y },
|
|
622
|
+
{ x: x + rectWidth, y: y + rectHeight },
|
|
623
|
+
{ x, y: y + rectHeight },
|
|
624
|
+
];
|
|
625
|
+
|
|
626
|
+
// 计算归一化坐标(0-1 范围)
|
|
627
|
+
const normalizedPoints = points.map((point) => ({
|
|
628
|
+
x: width > 0 ? point.x / width : 0,
|
|
629
|
+
y: height > 0 ? point.y / height : 0,
|
|
630
|
+
}));
|
|
631
|
+
|
|
632
|
+
annotations.push({
|
|
633
|
+
id,
|
|
634
|
+
x,
|
|
635
|
+
y,
|
|
636
|
+
width: rectWidth,
|
|
637
|
+
height: rectHeight,
|
|
638
|
+
points,
|
|
639
|
+
normalized: {
|
|
640
|
+
x: width > 0 ? x / width : 0,
|
|
641
|
+
y: height > 0 ? y / height : 0,
|
|
642
|
+
width: width > 0 ? rectWidth / width : 0,
|
|
643
|
+
height: height > 0 ? rectHeight / height : 0,
|
|
644
|
+
points: normalizedPoints,
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
return annotations;
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
// 获取图片信息
|
|
654
|
+
const getImageInfo = () => {
|
|
655
|
+
return {
|
|
656
|
+
id: props.imageSource.id,
|
|
657
|
+
url: props.imageSource.url,
|
|
658
|
+
width: imageWidth.value,
|
|
659
|
+
height: imageHeight.value,
|
|
660
|
+
};
|
|
661
|
+
};
|
|
662
|
+
|
|
663
|
+
const exportCanvasJSON = (): string => {
|
|
664
|
+
const canvasData = {
|
|
665
|
+
version: "1.0",
|
|
666
|
+
canvas: {
|
|
667
|
+
width: imageWidth.value,
|
|
668
|
+
height: imageHeight.value,
|
|
669
|
+
zoom: zoomLevel.value / 100,
|
|
670
|
+
},
|
|
671
|
+
image: getImageInfo(),
|
|
672
|
+
leaferJSON: app?.tree?.toJSON?.() || null,
|
|
673
|
+
annotations: getROIAnnotations(),
|
|
674
|
+
exportedAt: new Date().toISOString(),
|
|
675
|
+
};
|
|
676
|
+
|
|
677
|
+
return JSON.stringify(canvasData, null, 2);
|
|
678
|
+
};
|
|
679
|
+
|
|
680
|
+
interface ImportCanvasOptions {
|
|
681
|
+
resetZoom?: boolean;
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const importCanvasJSON = async (
|
|
685
|
+
jsonString: string,
|
|
686
|
+
options?: ImportCanvasOptions,
|
|
687
|
+
): Promise<boolean> => {
|
|
688
|
+
try {
|
|
689
|
+
const data = JSON.parse(jsonString);
|
|
690
|
+
|
|
691
|
+
if (!data.leaferJSON) {
|
|
692
|
+
console.error("无效的 JSON 数据:缺少 leaferJSON 字段");
|
|
693
|
+
return false;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
// 清空 contentLayer 中的所有元素
|
|
697
|
+
if (contentLayer) {
|
|
698
|
+
// 重置 contentLayer 相关信息
|
|
699
|
+
// contentLayer.scale = 1;
|
|
700
|
+
// contentLayer.x = 0;
|
|
701
|
+
// contentLayer.y = 0;
|
|
702
|
+
// 清空所有子元素
|
|
703
|
+
while (contentLayer.children.length > 0) {
|
|
704
|
+
contentLayer.children[0].remove();
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// 从 leaferJSON 加载新元素
|
|
708
|
+
if (data.leaferJSON && data.leaferJSON.children) {
|
|
709
|
+
// 检查是否有图片元素(可能在 children 的 children 中)
|
|
710
|
+
let imageData = null;
|
|
711
|
+
|
|
712
|
+
// 递归查找图片元素
|
|
713
|
+
const findImage = (children: any[]) => {
|
|
714
|
+
for (const child of children) {
|
|
715
|
+
if (child.url) {
|
|
716
|
+
imageData = child;
|
|
717
|
+
return true;
|
|
718
|
+
}
|
|
719
|
+
if (child.children && child.children.length > 0) {
|
|
720
|
+
if (findImage(child.children)) {
|
|
721
|
+
return true;
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
return false;
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
findImage(data.leaferJSON.children);
|
|
729
|
+
if (imageData) {
|
|
730
|
+
// 使用 loadImage 加载图片,确保获取到真实尺寸
|
|
731
|
+
try {
|
|
732
|
+
await loadImage((imageData as any).url);
|
|
733
|
+
} catch (error) {
|
|
734
|
+
console.error("Failed to load image:", error);
|
|
735
|
+
// 如果加载失败,使用JSON中的尺寸
|
|
736
|
+
imageWidth.value =
|
|
737
|
+
(imageData as any).width || data.canvas?.width || 0;
|
|
738
|
+
imageHeight.value =
|
|
739
|
+
(imageData as any).height || data.canvas?.height || 0;
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
// 递归添加元素(跳过图片元素,因为已经通过 loadImage 添加)
|
|
744
|
+
const addElements = (children: any[]) => {
|
|
745
|
+
for (const child of children) {
|
|
746
|
+
if ((child as any).url) {
|
|
747
|
+
// 跳过图片元素,因为已经通过 loadImage 添加
|
|
748
|
+
continue;
|
|
749
|
+
}
|
|
750
|
+
// 创建元素并添加到 contentLayer
|
|
751
|
+
const element = JSON.parse(JSON.stringify(child));
|
|
752
|
+
contentLayer.add(element);
|
|
753
|
+
// 递归添加子元素
|
|
754
|
+
if ((child as any).children && (child as any).children.length > 0) {
|
|
755
|
+
addElements((child as any).children);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
addElements(data.leaferJSON.children[0].children);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
app?.tree.forceUpdate();
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// 如果需要,重置缩放
|
|
767
|
+
if (options?.resetZoom && data.canvas?.zoom) {
|
|
768
|
+
zoomLevel.value = data.canvas.zoom * 100;
|
|
769
|
+
updateZoomLevel();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
// 触发 ROI 变化事件
|
|
773
|
+
emit("roiChange", getROIAnnotations());
|
|
774
|
+
|
|
775
|
+
return true;
|
|
776
|
+
} catch (error) {
|
|
777
|
+
console.error("导入 JSON 失败:", error);
|
|
778
|
+
return false;
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
|
|
782
|
+
// 组件挂载时初始化
|
|
783
|
+
onMounted(() => {
|
|
784
|
+
// 初始化逻辑将在此处添加
|
|
785
|
+
nextTick(() => {
|
|
786
|
+
initCanvas();
|
|
787
|
+
loadImage();
|
|
788
|
+
|
|
789
|
+
// 添加事件监听
|
|
790
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
791
|
+
window.addEventListener("mousemove", handleMouseMove);
|
|
792
|
+
|
|
793
|
+
// 注册热键
|
|
794
|
+
const unsubscribe = tinykeys(window, {
|
|
795
|
+
// 选择工具
|
|
796
|
+
v: (event: KeyboardEvent) => {
|
|
797
|
+
if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
|
|
798
|
+
event.preventDefault();
|
|
799
|
+
selectTool();
|
|
800
|
+
},
|
|
801
|
+
// 框选工具
|
|
802
|
+
m: (event: KeyboardEvent) => {
|
|
803
|
+
if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
|
|
804
|
+
event.preventDefault();
|
|
805
|
+
rectangleTool();
|
|
806
|
+
},
|
|
807
|
+
// 撤销
|
|
808
|
+
"$mod+KeyZ": (event: KeyboardEvent) => {
|
|
809
|
+
if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
|
|
810
|
+
event.preventDefault();
|
|
811
|
+
event.stopPropagation();
|
|
812
|
+
undo();
|
|
813
|
+
},
|
|
814
|
+
// 重做
|
|
815
|
+
"$mod+KeyY": (event: KeyboardEvent) => {
|
|
816
|
+
if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
|
|
817
|
+
event.preventDefault();
|
|
818
|
+
event.stopPropagation();
|
|
819
|
+
redo();
|
|
820
|
+
},
|
|
821
|
+
// 删除
|
|
822
|
+
Delete: (event: KeyboardEvent) => {
|
|
823
|
+
if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
|
|
824
|
+
event.preventDefault();
|
|
825
|
+
event.stopPropagation();
|
|
826
|
+
deleteSelected();
|
|
827
|
+
},
|
|
828
|
+
// 放大
|
|
829
|
+
"$mod+Equal": (event: KeyboardEvent) => {
|
|
830
|
+
if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
|
|
831
|
+
event.preventDefault();
|
|
832
|
+
event.stopPropagation();
|
|
833
|
+
zoomIn();
|
|
834
|
+
},
|
|
835
|
+
// 缩小
|
|
836
|
+
"$mod+Minus": (event: KeyboardEvent) => {
|
|
837
|
+
if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
|
|
838
|
+
event.preventDefault();
|
|
839
|
+
event.stopPropagation();
|
|
840
|
+
zoomOut();
|
|
841
|
+
},
|
|
842
|
+
// 重置缩放
|
|
843
|
+
"$mod+0": (event: KeyboardEvent) => {
|
|
844
|
+
if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
|
|
845
|
+
event.preventDefault();
|
|
846
|
+
event.stopPropagation();
|
|
847
|
+
resetZoom();
|
|
848
|
+
},
|
|
849
|
+
// 切换热键提示
|
|
850
|
+
Alt: (event: KeyboardEvent) => {
|
|
851
|
+
if (!isCanvasFocused.value && !isMouseOverCanvas.value) return;
|
|
852
|
+
event.preventDefault();
|
|
853
|
+
showHotkeys.value = !showHotkeys.value;
|
|
854
|
+
},
|
|
855
|
+
});
|
|
856
|
+
|
|
857
|
+
// 保存取消订阅函数
|
|
858
|
+
window.__roiEditorHotkeysUnsubscribe = unsubscribe;
|
|
859
|
+
});
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// 鼠标移动事件处理
|
|
863
|
+
const handleMouseMove = (e: MouseEvent) => {
|
|
864
|
+
// 更新鼠标位置
|
|
865
|
+
mousePosition.value = {
|
|
866
|
+
x: e.clientX,
|
|
867
|
+
y: e.clientY,
|
|
868
|
+
};
|
|
869
|
+
};
|
|
870
|
+
|
|
871
|
+
/**
|
|
872
|
+
* 调整图片在画布中的显示
|
|
873
|
+
* 1. 计算合适的缩放比例,使图片完全显示在画布中
|
|
874
|
+
* 2. 将画布移动到使图片绝对居中的位置
|
|
875
|
+
*/
|
|
876
|
+
const fitImageToCanvas = () => {
|
|
877
|
+
if (!app || !imageBox || !imageWidth.value || !imageHeight.value) return;
|
|
878
|
+
|
|
879
|
+
// 获取画布尺寸
|
|
880
|
+
const canvasWidth = app.width as number;
|
|
881
|
+
const canvasHeight = app.height as number;
|
|
882
|
+
|
|
883
|
+
// 获取图片尺寸
|
|
884
|
+
const imageWidthVal = imageWidth.value;
|
|
885
|
+
const imageHeightVal = imageHeight.value;
|
|
886
|
+
|
|
887
|
+
// 计算缩放比例
|
|
888
|
+
const scaleX = canvasWidth / imageWidthVal;
|
|
889
|
+
const scaleY = canvasHeight / imageHeightVal;
|
|
890
|
+
const scale = Math.min(scaleX, scaleY, 1); // 不放大图片,只缩小
|
|
891
|
+
|
|
892
|
+
// 计算居中位置
|
|
893
|
+
const centerX = (canvasWidth - imageWidthVal * Number(scale.toFixed(2))) / 2;
|
|
894
|
+
const centerY = (canvasHeight - imageHeightVal * Number(scale.toFixed(2))) / 2;
|
|
895
|
+
|
|
896
|
+
// 设置画布缩放和位置
|
|
897
|
+
app.tree.scale = Number(scale.toFixed(2));
|
|
898
|
+
app.tree.x = centerX;
|
|
899
|
+
app.tree.y = centerY;
|
|
900
|
+
// 更新缩放级别显示
|
|
901
|
+
updateZoomLevel();
|
|
902
|
+
// contentLayer.scale = scale;
|
|
903
|
+
// contentLayer.x = centerX;
|
|
904
|
+
// contentLayer.y = centerY;
|
|
905
|
+
// imageBox.scale = scale;
|
|
906
|
+
// imageBox.x = centerX;
|
|
907
|
+
// imageBox.y = centerY;
|
|
908
|
+
|
|
909
|
+
console.log(
|
|
910
|
+
`Image fit to canvas: scale=${scale.toFixed(2)}, position=(${centerX.toFixed(2)}, ${centerY.toFixed(2)})`,
|
|
911
|
+
);
|
|
912
|
+
};
|
|
913
|
+
|
|
914
|
+
// 指针按下事件处理
|
|
915
|
+
const handlePointerDown = (e: any) => {
|
|
916
|
+
console.log("Pointer down:", e);
|
|
917
|
+
// 只在矩形工具模式下处理
|
|
918
|
+
if (currentTool.value !== "rectangle") return;
|
|
919
|
+
|
|
920
|
+
// 检查是否按住Ctrl键
|
|
921
|
+
if (e.ctrlKey || e.metaKey) {
|
|
922
|
+
// 按住Ctrl键,不创建新Rect
|
|
923
|
+
return;
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// 检查是否点击在已有的Rect上或其缩放手柄上
|
|
927
|
+
if (e.target) {
|
|
928
|
+
// 检查是否点击在元素本身
|
|
929
|
+
if (e.target.parent === contentLayer && !e.target.url) {
|
|
930
|
+
// 点击在已有的Rect上,不创建新Rect
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
// 检查是否点击在元素的缩放手柄上(通过检查目标元素是否有parent且parent是contentLayer中的元素)
|
|
934
|
+
if (
|
|
935
|
+
e.target.parent &&
|
|
936
|
+
e.target.parent.parent === contentLayer &&
|
|
937
|
+
!e.target.parent.url
|
|
938
|
+
) {
|
|
939
|
+
// 点击在缩放手柄上,不创建新Rect
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
942
|
+
// 检查是否是调整大小的操作
|
|
943
|
+
if (e.target.pointType === "resize") {
|
|
944
|
+
// 调整大小操作,不创建新Rect
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// 检查是否是旋转的操作
|
|
949
|
+
if (e.target.pointType === "rotate") {
|
|
950
|
+
// 旋转操作,不创建新Rect
|
|
951
|
+
return;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
const getBoxPoint = contentLayer.getBoxPoint({ x: e.x, y: e.y });
|
|
955
|
+
// 检查是否在图片区域内
|
|
956
|
+
if (!isPointInImageArea(getBoxPoint.x, getBoxPoint.y)) return;
|
|
957
|
+
|
|
958
|
+
// 开始拖拽
|
|
959
|
+
isDragging.value = true;
|
|
960
|
+
|
|
961
|
+
startX.value = getBoxPoint.x;
|
|
962
|
+
startY.value = getBoxPoint.y;
|
|
963
|
+
};
|
|
964
|
+
|
|
965
|
+
// 指针移动事件处理
|
|
966
|
+
const handlePointerMove = (e: any) => {
|
|
967
|
+
if (!isDragging.value) return;
|
|
968
|
+
|
|
969
|
+
// 计算矩形尺寸
|
|
970
|
+
const getBoxPoint = contentLayer.getBoxPoint({ x: e.x, y: e.y });
|
|
971
|
+
const endX = getBoxPoint.x;
|
|
972
|
+
const endY = getBoxPoint.y;
|
|
973
|
+
|
|
974
|
+
// 确保矩形在图片区域内
|
|
975
|
+
const constrainedX = Math.max(0, Math.min(endX, imageWidth.value || 0));
|
|
976
|
+
const constrainedY = Math.max(0, Math.min(endY, imageHeight.value || 0));
|
|
977
|
+
|
|
978
|
+
// 计算矩形位置和尺寸
|
|
979
|
+
const x = Math.min(startX.value, constrainedX);
|
|
980
|
+
const y = Math.min(startY.value, constrainedY);
|
|
981
|
+
const width = Math.abs(constrainedX - startX.value);
|
|
982
|
+
const height = Math.abs(constrainedY - startY.value);
|
|
983
|
+
|
|
984
|
+
// 如果临时矩形不存在,创建它
|
|
985
|
+
if (!tempRect) {
|
|
986
|
+
tempRect = new Rect({
|
|
987
|
+
id: crypto.randomUUID(),
|
|
988
|
+
x: x,
|
|
989
|
+
y: y,
|
|
990
|
+
width: width,
|
|
991
|
+
height: height,
|
|
992
|
+
stroke: "rgba(100, 149, 237, 0.8)",
|
|
993
|
+
fill: "rgba(100, 149, 237, 0.3)",
|
|
994
|
+
strokeWidth: 2,
|
|
995
|
+
draggable: false,
|
|
996
|
+
editable: false,
|
|
997
|
+
...props.options.regionStyle,
|
|
998
|
+
});
|
|
999
|
+
|
|
1000
|
+
contentLayer.add(tempRect);
|
|
1001
|
+
} else {
|
|
1002
|
+
// 更新临时矩形的位置和尺寸
|
|
1003
|
+
tempRect.x = x;
|
|
1004
|
+
tempRect.y = y;
|
|
1005
|
+
tempRect.width = width;
|
|
1006
|
+
tempRect.height = height;
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
// 指针释放事件处理
|
|
1011
|
+
const handlePointerUp = () => {
|
|
1012
|
+
if (!isDragging.value) return;
|
|
1013
|
+
|
|
1014
|
+
// 结束拖拽
|
|
1015
|
+
isDragging.value = false;
|
|
1016
|
+
|
|
1017
|
+
// 检查矩形是否有效(最小尺寸)
|
|
1018
|
+
if (tempRect) {
|
|
1019
|
+
const width = tempRect.width || 0;
|
|
1020
|
+
const height = tempRect.height || 0;
|
|
1021
|
+
|
|
1022
|
+
if (width < 5 || height < 5) {
|
|
1023
|
+
// 移除过小的矩形
|
|
1024
|
+
tempRect.remove();
|
|
1025
|
+
tempRect = null;
|
|
1026
|
+
} else {
|
|
1027
|
+
// 检查是否超过最大区域数量限制(默认值为20)
|
|
1028
|
+
const maxRegions = props.options.maxRegions ?? 20;
|
|
1029
|
+
const currentRegionCount = getROIAnnotations().length;
|
|
1030
|
+
if (currentRegionCount >= maxRegions) {
|
|
1031
|
+
alert(`已达到最大区域数量限制(${maxRegions}个)`);
|
|
1032
|
+
tempRect.remove();
|
|
1033
|
+
tempRect = null;
|
|
1034
|
+
return;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
// 设置矩形为可编辑可拖动
|
|
1038
|
+
tempRect.draggable = true;
|
|
1039
|
+
tempRect.editable = true;
|
|
1040
|
+
|
|
1041
|
+
// 设置拖拽边界,确保不能拖出图片范围
|
|
1042
|
+
tempRect.dragBounds = {
|
|
1043
|
+
x: 0,
|
|
1044
|
+
y: 0,
|
|
1045
|
+
width: imageWidth.value || 0,
|
|
1046
|
+
height: imageHeight.value || 0,
|
|
1047
|
+
};
|
|
1048
|
+
|
|
1049
|
+
// 使用命令模式添加元素,以便支持撤销/重做
|
|
1050
|
+
// 使用 contentLayer 作为父容器,因为 ROI 是添加在 contentLayer 上的
|
|
1051
|
+
const addCommand = new AddElementCommand(contentLayer, tempRect as any);
|
|
1052
|
+
executeCommand(addCommand);
|
|
1053
|
+
|
|
1054
|
+
// 触发ROI变化事件
|
|
1055
|
+
emit("roiChange", getROIAnnotations());
|
|
1056
|
+
|
|
1057
|
+
tempRect = null;
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
// 检查点是否在图片区域内
|
|
1063
|
+
const isPointInImageArea = (x: number, y: number): boolean => {
|
|
1064
|
+
return (
|
|
1065
|
+
x >= 0 &&
|
|
1066
|
+
x <= (imageWidth.value || 0) &&
|
|
1067
|
+
y >= 0 &&
|
|
1068
|
+
y <= (imageHeight.value || 0)
|
|
1069
|
+
);
|
|
1070
|
+
};
|
|
1071
|
+
|
|
1072
|
+
// 键盘事件处理
|
|
1073
|
+
const handleKeyDown = (e: KeyboardEvent) => {
|
|
1074
|
+
// 检查是否按下空格键
|
|
1075
|
+
if (e.code === "Space") {
|
|
1076
|
+
// 检查鼠标是否在画布内
|
|
1077
|
+
if (isMouseInCanvas()) {
|
|
1078
|
+
// 阻止默认的滚动行为
|
|
1079
|
+
e.preventDefault();
|
|
1080
|
+
return false;
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
// 判断鼠标是否在画布内
|
|
1086
|
+
const isMouseInCanvas = (): boolean => {
|
|
1087
|
+
if (!canvasContainer.value) return false;
|
|
1088
|
+
|
|
1089
|
+
const rect = canvasContainer.value.getBoundingClientRect();
|
|
1090
|
+
|
|
1091
|
+
// 检查鼠标是否在画布范围内
|
|
1092
|
+
return (
|
|
1093
|
+
mousePosition.value.x >= rect.left &&
|
|
1094
|
+
mousePosition.value.x <= rect.right &&
|
|
1095
|
+
mousePosition.value.y >= rect.top &&
|
|
1096
|
+
mousePosition.value.y <= rect.bottom
|
|
1097
|
+
);
|
|
1098
|
+
};
|
|
1099
|
+
|
|
1100
|
+
// 组件卸载时清理
|
|
1101
|
+
onUnmounted(() => {
|
|
1102
|
+
// 清理逻辑将在此处添加
|
|
1103
|
+
if (imageBox) {
|
|
1104
|
+
app?.tree.remove(imageBox);
|
|
1105
|
+
imageBox = null;
|
|
1106
|
+
}
|
|
1107
|
+
app?.destroy();
|
|
1108
|
+
app = null;
|
|
1109
|
+
|
|
1110
|
+
// 移除事件监听
|
|
1111
|
+
window.removeEventListener("keydown", handleKeyDown);
|
|
1112
|
+
window.removeEventListener("mousemove", handleMouseMove);
|
|
1113
|
+
|
|
1114
|
+
// 清理热键
|
|
1115
|
+
if (window.__roiEditorHotkeysUnsubscribe) {
|
|
1116
|
+
window.__roiEditorHotkeysUnsubscribe();
|
|
1117
|
+
delete window.__roiEditorHotkeysUnsubscribe;
|
|
1118
|
+
}
|
|
1119
|
+
});
|
|
1120
|
+
|
|
1121
|
+
// 工具方法
|
|
1122
|
+
const selectTool = () => {
|
|
1123
|
+
console.log("Select tool clicked");
|
|
1124
|
+
currentTool.value = "select";
|
|
1125
|
+
// 后续实现选择工具逻辑
|
|
1126
|
+
};
|
|
1127
|
+
|
|
1128
|
+
const rectangleTool = () => {
|
|
1129
|
+
console.log("Rectangle tool clicked");
|
|
1130
|
+
currentTool.value = "rectangle";
|
|
1131
|
+
// 后续实现框选工具逻辑
|
|
1132
|
+
};
|
|
1133
|
+
|
|
1134
|
+
const undo = () => {
|
|
1135
|
+
console.log("Undo clicked");
|
|
1136
|
+
commandManager.undo();
|
|
1137
|
+
// 触发ROI变化事件
|
|
1138
|
+
emit("roiChange", getROIAnnotations());
|
|
1139
|
+
};
|
|
1140
|
+
|
|
1141
|
+
const redo = () => {
|
|
1142
|
+
console.log("Redo clicked");
|
|
1143
|
+
commandManager.redo();
|
|
1144
|
+
// 触发ROI变化事件
|
|
1145
|
+
emit("roiChange", getROIAnnotations());
|
|
1146
|
+
};
|
|
1147
|
+
|
|
1148
|
+
/**
|
|
1149
|
+
* 缩小
|
|
1150
|
+
*/
|
|
1151
|
+
const zoomOut = () => {
|
|
1152
|
+
if (!app) return;
|
|
1153
|
+
app.tree.zoom("out");
|
|
1154
|
+
// 更新缩放级别显示
|
|
1155
|
+
updateZoomLevel();
|
|
1156
|
+
};
|
|
1157
|
+
|
|
1158
|
+
/**
|
|
1159
|
+
* 放大
|
|
1160
|
+
*/
|
|
1161
|
+
const zoomIn = () => {
|
|
1162
|
+
if (!app) return;
|
|
1163
|
+
app.tree.zoom("in");
|
|
1164
|
+
// 更新缩放级别显示
|
|
1165
|
+
updateZoomLevel();
|
|
1166
|
+
};
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* 重置缩放
|
|
1170
|
+
*/
|
|
1171
|
+
const resetZoom = () => {
|
|
1172
|
+
if (!app) return;
|
|
1173
|
+
app.tree.zoom(1);
|
|
1174
|
+
// 更新缩放级别显示
|
|
1175
|
+
updateZoomLevel();
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
/**
|
|
1179
|
+
* 更新缩放级别显示
|
|
1180
|
+
*/
|
|
1181
|
+
const updateZoomLevel = () => {
|
|
1182
|
+
if (!app || !app.tree || app.tree.scaleX === undefined) return;
|
|
1183
|
+
zoomLevel.value = Math.round(app.tree.scaleX * 100);
|
|
1184
|
+
};
|
|
1185
|
+
|
|
1186
|
+
/**
|
|
1187
|
+
* 删除选中的ROI
|
|
1188
|
+
*/
|
|
1189
|
+
const deleteSelected = () => {
|
|
1190
|
+
console.log("Delete selected clicked");
|
|
1191
|
+
if (!app) return;
|
|
1192
|
+
|
|
1193
|
+
// 获取选中的元素
|
|
1194
|
+
const selectedElements = app.editor?.list || [];
|
|
1195
|
+
|
|
1196
|
+
// 检查是否有选中的元素
|
|
1197
|
+
if (selectedElements.length === 0) {
|
|
1198
|
+
console.log("No elements selected");
|
|
1199
|
+
return;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
// 过滤出在contentLayer中的Rect元素
|
|
1203
|
+
const selectedROIs = selectedElements.filter(
|
|
1204
|
+
(element) => element instanceof Rect && element.parent === contentLayer,
|
|
1205
|
+
);
|
|
1206
|
+
|
|
1207
|
+
// 检查是否有选中的ROI
|
|
1208
|
+
if (selectedROIs.length === 0) {
|
|
1209
|
+
console.log("No ROI elements selected");
|
|
1210
|
+
return;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
// 删除选中的ROI
|
|
1214
|
+
// 使用 BatchCommand 将多个删除操作合并为一个,以便批量撤销/重做
|
|
1215
|
+
const removeCommands: ICommand[] = [];
|
|
1216
|
+
selectedROIs.forEach((element) => {
|
|
1217
|
+
// 使用命令模式删除元素,以便支持撤销/重做
|
|
1218
|
+
// 注意:这里使用 element.remove() 而不是 destroy(),因为 destroy 会破坏元素导致无法 undo
|
|
1219
|
+
const index = element.parent?.children.indexOf(element) ?? -1;
|
|
1220
|
+
element.remove();
|
|
1221
|
+
// 使用 contentLayer 作为父容器,因为 ROI 是添加在 contentLayer 上的
|
|
1222
|
+
const removeCommand = new RemoveElementCommand(
|
|
1223
|
+
contentLayer,
|
|
1224
|
+
element as any,
|
|
1225
|
+
index,
|
|
1226
|
+
);
|
|
1227
|
+
removeCommands.push(removeCommand);
|
|
1228
|
+
});
|
|
1229
|
+
|
|
1230
|
+
// 使用 BatchCommand 批量执行
|
|
1231
|
+
if (removeCommands.length > 0) {
|
|
1232
|
+
const batchCommand = new BatchCommand(removeCommands);
|
|
1233
|
+
executeCommand(batchCommand);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
// 取消编辑器选中状态,避免残留边框
|
|
1237
|
+
if (app && app.editor) {
|
|
1238
|
+
// 使用cancel()方法取消选中,不会影响其他Rect的可选性
|
|
1239
|
+
app.editor.cancel();
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
console.log(`Deleted ${selectedROIs.length} selected ROI(s)`);
|
|
1243
|
+
|
|
1244
|
+
// 触发ROI变化事件
|
|
1245
|
+
emit("roiChange", getROIAnnotations());
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
// 暴露方法
|
|
1249
|
+
defineExpose({
|
|
1250
|
+
getROIAnnotations,
|
|
1251
|
+
getImageInfo,
|
|
1252
|
+
exportCanvasJSON,
|
|
1253
|
+
importCanvasJSON,
|
|
1254
|
+
loadImage,
|
|
1255
|
+
// selectTool,
|
|
1256
|
+
// rectangleTool,
|
|
1257
|
+
// undo,
|
|
1258
|
+
// redo,
|
|
1259
|
+
// deleteSelected,
|
|
1260
|
+
});
|
|
1261
|
+
</script>
|
|
1262
|
+
|
|
1263
|
+
<style>
|
|
1264
|
+
:root {
|
|
1265
|
+
/* 颜色变量 */
|
|
1266
|
+
--leafer-roi-color-primary: #007aff;
|
|
1267
|
+
--leafer-roi-color-background: #f5f5f5;
|
|
1268
|
+
--leafer-roi-color-background-light: #f0f0f0;
|
|
1269
|
+
--leafer-roi-color-white: #fff;
|
|
1270
|
+
--leafer-roi-color-text: #333;
|
|
1271
|
+
--leafer-roi-color-text-secondary: #666;
|
|
1272
|
+
--leafer-roi-color-text-tertiary: #999999;
|
|
1273
|
+
--leafer-roi-color-border: #ddd;
|
|
1274
|
+
--leafer-roi-color-border-light: #e0e0e0;
|
|
1275
|
+
--leafer-roi-color-error: #e74c3c;
|
|
1276
|
+
--leafer-roi-color-button: #3498db;
|
|
1277
|
+
--leafer-roi-color-button-hover: #2980b9;
|
|
1278
|
+
|
|
1279
|
+
/* 尺寸变量 */
|
|
1280
|
+
--leafer-roi-padding-toolbar: 10px;
|
|
1281
|
+
--leafer-roi-padding-tool-button: 8px;
|
|
1282
|
+
--leafer-roi-size-tool-icon: 18px;
|
|
1283
|
+
--leafer-roi-size-zoom-button: 36px;
|
|
1284
|
+
--leafer-roi-size-zoom-value: 60px;
|
|
1285
|
+
--leafer-roi-font-size-hotkey: 10px;
|
|
1286
|
+
--leafer-roi-padding-hotkey: 1px 3px;
|
|
1287
|
+
--leafer-roi-padding-error: 20px;
|
|
1288
|
+
--leafer-roi-padding-error-button: 8px 16px;
|
|
1289
|
+
|
|
1290
|
+
/* 边框圆角 */
|
|
1291
|
+
--leafer-roi-border-radius-tool-button: 4px;
|
|
1292
|
+
--leafer-roi-border-radius-hotkey: 2px;
|
|
1293
|
+
--leafer-roi-border-radius-overlay: 8px;
|
|
1294
|
+
--leafer-roi-border-radius-zoom: 8px;
|
|
1295
|
+
|
|
1296
|
+
/* 阴影 */
|
|
1297
|
+
--leafer-roi-shadow-tool-button: 0 2px 4px rgba(0, 0, 0, 0.1);
|
|
1298
|
+
--leafer-roi-shadow-tool-button-active: 0 2px 4px rgba(0, 122, 255, 0.3);
|
|
1299
|
+
--leafer-roi-shadow-tool-button-hover: 0 4px 6px rgba(0, 0, 0, 0.1);
|
|
1300
|
+
--leafer-roi-shadow-overlay: 0 4px 12px rgba(0, 0, 0, 0.1);
|
|
1301
|
+
--leafer-roi-shadow-zoom: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|
1302
|
+
|
|
1303
|
+
/* 动画 */
|
|
1304
|
+
--leafer-roi-transition-time: 0.2s;
|
|
1305
|
+
--leafer-roi-animation-gradient: 2s;
|
|
1306
|
+
}
|
|
1307
|
+
</style>
|
|
1308
|
+
<style scoped>
|
|
1309
|
+
.roi-editor {
|
|
1310
|
+
width: 100%;
|
|
1311
|
+
height: 100%;
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
.canvas-container {
|
|
1315
|
+
width: 100%;
|
|
1316
|
+
height: calc(100% - 55px);
|
|
1317
|
+
position: relative;
|
|
1318
|
+
overflow: hidden;
|
|
1319
|
+
outline: none;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
.canvas-container:focus {
|
|
1323
|
+
outline: 2px solid var(--leafer-roi-color-primary);
|
|
1324
|
+
outline-offset: -2px;
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
.loading-overlay {
|
|
1328
|
+
position: absolute;
|
|
1329
|
+
top: 50%;
|
|
1330
|
+
left: 50%;
|
|
1331
|
+
transform: translate(-50%, -50%);
|
|
1332
|
+
background-color: var(--leafer-roi-color-background-light);
|
|
1333
|
+
border-radius: var(--leafer-roi-border-radius-overlay);
|
|
1334
|
+
box-shadow: var(--leafer-roi-shadow-overlay);
|
|
1335
|
+
overflow: hidden;
|
|
1336
|
+
display: flex;
|
|
1337
|
+
justify-content: center;
|
|
1338
|
+
align-items: center;
|
|
1339
|
+
z-index: 1000;
|
|
1340
|
+
min-width: 100%;
|
|
1341
|
+
min-height: 100%;
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
.gradient-animation {
|
|
1345
|
+
position: absolute;
|
|
1346
|
+
top: 0;
|
|
1347
|
+
left: 0;
|
|
1348
|
+
right: 0;
|
|
1349
|
+
bottom: 0;
|
|
1350
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
1351
|
+
background-size: 200% 200%;
|
|
1352
|
+
animation: gradientShift var(--leafer-roi-animation-gradient) ease-in-out
|
|
1353
|
+
infinite;
|
|
1354
|
+
opacity: 0.7;
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
@keyframes gradientShift {
|
|
1358
|
+
0% {
|
|
1359
|
+
background-position: 0% 50%;
|
|
1360
|
+
}
|
|
1361
|
+
50% {
|
|
1362
|
+
background-position: 100% 50%;
|
|
1363
|
+
}
|
|
1364
|
+
100% {
|
|
1365
|
+
background-position: 0% 50%;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
.loading-text {
|
|
1370
|
+
position: relative;
|
|
1371
|
+
z-index: 1;
|
|
1372
|
+
color: white;
|
|
1373
|
+
font-size: 16px;
|
|
1374
|
+
font-weight: 500;
|
|
1375
|
+
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
|
|
1376
|
+
}
|
|
1377
|
+
|
|
1378
|
+
.error-overlay {
|
|
1379
|
+
position: absolute;
|
|
1380
|
+
top: 50%;
|
|
1381
|
+
left: 50%;
|
|
1382
|
+
transform: translate(-50%, -50%);
|
|
1383
|
+
background-color: var(--leafer-roi-color-white);
|
|
1384
|
+
border-radius: var(--leafer-roi-border-radius-overlay);
|
|
1385
|
+
box-shadow: var(--leafer-roi-shadow-overlay);
|
|
1386
|
+
padding: var(--leafer-roi-padding-error);
|
|
1387
|
+
display: flex;
|
|
1388
|
+
flex-direction: column;
|
|
1389
|
+
justify-content: center;
|
|
1390
|
+
align-items: center;
|
|
1391
|
+
z-index: 1000;
|
|
1392
|
+
min-width: 200px;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
.error-overlay p {
|
|
1396
|
+
margin-bottom: 20px;
|
|
1397
|
+
color: var(--leafer-roi-color-error);
|
|
1398
|
+
font-size: 16px;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
.error-overlay button {
|
|
1402
|
+
padding: var(--leafer-roi-padding-error-button);
|
|
1403
|
+
background-color: var(--leafer-roi-color-button);
|
|
1404
|
+
color: white;
|
|
1405
|
+
border: none;
|
|
1406
|
+
border-radius: var(--leafer-roi-border-radius-tool-button);
|
|
1407
|
+
cursor: pointer;
|
|
1408
|
+
font-size: 14px;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
.error-overlay button:hover {
|
|
1412
|
+
background-color: var(--leafer-roi-color-button-hover);
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
/* 缩放控制器样式 */
|
|
1416
|
+
.zoom-controller {
|
|
1417
|
+
position: absolute;
|
|
1418
|
+
left: 16px;
|
|
1419
|
+
bottom: 16px;
|
|
1420
|
+
display: flex;
|
|
1421
|
+
align-items: center;
|
|
1422
|
+
background-color: var(--leafer-roi-color-white);
|
|
1423
|
+
border-radius: var(--leafer-roi-border-radius-zoom);
|
|
1424
|
+
box-shadow: var(--leafer-roi-shadow-zoom);
|
|
1425
|
+
overflow: hidden;
|
|
1426
|
+
z-index: 100;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
.zoom-button {
|
|
1430
|
+
width: var(--leafer-roi-size-zoom-button);
|
|
1431
|
+
height: var(--leafer-roi-size-zoom-button);
|
|
1432
|
+
border: none;
|
|
1433
|
+
background-color: var(--leafer-roi-color-white);
|
|
1434
|
+
color: var(--leafer-roi-color-text);
|
|
1435
|
+
cursor: pointer;
|
|
1436
|
+
display: flex;
|
|
1437
|
+
justify-content: center;
|
|
1438
|
+
align-items: center;
|
|
1439
|
+
transition: all var(--leafer-roi-transition-time) ease;
|
|
1440
|
+
position: relative;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
.zoom-button:hover {
|
|
1444
|
+
background-color: var(--leafer-roi-color-background-light);
|
|
1445
|
+
color: var(--leafer-roi-color-primary);
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
.zoom-button:active {
|
|
1449
|
+
background-color: #e0e0e0;
|
|
1450
|
+
}
|
|
1451
|
+
|
|
1452
|
+
.zoom-value {
|
|
1453
|
+
min-width: var(--leafer-roi-size-zoom-value);
|
|
1454
|
+
height: var(--leafer-roi-size-zoom-button);
|
|
1455
|
+
line-height: var(--leafer-roi-size-zoom-button);
|
|
1456
|
+
text-align: center;
|
|
1457
|
+
font-size: 14px;
|
|
1458
|
+
font-weight: 500;
|
|
1459
|
+
color: var(--leafer-roi-color-text);
|
|
1460
|
+
cursor: pointer;
|
|
1461
|
+
border-left: 1px solid var(--leafer-roi-color-border-light);
|
|
1462
|
+
border-right: 1px solid var(--leafer-roi-color-border-light);
|
|
1463
|
+
transition: all var(--leafer-roi-transition-time) ease;
|
|
1464
|
+
position: relative;
|
|
1465
|
+
}
|
|
1466
|
+
.zoom-value .hotkey-hint {
|
|
1467
|
+
line-height: 1;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
.zoom-value:hover {
|
|
1471
|
+
background-color: var(--leafer-roi-color-background-light);
|
|
1472
|
+
color: var(--leafer-roi-color-primary);
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1475
|
+
/* 工具栏样式 */
|
|
1476
|
+
.toolbar {
|
|
1477
|
+
display: flex;
|
|
1478
|
+
justify-content: center;
|
|
1479
|
+
align-items: center;
|
|
1480
|
+
padding: var(--leafer-roi-padding-toolbar);
|
|
1481
|
+
background-color: var(--leafer-roi-color-background);
|
|
1482
|
+
border-top: 1px solid var(--leafer-roi-color-border);
|
|
1483
|
+
gap: 10px;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
.tool-button {
|
|
1487
|
+
/* width: 40px;
|
|
1488
|
+
height: 40px; */
|
|
1489
|
+
padding: var(--leafer-roi-padding-tool-button);
|
|
1490
|
+
border: none;
|
|
1491
|
+
border-radius: var(--leafer-roi-border-radius-tool-button);
|
|
1492
|
+
background-color: var(--leafer-roi-color-white);
|
|
1493
|
+
color: var(--leafer-roi-color-text);
|
|
1494
|
+
cursor: pointer;
|
|
1495
|
+
display: flex;
|
|
1496
|
+
justify-content: center;
|
|
1497
|
+
align-items: center;
|
|
1498
|
+
box-shadow: var(--leafer-roi-shadow-tool-button);
|
|
1499
|
+
transition: all var(--leafer-roi-transition-time) ease;
|
|
1500
|
+
position: relative;
|
|
1501
|
+
}
|
|
1502
|
+
|
|
1503
|
+
.tool-button:hover {
|
|
1504
|
+
background-color: #e3e3e3;
|
|
1505
|
+
transform: translateY(-1px);
|
|
1506
|
+
box-shadow: var(--leafer-roi-shadow-tool-button-hover);
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
.tool-button:active {
|
|
1510
|
+
transform: translateY(0);
|
|
1511
|
+
box-shadow: var(--leafer-roi-shadow-tool-button);
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
.tool-button.active {
|
|
1515
|
+
background-color: var(--leafer-roi-color-primary);
|
|
1516
|
+
color: white;
|
|
1517
|
+
box-shadow: var(--leafer-roi-shadow-tool-button-active);
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
.tool-button svg {
|
|
1521
|
+
width: var(--leafer-roi-size-tool-icon);
|
|
1522
|
+
height: var(--leafer-roi-size-tool-icon);
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
/* 热键提示样式 */
|
|
1526
|
+
.hotkey-hint {
|
|
1527
|
+
position: absolute;
|
|
1528
|
+
bottom: 2px;
|
|
1529
|
+
right: 2px;
|
|
1530
|
+
font-size: var(--leafer-roi-font-size-hotkey);
|
|
1531
|
+
font-weight: bold;
|
|
1532
|
+
color: var(--leafer-roi-color-text-tertiary);
|
|
1533
|
+
background-color: var(--leafer-roi-color-background);
|
|
1534
|
+
padding: var(--leafer-roi-padding-hotkey);
|
|
1535
|
+
border-radius: var(--leafer-roi-border-radius-hotkey);
|
|
1536
|
+
pointer-events: none;
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
.tool-button.active .hotkey-hint {
|
|
1540
|
+
color: rgba(255, 255, 255, 0.8);
|
|
1541
|
+
background-color: rgba(0, 0, 0, 0.2);
|
|
1542
|
+
}
|
|
1543
|
+
</style>
|
|
1544
|
+
|