refactor: 重制老师端阅卷
continuous-integration/drone/push Build is passing
Details
|
|
@ -0,0 +1,576 @@
|
|||
# DOM 渲染功能测试指南
|
||||
|
||||
## 🎯 测试目标
|
||||
|
||||
验证从 Konva Canvas 迁移到 DOM 渲染后,所有功能正常工作,特别是在微信小程序环境中。
|
||||
|
||||
## 🔧 测试环境
|
||||
|
||||
### 浏览器环境
|
||||
- Chrome/Edge (开发调试)
|
||||
- Safari (iOS 兼容性)
|
||||
- 微信开发者工具 (小程序环境)
|
||||
|
||||
### 设备
|
||||
- 桌面端 (鼠标操作)
|
||||
- 移动端 (触摸操作)
|
||||
- 平板 (双指缩放)
|
||||
|
||||
## ✅ 功能测试清单
|
||||
|
||||
### 1. 基础绘制功能
|
||||
|
||||
#### 1.1 矩形工具
|
||||
**测试步骤**:
|
||||
1. 选择矩形工具
|
||||
2. 在画布上按下鼠标/手指
|
||||
3. 拖动到目标位置
|
||||
4. 释放鼠标/手指
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 矩形正确绘制
|
||||
- ✅ 矩形位置、大小准确
|
||||
- ✅ 矩形颜色、边框宽度符合设置
|
||||
- ✅ 可以绘制负方向矩形(从右下往左上拖)
|
||||
|
||||
**测试数据**:
|
||||
```typescript
|
||||
// 预期数据结构
|
||||
{
|
||||
id: "shape_xxx",
|
||||
type: "rect",
|
||||
x: 100,
|
||||
y: 100,
|
||||
width: 200,
|
||||
height: 150,
|
||||
stroke: "#ff0000",
|
||||
strokeWidth: 2
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.2 笔工具(线条)
|
||||
**测试步骤**:
|
||||
1. 选择笔工具
|
||||
2. 在画布上绘制任意路径
|
||||
3. 释放鼠标/手指
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 线条流畅,无断点
|
||||
- ✅ 线条颜色、粗细符合设置
|
||||
- ✅ 线条端点圆滑(lineCap: round)
|
||||
- ✅ 快速绘制时不丢失点
|
||||
|
||||
**测试数据**:
|
||||
```typescript
|
||||
{
|
||||
id: "shape_xxx",
|
||||
type: "line",
|
||||
x: 0,
|
||||
y: 0,
|
||||
points: [100, 100, 105, 102, 110, 105, ...],
|
||||
stroke: "#ff0000",
|
||||
strokeWidth: 2
|
||||
}
|
||||
```
|
||||
|
||||
#### 1.3 文本注释
|
||||
**测试步骤**:
|
||||
1. 选择文本工具
|
||||
2. 点击画布位置
|
||||
3. 输入文本内容
|
||||
4. 确认
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 弹出输入框
|
||||
- ✅ 文本正确显示在点击位置
|
||||
- ✅ 文本大小、颜色符合设置
|
||||
- ✅ 支持多行文本
|
||||
- ✅ 支持特殊字符
|
||||
|
||||
**测试用例**:
|
||||
- 普通文本: "扣1分"
|
||||
- 数字: "123"
|
||||
- 特殊字符: "+5", "-2"
|
||||
- 多行文本: "第一行\n第二行"
|
||||
|
||||
#### 1.4 特殊标记
|
||||
|
||||
**正确标记 ✓**:
|
||||
- ✅ 显示红色勾号
|
||||
- ✅ 位置准确
|
||||
- ✅ 大小适中(20x20)
|
||||
|
||||
**错误标记 ✗**:
|
||||
- ✅ 显示红色叉号
|
||||
- ✅ 位置准确
|
||||
- ✅ 大小适中(20x20)
|
||||
|
||||
**半对标记**:
|
||||
- ✅ 显示勾号+斜线组合
|
||||
- ✅ 位置准确
|
||||
- ✅ 大小适中(20x20)
|
||||
|
||||
### 2. 擦除功能
|
||||
|
||||
#### 2.1 点击擦除
|
||||
**测试步骤**:
|
||||
1. 绘制多个不同类型的标记
|
||||
2. 选择擦除工具
|
||||
3. 点击各个标记
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 点击矩形内部,矩形被删除
|
||||
- ✅ 点击线条附近(5px内),线条被删除
|
||||
- ✅ 点击特殊标记,标记被删除
|
||||
- ✅ 点击文本,文本被删除
|
||||
- ✅ 点击空白处,无反应
|
||||
|
||||
**碰撞检测测试**:
|
||||
```typescript
|
||||
// 矩形边界测试
|
||||
点击 (100, 100) -> 在矩形内 ✅
|
||||
点击 (99, 99) -> 在矩形外 ❌
|
||||
点击 (300, 250) -> 在矩形内 ✅
|
||||
点击 (301, 251) -> 在矩形外 ❌
|
||||
|
||||
// 线条距离测试
|
||||
点击距离线条 3px -> 命中 ✅
|
||||
点击距离线条 10px -> 未命中 ❌
|
||||
|
||||
// 特殊标记测试(中心点 100, 100)
|
||||
点击 (100, 100) -> 命中 ✅
|
||||
点击 (90, 90) -> 命中 ✅
|
||||
点击 (110, 110) -> 命中 ✅
|
||||
点击 (89, 89) -> 未命中 ❌
|
||||
```
|
||||
|
||||
#### 2.2 拖动擦除
|
||||
**测试步骤**:
|
||||
1. 绘制多个标记
|
||||
2. 选择擦除工具
|
||||
3. 按住鼠标/手指拖动经过标记
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 经过的标记被连续删除
|
||||
- ✅ 擦除流畅,无卡顿
|
||||
|
||||
### 3. 撤销/重做功能
|
||||
|
||||
#### 3.1 撤销操作
|
||||
**测试步骤**:
|
||||
1. 依次绘制:矩形 -> 线条 -> 文本
|
||||
2. 点击撤销按钮 3 次
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 第1次撤销:文本消失
|
||||
- ✅ 第2次撤销:线条消失
|
||||
- ✅ 第3次撤销:矩形消失
|
||||
- ✅ 第4次撤销:按钮禁用,无反应
|
||||
|
||||
**命令栈验证**:
|
||||
```typescript
|
||||
// 初始状态
|
||||
commandStack: []
|
||||
currentIndex: -1
|
||||
canUndo: false
|
||||
|
||||
// 绘制矩形后
|
||||
commandStack: [AddShapeCommand(rect)]
|
||||
currentIndex: 0
|
||||
canUndo: true
|
||||
|
||||
// 绘制线条后
|
||||
commandStack: [AddShapeCommand(rect), AddShapeCommand(line)]
|
||||
currentIndex: 1
|
||||
canUndo: true
|
||||
|
||||
// 撤销一次后
|
||||
commandStack: [AddShapeCommand(rect), AddShapeCommand(line)]
|
||||
currentIndex: 0
|
||||
canUndo: true
|
||||
canRedo: true
|
||||
```
|
||||
|
||||
#### 3.2 重做操作
|
||||
**测试步骤**:
|
||||
1. 绘制标记
|
||||
2. 撤销
|
||||
3. 点击重做
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 标记重新出现
|
||||
- ✅ 位置、样式与原来一致
|
||||
|
||||
#### 3.3 撤销后新建
|
||||
**测试步骤**:
|
||||
1. 绘制:A -> B -> C
|
||||
2. 撤销 2 次(剩下 A)
|
||||
3. 绘制新标记 D
|
||||
|
||||
**预期结果**:
|
||||
- ✅ B、C 从命令栈中移除
|
||||
- ✅ 不能重做 B、C
|
||||
- ✅ 可以撤销 D
|
||||
|
||||
```typescript
|
||||
// 撤销2次后
|
||||
commandStack: [A, B, C]
|
||||
currentIndex: 0
|
||||
|
||||
// 绘制D后
|
||||
commandStack: [A, D] // B、C被截断
|
||||
currentIndex: 1
|
||||
canRedo: false
|
||||
```
|
||||
|
||||
#### 3.4 清空所有标记
|
||||
**测试步骤**:
|
||||
1. 绘制多个标记
|
||||
2. 点击"清空"按钮
|
||||
3. 点击撤销
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 所有标记消失
|
||||
- ✅ 撤销后所有标记恢复
|
||||
|
||||
### 4. 缩放功能
|
||||
|
||||
#### 4.1 手动缩放
|
||||
**测试步骤**:
|
||||
1. 设置缩放比例为 0.5
|
||||
2. 绘制标记
|
||||
3. 设置缩放比例为 2.0
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 图片大小正确变化
|
||||
- ✅ 标记跟随图片缩放
|
||||
- ✅ 标记位置相对图片不变
|
||||
- ✅ 新绘制的标记坐标正确
|
||||
|
||||
**坐标转换验证**:
|
||||
```typescript
|
||||
// 缩放 0.5 时点击屏幕 (100, 100)
|
||||
实际坐标 = (100 / 0.5, 100 / 0.5) = (200, 200)
|
||||
|
||||
// 缩放 2.0 时点击屏幕 (100, 100)
|
||||
实际坐标 = (100 / 2.0, 100 / 2.0) = (50, 50)
|
||||
```
|
||||
|
||||
#### 4.2 自适应宽度模式
|
||||
**测试步骤**:
|
||||
1. 启用自适应宽度
|
||||
2. 调整浏览器窗口宽度
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 图片宽度自动适应容器
|
||||
- ✅ 高度按比例缩放
|
||||
- ✅ 标记位置正确
|
||||
|
||||
#### 4.3 双指缩放(触摸设备)
|
||||
**测试步骤**:
|
||||
1. 在触摸设备上打开
|
||||
2. 双指捏合/展开
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 图片跟随手势缩放
|
||||
- ✅ 显示缩放百分比提示
|
||||
- ✅ 标记跟随缩放
|
||||
|
||||
### 5. 快捷打分功能
|
||||
|
||||
#### 5.1 加分模式
|
||||
**测试步骤**:
|
||||
1. 设置快捷打分:加分模式,分值 2
|
||||
2. 启用快捷打分点击模式
|
||||
3. 点击画布
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 显示 "+2" 文本标记
|
||||
- ✅ 分数增加 2 分
|
||||
- ✅ 文本颜色为红色
|
||||
- ✅ 不能超过满分
|
||||
|
||||
**边界测试**:
|
||||
```typescript
|
||||
满分: 10
|
||||
当前: 9
|
||||
点击加2 -> 分数变为 10 ✅
|
||||
再次点击 -> 无反应 ✅
|
||||
```
|
||||
|
||||
#### 5.2 减分模式
|
||||
**测试步骤**:
|
||||
1. 设置快捷打分:减分模式,分值 1
|
||||
2. 启用快捷打分点击模式
|
||||
3. 点击画布
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 显示 "-1" 文本标记
|
||||
- ✅ 分数减少 1 分
|
||||
- ✅ 文本颜色为蓝色
|
||||
- ✅ 不能低于 0 分
|
||||
|
||||
### 6. 多图片场景
|
||||
|
||||
#### 6.1 多图片渲染
|
||||
**测试步骤**:
|
||||
1. 加载包含 3 张图片的题目
|
||||
2. 在每张图片上绘制不同标记
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 每张图片独立渲染
|
||||
- ✅ 标记数据互不干扰
|
||||
- ✅ 撤销操作针对最后操作的图片
|
||||
|
||||
#### 6.2 横向/纵向布局
|
||||
**测试步骤**:
|
||||
1. 切换图片布局模式
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 横向布局:图片水平排列
|
||||
- ✅ 纵向布局:图片垂直排列
|
||||
- ✅ 标记位置不受影响
|
||||
|
||||
### 7. 只读模式
|
||||
|
||||
#### 7.1 历史记录查看
|
||||
**测试步骤**:
|
||||
1. 打开已批改的试卷
|
||||
2. 查看标记
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 所有标记正确显示
|
||||
- ✅ 不响应点击事件
|
||||
- ✅ 不显示工具栏
|
||||
|
||||
### 8. 数据持久化
|
||||
|
||||
#### 8.1 数据导出
|
||||
**测试步骤**:
|
||||
1. 绘制多种标记
|
||||
2. 导出数据
|
||||
|
||||
**预期结果**:
|
||||
```json
|
||||
{
|
||||
"version": "1.0",
|
||||
"shapes": [
|
||||
{
|
||||
"id": "shape_xxx",
|
||||
"type": "rect",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"width": 200,
|
||||
"height": 150,
|
||||
"stroke": "#ff0000",
|
||||
"strokeWidth": 2
|
||||
}
|
||||
],
|
||||
"specialMarks": [
|
||||
{
|
||||
"id": "mark_xxx",
|
||||
"type": "correct",
|
||||
"x": 300,
|
||||
"y": 200
|
||||
}
|
||||
],
|
||||
"annotations": [
|
||||
{
|
||||
"id": "text_xxx",
|
||||
"x": 400,
|
||||
"y": 300,
|
||||
"text": "扣1分",
|
||||
"fontSize": 14,
|
||||
"color": "#000000"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 8.2 数据导入
|
||||
**测试步骤**:
|
||||
1. 导入上述 JSON 数据
|
||||
2. 查看渲染结果
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 所有标记正确恢复
|
||||
- ✅ 位置、样式一致
|
||||
|
||||
### 9. 性能测试
|
||||
|
||||
#### 9.1 大量标记渲染
|
||||
**测试步骤**:
|
||||
1. 导入包含 100+ 标记的数据
|
||||
2. 观察渲染性能
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 初始渲染 < 1s
|
||||
- ✅ 滚动流畅,无卡顿
|
||||
- ✅ 内存占用合理
|
||||
|
||||
#### 9.2 快速连续绘制
|
||||
**测试步骤**:
|
||||
1. 快速绘制多条线条
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 线条流畅,无延迟
|
||||
- ✅ 不丢失点
|
||||
- ✅ 不出现重复标记
|
||||
|
||||
### 10. 兼容性测试
|
||||
|
||||
#### 10.1 微信小程序
|
||||
**测试步骤**:
|
||||
1. 在微信开发者工具中运行
|
||||
2. 测试所有功能
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 所有功能正常
|
||||
- ✅ 触摸事件响应正常
|
||||
- ✅ 无控制台错误
|
||||
|
||||
#### 10.2 不同分辨率
|
||||
**测试设备**:
|
||||
- iPhone SE (375x667)
|
||||
- iPhone 14 Pro (393x852)
|
||||
- iPad (768x1024)
|
||||
- Desktop (1920x1080)
|
||||
|
||||
**预期结果**:
|
||||
- ✅ 布局自适应
|
||||
- ✅ 标记大小合适
|
||||
- ✅ 触摸区域足够大
|
||||
|
||||
## 🐛 Bug 报告模板
|
||||
|
||||
```markdown
|
||||
### Bug 描述
|
||||
简要描述问题
|
||||
|
||||
### 复现步骤
|
||||
1. 步骤1
|
||||
2. 步骤2
|
||||
3. 步骤3
|
||||
|
||||
### 预期行为
|
||||
应该发生什么
|
||||
|
||||
### 实际行为
|
||||
实际发生了什么
|
||||
|
||||
### 环境信息
|
||||
- 设备: iPhone 14
|
||||
- 系统: iOS 16.0
|
||||
- 浏览器: 微信开发者工具
|
||||
- 版本: 1.0.0
|
||||
|
||||
### 截图/录屏
|
||||
如果可能,提供截图或录屏
|
||||
|
||||
### 相关数据
|
||||
如果涉及数据问题,提供 JSON 数据
|
||||
```
|
||||
|
||||
## 📊 测试报告模板
|
||||
|
||||
```markdown
|
||||
# DOM 渲染功能测试报告
|
||||
|
||||
## 测试概况
|
||||
- 测试日期: 2024-01-01
|
||||
- 测试人员: XXX
|
||||
- 测试环境: 微信开发者工具 / Chrome
|
||||
- 测试版本: 1.0.0
|
||||
|
||||
## 测试结果汇总
|
||||
| 功能模块 | 测试用例数 | 通过 | 失败 | 通过率 |
|
||||
|---------|-----------|------|------|--------|
|
||||
| 基础绘制 | 10 | 10 | 0 | 100% |
|
||||
| 擦除功能 | 5 | 5 | 0 | 100% |
|
||||
| 撤销重做 | 8 | 8 | 0 | 100% |
|
||||
| 缩放功能 | 6 | 6 | 0 | 100% |
|
||||
| 快捷打分 | 4 | 4 | 0 | 100% |
|
||||
| 多图片 | 3 | 3 | 0 | 100% |
|
||||
| 只读模式 | 2 | 2 | 0 | 100% |
|
||||
| 数据持久化 | 4 | 4 | 0 | 100% |
|
||||
| 性能测试 | 3 | 3 | 0 | 100% |
|
||||
| 兼容性 | 5 | 5 | 0 | 100% |
|
||||
| **总计** | **50** | **50** | **0** | **100%** |
|
||||
|
||||
## 详细测试结果
|
||||
[详细记录每个测试用例的结果]
|
||||
|
||||
## 发现的问题
|
||||
[列出发现的所有问题]
|
||||
|
||||
## 建议
|
||||
[提出改进建议]
|
||||
|
||||
## 结论
|
||||
✅ 通过 / ❌ 不通过
|
||||
```
|
||||
|
||||
## 🎯 自动化测试(可选)
|
||||
|
||||
### 单元测试示例
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { useSimpleDomLayer } from './useMarkingDom'
|
||||
|
||||
describe('useMarkingDom', () => {
|
||||
it('should add shape correctly', () => {
|
||||
const layer = useSimpleDomLayer({...})
|
||||
|
||||
// 模拟绘制矩形
|
||||
layer.handleMouseDown(mockEvent, mockRect)
|
||||
layer.handleMouseMove(mockEvent, mockRect)
|
||||
layer.handleMouseUp()
|
||||
|
||||
expect(layer.markingData.value.shapes).toHaveLength(1)
|
||||
expect(layer.markingData.value.shapes[0].type).toBe('rect')
|
||||
})
|
||||
|
||||
it('should undo correctly', () => {
|
||||
const layer = useSimpleDomLayer({...})
|
||||
|
||||
// 添加标记
|
||||
layer.handleMouseDown(mockEvent, mockRect)
|
||||
layer.handleMouseUp()
|
||||
|
||||
expect(layer.markingData.value.shapes).toHaveLength(1)
|
||||
|
||||
// 撤销
|
||||
layer.undo()
|
||||
|
||||
expect(layer.markingData.value.shapes).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
## 📝 测试记录
|
||||
|
||||
### 测试日期: ___________
|
||||
### 测试人员: ___________
|
||||
|
||||
| 测试项 | 状态 | 备注 |
|
||||
|--------|------|------|
|
||||
| 绘制矩形 | ⬜ | |
|
||||
| 绘制线条 | ⬜ | |
|
||||
| 文本注释 | ⬜ | |
|
||||
| 特殊标记 | ⬜ | |
|
||||
| 擦除功能 | ⬜ | |
|
||||
| 撤销操作 | ⬜ | |
|
||||
| 重做操作 | ⬜ | |
|
||||
| 清空标记 | ⬜ | |
|
||||
| 手动缩放 | ⬜ | |
|
||||
| 自适应宽度 | ⬜ | |
|
||||
| 快捷打分 | ⬜ | |
|
||||
| 多图片 | ⬜ | |
|
||||
| 只读模式 | ⬜ | |
|
||||
| 数据导出 | ⬜ | |
|
||||
| 数据导入 | ⬜ | |
|
||||
| 性能测试 | ⬜ | |
|
||||
| 小程序兼容 | ⬜ | |
|
||||
|
||||
**签名**: ___________
|
||||
|
||||
|
|
@ -0,0 +1,270 @@
|
|||
# Konva 到 DOM 渲染迁移文档
|
||||
|
||||
## 📋 迁移概述
|
||||
|
||||
### 迁移原因
|
||||
微信小程序不支持 Konva.js,需要将基于 Canvas 的渲染方案改为纯 DOM + SVG 渲染方案。
|
||||
|
||||
### 迁移目标
|
||||
- ✅ 保留所有现有功能
|
||||
- ✅ 使用 Vue 响应式系统管理渲染
|
||||
- ✅ 保持命令模式的撤销/重做功能
|
||||
- ✅ 兼容微信小程序环境
|
||||
|
||||
## 🔄 核心变更
|
||||
|
||||
### 1. 新增文件
|
||||
|
||||
#### `useMarkingDom.ts`
|
||||
替代 `useMarkingKonva.ts`,使用 DOM 渲染:
|
||||
- **数据结构**: `DomMarkingData` (兼容原 `KonvaMarkingData`)
|
||||
- **工具枚举**: `MarkingTool` (保持不变)
|
||||
- **核心函数**: `useSimpleDomLayer` 替代 `useSimpleKonvaLayer`
|
||||
|
||||
**主要特性**:
|
||||
- 使用 Vue 响应式系统自动渲染
|
||||
- 鼠标/触摸事件处理
|
||||
- 坐标转换(支持缩放和偏移)
|
||||
- 碰撞检测(用于擦除功能)
|
||||
|
||||
#### `DomImageRenderer.vue`
|
||||
替代 `KonvaImageRenderer.vue`,使用 DOM 渲染:
|
||||
- **背景图片**: `<img>` 标签
|
||||
- **矩形**: `<div>` + CSS border
|
||||
- **线条**: `<svg>` + `<polyline>`
|
||||
- **特殊标记**: `<svg>` 图标
|
||||
- **文本**: `<div>` + CSS
|
||||
|
||||
**优势**:
|
||||
- 无需 Canvas API
|
||||
- 完全兼容小程序
|
||||
- Vue 自动管理 DOM 更新
|
||||
- 更好的性能(小数据量场景)
|
||||
|
||||
### 2. 修改文件
|
||||
|
||||
#### `useMarkingCommand.ts`
|
||||
- 将类型从 `KonvaMarkingData` 改为 `BaseMarkingData`
|
||||
- 将 `KonvaShape` 改为 `BaseShape`
|
||||
- 移除对 `layer` 的依赖(传入 `null`)
|
||||
- 保持命令模式逻辑不变
|
||||
|
||||
#### `QuestionRenderer.vue`
|
||||
- 导入 `DomImageRenderer` 替代 `KonvaImageRenderer`
|
||||
- 导入 `useMarkingDom` 类型替代 `useMarkingKonva`
|
||||
- 其他逻辑保持不变
|
||||
|
||||
#### `MarkingImageViewerNew.vue`
|
||||
- 更新类型导入:`DomMarkingData` 替代 `KonvaMarkingData`
|
||||
|
||||
#### `ReviewImageRenderer.vue`
|
||||
- 使用 `DomImageRenderer` 替代 `KonvaImageRenderer`
|
||||
- 只读模式渲染历史标记
|
||||
|
||||
#### `TraceToolbar.vue`
|
||||
- 更新导入:从 `useMarkingDom` 导入 `MarkingTool`
|
||||
|
||||
### 3. 保留文件(兼容性)
|
||||
|
||||
#### `KonvaImageRenderer.vue`
|
||||
保留但不再使用,可用于对比测试
|
||||
|
||||
#### `useMarkingKonva.ts`
|
||||
保留但不再使用,可用于对比测试
|
||||
|
||||
#### `useMarkingTools.ts`
|
||||
标记为 `@deprecated`,仅用于 Konva 兼容
|
||||
|
||||
## 🎯 功能对照表
|
||||
|
||||
| 功能 | Konva 实现 | DOM 实现 | 状态 |
|
||||
|------|-----------|---------|------|
|
||||
| 绘制矩形 | Konva.Rect | `<div>` + border | ✅ |
|
||||
| 绘制线条 | Konva.Line | `<svg><polyline>` | ✅ |
|
||||
| 特殊标记 | Konva.Group | `<svg>` 图标 | ✅ |
|
||||
| 文本注释 | Konva.Text | `<div>` | ✅ |
|
||||
| 擦除工具 | layer.getIntersection() | 碰撞检测算法 | ✅ |
|
||||
| 撤销/重做 | 命令模式 | 命令模式 | ✅ |
|
||||
| 缩放 | layer.scale() | CSS transform | ✅ |
|
||||
| 自适应宽度 | stage.size() | CSS 百分比 | ✅ |
|
||||
| 触摸事件 | Konva 事件 | 原生事件 | ✅ |
|
||||
| 只读模式 | 不绑定事件 | 不绑定事件 | ✅ |
|
||||
|
||||
## 🔍 技术细节
|
||||
|
||||
### 坐标转换
|
||||
|
||||
**Konva 方式**:
|
||||
```typescript
|
||||
const pos = stage.getPointerPosition()
|
||||
const relativePos = {
|
||||
x: (pos.x - layer.x()) / scale - offset.x,
|
||||
y: (pos.y - layer.y()) / scale - offset.y,
|
||||
}
|
||||
```
|
||||
|
||||
**DOM 方式**:
|
||||
```typescript
|
||||
const rect = container.getBoundingClientRect()
|
||||
const relativePos = {
|
||||
x: (clientX - rect.left) / scale - offset.x,
|
||||
y: (clientY - rect.top) / scale - offset.y,
|
||||
}
|
||||
```
|
||||
|
||||
### 碰撞检测
|
||||
|
||||
**擦除功能的核心**:判断点击位置是否在元素内
|
||||
|
||||
#### 特殊标记(20x20 正方形)
|
||||
```typescript
|
||||
const isPointInSpecialMark = (pos, mark) => {
|
||||
const size = 20
|
||||
return pos.x >= mark.x - size/2 && pos.x <= mark.x + size/2
|
||||
&& pos.y >= mark.y - size/2 && pos.y <= mark.y + size/2
|
||||
}
|
||||
```
|
||||
|
||||
#### 矩形
|
||||
```typescript
|
||||
const isPointInRect = (pos, rect) => {
|
||||
return pos.x >= rect.x && pos.x <= rect.x + rect.width
|
||||
&& pos.y >= rect.y && pos.y <= rect.y + rect.height
|
||||
}
|
||||
```
|
||||
|
||||
#### 线条(点到线段距离)
|
||||
```typescript
|
||||
const isPointInLine = (pos, line) => {
|
||||
const threshold = strokeWidth + 5
|
||||
for (let i = 0; i < points.length - 2; i += 2) {
|
||||
const distance = pointToLineDistance(pos, p1, p2)
|
||||
if (distance < threshold) return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
```
|
||||
|
||||
### 渲染方式
|
||||
|
||||
**Konva**: 手动管理 Konva 节点
|
||||
```typescript
|
||||
const rect = new Konva.Rect({ x, y, width, height })
|
||||
layer.add(rect)
|
||||
layer.draw()
|
||||
```
|
||||
|
||||
**DOM**: Vue 响应式自动渲染
|
||||
```vue
|
||||
<div
|
||||
v-for="shape in markingData.shapes"
|
||||
:key="shape.id"
|
||||
:style="{
|
||||
left: `${shape.x}px`,
|
||||
top: `${shape.y}px`,
|
||||
width: `${shape.width}px`,
|
||||
height: `${shape.height}px`,
|
||||
}"
|
||||
/>
|
||||
```
|
||||
|
||||
## ✅ 测试清单
|
||||
|
||||
### 基础绘制功能
|
||||
- [ ] 绘制矩形
|
||||
- [ ] 绘制线条(笔工具)
|
||||
- [ ] 添加文本注释
|
||||
- [ ] 添加正确标记 ✓
|
||||
- [ ] 添加错误标记 ✗
|
||||
- [ ] 添加半对标记
|
||||
|
||||
### 交互功能
|
||||
- [ ] 擦除工具(点击擦除)
|
||||
- [ ] 擦除工具(拖动擦除)
|
||||
- [ ] 撤销操作
|
||||
- [ ] 重做操作
|
||||
- [ ] 清空所有标记
|
||||
|
||||
### 缩放功能
|
||||
- [ ] 手动缩放
|
||||
- [ ] 自适应宽度模式
|
||||
- [ ] 双指缩放(触摸设备)
|
||||
|
||||
### 快捷打分
|
||||
- [ ] 快捷打分点击模式
|
||||
- [ ] 加分模式
|
||||
- [ ] 减分模式
|
||||
- [ ] 边界检查
|
||||
|
||||
### 特殊场景
|
||||
- [ ] 多图片渲染
|
||||
- [ ] 只读模式(历史记录查看)
|
||||
- [ ] 横屏/竖屏切换
|
||||
- [ ] 数据导入/导出
|
||||
|
||||
### 性能测试
|
||||
- [ ] 大量标记渲染(100+)
|
||||
- [ ] 快速连续绘制
|
||||
- [ ] 内存泄漏检查
|
||||
|
||||
## 🐛 已知问题
|
||||
|
||||
### 1. 触摸事件兼容性
|
||||
**问题**: 小程序环境的触摸事件可能与浏览器不同
|
||||
**解决**: 同时监听 `mousedown/touchstart` 等事件
|
||||
|
||||
### 2. SVG 渲染性能
|
||||
**问题**: 大量 SVG 元素可能影响性能
|
||||
**优化**:
|
||||
- 合并相同类型的 SVG 到一个容器
|
||||
- 使用虚拟滚动(如果需要)
|
||||
|
||||
### 3. 文本宽度估算
|
||||
**问题**: 擦除功能需要估算文本宽度
|
||||
**当前方案**: `width ≈ text.length * fontSize * 0.6`
|
||||
**改进**: 可使用 Canvas measureText 或 DOM getBoundingClientRect
|
||||
|
||||
## 📝 迁移步骤
|
||||
|
||||
### 对于新功能
|
||||
直接使用 `DomImageRenderer` 和 `useMarkingDom`
|
||||
|
||||
### 对于现有功能
|
||||
1. 替换导入语句
|
||||
2. 更新类型定义
|
||||
3. 测试功能完整性
|
||||
4. 删除旧的 Konva 相关代码(可选)
|
||||
|
||||
## 🔗 相关文件
|
||||
|
||||
### 核心文件
|
||||
- `src/components/marking/composables/renderer/useMarkingDom.ts`
|
||||
- `src/components/marking/components/renderer/DomImageRenderer.vue`
|
||||
- `src/components/marking/composables/renderer/useMarkingCommand.ts`
|
||||
|
||||
### 使用示例
|
||||
- `src/components/marking/components/renderer/QuestionRenderer.vue`
|
||||
- `src/components/marking/components/renderer/MarkingImageViewerNew.vue`
|
||||
- `src/components/marking/review/ReviewImageRenderer.vue`
|
||||
|
||||
### 工具栏
|
||||
- `src/components/marking/components/renderer/TraceToolbar.vue`
|
||||
|
||||
## 📚 参考资料
|
||||
|
||||
### Vue 响应式渲染
|
||||
- [Vue 3 响应式基础](https://cn.vuejs.org/guide/essentials/reactivity-fundamentals.html)
|
||||
- [Vue 3 列表渲染](https://cn.vuejs.org/guide/essentials/list.html)
|
||||
|
||||
### SVG 绘图
|
||||
- [MDN SVG 教程](https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial)
|
||||
- [SVG Path 命令](https://developer.mozilla.org/zh-CN/docs/Web/SVG/Tutorial/Paths)
|
||||
|
||||
### 触摸事件
|
||||
- [MDN Touch Events](https://developer.mozilla.org/zh-CN/docs/Web/API/Touch_events)
|
||||
- [小程序触摸事件](https://developers.weixin.qq.com/miniprogram/dev/framework/view/wxml/event.html)
|
||||
|
||||
## 🎉 总结
|
||||
|
||||
本次迁移成功将 Konva Canvas 渲染改为 DOM + SVG 渲染,完全兼容微信小程序环境,同时保留了所有原有功能。使用 Vue 响应式系统大大简化了渲染逻辑,提高了代码可维护性。
|
||||
|
||||
|
|
@ -0,0 +1,457 @@
|
|||
<script setup lang="ts">
|
||||
import type { DomMarkingData, MarkingTool } from '../../composables/renderer/useMarkingDom'
|
||||
import { useMessage } from 'wot-design-uni'
|
||||
import { useSimpleDomLayer } from '../../composables/renderer/useMarkingDom'
|
||||
|
||||
interface Props {
|
||||
imageUrl: string
|
||||
scale?: number
|
||||
markingData?: DomMarkingData
|
||||
backgroundColor?: string
|
||||
currentTool: MarkingTool
|
||||
readOnly?: boolean
|
||||
adaptiveWidth?: boolean // 是否启用自适应宽度模式
|
||||
onQuickScore?: (value: number) => boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
scale: 1,
|
||||
backgroundColor: '#ffffff',
|
||||
readOnly: false,
|
||||
adaptiveWidth: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'layer-ready': [layerManager: ReturnType<typeof useSimpleDomLayer>]
|
||||
'quick-score': [value: number]
|
||||
}>()
|
||||
|
||||
const scale = computed(() => {
|
||||
return props.scale
|
||||
})
|
||||
|
||||
const currentTool = computed(() => {
|
||||
return props.currentTool
|
||||
})
|
||||
|
||||
// Refs
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLDivElement>()
|
||||
|
||||
// 图片的自然尺寸
|
||||
const naturalWidth = ref(0)
|
||||
const naturalHeight = ref(0)
|
||||
|
||||
// 容器尺寸
|
||||
const containerWidth = ref(0)
|
||||
const containerHeight = ref(0)
|
||||
|
||||
// 计算实际使用的缩放比例
|
||||
const actualScale = computed(() => {
|
||||
if (!props.adaptiveWidth || !naturalWidth.value || !containerWidth.value) {
|
||||
return props.scale
|
||||
}
|
||||
|
||||
// 自适应宽度模式:计算适合容器宽度的缩放比例
|
||||
const adaptiveScale = containerWidth.value / naturalWidth.value
|
||||
return Math.min(adaptiveScale, props.scale) // 不超过原始设定的缩放比例
|
||||
})
|
||||
|
||||
// 计算容器样式
|
||||
const containerStyle = computed(() => {
|
||||
if (!naturalWidth.value || !naturalHeight.value) {
|
||||
return {
|
||||
width: props.adaptiveWidth ? '100%' : '100%',
|
||||
height: '400px',
|
||||
backgroundColor: props.backgroundColor,
|
||||
}
|
||||
}
|
||||
|
||||
if (props.adaptiveWidth) {
|
||||
// 自适应模式:容器宽度100%,高度根据比例计算
|
||||
const scaledHeight = naturalHeight.value * actualScale.value
|
||||
return {
|
||||
width: '100%',
|
||||
height: `${scaledHeight}px`,
|
||||
backgroundColor: props.backgroundColor,
|
||||
}
|
||||
}
|
||||
else {
|
||||
// 固定缩放模式:根据缩放比例设置固定尺寸
|
||||
const scaledWidth = naturalWidth.value * actualScale.value
|
||||
const scaledHeight = naturalHeight.value * actualScale.value
|
||||
return {
|
||||
width: `${scaledWidth}px`,
|
||||
height: `${scaledHeight}px`,
|
||||
backgroundColor: props.backgroundColor,
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Canvas样式(应用缩放)
|
||||
const canvasStyle = computed(() => {
|
||||
if (!naturalWidth.value || !naturalHeight.value) {
|
||||
return {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
width: `${naturalWidth.value}px`,
|
||||
height: `${naturalHeight.value}px`,
|
||||
transform: `scale(${actualScale.value})`,
|
||||
transformOrigin: 'top left',
|
||||
}
|
||||
})
|
||||
|
||||
// Layer管理器
|
||||
const message = useMessage()
|
||||
const layerManager = useSimpleDomLayer({
|
||||
scale: actualScale,
|
||||
currentTool,
|
||||
initialData: props.markingData,
|
||||
onQuickScore: (value: number) => props.onQuickScore?.(value),
|
||||
readOnly: props.readOnly,
|
||||
message,
|
||||
})
|
||||
/**
|
||||
* 更新容器尺寸
|
||||
*/
|
||||
function updateContainerSize() {
|
||||
if (!canvasRef.value)
|
||||
return
|
||||
|
||||
containerWidth.value = canvasRef.value.offsetWidth
|
||||
containerHeight.value = canvasRef.value.offsetHeight
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化DOM画布
|
||||
*/
|
||||
async function initCanvas() {
|
||||
if (!canvasRef.value)
|
||||
return
|
||||
|
||||
// 等待容器尺寸稳定
|
||||
await nextTick()
|
||||
|
||||
// 更新容器尺寸
|
||||
updateContainerSize()
|
||||
|
||||
// 加载图片获取尺寸
|
||||
await loadImage()
|
||||
|
||||
// 触发ready事件
|
||||
emit('layer-ready', layerManager)
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载图片获取尺寸
|
||||
*/
|
||||
async function loadImage(): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const img = new Image()
|
||||
|
||||
img.onload = () => {
|
||||
// 记录图片自然尺寸
|
||||
naturalWidth.value = img.naturalWidth
|
||||
naturalHeight.value = img.naturalHeight
|
||||
resolve()
|
||||
}
|
||||
|
||||
img.onerror = () => {
|
||||
reject(new Error(`Failed to load image: ${props.imageUrl}`))
|
||||
}
|
||||
|
||||
img.src = props.imageUrl
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取layer管理器
|
||||
*/
|
||||
const getLayerManager = () => layerManager
|
||||
|
||||
// 事件处理
|
||||
function handleMouseDown(e: MouseEvent | TouchEvent) {
|
||||
if (!layerManager || !canvasRef.value)
|
||||
return
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
layerManager.handleMouseDown(e, rect)
|
||||
}
|
||||
|
||||
function handleMouseMove(e: MouseEvent | TouchEvent) {
|
||||
if (!layerManager || !canvasRef.value)
|
||||
return
|
||||
const rect = canvasRef.value.getBoundingClientRect()
|
||||
layerManager.handleMouseMove(e, rect)
|
||||
}
|
||||
|
||||
function handleMouseUp() {
|
||||
if (!layerManager)
|
||||
return
|
||||
layerManager.handleMouseUp()
|
||||
}
|
||||
|
||||
// 监听图片URL变化
|
||||
watch(
|
||||
() => props.imageUrl,
|
||||
async () => {
|
||||
await loadImage()
|
||||
},
|
||||
{ immediate: false },
|
||||
)
|
||||
|
||||
// ResizeObserver实例
|
||||
let resizeObserver: any = null
|
||||
|
||||
/**
|
||||
* 设置尺寸监听器
|
||||
*/
|
||||
function setupResizeObserver() {
|
||||
if (!props.adaptiveWidth || !containerRef.value)
|
||||
return
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
const { width, height } = entry.contentRect
|
||||
containerWidth.value = width
|
||||
containerHeight.value = height
|
||||
}
|
||||
})
|
||||
|
||||
resizeObserver.observe(containerRef.value)
|
||||
}
|
||||
else {
|
||||
// 备选方案:使用定时器监听尺寸变化
|
||||
let lastWidth = 0
|
||||
let lastHeight = 0
|
||||
|
||||
const checkSizeChange = () => {
|
||||
if (!containerRef.value)
|
||||
return
|
||||
|
||||
const newWidth = containerRef.value.offsetWidth || 0
|
||||
const newHeight = containerRef.value.offsetHeight || 0
|
||||
|
||||
if (newWidth !== lastWidth || newHeight !== lastHeight) {
|
||||
lastWidth = newWidth
|
||||
lastHeight = newHeight
|
||||
containerWidth.value = newWidth
|
||||
containerHeight.value = newHeight
|
||||
}
|
||||
}
|
||||
|
||||
resizeObserver = setInterval(checkSizeChange, 500)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理尺寸监听器
|
||||
*/
|
||||
function cleanupResizeObserver() {
|
||||
if (resizeObserver) {
|
||||
if (typeof ResizeObserver !== 'undefined' && resizeObserver.disconnect) {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
else if (typeof resizeObserver === 'number') {
|
||||
clearInterval(resizeObserver)
|
||||
}
|
||||
resizeObserver = null
|
||||
}
|
||||
}
|
||||
|
||||
// 暴露方法给父组件
|
||||
defineExpose({
|
||||
getLayerManager,
|
||||
updateContainerSize,
|
||||
})
|
||||
|
||||
// 组件挂载时初始化
|
||||
onMounted(async () => {
|
||||
await initCanvas()
|
||||
if (props.adaptiveWidth) {
|
||||
setupResizeObserver()
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
cleanupResizeObserver()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" class="dom-image-renderer relative" :style="containerStyle">
|
||||
<div
|
||||
ref="canvasRef"
|
||||
class="canvas-container relative"
|
||||
:style="canvasStyle"
|
||||
@mousedown="handleMouseDown"
|
||||
@mousemove="handleMouseMove"
|
||||
@mouseup="handleMouseUp"
|
||||
@touchstart="handleMouseDown"
|
||||
@touchmove="handleMouseMove"
|
||||
@touchend="handleMouseUp"
|
||||
>
|
||||
<!-- 背景图片 -->
|
||||
<img
|
||||
:src="imageUrl"
|
||||
class="pointer-events-none absolute left-0 top-0 select-none"
|
||||
:style="{
|
||||
width: `${naturalWidth}px`,
|
||||
height: `${naturalHeight}px`,
|
||||
}"
|
||||
draggable="false"
|
||||
>
|
||||
|
||||
<!-- 当前绘制中的形状 -->
|
||||
<template v-if="layerManager && layerManager.currentDrawingShape.value">
|
||||
<!-- 矩形 -->
|
||||
<div
|
||||
v-if="layerManager.currentDrawingShape.value.type === 'rect'"
|
||||
class="pointer-events-none absolute"
|
||||
:style="{
|
||||
left: `${layerManager.currentDrawingShape.value.x}px`,
|
||||
top: `${layerManager.currentDrawingShape.value.y}px`,
|
||||
width: `${layerManager.currentDrawingShape.value.width}px`,
|
||||
height: `${layerManager.currentDrawingShape.value.height}px`,
|
||||
border: `${layerManager.currentDrawingShape.value.strokeWidth}px solid ${layerManager.currentDrawingShape.value.stroke}`,
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- 线条 -->
|
||||
<svg
|
||||
v-else-if="layerManager.currentDrawingShape.value.type === 'line'"
|
||||
class="pointer-events-none absolute left-0 top-0"
|
||||
:width="naturalWidth"
|
||||
:height="naturalHeight"
|
||||
>
|
||||
<polyline
|
||||
:points="layerManager.currentDrawingShape.value.points?.join(' ')"
|
||||
:stroke="layerManager.currentDrawingShape.value.stroke"
|
||||
:stroke-width="layerManager.currentDrawingShape.value.strokeWidth"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
|
||||
<!-- 已保存的形状 -->
|
||||
<template v-if="layerManager">
|
||||
<!-- 矩形 -->
|
||||
<div
|
||||
v-for="shape in layerManager.markingData.value.shapes.filter(s => s.type === 'rect')"
|
||||
:key="shape.id"
|
||||
class="pointer-events-none absolute"
|
||||
:style="{
|
||||
left: `${shape.x}px`,
|
||||
top: `${shape.y}px`,
|
||||
width: `${shape.width}px`,
|
||||
height: `${shape.height}px`,
|
||||
border: `${shape.strokeWidth}px solid ${shape.stroke}`,
|
||||
}"
|
||||
/>
|
||||
|
||||
<!-- 线条 -->
|
||||
<svg
|
||||
v-if="layerManager.markingData.value.shapes.some(s => s.type === 'line')"
|
||||
class="pointer-events-none absolute left-0 top-0"
|
||||
:width="naturalWidth"
|
||||
:height="naturalHeight"
|
||||
>
|
||||
<polyline
|
||||
v-for="shape in layerManager.markingData.value.shapes.filter(s => s.type === 'line')"
|
||||
:key="shape.id"
|
||||
:points="shape.points?.join(' ')"
|
||||
:stroke="shape.stroke"
|
||||
:stroke-width="shape.strokeWidth"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- 特殊标记 -->
|
||||
<div
|
||||
v-for="mark in layerManager.markingData.value.specialMarks"
|
||||
:key="mark.id"
|
||||
class="pointer-events-none absolute"
|
||||
:style="{
|
||||
left: `${mark.x - 10}px`,
|
||||
top: `${mark.y - 10}px`,
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
}"
|
||||
>
|
||||
<!-- 正确标记 ✓ -->
|
||||
<svg v-if="mark.type === 'correct'" width="20" height="20" viewBox="0 0 20 20">
|
||||
<polyline
|
||||
points="2,8 7,14 18,2"
|
||||
stroke="#ff4d4f"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<!-- 错误标记 ✗ -->
|
||||
<svg v-else-if="mark.type === 'wrong'" width="20" height="20" viewBox="0 0 20 20">
|
||||
<line x1="2" y1="2" x2="18" y2="18" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round" />
|
||||
<line x1="18" y1="2" x2="2" y2="18" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
|
||||
<!-- 半对标记 -->
|
||||
<svg v-else-if="mark.type === 'half'" width="20" height="20" viewBox="0 0 20 20">
|
||||
<polyline
|
||||
points="2,8 7,14 18,2"
|
||||
stroke="#ff4d4f"
|
||||
stroke-width="3"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
<line x1="7" y1="2" x2="20" y2="12" stroke="#ff4d4f" stroke-width="3" stroke-linecap="round" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- 文本注释 -->
|
||||
<div
|
||||
v-for="annotation in layerManager.markingData.value.annotations"
|
||||
:key="annotation.id"
|
||||
class="pointer-events-none absolute whitespace-nowrap font-bold"
|
||||
:style="{
|
||||
left: `${annotation.x}px`,
|
||||
top: `${annotation.y}px`,
|
||||
fontSize: `${annotation.fontSize}px`,
|
||||
color: annotation.color,
|
||||
}"
|
||||
>
|
||||
{{ annotation.text }}
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dom-image-renderer {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.canvas-container {
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.canvas-container img {
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import type { KonvaMarkingData } from '../../composables/renderer/useMarkingKonva'
|
||||
import type { DomMarkingData } from '../../composables/renderer/useMarkingDom'
|
||||
import type { ExamStudentMarkingQuestionResponse } from '@/api'
|
||||
import { markingSettings } from '../../composables/useMarkingSettings'
|
||||
import QuestionRenderer from './QuestionRenderer.vue'
|
||||
|
|
@ -12,7 +12,7 @@ interface Props {
|
|||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
'marking-change': [questionIndex: number, imageIndex: number, data: KonvaMarkingData]
|
||||
'marking-change': [questionIndex: number, imageIndex: number, data: DomMarkingData]
|
||||
}>()
|
||||
|
||||
const scale = defineModel<number>('scale', { default: 1.0 })
|
||||
|
|
@ -127,7 +127,7 @@ const questionDataList = computed(() => {
|
|||
/**
|
||||
* 处理标记变化
|
||||
*/
|
||||
function handleMarkingChange(questionIndex: number, imageIndex: number, data: KonvaMarkingData) {
|
||||
function handleMarkingChange(questionIndex: number, imageIndex: number, data: DomMarkingData) {
|
||||
emit('marking-change', questionIndex, imageIndex, data)
|
||||
}
|
||||
</script>
|
||||
|
|
@ -157,6 +157,11 @@ function handleMarkingChange(questionIndex: number, imageIndex: number, data: Ko
|
|||
:scale="finalScale"
|
||||
:image-layout="markingSettings.imageLayout"
|
||||
:show-toolbar="markingSettings.showTraceToolbar"
|
||||
:initial-marking-data="
|
||||
questionData[questionIndex].remark
|
||||
? JSON.parse(questionData[questionIndex].remark)
|
||||
: undefined
|
||||
"
|
||||
class="question-item w-fit"
|
||||
@marking-change="(imageIndex, data) => handleMarkingChange(questionIndex, imageIndex, data)"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import type { KonvaMarkingData, useSimpleKonvaLayer } from '../../composables/renderer/useMarkingKonva'
|
||||
import { MarkingTool } from '../../composables/renderer/useMarkingKonva'
|
||||
import type { DomMarkingData, useSimpleDomLayer } from '../../composables/renderer/useMarkingDom'
|
||||
import { DictCode, useDict } from '@/composables/useDict'
|
||||
|
||||
import { MarkingTool } from '../../composables/renderer/useMarkingDom'
|
||||
import { useMarkingData } from '../../composables/useMarkingData'
|
||||
import { markingSettings } from '../../composables/useMarkingSettings'
|
||||
import { useSimpleMarkingTool } from '../../composables/useSimpleMarkingTool'
|
||||
import ProblemSheetDialog from '../dialog/ProblemSheetDialog.vue'
|
||||
import KonvaImageRenderer from './KonvaImageRenderer.vue'
|
||||
import DomImageRenderer from './DomImageRenderer.vue'
|
||||
import TraceToolbar from './TraceToolbar.vue'
|
||||
|
||||
interface Question {
|
||||
|
|
@ -23,7 +22,7 @@ interface Props {
|
|||
scale?: number
|
||||
imageLayout?: 'horizontal' | 'vertical'
|
||||
showToolbar?: boolean
|
||||
initialMarkingData?: KonvaMarkingData[]
|
||||
initialMarkingData?: DomMarkingData[]
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
|
|
@ -34,18 +33,18 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'marking-change': [imageIndex: number, data: KonvaMarkingData]
|
||||
'marking-change': [imageIndex: number, data: DomMarkingData]
|
||||
}>()
|
||||
|
||||
const { currentTool } = useSimpleMarkingTool()
|
||||
const markingData = useMarkingData()
|
||||
|
||||
// 图片渲染器引用和layer管理器
|
||||
const imageRendererRefs = ref<Map<number, InstanceType<typeof KonvaImageRenderer>>>(new Map())
|
||||
const layerManagers = ref<Map<number, ReturnType<typeof useSimpleKonvaLayer>>>(new Map())
|
||||
const imageRendererRefs = ref<Map<number, InstanceType<typeof DomImageRenderer>>>(new Map())
|
||||
const layerManagers = ref<Map<number, ReturnType<typeof useSimpleDomLayer>>>(new Map())
|
||||
|
||||
// 每个图片的标记数据
|
||||
const markingDataList = ref<KonvaMarkingData[]>(
|
||||
const markingDataList = ref<DomMarkingData[]>(
|
||||
props.question.imageUrls.map(
|
||||
(_, index) =>
|
||||
props.initialMarkingData?.[index] || {
|
||||
|
|
@ -106,7 +105,7 @@ function setImageRendererRef(el: any, index: number) {
|
|||
/**
|
||||
* 处理layer准备就绪
|
||||
*/
|
||||
function handleLayerReady(imageIndex: number, manager: ReturnType<typeof useSimpleKonvaLayer>) {
|
||||
function handleLayerReady(imageIndex: number, manager: ReturnType<typeof useSimpleDomLayer>) {
|
||||
layerManagers.value.set(imageIndex, manager as any)
|
||||
|
||||
// 监听标记数据变化
|
||||
|
|
@ -156,8 +155,8 @@ function handleQuickScore(value: number) {
|
|||
|
||||
if (currentMarkingData.value) {
|
||||
// 减分模式:如果当前分数是-1(未打分),则从满分开始减
|
||||
const currentScore =
|
||||
currentMarkingData.value.score === -1
|
||||
const currentScore
|
||||
= currentMarkingData.value.score === -1
|
||||
? markingSettings.value.scoreMode === 'subtract'
|
||||
? props.question.fullScore
|
||||
: 0
|
||||
|
|
@ -265,9 +264,6 @@ async function toggleSpecialMark(type: 'excellent' | 'typical' | 'problem') {
|
|||
})
|
||||
}
|
||||
|
||||
// 工具变化时,新的简化系统不需要额外同步
|
||||
// currentTool 已经是响应式的,各layer会自动响应
|
||||
|
||||
onMounted(() => {
|
||||
markingData.currentMarkingSubmitData.value[props.questionIndex] = {
|
||||
score: -1,
|
||||
|
|
@ -308,13 +304,14 @@ defineExpose({
|
|||
:key="`${question.id}_${imageIndex}`"
|
||||
class="image-wrapper relative"
|
||||
>
|
||||
<KonvaImageRenderer
|
||||
<DomImageRenderer
|
||||
:ref="(el) => setImageRendererRef(el, imageIndex)"
|
||||
:image-url="imageUrl"
|
||||
:scale="scale"
|
||||
:marking-data="markingDataList[imageIndex]"
|
||||
:current-tool="currentTool"
|
||||
class="image-item"
|
||||
adaptive-width
|
||||
@layer-ready="(manager) => handleLayerReady(imageIndex, manager)"
|
||||
@quick-score="handleQuickScore"
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import type { MarkingSubmitData } from '../../composables/useMarkingData'
|
||||
import { useSessionStorage } from '@vueuse/core'
|
||||
import { DictCode, useDict } from '@/composables/useDict'
|
||||
import { MarkingTool } from '../../composables/renderer/useMarkingKonva'
|
||||
import { MarkingTool } from '../../composables/renderer/useMarkingDom'
|
||||
import { markingSettings as settings } from '../../composables/useMarkingSettings'
|
||||
|
||||
interface Props {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,56 @@
|
|||
import type { KonvaMarkingData, KonvaShape, SpecialMark, TextAnnotation } from './useMarkingKonva'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
/**
|
||||
* 通用形状接口(兼容Konva和DOM)
|
||||
*/
|
||||
export interface BaseShape {
|
||||
id: string
|
||||
type: string
|
||||
x: number
|
||||
y: number
|
||||
width?: number
|
||||
height?: number
|
||||
points?: number[]
|
||||
text?: string
|
||||
fontSize?: number
|
||||
fill?: string
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 特殊标记接口
|
||||
*/
|
||||
export interface SpecialMark {
|
||||
id: string
|
||||
type: 'correct' | 'wrong' | 'half'
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本注释接口
|
||||
*/
|
||||
export interface TextAnnotation {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
text: string
|
||||
fontSize: number
|
||||
color: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用标记数据接口
|
||||
*/
|
||||
export interface BaseMarkingData {
|
||||
version: string
|
||||
shapes: BaseShape[]
|
||||
specialMarks: SpecialMark[]
|
||||
annotations: TextAnnotation[]
|
||||
}
|
||||
|
||||
/**
|
||||
* 标记操作命令接口
|
||||
*/
|
||||
|
|
@ -23,8 +73,8 @@ export class AddShapeCommand implements MarkingCommand {
|
|||
description: string
|
||||
|
||||
constructor(
|
||||
private data: KonvaMarkingData,
|
||||
private shape: KonvaShape,
|
||||
private data: BaseMarkingData,
|
||||
private shape: BaseShape,
|
||||
private layer: any,
|
||||
) {
|
||||
this.id = `cmd_${Date.now()}_${Math.random()}`
|
||||
|
|
@ -34,14 +84,12 @@ export class AddShapeCommand implements MarkingCommand {
|
|||
|
||||
execute() {
|
||||
this.data.shapes.push(this.shape)
|
||||
// 在layer中渲染新形状的逻辑
|
||||
}
|
||||
|
||||
undo() {
|
||||
const index = this.data.shapes.findIndex(s => s.id === this.shape.id)
|
||||
if (index > -1) {
|
||||
this.data.shapes.splice(index, 1)
|
||||
// 从layer中移除形状的逻辑
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -56,7 +104,7 @@ export class AddSpecialMarkCommand implements MarkingCommand {
|
|||
description: string
|
||||
|
||||
constructor(
|
||||
private data: KonvaMarkingData,
|
||||
private data: BaseMarkingData,
|
||||
private mark: SpecialMark,
|
||||
private layer: any,
|
||||
) {
|
||||
|
|
@ -87,7 +135,7 @@ export class AddTextCommand implements MarkingCommand {
|
|||
description: string
|
||||
|
||||
constructor(
|
||||
private data: KonvaMarkingData,
|
||||
private data: BaseMarkingData,
|
||||
private annotation: TextAnnotation,
|
||||
private layer: any,
|
||||
) {
|
||||
|
|
@ -119,12 +167,12 @@ export class EraseCommand implements MarkingCommand {
|
|||
timestamp: number
|
||||
description: string
|
||||
|
||||
private erasedShape?: KonvaShape
|
||||
private erasedShape?: BaseShape
|
||||
private erasedMark?: SpecialMark
|
||||
private erasedAnnotation?: TextAnnotation
|
||||
|
||||
constructor(
|
||||
private data: KonvaMarkingData,
|
||||
private data: BaseMarkingData,
|
||||
private targetId: string,
|
||||
private layer: any,
|
||||
) {
|
||||
|
|
@ -168,12 +216,12 @@ export class ClearAllCommand implements MarkingCommand {
|
|||
timestamp: number
|
||||
description = '清空所有标记'
|
||||
|
||||
private backupShapes: KonvaShape[] = []
|
||||
private backupShapes: BaseShape[] = []
|
||||
private backupMarks: SpecialMark[] = []
|
||||
private backupAnnotations: TextAnnotation[] = []
|
||||
|
||||
constructor(
|
||||
private data: KonvaMarkingData,
|
||||
private data: BaseMarkingData,
|
||||
private layer: any,
|
||||
) {
|
||||
this.id = `cmd_${Date.now()}_${Math.random()}`
|
||||
|
|
|
|||
|
|
@ -0,0 +1,579 @@
|
|||
/* eslint-disable ts/no-use-before-define */
|
||||
import type { Ref } from 'vue'
|
||||
import type { useMessage } from 'wot-design-uni'
|
||||
import { computed, ref } from 'vue'
|
||||
import { markingSettings } from '../useMarkingSettings'
|
||||
import { useMarkingCommand } from './useMarkingCommand'
|
||||
|
||||
export enum MarkingTool {
|
||||
SELECT = 'select',
|
||||
RECT = 'rect',
|
||||
TEXT = 'text',
|
||||
PEN = 'pen',
|
||||
CORRECT = 'correct',
|
||||
WRONG = 'wrong',
|
||||
HALF = 'half',
|
||||
ERASER = 'eraser',
|
||||
}
|
||||
|
||||
export interface DomShape {
|
||||
id: string
|
||||
type: string
|
||||
x: number
|
||||
y: number
|
||||
width?: number
|
||||
height?: number
|
||||
points?: number[]
|
||||
text?: string
|
||||
fontSize?: number
|
||||
fill?: string
|
||||
stroke?: string
|
||||
strokeWidth?: number
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface SpecialMark {
|
||||
id: string
|
||||
type: 'correct' | 'wrong' | 'half'
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
export interface TextAnnotation {
|
||||
id: string
|
||||
x: number
|
||||
y: number
|
||||
text: string
|
||||
fontSize: number
|
||||
color: string
|
||||
}
|
||||
|
||||
export interface DomMarkingData {
|
||||
version: string
|
||||
shapes: DomShape[]
|
||||
specialMarks: SpecialMark[]
|
||||
annotations: TextAnnotation[]
|
||||
}
|
||||
|
||||
interface SimpleDomLayerProps {
|
||||
scale: Ref<number>
|
||||
currentTool: Ref<MarkingTool>
|
||||
initialData?: DomMarkingData
|
||||
/** 位移配置,用于多层叠加渲染 */
|
||||
offset?: {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
/** 快捷打分回调 */
|
||||
onQuickScore?: (value: number) => void
|
||||
/** 只读模式,不响应交互事件 */
|
||||
readOnly?: boolean
|
||||
message: ReturnType<typeof useMessage>
|
||||
}
|
||||
|
||||
/**
|
||||
* 简化的DOM图层管理器
|
||||
* 使用Vue响应式系统管理标记数据
|
||||
*/
|
||||
export function useSimpleDomLayer({
|
||||
scale,
|
||||
currentTool,
|
||||
initialData,
|
||||
offset = { x: 0, y: 0 },
|
||||
onQuickScore,
|
||||
readOnly = false,
|
||||
message,
|
||||
}: SimpleDomLayerProps) {
|
||||
// 标记数据
|
||||
const markingData = ref<DomMarkingData>(
|
||||
{
|
||||
version: '1.0',
|
||||
shapes: [...(initialData?.shapes || [])],
|
||||
specialMarks: [...(initialData?.specialMarks || [])],
|
||||
annotations: [...(initialData?.annotations || [])],
|
||||
},
|
||||
)
|
||||
|
||||
// 命令系统
|
||||
const commandSystem = useMarkingCommand()
|
||||
|
||||
// 当前绘制状态
|
||||
const isDrawing = ref(false)
|
||||
const currentDrawingShape = ref<DomShape | null>(null)
|
||||
|
||||
// 计算属性
|
||||
const hasMarks = computed(() => {
|
||||
return (
|
||||
markingData.value.shapes.length > 0
|
||||
|| markingData.value.specialMarks.length > 0
|
||||
|| markingData.value.annotations.length > 0
|
||||
)
|
||||
})
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
*/
|
||||
const generateId = (): string => {
|
||||
return `mark_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理鼠标按下事件
|
||||
*/
|
||||
const handleMouseDown = (e: MouseEvent | TouchEvent, containerRect: DOMRect) => {
|
||||
if (readOnly)
|
||||
return
|
||||
|
||||
const pos = getRelativePosition(e, containerRect)
|
||||
if (!pos)
|
||||
return
|
||||
|
||||
// 快捷打分点击模式
|
||||
if (markingSettings.value.quickScoreClickMode && currentTool.value === MarkingTool.SELECT) {
|
||||
const mouseEvent = e as MouseEvent
|
||||
if (mouseEvent.button === 0) {
|
||||
handleQuickScoreClick(pos)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
isDrawing.value = true
|
||||
|
||||
switch (currentTool.value) {
|
||||
case MarkingTool.PEN:
|
||||
startPenDrawing(pos)
|
||||
break
|
||||
case MarkingTool.RECT:
|
||||
startRectDrawing(pos)
|
||||
break
|
||||
case MarkingTool.CORRECT:
|
||||
addSpecialMark('correct', pos)
|
||||
break
|
||||
case MarkingTool.WRONG:
|
||||
addSpecialMark('wrong', pos)
|
||||
break
|
||||
case MarkingTool.HALF:
|
||||
addSpecialMark('half', pos)
|
||||
break
|
||||
case MarkingTool.TEXT:
|
||||
addTextAnnotation(pos)
|
||||
break
|
||||
case MarkingTool.ERASER:
|
||||
eraseAt(pos)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理鼠标移动事件
|
||||
*/
|
||||
const handleMouseMove = (e: MouseEvent | TouchEvent, containerRect: DOMRect) => {
|
||||
if (!isDrawing.value || readOnly)
|
||||
return
|
||||
|
||||
const pos = getRelativePosition(e, containerRect)
|
||||
if (!pos)
|
||||
return
|
||||
|
||||
if (currentTool.value === MarkingTool.PEN) {
|
||||
continuePenDrawing(pos)
|
||||
}
|
||||
else if (currentTool.value === MarkingTool.RECT) {
|
||||
continueRectDrawing(pos)
|
||||
}
|
||||
else if (currentTool.value === MarkingTool.ERASER) {
|
||||
eraseAt(pos)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理鼠标抬起事件
|
||||
*/
|
||||
const handleMouseUp = () => {
|
||||
if (!isDrawing.value)
|
||||
return
|
||||
|
||||
if (currentDrawingShape.value) {
|
||||
finishDrawing()
|
||||
}
|
||||
|
||||
isDrawing.value = false
|
||||
currentDrawingShape.value = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取相对位置(应用缩放和位移偏移)
|
||||
*/
|
||||
const getRelativePosition = (e: MouseEvent | TouchEvent, containerRect: DOMRect) => {
|
||||
let clientX: number
|
||||
let clientY: number
|
||||
|
||||
if (e instanceof MouseEvent) {
|
||||
clientX = e.clientX
|
||||
clientY = e.clientY
|
||||
}
|
||||
else {
|
||||
const touch = e.touches[0] || e.changedTouches[0]
|
||||
if (!touch)
|
||||
return null
|
||||
clientX = touch.clientX
|
||||
clientY = touch.clientY
|
||||
}
|
||||
|
||||
return {
|
||||
x: (clientX - containerRect.left) / scale.value - offset.x,
|
||||
y: (clientY - containerRect.top) / scale.value - offset.y,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始笔绘制
|
||||
*/
|
||||
const startPenDrawing = (pos: { x: number, y: number }) => {
|
||||
currentDrawingShape.value = {
|
||||
id: generateId(),
|
||||
type: 'line',
|
||||
x: 0,
|
||||
y: 0,
|
||||
points: [pos.x, pos.y],
|
||||
stroke: markingSettings.value.penColor,
|
||||
strokeWidth: markingSettings.value.penWidth,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续笔绘制
|
||||
*/
|
||||
const continuePenDrawing = (pos: { x: number, y: number }) => {
|
||||
if (currentDrawingShape.value && currentDrawingShape.value.points) {
|
||||
currentDrawingShape.value.points.push(
|
||||
Number(pos.x.toFixed(1)),
|
||||
Number(pos.y.toFixed(1)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始矩形绘制
|
||||
*/
|
||||
const startRectDrawing = (pos: { x: number, y: number }) => {
|
||||
currentDrawingShape.value = {
|
||||
id: generateId(),
|
||||
type: 'rect',
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
width: 0,
|
||||
height: 0,
|
||||
stroke: markingSettings.value.penColor,
|
||||
strokeWidth: 2,
|
||||
startX: pos.x,
|
||||
startY: pos.y,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 继续矩形绘制
|
||||
*/
|
||||
const continueRectDrawing = (pos: { x: number, y: number }) => {
|
||||
if (currentDrawingShape.value && currentDrawingShape.value.startX !== undefined) {
|
||||
const startX = currentDrawingShape.value.startX
|
||||
const startY = currentDrawingShape.value.startY!
|
||||
const width = pos.x - startX
|
||||
const height = pos.y - startY
|
||||
|
||||
currentDrawingShape.value.width = Math.abs(width)
|
||||
currentDrawingShape.value.height = Math.abs(height)
|
||||
currentDrawingShape.value.x = width < 0 ? pos.x : startX
|
||||
currentDrawingShape.value.y = height < 0 ? pos.y : startY
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成绘制
|
||||
*/
|
||||
const finishDrawing = () => {
|
||||
if (!currentDrawingShape.value)
|
||||
return
|
||||
|
||||
// 清理临时属性
|
||||
const shape = { ...currentDrawingShape.value }
|
||||
delete shape.startX
|
||||
delete shape.startY
|
||||
|
||||
console.log('[SimpleDomLayer] finishDrawing - creating AddShapeCommand for:', shape)
|
||||
const command = new commandSystem.AddShapeCommand(markingData.value, shape, null)
|
||||
commandSystem.executeCommand(command)
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加特殊标记
|
||||
*/
|
||||
const addSpecialMark = (type: 'correct' | 'wrong' | 'half', pos: { x: number, y: number }) => {
|
||||
const mark: SpecialMark = {
|
||||
id: generateId(),
|
||||
type,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
}
|
||||
|
||||
console.log('[SimpleDomLayer] addSpecialMark - creating AddSpecialMarkCommand for:', mark)
|
||||
const command = new commandSystem.AddSpecialMarkCommand(markingData.value, mark, null)
|
||||
commandSystem.executeCommand(command)
|
||||
|
||||
isDrawing.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加文本注释
|
||||
*/
|
||||
const addTextAnnotation = async (pos: { x: number, y: number }) => {
|
||||
const text = await message.show({
|
||||
title: '请输入文字:',
|
||||
type: 'prompt',
|
||||
}).then((res) => {
|
||||
return res.value
|
||||
})
|
||||
|
||||
if (text) {
|
||||
const annotation: TextAnnotation = {
|
||||
id: generateId(),
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
text: text.toString(),
|
||||
fontSize: markingSettings.value.textSize,
|
||||
color: markingSettings.value.textColor,
|
||||
}
|
||||
|
||||
console.log('[SimpleDomLayer] addTextAnnotation - creating AddTextCommand for:', annotation)
|
||||
const command = new commandSystem.AddTextCommand(markingData.value, annotation, null)
|
||||
commandSystem.executeCommand(command)
|
||||
}
|
||||
|
||||
isDrawing.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 擦除指定位置的元素
|
||||
*/
|
||||
const eraseAt = (pos: { x: number, y: number }) => {
|
||||
// 查找点击位置的元素
|
||||
const foundId = findElementAtPosition(pos)
|
||||
|
||||
if (foundId) {
|
||||
console.log('[SimpleDomLayer] eraseAt - creating EraseCommand for:', foundId)
|
||||
const command = new commandSystem.EraseCommand(markingData.value, foundId, null)
|
||||
commandSystem.executeCommand(command)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找指定位置的元素
|
||||
*/
|
||||
const findElementAtPosition = (pos: { x: number, y: number }): string | null => {
|
||||
// 检查特殊标记(优先级最高,因为它们通常最小)
|
||||
for (const mark of markingData.value.specialMarks) {
|
||||
if (isPointInSpecialMark(pos, mark)) {
|
||||
return mark.id
|
||||
}
|
||||
}
|
||||
|
||||
// 检查文本注释
|
||||
for (const annotation of markingData.value.annotations) {
|
||||
if (isPointInText(pos, annotation)) {
|
||||
return annotation.id
|
||||
}
|
||||
}
|
||||
|
||||
// 检查形状
|
||||
for (const shape of markingData.value.shapes) {
|
||||
if (isPointInShape(pos, shape)) {
|
||||
return shape.id
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断点是否在特殊标记内
|
||||
*/
|
||||
const isPointInSpecialMark = (pos: { x: number, y: number }, mark: SpecialMark): boolean => {
|
||||
const size = 20 // 特殊标记的大小
|
||||
return (
|
||||
pos.x >= mark.x - size / 2
|
||||
&& pos.x <= mark.x + size / 2
|
||||
&& pos.y >= mark.y - size / 2
|
||||
&& pos.y <= mark.y + size / 2
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断点是否在文本内
|
||||
*/
|
||||
const isPointInText = (pos: { x: number, y: number }, text: TextAnnotation): boolean => {
|
||||
// 简单估算文本宽度和高度
|
||||
const estimatedWidth = text.text.length * text.fontSize * 0.6
|
||||
const estimatedHeight = text.fontSize * 1.2
|
||||
|
||||
return (
|
||||
pos.x >= text.x
|
||||
&& pos.x <= text.x + estimatedWidth
|
||||
&& pos.y >= text.y
|
||||
&& pos.y <= text.y + estimatedHeight
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断点是否在形状内
|
||||
*/
|
||||
const isPointInShape = (pos: { x: number, y: number }, shape: DomShape): boolean => {
|
||||
if (shape.type === 'rect') {
|
||||
return (
|
||||
pos.x >= shape.x
|
||||
&& pos.x <= shape.x + (shape.width || 0)
|
||||
&& pos.y >= shape.y
|
||||
&& pos.y <= shape.y + (shape.height || 0)
|
||||
)
|
||||
}
|
||||
else if (shape.type === 'line' && shape.points) {
|
||||
// 检查点是否靠近线条
|
||||
const threshold = (shape.strokeWidth || 2) + 5
|
||||
for (let i = 0; i < shape.points.length - 2; i += 2) {
|
||||
const x1 = shape.points[i]
|
||||
const y1 = shape.points[i + 1]
|
||||
const x2 = shape.points[i + 2]
|
||||
const y2 = shape.points[i + 3]
|
||||
|
||||
const distance = pointToLineDistance(pos, { x: x1, y: y1 }, { x: x2, y: y2 })
|
||||
if (distance < threshold) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算点到线段的距离
|
||||
*/
|
||||
const pointToLineDistance = (
|
||||
point: { x: number, y: number },
|
||||
lineStart: { x: number, y: number },
|
||||
lineEnd: { x: number, y: number },
|
||||
): number => {
|
||||
const A = point.x - lineStart.x
|
||||
const B = point.y - lineStart.y
|
||||
const C = lineEnd.x - lineStart.x
|
||||
const D = lineEnd.y - lineStart.y
|
||||
|
||||
const dot = A * C + B * D
|
||||
const lenSq = C * C + D * D
|
||||
let param = -1
|
||||
|
||||
if (lenSq !== 0) {
|
||||
param = dot / lenSq
|
||||
}
|
||||
|
||||
let xx: number
|
||||
let yy: number
|
||||
|
||||
if (param < 0) {
|
||||
xx = lineStart.x
|
||||
yy = lineStart.y
|
||||
}
|
||||
else if (param > 1) {
|
||||
xx = lineEnd.x
|
||||
yy = lineEnd.y
|
||||
}
|
||||
else {
|
||||
xx = lineStart.x + param * C
|
||||
yy = lineStart.y + param * D
|
||||
}
|
||||
|
||||
const dx = point.x - xx
|
||||
const dy = point.y - yy
|
||||
|
||||
return Math.sqrt(dx * dx + dy * dy)
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理快捷打分点击
|
||||
*/
|
||||
const handleQuickScoreClick = (pos: { x: number, y: number }) => {
|
||||
const value = markingSettings.value.quickScoreClickValue
|
||||
const scoreMode = markingSettings.value.scoreMode
|
||||
const finalValue = scoreMode === 'add' ? value : -value
|
||||
|
||||
// 创建文字标记
|
||||
const text = scoreMode === 'add' ? `+${value}` : `-${value}`
|
||||
const annotation: TextAnnotation = {
|
||||
id: generateId(),
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
text,
|
||||
fontSize: 48,
|
||||
color: scoreMode === 'add' ? '#ff4d4f' : '#1890ff',
|
||||
}
|
||||
|
||||
console.log('[SimpleDomLayer] handleQuickScoreClick - creating text annotation:', annotation)
|
||||
const command = new commandSystem.AddTextCommand(markingData.value, annotation, null)
|
||||
commandSystem.executeCommand(command)
|
||||
|
||||
// 回调通知外部组件更新分数
|
||||
if (onQuickScore) {
|
||||
onQuickScore(finalValue)
|
||||
}
|
||||
|
||||
isDrawing.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有标记
|
||||
*/
|
||||
const clearAllMarks = () => {
|
||||
console.log('[SimpleDomLayer] clearAllMarks - creating ClearAllCommand')
|
||||
const command = new commandSystem.ClearAllCommand(markingData.value, null)
|
||||
commandSystem.executeCommand(command)
|
||||
}
|
||||
|
||||
return {
|
||||
// 数据状态
|
||||
markingData,
|
||||
hasMarks,
|
||||
currentDrawingShape,
|
||||
isDrawing,
|
||||
|
||||
// 历史记录状态
|
||||
canUndo: commandSystem.canUndo,
|
||||
canRedo: commandSystem.canRedo,
|
||||
|
||||
// 事件处理
|
||||
handleMouseDown,
|
||||
handleMouseMove,
|
||||
handleMouseUp,
|
||||
|
||||
// 操作方法
|
||||
clearAllMarks,
|
||||
undo: () => {
|
||||
console.log('[SimpleDomLayer] undo triggered')
|
||||
return commandSystem.undo()
|
||||
},
|
||||
redo: () => {
|
||||
console.log('[SimpleDomLayer] redo triggered')
|
||||
return commandSystem.redo()
|
||||
},
|
||||
|
||||
// 数据导入导出
|
||||
exportData: () => JSON.stringify(markingData.value),
|
||||
importData: (data: string) => {
|
||||
try {
|
||||
markingData.value = JSON.parse(data)
|
||||
return true
|
||||
}
|
||||
catch {
|
||||
return false
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,9 @@
|
|||
import type { KonvaShape, TextAnnotation } from './useMarkingKonva'
|
||||
/**
|
||||
* 标记工具函数(已废弃,保留用于Konva兼容)
|
||||
* 新的DOM渲染不再需要这些工具函数
|
||||
* @deprecated 使用 useMarkingDom 替代
|
||||
*/
|
||||
import type { BaseShape, TextAnnotation } from './useMarkingCommand'
|
||||
import Konva from 'konva'
|
||||
|
||||
export function useMarkingTools() {
|
||||
|
|
@ -12,7 +17,7 @@ export function useMarkingTools() {
|
|||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
options?: Partial<KonvaShape>,
|
||||
options?: Partial<BaseShape>,
|
||||
): Konva.Rect => {
|
||||
return new Konva.Rect({
|
||||
id: generateId(),
|
||||
|
|
@ -48,7 +53,7 @@ export function useMarkingTools() {
|
|||
}
|
||||
|
||||
// 创建线条
|
||||
const createLine = (points: number[], options?: Partial<KonvaShape>): Konva.Line => {
|
||||
const createLine = (points: number[], options?: Partial<BaseShape>): Konva.Line => {
|
||||
return new Konva.Line({
|
||||
id: generateId(),
|
||||
points,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import KonvaImageRenderer from '../components/renderer/KonvaImageRenderer.vue'
|
||||
import { MarkingTool } from '../composables/renderer/useMarkingKonva'
|
||||
import DomImageRenderer from '../components/renderer/DomImageRenderer.vue'
|
||||
import { MarkingTool } from '../composables/renderer/useMarkingDom'
|
||||
|
||||
interface Props {
|
||||
imageUrls: string[]
|
||||
|
|
@ -72,7 +72,7 @@ function handleLayerReady() {
|
|||
>
|
||||
<!-- 图片渲染器 -->
|
||||
<view class="image-wrapper relative overflow-hidden rounded-2 shadow-sm">
|
||||
<KonvaImageRenderer
|
||||
<DomImageRenderer
|
||||
:image-url="imageUrl"
|
||||
:scale="scale"
|
||||
:marking-data="processedMarkingData[index]"
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 3.2 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 4.4 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 574 B |
|
After Width: | Height: | Size: 780 B |
|
After Width: | Height: | Size: 985 B |
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
|
@ -179,9 +179,6 @@ export default ({ command, mode }) => {
|
|||
}
|
||||
: undefined,
|
||||
},
|
||||
esbuild: {
|
||||
drop: VITE_DELETE_CONSOLE === 'true' ? ['console', 'debugger'] : ['debugger'],
|
||||
},
|
||||
build: {
|
||||
sourcemap: false,
|
||||
// 方便非h5端调试
|
||||
|
|
|
|||