提交 0e354bfb authored 作者: Gorvey's avatar Gorvey

feat: update 更新demo

上级 668ca2d6
# 图片尺寸“是怎么转换”的
1. 原图尺寸
-
\ No newline at end of file
帮助我设计一个基于leafer-ui的画布编辑器
这是一个需要渲染一个底图,然后可以使用框选工具,在页面上框选一个区域,然后对这个区域进行一些标识,让用户使用时根据区域和信息进行业务互动
1. 请看src/api目录,有一个pageinfo.json,里面有底图,marklist.json是框选的数据
top,bottom是定位信息,里面的值是
\ No newline at end of file
......@@ -10,6 +10,8 @@
"dependencies": {
"@leafer-in/editor": "^1.9.2",
"@leafer-in/export": "^1.9.2",
"@leafer-in/find": "^1.9.4",
"@leafer-in/flow": "^1.9.4",
"@leafer-in/resize": "^1.9.2",
"@leafer-in/state": "^1.9.2",
"@leafer-in/view": "^1.9.2",
......@@ -19,6 +21,9 @@
"element-ui": "^2.15.14",
"konva": "^9.3.22",
"leafer-ui": "^1.9.2",
"leafer-x-easy-snap": "^1.10.0",
"leafer-x-ruler": "^2.0.0",
"leafer-x-tooltip-canvas": "^1.0.1",
"lodash": "^4.17.21",
"vue": "^2.6.14",
"vue-konva": "^3.2.2"
......
......@@ -14,6 +14,12 @@ importers:
'@leafer-in/export':
specifier: ^1.9.2
version: 1.9.2(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2)
'@leafer-in/find':
specifier: ^1.9.4
version: 1.9.4(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2)
'@leafer-in/flow':
specifier: ^1.9.4
version: 1.9.4(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-in/resize@1.9.2(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2)
'@leafer-in/resize':
specifier: ^1.9.2
version: 1.9.2(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2)
......@@ -41,6 +47,15 @@ importers:
leafer-ui:
specifier: ^1.9.2
version: 1.9.2
leafer-x-easy-snap:
specifier: ^1.10.0
version: 1.10.0
leafer-x-ruler:
specifier: ^2.0.0
version: 2.0.0(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-in/resize@1.9.2(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2))(@leafer-ui/draw@1.9.2)
leafer-x-tooltip-canvas:
specifier: ^1.0.1
version: 1.0.1(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-in/resize@1.9.2(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2)
lodash:
specifier: ^4.17.21
version: 4.17.21
......@@ -686,6 +701,21 @@ packages:
'@leafer-ui/draw': ^1.9.2
'@leafer-ui/interface': ^1.9.2
'@leafer-in/find@1.9.4':
resolution: {integrity: sha512-qmGCYPrQ/s3No08/WFWq9C9WI2FcyBJGuM08a1Drr5VAnTHZ5L97FLs1yfZbFou3ehni137Fwx9b7PyqzkkLJg==}
peerDependencies:
'@leafer-in/interface': ^1.9.4
'@leafer-ui/draw': ^1.9.4
'@leafer-ui/interface': ^1.9.4
'@leafer-in/flow@1.9.4':
resolution: {integrity: sha512-0gTkBHqt2IZ6QcvgLSIx8x7LBJ3mQ12doyBpU8Ase54E3q5nqt+ErlM4SBQNuOSTEUzvplYAXVXXv4AHEeh/jA==}
peerDependencies:
'@leafer-in/interface': ^1.9.4
'@leafer-in/resize': ^1.9.4
'@leafer-ui/draw': ^1.9.4
'@leafer-ui/interface': ^1.9.4
'@leafer-in/interface@1.9.2':
resolution: {integrity: sha512-XjumeGJpRpPyZR+20vYKkcY7lGXiOkZk5kWHkaqG5Sq/gVj5nwXcZFn6ruEPXTOx7vwmt7H/hLOWoFH4mSCfhg==}
peerDependencies:
......@@ -2639,6 +2669,15 @@ packages:
leafer-ui@1.9.2:
resolution: {integrity: sha512-VlPGCK6eJp0OCLEravau8soK17p2QLy50JdxvqEw6mCSoeE0Jc09plEHeFmMdbD4QkLwUhWO6xVuCrPpAc1xIA==}
leafer-x-easy-snap@1.10.0:
resolution: {integrity: sha512-t2kCKPH2vvkxPxIxngrrEaNbvoTrtON+KxatcGnkEnur/qfAprLfH0EbAt5UUG+Vn0ZRmCj8xuteLT1MnXTL2g==}
leafer-x-ruler@2.0.0:
resolution: {integrity: sha512-4uEtGYS/tOqXc74TzF83tM7zKK7rmPmzEFgmM6halx+tnsaBhsjT4kD/yeRbsPq4eQc85L9ViiDPzymRuxInSw==}
leafer-x-tooltip-canvas@1.0.1:
resolution: {integrity: sha512-brweqF0xZLOYhFGHDX25cW17g0mZz+dtcFF+WsB27PPgjQ0epjNd++2NZLmdFUzl+laYPBBphnxHHR2gowVy+w==}
levn@0.4.1:
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
engines: {node: '>= 0.8.0'}
......@@ -4812,6 +4851,19 @@ snapshots:
'@leafer-ui/draw': 1.9.2
'@leafer-ui/interface': 1.9.2
'@leafer-in/find@1.9.4(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2)':
dependencies:
'@leafer-in/interface': 1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2)
'@leafer-ui/draw': 1.9.2
'@leafer-ui/interface': 1.9.2
'@leafer-in/flow@1.9.4(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-in/resize@1.9.2(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2)':
dependencies:
'@leafer-in/interface': 1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2)
'@leafer-in/resize': 1.9.2(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2)
'@leafer-ui/draw': 1.9.2
'@leafer-ui/interface': 1.9.2
'@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2)':
dependencies:
'@leafer-ui/interface': 1.9.2
......@@ -7135,6 +7187,29 @@ snapshots:
'@leafer/interface': 1.9.2
'@leafer/partner': 1.9.2
leafer-x-easy-snap@1.10.0: {}
leafer-x-ruler@2.0.0(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-in/resize@1.9.2(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2))(@leafer-ui/draw@1.9.2):
dependencies:
'@leafer-in/editor': 1.9.2(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-in/resize@1.9.2(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2))(@leafer-ui/core@1.9.2)(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2)
'@leafer-ui/core': 1.9.2
'@leafer-ui/interface': 1.9.2
transitivePeerDependencies:
- '@leafer-in/interface'
- '@leafer-in/resize'
- '@leafer-ui/draw'
leafer-x-tooltip-canvas@1.0.1(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-in/resize@1.9.2(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2):
dependencies:
'@leafer-in/flow': 1.9.4(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-in/resize@1.9.2(@leafer-in/interface@1.9.2(@leafer-ui/interface@1.9.2)(@leafer/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2))(@leafer-ui/draw@1.9.2)(@leafer-ui/interface@1.9.2)
'@leafer-ui/core': 1.9.2
leafer-ui: 1.9.2
transitivePeerDependencies:
- '@leafer-in/interface'
- '@leafer-in/resize'
- '@leafer-ui/draw'
- '@leafer-ui/interface'
levn@0.4.1:
dependencies:
prelude-ls: 1.2.1
......
[
{
"pageId": 82412,
"strokes": "49.007,14.865,1,true,false,1758447403.574;48.901,15.563,1,false,false,1758447403.574;48.774,16.368,0.991,false,false,1758447403.574;48.584,17.257,0.991,false,false,1758447403.574;48.288,18.315,1,false,false,1758447403.72;48.055,19.225,1,false,false,1758447403.72;47.843,20.051,1,false,false,1758447403.72;48.182,17.892,1,false,false,1758447403.72;48.584,16.41,1,false,false,1758447404.354;49.007,15.204,1,false,false,1758447404.354;50.616,19.12,1,false,false,1758447404.354;50.785,20.241,1,false,false,1758447404.354;50.701,20.114,1,false,false,1758447404.354;50.637,19.839,1,false,false,1758447404.354;50.468,19.585,1,false,false,1758447404.354;50.468,19.585,1,false,false,1758447404.354;-3,-3,0,false,true,0;",
"isSaved": false,
"isDraw": false
},
{
"pageId": 82412,
"strokes": "53.029,15.246,0.906,true,false,1758447405.719;53.029,15.331,0.929,false,false,1758447405.719;53.093,16.939,0.951,false,false,1758447405.719;53.05,18.103,0.964,false,false,1758447405.719;52.923,19.247,0.964,false,false,1758447405.719;52.902,18.4,0.996,false,false,1758447405.719;53.347,15.775,0.991,false,false,1758447405.719;53.579,15.098,0.987,false,false,1758447405.719;53.77,14.907,0.987,false,false,1758447405.719;53.96,14.865,0.987,false,false,1758447405.915;54.553,14.738,0.991,false,false,1758447405.915;54.807,14.738,0.991,false,false,1758447405.915;55.103,14.823,1,false,false,1758447405.915;55.273,14.886,0.991,false,false,1758447405.915;55.315,14.928,1,false,false,1758447405.915;55.315,14.95,0.991,false,false,1758447405.915;55.315,15.034,0.987,false,false,1758447405.915;55.315,15.182,0.991,false,false,1758447405.915;55.167,15.563,0.991,false,false,1758447406.401;53.812,16.855,1,false,false,1758447406.401;53.601,17.003,1,false,false,1758447406.401;54.066,19.966,1,false,false,1758447406.402;53.41,20.072,1,false,false,1758447406.402;53.262,20.051,1,false,false,1758447406.402;53.093,20.009,1,false,false,1758447406.402;52.881,19.733,0.147,false,false,1758447406.402;52.881,19.733,0.147,false,false,1758447406.402;-3,-3,0,false,true,0;",
"isSaved": false,
"isDraw": false
},
{
"pageId": 82412,
"strokes": "59.421,15.5,0.96,true,false,1758447407.523;59.421,15.415,1,false,false,1758447407.523;59.358,15.246,1,false,false,1758447407.523;59.337,15.204,1,false,false,1758447407.523;59.294,15.204,1,false,false,1758447407.523;59.019,15.267,1,false,false,1758447407.523;58.3,15.521,1,false,false,1758447407.523;58.003,15.69,1,false,false,1758447407.523;57.22,16.579,1,false,false,1758447407.523;56.945,16.939,1,false,false,1758447407.912;58.638,20.284,1,false,false,1758447407.912;59.04,20.157,1,false,false,1758447407.912;59.57,19.776,1,false,false,1758447407.912;59.845,19.31,1,false,false,1758447407.912;60.056,18.908,1,false,false,1758447407.912;60.056,18.908,1,false,false,1758447407.912;-3,-3,0,false,true,0;",
"isSaved": false,
"isDraw": false
},
{
"pageId": 82412,
"strokes": "62.152,17.574,0.969,true,false,1758447409.277;62.046,19.649,0.973,false,false,1758447409.277;61.983,20.834,0.987,false,false,1758447409.277;61.962,20.749,0.987,false,false,1758447409.277;61.856,20.136,0.121,false,false,1758447409.277;61.856,20.136,0.121,false,false,1758447409.277;-3,-3,0,false,true,0;",
"isSaved": false,
"isDraw": false
},
{
"pageId": 82412,
"strokes": "64.099,16.008,0.982,true,false,1758447409.96;64.184,16.156,0.991,false,false,1758447409.96;63.972,19.352,1,false,false,1758447409.96;63.761,19.691,1,false,false,1758447409.96;63.443,20.072,1,false,false,1758447409.96;63.189,20.326,1,false,false,1758447409.96;62.914,20.39,1,false,false,1758447409.96;61.771,20.199,1,false,false,1758447409.96;61.432,20.136,1,false,false,1758447409.96;61.39,20.136,0.138,false,false,1758447409.971;61.39,20.136,0.138,false,false,1758447409.971;-3,-3,0,false,true,0;",
"isSaved": false,
"isDraw": false
},
{
"pageId": 82412,
"strokes": "67.804,15.944,1,true,false,1758447411.52;68.693,16.029,1,false,false,1758447411.52;68.989,15.987,1,false,false,1758447411.52;69.391,15.712,0.946,false,false,1758447411.52;69.243,15.648,0.826,false,false,1758447411.52;68.925,15.733,0.205,false,false,1758447411.52;68.925,15.733,0.205,false,false,1758447411.52;-3,-3,0,false,true,0;",
"isSaved": false,
"isDraw": false
},
{
"pageId": 82412,
"strokes": "67.147,17.49,0.982,true,false,1758447411.813;66.766,22.083,0.996,false,false,1758447411.813;66.788,22.189,1,false,false,1758447411.813;66.809,22.168,1,false,false,1758447411.813;66.83,22.146,1,false,false,1758447411.813;66.872,22.125,1,false,false,1758447411.813;66.999,22.104,1,false,false,1758447411.813;67.444,22.041,1,false,false,1758447411.813;67.952,21.935,1,false,false,1758447411.813;68.523,21.808,1,false,false,1758447411.958;69.243,21.511,1,false,false,1758447411.958;69.476,21.384,1,false,false,1758447411.958;69.582,21.109,0.987,false,false,1758447411.958;69.582,21.109,0.987,false,false,1758447411.958;-3,-3,0,false,true,0;",
"isSaved": false,
"isDraw": false
},
{
"pageId": 82412,
"strokes": "67.02,19.331,0.978,true,false,1758447412.397;68.269,19.268,1,false,false,1758447412.397;69.306,18.971,0.875,false,false,1758447412.397;69.37,18.971,0.129,false,false,1758447412.397;69.37,18.971,0.129,false,false,1758447412.397;-3,-3,0,false,true,0;",
"isSaved": false,
"isDraw": false
},
{
"pageId": 82412,
"strokes": "73.392,15.987,1,true,false,1758447413.761;74.492,16.325,1,false,false,1758447413.761;74.789,16.156,1,false,false,1758447413.761;76.313,15.69,1,false,false,1758447413.761;76.292,15.542,1,false,false,1758447413.761;76.08,15.479,0.978,false,false,1758447413.761;76.08,15.479,0.978,false,false,1758447413.761;-3,-3,0,false,true,0;",
"isSaved": false,
"isDraw": false
},
{
"pageId": 82412,
"strokes": "73.413,16.558,0.973,true,false,1758447414.104;73.392,16.643,0.982,false,false,1758447414.104;73.328,18.527,0.991,false,false,1758447414.104;73.307,21.215,1,false,false,1758447414.104;73.307,21.956,1,false,false,1758447414.104;73.286,22.231,1,false,false,1758447414.104;73.265,22.273,1,false,false,1758447414.105;73.222,21.914,1,false,false,1758447414.105;73.159,19.31,1,false,false,1758447414.105;73.222,18.866,1,false,false,1758447414.494;73.307,18.76,1,false,false,1758447414.494;73.688,18.908,1,false,false,1758447414.494;74.344,18.823,1,false,false,1758447414.494;75.276,18.717,1,false,false,1758447414.494;75.72,18.59,1,false,false,1758447414.494;75.678,18.442,1,false,false,1758447414.494;75.614,18.336,1,false,false,1758447414.494;75.614,18.336,1,false,false,1758447414.494;-3,-3,0,false,true,0;",
"isSaved": false,
"isDraw": false
},
{
"pageId": 82412,
"strokes": "80.123,15.373,1,true,false,1758447416.2;79.191,15.923,1,false,false,1758447416.2;79.001,21.765,1,false,false,1758447416.2;79.086,21.638,1,false,false,1758447416.2;79.953,20.538,1,false,false,1758447416.2;80.292,19.924,1,false,false,1758447416.2;81.308,17.511,1,false,false,1758447416.2;81.266,17.595,1,false,false,1758447416.2;81.223,17.913,1,false,false,1758447416.2;80.991,19.903,1,false,false,1758447416.541;80.885,20.919,1,false,false,1758447416.541;80.991,21.681,1,false,false,1758447416.541;80.969,21.617,1,false,false,1758447416.541;80.779,21.448,0.862,false,false,1758447416.541;80.779,21.448,0.862,false,false,1758447416.541;-3,-3,0,false,true,0;",
"isSaved": false,
"isDraw": false
}
]
This source diff could not be displayed because it is too large. You can view the blob instead.
<template>
<div class="annotate-list">
<detail-header class="p-x-16 m-b-24">我的批注</detail-header>
<el-scrollbar>
<div class="noData" v-if="!store.length">
<p class="nodata-txt">暂无批注</p>
</div>
<div class="annotate-item" v-for="v in store" :key="v.id">
<div class="flex items-center">
<div class="image-wrap">
<img :src="require(`../tools-image/${v.icon}.png`)" />
</div>
<div class="m-x-8">{{ v.label }}[{{ v.time }}]</div>
</div>
<div class="cursor-pointer" @click="delAnnotate(v)">
<i class="el-icon-delete text-14 gray-500"></i>
</div>
</div>
</el-scrollbar>
</div>
</template>
<script>
import { get } from 'lodash'
export default {
props: {
app: {
type: Object,
default: () => {}
}
},
data() {
return {}
},
computed: {
store() {
return get(this.app, 'store', [])
}
},
watch: {},
created() {},
mounted() {},
methods: {
delAnnotate(v) {
// eslint-disable-next-line vue/no-mutating-props
this.app.store = this.app.store.filter(item => item.id !== v.id)
this.app.saveStoreState()
}
}
}
</script>
<style lang="css" scoped>
.annotate-item {
display: flex;
align-items: center;
width: 100%;
justify-content: space-between;
}
.annotate-item + .annotate-item {
margin: 12px 0 0;
}
.annotate-list {
width: 320px;
height: calc(100vh - 50px - 40px);
background-color: #fff;
padding: 24px 0;
box-sizing: border-box;
}
.annotate-list .el-scrollbar {
height: calc(100vh - 50px - 40px - 60px - 20px);
}
.annotate-list :deep(.el-scrollbar .el-scrollbar__wrap) {
padding: 0 16px;
overflow-x: hidden;
}
.image-wrap {
width: 16px;
height: 16px;
}
.image-wrap img {
width: 100%;
height: 100%;
}
.noData {
text-align: center;
margin-top: 24px;
}
.noData .nodata-img {
width: 144px;
height: 100px;
}
.noData .nodata-txt {
color: #939699;
font-size: 14px;
}
</style>
<!--
* @Author: zengzhe
* @Date: 2024-08-19 09:29:54
* @LastEditors: zengzhe
* @LastEditTime: 2024-08-26 15:01:51
* @Description:
-->
<template>
<div v-show="configPanelVisible" class="config-panel">
<div
:class="{
active: isStrokeActive(v)
}"
@click="handleClickStroke(v)"
class="stroke-item"
v-for="v in strokeWidthList"
:key="v"
>
<div
:style="{
width: v + 'px',
height: v + 'px'
}"
class="stroke-item-inner"
></div>
</div>
<div
:class="{
active: isColorActive(v)
}"
@click="handleClickColor(v)"
class="color-item"
v-for="v in colorList"
:key="v"
>
<div
:style="{
background: v
}"
class="color-item-inner"
></div>
</div>
</div>
</template>
<script>
import { get } from 'lodash'
export default {
props: {
app: {
type: Object,
default: () => {}
}
},
data() {
return {
strokeWidthList: [1, 2, 4, 6, 8],
colorList: [
'#FA5151',
'#FFC300',
'#02C160',
'#0DAEFF',
'#6367F0',
'#000000',
'#FFFFFF'
]
}
},
computed: {
configPanelVisible() {
return (
get(this.app, 'activeTool', '') &&
get(this.app, 'activeTool.configPanelVisible', true)
)
}
},
watch: {},
created() {},
mounted() {},
methods: {
isStrokeActive(v) {
return get(this, 'app.panelConfig.strokeWidth') == v
},
isColorActive(v) {
return get(this, 'app.panelConfig.stroke') == v
},
handleClickStroke(v) {
// eslint-disable-next-line vue/no-mutating-props
this.app.panelConfig.strokeWidth = v
},
handleClickColor(v) {
// eslint-disable-next-line vue/no-mutating-props
this.app.panelConfig.stroke = v
}
}
}
</script>
<style lang="css" scoped>
.config-item {
width: 20px;
height: 20px;
}
.color-item-inner {
width: 20px;
height: 20px;
border-radius: 4px;
margin: 0 6px;
cursor: pointer;
}
.color-item.active .color-item-inner {
border: 1px solid #fff;
}
.stroke-item {
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.stroke-item-inner {
background-color: #fff;
border-radius: 50%;
}
.stroke-item.active .stroke-item-inner {
border: 1px solid #fff;
background: var(--primary);
}
.config-panel {
user-select: none;
display: flex;
align-items: center;
height: 40px;
border-radius: 8px;
background: #303132;
position: fixed;
bottom: 64px;
left: 50%;
transform: translateX(-50%);
}
</style>
<!-- eslint-disable vue/multi-word-component-names -->
<template>
<div class="tools">
<div @click="undo" class="tool-item">
<el-tooltip effect="dark" content="回退" placement="top">
<img :src="require('../tools-image/tool-icon-1.png')" />
</el-tooltip>
</div>
<!-- <div @click="rotate('left')" class="tool-item">
<el-tooltip effect="dark" content="左旋" placement="top">
<img :src="require('../tools-image/tool-icon-2.png')" />
</el-tooltip>
</div>
<div @click="rotate('right')" class="tool-item">
<el-tooltip effect="dark" content="右旋" placement="top">
<img :src="require('../tools-image/tool-icon-3.png')" />
</el-tooltip>
</div> -->
<div class="divider"></div>
<div
:class="{ active: isActiveTool(v) }"
@click="onActive(v)"
class="tool-item"
v-for="v in toolGroups1"
:key="v.label"
>
<el-tooltip effect="dark" :content="v.label" placement="top">
<img :src="require(`../tools-image/${v.icon}.png`)" />
</el-tooltip>
</div>
<div class="divider"></div>
<div
:class="{ active: isActiveTool(v) }"
@click="onActive(v)"
class="tool-item"
v-for="v in toolGroups2"
:key="v.label"
>
<el-tooltip effect="dark" :content="v.label" placement="top">
<img :src="require(`../tools-image/${v.icon}.png`)" />
</el-tooltip>
</div>
<div class="divider"></div>
<div
:class="{ active: isActiveTool(v) }"
@click="onActive(v)"
class="tool-item"
v-for="v in toolGroups3"
:key="v.label"
>
<el-tooltip effect="dark" :content="v.label" placement="top">
<img :src="require(`../tools-image/${v.icon}.png`)" />
</el-tooltip>
</div>
</div>
</template>
<script>
import { toolGroups1, toolGroups2, toolGroups3 } from '../tools'
import { get } from 'lodash'
export default {
props: {
app: {
type: Object,
default: () => {}
}
},
data() {
return {
toolGroups1,
toolGroups2,
toolGroups3
}
},
computed: {},
watch: {},
created() {},
mounted() {},
methods: {
rotate(val) {
let rotation = this.app.bgLayerConfig.rotation
if (val == 'right') {
//向右旋转
rotation += 90
} else {
// 向左旋转
if (rotation == 0 || rotation == 360) {
// 为了限制 rotation范围为0-360之间
// 不然一直向左 rotation会变为负数
rotation = 270
} else {
rotation += -90
}
}
this.app.setBgLayerRotate(rotation)
},
undo() {
this.app.undo()
},
isActiveTool(v) {
return get(this, 'app.activeTool.label') === v.label
},
onActive(v) {
// 再次选择,取消已选工具
if (this.app.activeTool == v) {
this.app.setActiveTool()
return
}
this.app.setActiveTool(v)
}
}
}
</script>
<style lang="css" scoped>
.tools {
display: flex;
align-items: center;
justify-content: center;
}
.tool-item {
width: 24px;
height: 24px;
transition: all 0.3s;
padding: 0 10px;
cursor: pointer;
}
.tool-item img {
width: 100%;
height: 100%;
object-fit: cover;
}
.tool-item.active {
filter: invert(35%) sepia(93%) saturate(1246%) hue-rotate(188deg) brightness(97%) contrast(117%);
}
.divider {
width: 1px;
height: 16px;
background-color: #e1e3e5;
}
</style>
<!-- eslint-disable vue/multi-word-component-names -->
<!--
* @Author: zengzhe
* @Date: 2024-08-14 14:16:15
* @LastEditors: zengzhe
* @LastEditTime: 2024-08-28 21:38:15
* @Description:
-->
<template>
<div class="canvas-container">
<div class="top p-x-14">
<div class="flex items-center">
<el-button @click="closePage" round size="mini" class="m-r-12">
退出
</el-button>
<div class="font-bold text-16 gray-800">图片批注</div>
</div>
<div class="btns">
<el-button size="small" @click="toggleAnnotateVisible">
{{ showAnnotateList ? '收起批注' : '查看批注' }}
</el-button>
<el-button
:loading="saveLoading"
size="small"
type="primary"
@click="handleSave"
>
完成批注
</el-button>
</div>
</div>
<div class="stage" v-show="isReady">
<v-stage
@mousedown="handleStageMouseDown"
@touchstart="handleStageMouseDown"
ref="stage"
class="w-full h-full"
:style="{
cursor: app ? app.cursor || '' : ''
}"
:config="app.stageConfig"
>
<!-- 要批注的图片 -->
<v-layer id="bg">
<v-image
v-if="app.bgLayerConfig && app.bgLayerConfig.image"
:config="app.bgLayerConfig"
></v-image>
</v-layer>
<!-- 图形 -->
<v-layer>
<component
:is="v.component"
@transformend="handleTransformEnd"
v-for="v in store"
:key="v.id"
:config="v"
></component>
<v-transformer ref="transformer" />
</v-layer>
</v-stage>
</div>
<toolsPage class="tools" :app="app" />
<configPanel class="config-panel" :app="app"></configPanel>
<annotateList
:class="{
visible: showAnnotateList
}"
class="annotate-list"
:app="app"
></annotateList>
</div>
</template>
<script>
import toolsPage from './components/tools.vue'
import configPanel from './components/config-panel.vue'
import annotateList from './components/annotate-list.vue'
import { createApp } from './konva'
// import data from './data.json'
import { get } from 'lodash'
export default {
data() {
return {
activeExerciseData: {},
isReady: false,
exercise: {},
saveLoading: false,
showAnnotateList: false,
singNature: '',
app: {
stageConfig: {}
},
selectedShapeName: ''
}
},
components: {
configPanel,
toolsPage,
annotateList
},
computed: {
store() {
if (!this.app) {
return []
}
return this.app.store
}
},
watch: {},
created() {
// let exercise = {}
// try {
// exercise = JSON.parse(
// sessionStorage.getItem('work-image-annotate-exercise')
// )
// } catch (e) {
// console.error(e)
// exercise = {}
// }
// this.exercise = exercise
},
mounted() {
// this.getDetail()
},
methods: {
closePage() {
this.$emit('close')
},
toggleAnnotateVisible() {
this.showAnnotateList = !this.showAnnotateList
},
handleTransformEnd(e) {
// shape is transformed, let us save new attrs back to the node
// find element in our state
const rect = this.store.find(r => r.name === this.selectedShapeName)
const node = e.target
// update the state
rect.x = e.target.x()
rect.y = e.target.y()
rect.rotation = e.target.rotation()
rect.scaleX = e.target.scaleX()
rect.scaleY = e.target.scaleY()
// Update the state for shapes other than text
// if (node.attrs.component !== 'v-text') {
// rect.x = e.target.x()
// rect.y = e.target.y()
// rect.rotation = e.target.rotation()
// rect.scaleX = e.target.scaleX()
// rect.scaleY = e.target.scaleY()
// } else {
rect.rotation = e.target.rotation()
rect.x = node.x()
rect.y = node.y()
rect.scaleX = e.target.scaleX()
rect.scaleY = e.target.scaleY()
// }
this.app.saveStoreState()
},
handleStageMouseDown(e) {
// clicked on stage - clear selection
if (e.target === e.target.getStage()) {
this.selectedShapeName = ''
this.updateTransformer()
return
}
// clicked on transformer - do nothing
const clickedOnTransformer =
e.target.getParent().className === 'Transformer'
if (clickedOnTransformer) {
return
}
// find clicked rect by its name
const name = e.target.name()
const rect = this.store.find(r => r.name === name)
if (rect) {
this.app.setActiveTool()
this.selectedShapeName = name
} else {
this.selectedShapeName = ''
}
this.updateTransformer()
},
updateTransformer() {
// here we need to manually attach or detach Transformer node
const transformerNode = this.$refs.transformer.getNode()
const stage = transformerNode.getStage()
const { selectedShapeName } = this
const selectedNode = stage.findOne('.' + selectedShapeName)
// do nothing if selected node is already attached
if (selectedNode === transformerNode.node()) {
return
}
if (selectedNode) {
// attach to another node
transformerNode.nodes([selectedNode])
} else {
// remove transformer
transformerNode.nodes([])
}
},
//-----------------------------------//
getSignature() {
let data = {
type: 'documentAttach',
folder: 'ai-pen-image-annotate',
filename: 'eventsList',
isCallBack: '1'
}
return this.$axios.request({
axios: {
url: this.$API.OSS.signature,
method: 'get',
params: data
},
success: data => {
this.singNature = data.data
}
})
},
dataURLtoFile(dataurl, filename) {
var arr = dataurl.split(','),
mime = arr[0].match(/:(.*?);/)[1],
bstr = atob(arr[1]),
n = bstr.length,
u8arr = new Uint8Array(n)
while (n--) {
u8arr[n] = bstr.charCodeAt(n)
}
return new File([u8arr], filename, { type: mime })
},
async handleAnnotateImageToUrl() {
},
async handleSave() {
if (!get(this, 'app.store.length', 0)) {
return this.$message.warning('您还没有批注任何内容!')
}
try {
await this.$confirm(
'是否确认完成批注?确认后将以作业点评的方式发送至学员',
'确认完成批注',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
customClass: 'customClass'
}
)
} catch (e) {
return
}
if (this.saveLoading) return
this.saveLoading = true
let annotateImage
try {
annotateImage = await this.handleAnnotateImageToUrl()
} catch (e) {
return
}
try {
const params = {
rotation: 0,
originImage: this.app.config.stageBg,
annotateImage: annotateImage,
store: JSON.stringify(this.store),
targetType: 3,
exerciseID: this.activeExerciseData.exerciseID,
arrBangFileID: this.$route.query.id,
studentID: this.activeExerciseData.studentID,
paperID: this.activeExerciseData.pageID
}
this.$message.success('保存成功!')
this.$emit('saveSuccess', params)
} catch (error) {
console.error(error)
this.$message.warning('保存批注时发生错误,请重试!')
}
this.saveLoading = false
},
loadData(data) {
this.activeExerciseData = data
createApp({
debug: false,
defaultRotation: 0, //背景旋转角度
defaultStore: data.store, //已有的批注数据
stageBg: data.pageUrl, // 背景图片
stagePadding: 300,
getCompThis: () => {
return this
},
points: data.dotMatrixs || [], // 新增,点阵数据
marks: [data.exerciseMark] || [] // 新增,mark数据
}).then(app => {
setTimeout(() => {
this.app = app
}, 20)
setTimeout(() => {
// this.app.setBgLayerRotate(this.app.defaultRotation)
this.isReady = true
}, 200)
})
// })
},
/**
* 聚焦到指定的 mark
* @param {object} markData
*/
focusMark(markData) {
if (this.app && typeof this.app.focusMark === 'function') {
this.app.focusMark(markData)
}
}
}
}
</script>
<style lang="css" scoped>
.canvas-container {
user-select: none;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
background: #f2f5f7;
}
.canvas-container .stage {
grid-area: stage;
padding: 70px 0 60px;
}
.canvas-container .stage .el-scrollbar {
height: calc(100vh - 50px - 40px - 40px - 70px);
}
.canvas-container .stage :deep(.el-scrollbar .el-scrollbar__wrap) {
overflow-x: hidden;
}
.canvas-container .top {
box-sizing: border-box;
z-index: 1001;
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 50px;
background: #fff;
display: flex;
align-items: center;
justify-content: space-between;
box-shadow: 0 0 10px #30313226;
}
.canvas-container .tools {
z-index: 1001;
position: fixed;
bottom: 0;
left: 0;
width: 100%;
height: 40px;
background: #fff;
box-shadow: 0 0 10px #00000026;
}
.annotate-list {
position: fixed;
top: 50px;
right: -400px;
transition: right 0.3s;
}
.annotate-list.visible {
right: 0;
}
.btns .el-button {
border-radius: 16px;
background: #e1e3e5;
}
.btns .el-button--primary {
background: var(--primary);
}
</style>
import { cloneDeep, get } from 'lodash'
import Konva from 'konva'
const toolImage4 = require('./tools-image/tool-icon-4.svg')
const toolImage5 = require('./tools-image/tool-icon-5.svg')
const toolImage6 = require('./tools-image/tool-icon-6.svg')
const toolImage7 = require('./tools-image/tool-icon-7.svg')
let imageList = {
toolImage4,
toolImage5,
toolImage6,
toolImage7
}
export class KonvaCanvas {
/**
* @constructor
* @param {KonvaConfig} config
*/
constructor(config) {
let { stageBg, debug, defaultStore, defaultRotation } = config
this.config = cloneDeep(config)
this.debug = debug // 调试模式
this.cursor = ''
this.defaultRotation = defaultRotation
this.stageBg = stageBg
this.defaultStore = defaultStore
this.points = config.points || []
this.marks = config.marks || []
this.containerWidth = config.containerWidth
this.containerHeight = config.containerHeight
//----------工具栏-------------S-//
this.panelConfig = {
strokeWidth: 2,
stroke: '#FA5151'
}
this.startPointer = { x: 0, y: 0 }
this.activeTool = null
//----------工具栏-------------S-//
//----------历史记录-------------S-//
this.maxHistoryLength = 20
this.history = [[], []]
this.historyIndex = -1
//----------历史记录-------------E-//
}
async init() {
//1.回显状态
this.store = []
if (this.defaultStore && this.defaultStore.length) {
this.store = this.defaultStore
// 异步加载所有图片,全部加载完成后再继续
const loadImagePromises = this.store.map(item => {
if (item.imageUrl) {
return loadImage(imageList[item.imageUrl]).then(({ image }) => {
let index = this.store.findIndex(v => v.id == item.id)
this.config.getCompThis().$set(this.store, index, {
...this.store[index],
image: image
})
})
} else {
return Promise.resolve()
}
})
await Promise.all(loadImagePromises)
this.history = [[], cloneDeep(this.store)]
}
// 设置舞台大小 和 背景图标宽高
const maxEdge = 840
const { stageBg } = this.config
// 加载背景图像
const { image, width, height } = await loadImage(stageBg)
// 计算缩放比例,使背景图最大边为 maxEdge
const scale = Math.min(maxEdge / width, maxEdge / height, 1)
const newWidth = width * scale
const newHeight = height * scale
// 舞台宽高 = 背景图宽高
const stageWidth = newWidth
const stageHeight = newHeight
console.log(maxEdge)
console.log(newWidth, newHeight)
// 背景图层配置,居中
this.bgLayerConfig = {
draggable: false,
id: 'bg',
name: 'bg',
image,
x: 0,
y: 0,
offsetX: 0,
offsetY: 0,
rotation: 0,
width: newWidth,
height: newHeight
}
// 记录图片缩放比率和原始宽高
this.imageScale = scale
this.imageRealWidth = this.getBitmapDPISize(width, height).real_width
this.imageRealHeight = this.getBitmapDPISize(width, height).real_height
this.imageRealScale = this.bgLayerConfig.width / this.imageRealWidth
// 画布配置
this.stageConfig = {
width: stageWidth,
height: stageHeight
}
// --------- 新增initScale计算方式 ---------
// 参考 dot-matrix-canvas.vue 的 initCanvas
let containerWidth = this.containerWidth || stageWidth
let containerHeight = this.containerHeight || stageHeight
let contentWidth = width
let contentHeight = height
// 计算缩放比例,使图片内容正好适配容器
const scaleW = containerWidth / contentWidth
const scaleH = containerHeight / contentHeight
const initScale = Math.min(scaleW, scaleH, 1)
this.initScale = initScale
// --------- END ---------
let stage = this.getStage()
stage.on('mousedown', e => {
if (e.target === e.target.getStage() || e.target.attrs.name == 'bg') {
if (this.debug) {
console.log('-------stage.mousedown-------')
}
if (this.activeTool) {
console.log(`-------触发工具:${this.activeTool.label}-------`)
if (get(this, 'activeTool.mousedown')) {
this.activeTool.mousedown(e, this, this.activeTool)
}
}
}
})
stage.on('mousemove', e => {
if (this.activeTool) {
if (get(this, 'activeTool.mousemove')) {
this.activeTool.mousemove(e, this, this.activeTool)
}
}
})
stage.on('mouseup', e => {
if (this.debug) {
console.log('-------stage.mouseup-------')
}
if (this.activeTool) {
if (get(this, 'activeTool.mouseup')) {
this.activeTool.mouseup(e, this, this.activeTool)
}
}
})
// 拖拽移动后
stage.on('dragend', e => {
if (get(e, 'target.attrs.id')) {
let index = this.store.findIndex(v => v.id == e.target.attrs.id)
this.config.getCompThis().$set(this.store, index, {
...this.store[index],
x: e.target.x(),
y: e.target.y()
})
if (this.debug) {
console.log('-------stage.dragend-------')
}
this.saveStoreState()
}
})
stage.on('dblclick', e => {
if (this.debug) {
console.log('-------stage.dblclick-------')
}
if (this.activeTool) {
if (get(this, 'activeTool.dblclick')) {
this.activeTool.dblclick(e, this, this.activeTool)
}
}
})
// stage.on('dragend', e => {
// console.log(e)
// this.saveStoreState()
// })
// 计算 initScale,参考 dot-matrix-canvas.vue
// initScale = 背景图实际渲染宽度 / 原始宽度
// this.initScale = 20
// if (this.bgLayerConfig && this.bgLayerConfig.image) {
// const img = this.bgLayerConfig.image
// if (img.width) {
// this.initScale = img.width / this.bgLayerConfig.width
// }
// }
// 渲染点阵和 mark
this.renderPointsAndMarks()
}
// 像素坐标转换成物理坐标
getBitmapDPISize(imgWidth, imgHeight) {
const densityDPI = 300 / 25.4 // 300 DPI
const real_width = imgWidth / densityDPI // 像素转换成物理
const real_height = imgHeight / densityDPI
return { real_width, real_height }
}
/**
* 渲染点阵和 mark
*/
renderPointsAndMarks() {
const stage = this.getStage()
// 点阵图层
let pointsLayer = stage.findOne('#pointsLayer')
if (!pointsLayer) {
pointsLayer = new Konva.Layer({ id: 'pointsLayer' })
stage.add(pointsLayer)
} else {
pointsLayer.removeChildren()
}
// mark图层
let marksLayer = stage.findOne('#marksLayer')
if (!marksLayer) {
marksLayer = new Konva.Layer({ id: 'marksLayer' })
stage.add(marksLayer)
} else {
marksLayer.removeChildren()
}
// 渲染点阵
if (this.points && this.points.length) {
const lines = this.processPointsData(this.points)
lines.forEach(lineData => {
const line = new Konva.Line({
points: lineData.points,
stroke: '#000',
strokeWidth: lineData.linewidth,
lineCap: 'round',
lineJoin: 'round',
tension: 0
})
pointsLayer.add(line)
})
pointsLayer.batchDraw()
}
// 渲染 mark
if (this.marks && this.marks.length) {
const rectangles = this.processMarksData(this.marks)
rectangles.forEach(rectData => {
const rect = new Konva.Rect({
x: rectData.x,
y: rectData.y,
width: rectData.width,
height: rectData.height,
stroke: 'transparent',
strokeWidth: 1,
fill: 'transparent',
id: 'mark'
})
console.log(rect)
marksLayer.add(rect)
})
marksLayer.batchDraw()
// 渲染完mark后,自动定位到第一个mark
// if (rectangles.length > 0) {
// this.focusMark(rectangles[0])
// }
}
}
/**
* 点阵数据处理(分轴缩放,保证点阵和图片完全对齐)
*/
processPointsData(points) {
const lines = []
let currentLine = null
for (const point of points) {
const { x, y, linewidth, stroke_start, stroke_end } = point
if (stroke_start || !currentLine) {
currentLine = {
points: [],
linewidth: linewidth || 2
}
lines.push(currentLine)
}
if (stroke_end) {
currentLine = null
continue
}
// 分轴缩放
console.log(this.imageRealScale, this.initScale)
const pointX = x * this.imageRealScale
const pointY = y * this.imageRealScale
if (currentLine) {
currentLine.points.push(pointX, pointY)
}
}
return lines
}
/**
* mark数据处理
*/
processMarksData(marks) {
// 参考 dot-matrix-canvas.vue 的 processMarksData,使用 this.initScale
const rectangles = []
for (const mark of marks) {
const { topx, topy, bottomx, bottomy } = mark
if (
topx === undefined ||
topy === undefined ||
bottomx === undefined ||
bottomy === undefined
)
continue
const x1 = topx * this.initScale
const y1 = topy * this.initScale
const x2 = bottomx * this.initScale
const y2 = bottomy * this.initScale
const width = Math.abs(x2 - x1)
const height = Math.abs(y2 - y1)
const x = Math.min(x1, x2)
const y = Math.min(y1, y2)
rectangles.push({ x, y, width, height })
}
return rectangles
}
/**
* 聚焦到指定的mark
* @param {object} markData { x, y, width, height }
*/
focusMark(markData) {
if (!markData) return
const stageWidth = this.stageConfig.width
const stageHeight = this.stageConfig.height
const padding = 25
const stage = this.getStage()
const bgNode = stage.findOne('#bg')
const pointsLayer = stage.findOne('#pointsLayer')
const marksLayer = stage.findOne('#marksLayer')
// 计算缩放比例
const scaleX = (stageWidth - 2 * padding) / markData.width
const scaleY = (stageHeight - 2 * padding) / markData.height
const newScale = Math.min(scaleX, scaleY)
// 计算居中偏移
const markCenterX = markData.x + markData.width / 2
const markCenterY = markData.y + markData.height / 2
const newGroupX = stageWidth / 2 - markCenterX * newScale
const newGroupY = stageHeight / 2 - markCenterY * newScale
// 应用到所有图层
if (bgNode) {
bgNode.position({ x: newGroupX, y: newGroupY })
bgNode.scale({ x: newScale, y: newScale })
bgNode.getLayer().batchDraw()
}
if (pointsLayer) {
pointsLayer.position({ x: newGroupX, y: newGroupY })
pointsLayer.scale({ x: newScale, y: newScale })
pointsLayer.batchDraw()
}
if (marksLayer) {
marksLayer.position({ x: newGroupX, y: newGroupY })
marksLayer.scale({ x: newScale, y: newScale })
marksLayer.batchDraw()
}
}
get pointer() {
const { x, y } = this.getStage().getPointerPosition()
return { x, y }
}
get stage() {
return this.config.getCompThis().$refs.stage.getStage()
}
getStage() {
return this.config.getCompThis().$refs.stage.getStage()
}
setActiveTool(tool) {
if (tool) {
this.activeTool = tool
if (get(this, 'activeTool.onActive')) {
this.activeTool.onActive(this)
}
} else {
if (get(this, 'activeTool.onInactive')) {
this.activeTool.onInactive(this)
}
this.activeTool = null
}
}
/**
* 设置背景图层旋转角度并更新宽高和偏移
* @param {number} rotate 旋转角度,必须是90的倍数
*/
async setBgLayerRotate(rotate) {
// 验证旋转角度是否为90的倍数
if (rotate % 90 !== 0) {
console.error('旋转角度必须是90的倍数')
return
}
const rotation = Math.abs(rotate % 360)
// 旋转点的函数
const rotatePoint = ({ x, y }, rad) => {
const rcos = Math.cos(rad)
const rsin = Math.sin(rad)
return { x: x * rcos - y * rsin, y: y * rcos + x * rsin }
}
// 在中心点旋转的函数
function rotateAroundCenter(node, rotation) {
// 当前旋转原点(0, 0)相对于所需原点 - 中心(node.width()/2, node.height()/2)
const topLeft = { x: -node.width() / 2, y: -node.height() / 2 }
// 当前旋转角度
const current = rotatePoint(topLeft, Konva.getAngle(node.rotation()))
// 新旋转角度
const rotated = rotatePoint(topLeft, Konva.getAngle(rotation))
// 计算偏移量
const dx = rotated.x - current.x
const dy = rotated.y - current.y
return {
rotation,
dx,
dy
}
}
let { dx, dy } = rotateAroundCenter(this.stage.findOne('#bg'), rotation)
this.bgLayerConfig.rotation = rotation
this.bgLayerConfig.x += dx // 更新 x 坐标
this.bgLayerConfig.y += dy // 更新 y 坐标
if (this.debug) {
console.log(
`背景图层旋转角度设置为: ${rotate}°,新位置: (${this.bgLayerConfig.x}, ${this.bgLayerConfig.y}),新宽高: (${this.bgLayerConfig.width}, ${this.bgLayerConfig.height})`
)
}
}
//----------历史记录-------------S-//
saveStoreState() {
if (this.debug) {
console.log('-----历史记录已保存----')
}
this.history.push(cloneDeep(this.store))
}
// redo() {}
undo() {
if (this.history.length > 2) {
let store = cloneDeep(this.history[this.history.length - 2]) || []
this.store = store.map(v => {
return {
...v,
image: ''
}
})
this.store.forEach(item => {
if (item.imageUrl) {
loadImage(imageList[item.imageUrl]).then(({ image }) => {
let index = this.store.findIndex(v => v.id == item.id)
this.config.getCompThis().$set(this.store, index, {
...this.store[index],
image: image
})
})
}
})
this.history.pop()
} else {
if (this.debug) {
console.log('没有更多的历史记录可以撤销')
}
}
}
//----------历史记录-------------E-//
}
export async function createApp(config) {
const instance = new KonvaCanvas(config)
await instance.init()
return instance
}
export function loadImage(src) {
return new Promise((resolve, reject) => {
const image = new window.Image()
if (src.includes(';base64')) {
image.src = src // 直接使用 base64 字符串
} else {
image.src = src + '?time=' + new Date().valueOf()
image.crossOrigin = 'Anonymous'
}
image.onload = () => {
resolve({
image: image,
width: image.width,
height: image.height
})
}
image.onerror = () => {
console.error('底图加载失败')
reject('file load iamge')
}
})
}
export const createTextarea = (app, textNode, onUpdated) => {
textNode.hide()
const textPosition = textNode.getClientRect()
const areaPosition = {
x: app.stage.container().offsetLeft + textPosition.x,
y: app.stage.container().offsetTop + textPosition.y
}
const textarea = document.createElement('textarea')
app.stage.container().appendChild(textarea)
const transform = `translateY(-${Math.round(textNode.fontSize() / 20)}px)`
Object.assign(textarea.style, {
position: 'absolute',
display: 'inline-block',
minHeight: '1em',
backfaceVisibility: 'hidden',
resize: 'none',
background: 'transparent',
overflow: 'hidden',
overflowWrap: 'break-word',
boxSizing: 'content-box',
top: `${areaPosition.y}px`,
left: `${areaPosition.x}px`,
width: `${
textNode.width() * textNode.scaleX() - textNode.padding() * 2 + 10
}px`,
height: `${
textNode.height() * textNode.scaleX() - textNode.padding() * 2 + 5
}px`,
fontSize: `${textNode.fontSize() * textNode.scaleX()}px`,
border: 0,
padding: 0,
margin: 0,
outline: 0,
lineHeight: textNode.lineHeight().toString(),
fontFamily: textNode.fontFamily(),
textAlign: textNode.align(),
color: textNode.fill(),
caretColor: textNode.fill(),
zIndex: '99999',
transformOrigin: 'left top',
transform: transform
})
textarea.value = textNode.text()
textarea.style.height = `${textarea.scrollHeight + 3}px`
textarea.focus()
function removeTextarea() {
textarea.parentNode?.removeChild(textarea)
window.removeEventListener('click', handleOutsideClick)
textNode.show()
}
textarea.addEventListener('keydown', e => {
e.stopPropagation()
if (e.key === 'Escape') {
removeTextarea()
}
})
textarea.addEventListener('input', () => {
const text = textarea.value.replace(/\n/g, '<br/>')
const tempDiv = document.createElement('div')
tempDiv.innerHTML = text
tempDiv.style.position = 'absolute'
tempDiv.style.visibility = 'hidden'
tempDiv.style.whiteSpace = 'pre-wrap'
document.body.appendChild(tempDiv)
const newWidth = tempDiv.offsetWidth + 10
textarea.style.width = `${newWidth * textNode.scaleX()}px`
document.body.removeChild(tempDiv)
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight)
const rows = textarea.value.split('\n').length
textarea.style.height = 'auto'
const newHeight = lineHeight * rows
textarea.style.height = `${newHeight}px`
textNode.width(newWidth)
textNode.height(newHeight / textNode.scaleY())
})
function handleOutsideClick(e) {
if (e.target !== textarea) {
if (textarea.value.trim().length >= 1) {
textNode.text(textarea.value)
} else {
app.remove(textNode)
}
removeTextarea()
onUpdated()
}
}
setTimeout(() => {
window.addEventListener('click', handleOutsideClick)
})
}
import { cloneDeep, get } from 'lodash'
import Konva from 'konva'
const toolImage4 = require('./tools-image/tool-icon-4.svg')
const toolImage5 = require('./tools-image/tool-icon-5.svg')
const toolImage6 = require('./tools-image/tool-icon-6.svg')
const toolImage7 = require('./tools-image/tool-icon-7.svg')
let imageList = {
toolImage4,
toolImage5,
toolImage6,
toolImage7
}
export class KonvaCanvas {
/**
* @constructor
* @param {KonvaConfig} config
*/
constructor(config) {
let { stageBg, debug, defaultStore, defaultRotation } = config
this.config = cloneDeep(config)
this.debug = debug // 调试模式
this.cursor = ''
this.defaultRotation = defaultRotation
this.stageBg = stageBg
this.defaultStore = defaultStore
this.points = config.points || []
this.marks = config.marks || []
this.containerWidth = config.containerWidth
this.containerHeight = config.containerHeight
//----------工具栏-------------S-//
this.panelConfig = {
strokeWidth: 2,
stroke: '#FA5151'
}
this.startPointer = { x: 0, y: 0 }
this.activeTool = null
//----------工具栏-------------S-//
//----------历史记录-------------S-//
this.maxHistoryLength = 20
this.history = [[], []]
this.historyIndex = -1
//----------历史记录-------------E-//
}
async init() {
//1.回显状态
this.store = []
if (this.defaultStore && this.defaultStore.length) {
this.store = this.defaultStore
// 异步加载所有图片,全部加载完成后再继续
const loadImagePromises = this.store.map(item => {
if (item.imageUrl) {
return loadImage(imageList[item.imageUrl]).then(({ image }) => {
let index = this.store.findIndex(v => v.id == item.id)
this.config.getCompThis().$set(this.store, index, {
...this.store[index],
image: image
})
})
} else {
return Promise.resolve()
}
})
await Promise.all(loadImagePromises)
this.history = [[], cloneDeep(this.store)]
}
// 设置舞台大小 和 背景图标宽高
const maxEdge = 840
const { stageBg } = this.config
// 加载背景图像
const { image, width, height } = await loadImage(stageBg)
// 计算缩放比例,使背景图最大边为 maxEdge
const scale = Math.min(maxEdge / width, maxEdge / height, 1)
const newWidth = width * scale
const newHeight = height * scale
// 舞台宽高 = 背景图宽高
const stageWidth = newWidth
const stageHeight = newHeight
console.log(maxEdge)
console.log(newWidth, newHeight)
// 背景图层配置,居中
this.bgLayerConfig = {
draggable: false,
id: 'bg',
name: 'bg',
image,
x: 0,
y: 0,
offsetX: 0,
offsetY: 0,
rotation: 0,
width: newWidth,
height: newHeight
}
// 记录图片缩放比率和原始宽高
this.imageScale = scale
this.imageRealWidth = this.getBitmapDPISize(width, height).real_width
this.imageRealHeight = this.getBitmapDPISize(width, height).real_height
this.imageRealScale = this.bgLayerConfig.width / this.imageRealWidth
// 画布配置
this.stageConfig = {
width: stageWidth,
height: stageHeight
}
// --------- 新增initScale计算方式 ---------
// 参考 dot-matrix-canvas.vue 的 initCanvas
let containerWidth = this.containerWidth || stageWidth
let containerHeight = this.containerHeight || stageHeight
let contentWidth = width
let contentHeight = height
// 计算缩放比例,使图片内容正好适配容器
const scaleW = containerWidth / contentWidth
const scaleH = containerHeight / contentHeight
const initScale = Math.min(scaleW, scaleH, 1)
this.initScale = initScale
// --------- END ---------
let stage = this.getStage()
stage.on('mousedown', e => {
if (e.target === e.target.getStage() || e.target.attrs.name == 'bg') {
if (this.debug) {
console.log('-------stage.mousedown-------')
}
if (this.activeTool) {
console.log(`-------触发工具:${this.activeTool.label}-------`)
if (get(this, 'activeTool.mousedown')) {
this.activeTool.mousedown(e, this, this.activeTool)
}
}
}
})
stage.on('mousemove', e => {
if (this.activeTool) {
if (get(this, 'activeTool.mousemove')) {
this.activeTool.mousemove(e, this, this.activeTool)
}
}
})
stage.on('mouseup', e => {
if (this.debug) {
console.log('-------stage.mouseup-------')
}
if (this.activeTool) {
if (get(this, 'activeTool.mouseup')) {
this.activeTool.mouseup(e, this, this.activeTool)
}
}
})
// 拖拽移动后
stage.on('dragend', e => {
if (get(e, 'target.attrs.id')) {
let index = this.store.findIndex(v => v.id == e.target.attrs.id)
this.config.getCompThis().$set(this.store, index, {
...this.store[index],
x: e.target.x(),
y: e.target.y()
})
if (this.debug) {
console.log('-------stage.dragend-------')
}
this.saveStoreState()
}
})
stage.on('dblclick', e => {
if (this.debug) {
console.log('-------stage.dblclick-------')
}
if (this.activeTool) {
if (get(this, 'activeTool.dblclick')) {
this.activeTool.dblclick(e, this, this.activeTool)
}
}
})
// stage.on('dragend', e => {
// console.log(e)
// this.saveStoreState()
// })
// 计算 initScale,参考 dot-matrix-canvas.vue
// initScale = 背景图实际渲染宽度 / 原始宽度
// this.initScale = 20
// if (this.bgLayerConfig && this.bgLayerConfig.image) {
// const img = this.bgLayerConfig.image
// if (img.width) {
// this.initScale = img.width / this.bgLayerConfig.width
// }
// }
// 渲染点阵和 mark
this.renderPointsAndMarks()
}
// 像素坐标转换成物理坐标
getBitmapDPISize(imgWidth, imgHeight) {
const densityDPI = 300 / 25.4 // 300 DPI
const real_width = imgWidth / densityDPI // 像素转换成物理
const real_height = imgHeight / densityDPI
return { real_width, real_height }
}
/**
* 渲染点阵和 mark
*/
renderPointsAndMarks() {
const stage = this.getStage()
// 点阵图层
let pointsLayer = stage.findOne('#pointsLayer')
if (!pointsLayer) {
pointsLayer = new Konva.Layer({ id: 'pointsLayer' })
stage.add(pointsLayer)
} else {
pointsLayer.removeChildren()
}
// mark图层
let marksLayer = stage.findOne('#marksLayer')
if (!marksLayer) {
marksLayer = new Konva.Layer({ id: 'marksLayer' })
stage.add(marksLayer)
} else {
marksLayer.removeChildren()
}
// 渲染点阵
if (this.points && this.points.length) {
const lines = this.processPointsData(this.points)
lines.forEach(lineData => {
const line = new Konva.Line({
points: lineData.points,
stroke: '#000',
strokeWidth: lineData.linewidth,
lineCap: 'round',
lineJoin: 'round',
tension: 0
})
pointsLayer.add(line)
})
pointsLayer.batchDraw()
}
// 渲染 mark
if (this.marks && this.marks.length) {
const rectangles = this.processMarksData(this.marks)
rectangles.forEach(rectData => {
const rect = new Konva.Rect({
x: rectData.x,
y: rectData.y,
width: rectData.width,
height: rectData.height,
stroke: 'transparent',
strokeWidth: 1,
fill: 'transparent',
id: 'mark'
})
console.log(rect)
marksLayer.add(rect)
})
marksLayer.batchDraw()
// 渲染完mark后,自动定位到第一个mark
// if (rectangles.length > 0) {
// this.focusMark(rectangles[0])
// }
}
}
/**
* 点阵数据处理(分轴缩放,保证点阵和图片完全对齐)
*/
processPointsData(points) {
const lines = []
let currentLine = null
for (const point of points) {
const { x, y, linewidth, stroke_start, stroke_end } = point
if (stroke_start || !currentLine) {
currentLine = {
points: [],
linewidth: linewidth || 2
}
lines.push(currentLine)
}
if (stroke_end) {
currentLine = null
continue
}
// 分轴缩放
console.log(this.imageRealScale, this.initScale)
const pointX = x * this.imageRealScale
const pointY = y * this.imageRealScale
if (currentLine) {
currentLine.points.push(pointX, pointY)
}
}
return lines
}
/**
* mark数据处理
*/
processMarksData(marks) {
// 参考 dot-matrix-canvas.vue 的 processMarksData,使用 this.initScale
const rectangles = []
for (const mark of marks) {
const { topx, topy, bottomx, bottomy } = mark
if (
topx === undefined ||
topy === undefined ||
bottomx === undefined ||
bottomy === undefined
)
continue
const x1 = topx * this.initScale
const y1 = topy * this.initScale
const x2 = bottomx * this.initScale
const y2 = bottomy * this.initScale
const width = Math.abs(x2 - x1)
const height = Math.abs(y2 - y1)
const x = Math.min(x1, x2)
const y = Math.min(y1, y2)
rectangles.push({ x, y, width, height })
}
return rectangles
}
/**
* 聚焦到指定的mark
* @param {object} markData { x, y, width, height }
*/
focusMark(markData) {
if (!markData) return
const stageWidth = this.stageConfig.width
const stageHeight = this.stageConfig.height
const padding = 25
const stage = this.getStage()
const bgNode = stage.findOne('#bg')
const pointsLayer = stage.findOne('#pointsLayer')
const marksLayer = stage.findOne('#marksLayer')
// 计算缩放比例
const scaleX = (stageWidth - 2 * padding) / markData.width
const scaleY = (stageHeight - 2 * padding) / markData.height
const newScale = Math.min(scaleX, scaleY)
// 计算居中偏移
const markCenterX = markData.x + markData.width / 2
const markCenterY = markData.y + markData.height / 2
const newGroupX = stageWidth / 2 - markCenterX * newScale
const newGroupY = stageHeight / 2 - markCenterY * newScale
// 应用到所有图层
if (bgNode) {
bgNode.position({ x: newGroupX, y: newGroupY })
bgNode.scale({ x: newScale, y: newScale })
bgNode.getLayer().batchDraw()
}
if (pointsLayer) {
pointsLayer.position({ x: newGroupX, y: newGroupY })
pointsLayer.scale({ x: newScale, y: newScale })
pointsLayer.batchDraw()
}
if (marksLayer) {
marksLayer.position({ x: newGroupX, y: newGroupY })
marksLayer.scale({ x: newScale, y: newScale })
marksLayer.batchDraw()
}
}
get pointer() {
const { x, y } = this.getStage().getPointerPosition()
return { x, y }
}
get stage() {
return this.config.getCompThis().$refs.stage.getStage()
}
getStage() {
return this.config.getCompThis().$refs.stage.getStage()
}
setActiveTool(tool) {
if (tool) {
this.activeTool = tool
if (get(this, 'activeTool.onActive')) {
this.activeTool.onActive(this)
}
} else {
if (get(this, 'activeTool.onInactive')) {
this.activeTool.onInactive(this)
}
this.activeTool = null
}
}
/**
* 设置背景图层旋转角度并更新宽高和偏移
* @param {number} rotate 旋转角度,必须是90的倍数
*/
async setBgLayerRotate(rotate) {
// 验证旋转角度是否为90的倍数
if (rotate % 90 !== 0) {
console.error('旋转角度必须是90的倍数')
return
}
const rotation = Math.abs(rotate % 360)
// 旋转点的函数
const rotatePoint = ({ x, y }, rad) => {
const rcos = Math.cos(rad)
const rsin = Math.sin(rad)
return { x: x * rcos - y * rsin, y: y * rcos + x * rsin }
}
// 在中心点旋转的函数
function rotateAroundCenter(node, rotation) {
// 当前旋转原点(0, 0)相对于所需原点 - 中心(node.width()/2, node.height()/2)
const topLeft = { x: -node.width() / 2, y: -node.height() / 2 }
// 当前旋转角度
const current = rotatePoint(topLeft, Konva.getAngle(node.rotation()))
// 新旋转角度
const rotated = rotatePoint(topLeft, Konva.getAngle(rotation))
// 计算偏移量
const dx = rotated.x - current.x
const dy = rotated.y - current.y
return {
rotation,
dx,
dy
}
}
let { dx, dy } = rotateAroundCenter(this.stage.findOne('#bg'), rotation)
this.bgLayerConfig.rotation = rotation
this.bgLayerConfig.x += dx // 更新 x 坐标
this.bgLayerConfig.y += dy // 更新 y 坐标
if (this.debug) {
console.log(
`背景图层旋转角度设置为: ${rotate}°,新位置: (${this.bgLayerConfig.x}, ${this.bgLayerConfig.y}),新宽高: (${this.bgLayerConfig.width}, ${this.bgLayerConfig.height})`
)
}
}
//----------历史记录-------------S-//
saveStoreState() {
if (this.debug) {
console.log('-----历史记录已保存----')
}
this.history.push(cloneDeep(this.store))
}
// redo() {}
undo() {
if (this.history.length > 2) {
let store = cloneDeep(this.history[this.history.length - 2]) || []
this.store = store.map(v => {
return {
...v,
image: ''
}
})
this.store.forEach(item => {
if (item.imageUrl) {
loadImage(imageList[item.imageUrl]).then(({ image }) => {
let index = this.store.findIndex(v => v.id == item.id)
this.config.getCompThis().$set(this.store, index, {
...this.store[index],
image: image
})
})
}
})
this.history.pop()
} else {
if (this.debug) {
console.log('没有更多的历史记录可以撤销')
}
}
}
//----------历史记录-------------E-//
}
export async function createApp(config) {
const instance = new KonvaCanvas(config)
await instance.init()
return instance
}
export function loadImage(src) {
return new Promise((resolve, reject) => {
const image = new window.Image()
if (src.includes(';base64')) {
image.src = src // 直接使用 base64 字符串
} else {
image.src = src + '?time=' + new Date().valueOf()
image.crossOrigin = 'Anonymous'
}
image.onload = () => {
resolve({
image: image,
width: image.width,
height: image.height
})
}
image.onerror = () => {
console.error('底图加载失败')
reject('file load iamge')
}
})
}
export const createTextarea = (app, textNode, onUpdated) => {
textNode.hide()
const textPosition = textNode.getClientRect()
const areaPosition = {
x: app.stage.container().offsetLeft + textPosition.x,
y: app.stage.container().offsetTop + textPosition.y
}
const textarea = document.createElement('textarea')
app.stage.container().appendChild(textarea)
const transform = `translateY(-${Math.round(textNode.fontSize() / 20)}px)`
Object.assign(textarea.style, {
position: 'absolute',
display: 'inline-block',
minHeight: '1em',
backfaceVisibility: 'hidden',
resize: 'none',
background: 'transparent',
overflow: 'hidden',
overflowWrap: 'break-word',
boxSizing: 'content-box',
top: `${areaPosition.y}px`,
left: `${areaPosition.x}px`,
width: `${
textNode.width() * textNode.scaleX() - textNode.padding() * 2 + 10
}px`,
height: `${
textNode.height() * textNode.scaleX() - textNode.padding() * 2 + 5
}px`,
fontSize: `${textNode.fontSize() * textNode.scaleX()}px`,
border: 0,
padding: 0,
margin: 0,
outline: 0,
lineHeight: textNode.lineHeight().toString(),
fontFamily: textNode.fontFamily(),
textAlign: textNode.align(),
color: textNode.fill(),
caretColor: textNode.fill(),
zIndex: '99999',
transformOrigin: 'left top',
transform: transform
})
textarea.value = textNode.text()
textarea.style.height = `${textarea.scrollHeight + 3}px`
textarea.focus()
function removeTextarea() {
textarea.parentNode?.removeChild(textarea)
window.removeEventListener('click', handleOutsideClick)
textNode.show()
}
textarea.addEventListener('keydown', e => {
e.stopPropagation()
if (e.key === 'Escape') {
removeTextarea()
}
})
textarea.addEventListener('input', () => {
const text = textarea.value.replace(/\n/g, '<br/>')
const tempDiv = document.createElement('div')
tempDiv.innerHTML = text
tempDiv.style.position = 'absolute'
tempDiv.style.visibility = 'hidden'
tempDiv.style.whiteSpace = 'pre-wrap'
document.body.appendChild(tempDiv)
const newWidth = tempDiv.offsetWidth + 10
textarea.style.width = `${newWidth * textNode.scaleX()}px`
document.body.removeChild(tempDiv)
const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight)
const rows = textarea.value.split('\n').length
textarea.style.height = 'auto'
const newHeight = lineHeight * rows
textarea.style.height = `${newHeight}px`
textNode.width(newWidth)
textNode.height(newHeight / textNode.scaleY())
})
function handleOutsideClick(e) {
if (e.target !== textarea) {
if (textarea.value.trim().length >= 1) {
textNode.text(textarea.value)
} else {
app.remove(textNode)
}
removeTextarea()
onUpdated()
}
}
setTimeout(() => {
window.addEventListener('click', handleOutsideClick)
})
}
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g id="错红" transform="translate(-1022 -1046)">
<rect id="矩形_3230" data-name="矩形 3230" width="24" height="24" transform="translate(1022 1046)" fill="none"/>
<path id="路径_7315" data-name="路径 7315" d="M219.89,205.6l-6.574,6.574-6.574-6.574a.856.856,0,0,0-.587-.235.848.848,0,0,0-.822.822.859.859,0,0,0,.235.587l6.574,6.574-6.574,6.574a.8.8,0,0,0-.222.809.749.749,0,0,0,.574.587.837.837,0,0,0,.821-.222l6.574-6.574,6.574,6.574a.856.856,0,0,0,.587.235.848.848,0,0,0,.822-.822.859.859,0,0,0-.235-.587l-6.574-6.574,6.574-6.574a.8.8,0,0,0,.222-.809.752.752,0,0,0-.587-.587A.8.8,0,0,0,219.89,205.6Z" transform="translate(820.685 844.653)" fill="#fa5151"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g id="正确绿" transform="translate(-1022 -1046)">
<rect id="矩形_3230" data-name="矩形 3230" width="24" height="24" transform="translate(1022 1046)" fill="none"/>
<path id="路径_7316" data-name="路径 7316" d="M148.105,243.086l-5.368-5.368a.819.819,0,0,0-1.361.369.817.817,0,0,0,.217.8l6.512,6.512,13.025-13.025a.8.8,0,0,0,.217-.585.827.827,0,0,0-.255-.572.77.77,0,0,0-.56-.242.835.835,0,0,0-.572.229Z" transform="translate(882.652 820.024)" fill="#02c160"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g id="半对黄" transform="translate(-1022 -1046)">
<rect id="矩形_3230" data-name="矩形 3230" width="24" height="24" transform="translate(1022 1046)" fill="none"/>
<path id="联合_20" data-name="联合 20" d="M-1743.755-2354.089a.817.817,0,0,1-.216-.8.758.758,0,0,1,.572-.573.758.758,0,0,1,.788.2l5.368,5.368,6.462-6.476-2.225-2.225a.75.75,0,0,1,0-1.06.749.749,0,0,1,1.061,0l2.223,2.223,4.333-4.343a.83.83,0,0,1,.572-.229.771.771,0,0,1,.561.241.825.825,0,0,1,.255.573.8.8,0,0,1-.217.586l-4.338,4.337,1.914,1.914a.75.75,0,0,1,0,1.061.75.75,0,0,1-1.06,0l-1.914-1.915-7.626,7.626Z" transform="translate(2768 3413)" fill="#ffc300"/>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
<g id="疑问红" transform="translate(0)">
<rect id="矩形_3230" data-name="矩形 3230" width="24" height="24" transform="translate(0)" fill="none"/>
<path id="联合_19" data-name="联合 19" d="M-8377.047-1684.787a.986.986,0,0,1,.986-.987.986.986,0,0,1,.986.987.986.986,0,0,1-.986.987A.986.986,0,0,1-8377.047-1684.787Zm.953-2a.965.965,0,0,1-.965-.965v-.735a3.991,3.991,0,0,1,.27-1.735,3.082,3.082,0,0,1,1.246-1.362h-.024q.813-.629,1.372-1.118a5.551,5.551,0,0,0,.814-.839,2.781,2.781,0,0,0,.514-1.606,2.3,2.3,0,0,0-.2-1.061,3.763,3.763,0,0,0-.616-.915l-.049-.052a3.006,3.006,0,0,0-2.187-.836,2.564,2.564,0,0,0-2.094,1.014,4.577,4.577,0,0,0-.842,2.231.961.961,0,0,1-1.059.814.96.96,0,0,1-.848-1.072,6.771,6.771,0,0,1,.339-1.474,5.41,5.41,0,0,1,1.036-1.8,4.408,4.408,0,0,1,1.537-1.118,4.817,4.817,0,0,1,1.932-.384,4.993,4.993,0,0,1,3.585,1.3,4.437,4.437,0,0,1,1.42,3.352,4.69,4.69,0,0,1-.2,1.384,4.82,4.82,0,0,1-.523,1.151,6.676,6.676,0,0,1-1.025,1.165,15.557,15.557,0,0,1-1.675,1.327,1.686,1.686,0,0,0-.605.641,2.5,2.5,0,0,0-.14.967v.759a.965.965,0,0,1-.965.965Z" transform="translate(8387.768 1703.801)" fill="#fa5151"/>
</g>
</svg>
/*
* @Author: zengzhe
* @Date: 2024-08-15 14:18:08
* @LastEditors: zengzhe
* @LastEditTime: 2024-08-27 16:46:00
* @Description:
*/
import { clone } from 'lodash'
import { loadImage } from './konva'
import dayjs from 'dayjs'
const toolImage4 = require('./tools-image/tool-icon-4.svg')
const toolImage5 = require('./tools-image/tool-icon-5.svg')
const toolImage6 = require('./tools-image/tool-icon-6.svg')
const toolImage7 = require('./tools-image/tool-icon-7.svg')
export const toolGroups1 = [
{
label: '错误',
icon: 'tool-icon-4',
configPanelVisible: false,
config: {
width: 48,
height: 48,
draggable: true,
imageUrl: 'toolImage4',
component: 'v-image'
},
instance: null,
onActive(app) {
app.cursor = 'crosshair'
},
onInactive(app) {
app.cursor = ''
},
mouseup(event, app, tool) {
// const { image } = await loadImage(toolImage1)
tool.instance = {
time: dayjs().format('YYYY-MM-DD HH:mm'),
icon: tool.icon,
label: tool.label,
id: `v-image-${new Date().getTime()}`,
name: `v-image-${new Date().getTime()}`,
x: app.pointer.x - tool.config.width / 2,
y: app.pointer.y - tool.config.height / 2,
...tool.config
}
loadImage(toolImage4).then(({ image }) => {
let index = app.store.findIndex(v => v.id == tool.instance.id)
app.config.getCompThis().$set(app.store, index, {
...app.store[index],
image: image
})
})
let ids = app.store.map(v => v.id)
if (!ids.includes(tool.instance.id)) {
if (app.debug) {
console.log('错误工具push进入了store')
}
app.store.push(tool.instance)
app.saveStoreState()
}
}
},
{
label: '正确',
icon: 'tool-icon-5',
configPanelVisible: false,
config: {
width: 48,
height: 48,
draggable: true,
imageUrl: 'toolImage5',
component: 'v-image'
},
instance: null,
onActive(app) {
app.cursor = 'crosshair'
},
onInactive(app) {
app.cursor = ''
},
mouseup(event, app, tool) {
// const { image } = await loadImage(toolImage1)
tool.instance = {
time: dayjs().format('YYYY-MM-DD HH:mm'),
icon: tool.icon,
label: tool.label,
id: `v-image-${new Date().getTime()}`,
name: `v-image-${new Date().getTime()}`,
x: app.pointer.x - tool.config.width / 2,
y: app.pointer.y - tool.config.height / 2,
...tool.config
}
loadImage(toolImage5).then(({ image }) => {
let index = app.store.findIndex(v => v.id == tool.instance.id)
app.config.getCompThis().$set(app.store, index, {
...app.store[index],
image: image
})
})
let ids = app.store.map(v => v.id)
if (!ids.includes(tool.instance.id)) {
if (app.debug) {
console.log('正确工具push进入了store')
}
app.store.push(tool.instance)
app.saveStoreState()
}
}
},
{
label: '半对',
icon: 'tool-icon-6',
configPanelVisible: false,
config: {
width: 48,
height: 48,
draggable: true,
imageUrl: 'toolImage6',
component: 'v-image'
},
instance: null,
onActive(app) {
app.cursor = 'crosshair'
},
onInactive(app) {
app.cursor = ''
},
mouseup(event, app, tool) {
// const { image } = await loadImage(toolImage1)
tool.instance = {
time: dayjs().format('YYYY-MM-DD HH:mm'),
icon: tool.icon,
label: tool.label,
id: `v-image-${new Date().getTime()}`,
name: `v-image-${new Date().getTime()}`,
x: app.pointer.x - tool.config.width / 2,
y: app.pointer.y - tool.config.height / 2,
...tool.config
}
loadImage(toolImage6).then(({ image }) => {
let index = app.store.findIndex(v => v.id == tool.instance.id)
app.config.getCompThis().$set(app.store, index, {
...app.store[index],
image: image
})
})
let ids = app.store.map(v => v.id)
if (!ids.includes(tool.instance.id)) {
if (app.debug) {
console.log('半对工具push进入了store')
}
app.store.push(tool.instance)
app.saveStoreState()
}
}
},
{
label: '疑问',
icon: 'tool-icon-7',
configPanelVisible: false,
config: {
width: 48,
height: 48,
draggable: true,
imageUrl: 'toolImage7',
component: 'v-image'
},
instance: null,
onActive(app) {
app.cursor = 'crosshair'
},
onInactive(app) {
app.cursor = ''
},
mouseup(event, app, tool) {
// const { image } = await loadImage(toolImage1)
tool.instance = {
time: dayjs().format('YYYY-MM-DD HH:mm'),
icon: tool.icon,
label: tool.label,
id: `v-image-${new Date().getTime()}`,
name: `v-image-${new Date().getTime()}`,
x: app.pointer.x - tool.config.width / 2,
y: app.pointer.y - tool.config.height / 2,
...tool.config
}
loadImage(toolImage7).then(({ image }) => {
let index = app.store.findIndex(v => v.id == tool.instance.id)
app.config.getCompThis().$set(app.store, index, {
...app.store[index],
image: image
})
})
let ids = app.store.map(v => v.id)
if (!ids.includes(tool.instance.id)) {
if (app.debug) {
console.log('疑问工具push进入了store')
}
app.store.push(tool.instance)
app.saveStoreState()
}
}
}
]
export const toolGroups2 = [
{
label: '直线',
icon: 'tool-icon-8',
config: {
points: [0, 0, 0, 0], // 初始点设置为(0, 0) 到 (0, 0)
stroke: 'red',
strokeWidth: 2,
lineCap: 'round',
lineJoin: 'round',
draggable: true,
component: 'v-line'
},
instance: null,
onActive(app) {
app.cursor = 'crosshair'
//选择工具时,设置默认颜色和粗细
app.panelConfig.stroke = '#FA5151'
app.panelConfig.strokeWidth = 2
},
onInactive(app) {
app.cursor = ''
},
mousedown: (event, app, tool) => {
if (tool.instance) {
return
}
app.startPointer = clone(app.pointer)
tool.instance = {
time: dayjs().format('YYYY-MM-DD HH:mm'),
icon: tool.icon,
label: tool.label,
id: `v-line-${new Date().getTime()}`,
name: `v-line-${new Date().getTime()}`,
points: [
app.startPointer.x,
app.startPointer.y,
app.startPointer.x,
app.startPointer.y
],
...tool.config,
...app.panelConfig
}
let ids = app.store.map(v => v.id)
if (!ids.includes(tool.instance.id)) {
if (app.debug) {
console.log('直线工具push进入了store')
}
app.store.push(tool.instance)
}
},
mousemove: (event, app, tool) => {
if (!tool.instance) {
return
}
const endX = app.pointer.x
const endY = app.pointer.y
let index = app.store.findIndex(v => v.id == tool.instance.id)
app.config.getCompThis().$set(app.store, index, {
...app.store[index],
points: [app.startPointer.x, app.startPointer.y, endX, endY]
})
},
mouseup: (event, app, tool) => {
if (!tool.instance) {
return
}
if (
app.startPointer.x == app.pointer.x &&
app.startPointer.y == app.pointer.y
) {
app.store = app.store.filter(v => v.id != tool.instance.id)
tool.instance = null
if (app.debug) {
console.log('直线工具的起始位置相同,被销毁,从store里移除')
}
return
}
app.saveStoreState()
tool.instance = null
}
},
{
label: '箭头',
icon: 'tool-icon-9',
config: {
points: [0, 0, 0, 0], // 初始点设置为(0, 0) 到 (0, 0)
pointerLength: 10, // 箭头头部长度
pointerWidth: 10, // 箭头头部宽度
stroke: 'red',
strokeWidth: 2,
lineCap: 'round',
lineJoin: 'round',
draggable: true,
component: 'v-arrow'
},
instance: null,
onActive(app) {
app.cursor = 'crosshair'
//选择工具时,设置默认颜色和粗细
app.panelConfig.stroke = '#FA5151'
app.panelConfig.strokeWidth = 2
},
onInactive(app) {
app.cursor = ''
},
mousedown: (event, app, tool) => {
if (tool.instance) {
return
}
app.startPointer = clone(app.pointer)
tool.instance = {
time: dayjs().format('YYYY-MM-DD HH:mm'),
icon: tool.icon,
label: tool.label,
id: `v-arrow-${new Date().getTime()}`,
name: `v-arrow-${new Date().getTime()}`,
points: [
app.startPointer.x,
app.startPointer.y,
app.startPointer.x,
app.startPointer.y
],
...tool.config,
...app.panelConfig
}
let ids = app.store.map(v => v.id)
if (!ids.includes(tool.instance.id)) {
if (app.debug) {
console.log('箭头工具push进入了store')
}
app.store.push(tool.instance)
}
},
mousemove: (event, app, tool) => {
if (!tool.instance) {
return
}
const endX = app.pointer.x
const endY = app.pointer.y
let index = app.store.findIndex(v => v.id == tool.instance.id)
app.config.getCompThis().$set(app.store, index, {
...app.store[index],
points: [app.startPointer.x, app.startPointer.y, endX, endY]
})
},
mouseup: (event, app, tool) => {
if (!tool.instance) {
return
}
if (
app.startPointer.x == app.pointer.x &&
app.startPointer.y == app.pointer.y
) {
app.store = app.store.filter(v => v.id != tool.instance.id)
tool.instance = null
if (app.debug) {
console.log('箭头工具的起始位置相同,被销毁,从store里移除')
}
return
}
app.saveStoreState()
tool.instance = null
}
},
{
label: '矩形',
icon: 'tool-icon-10',
config: {
cornerRadius: 4,
stroke: 'red',
strokeWidth: 4,
strokeScaleEnabled: true,
draggable: true,
component: 'v-rect'
},
instance: null,
onActive(app) {
app.cursor = 'crosshair'
//选择工具时,设置默认颜色和粗细
app.panelConfig.stroke = '#FA5151'
app.panelConfig.strokeWidth = 2
},
onInactive(app) {
app.cursor = ''
},
// onActive(app, tool) {},
mousedown: (event, app, tool) => {
if (tool.instance) {
return
}
app.startPointer = clone(app.pointer)
tool.instance = {
time: dayjs().format('YYYY-MM-DD HH:mm'),
icon: tool.icon,
label: tool.label,
id: `v-rect-${new Date().getTime()}`,
name: `v-rect-${new Date().getTime()}`,
width: 0,
height: 0,
...tool.config,
...app.startPointer,
...app.panelConfig
}
let ids = app.store.map(v => v.id)
if (!ids.includes(tool.instance.id)) {
if (app.debug) {
console.log('矩形工具push进入了store')
}
app.store.push(tool.instance)
}
},
mousemove: (event, app, tool) => {
if (!tool.instance) {
return
}
let index = app.store.findIndex(v => v.id == tool.instance.id)
app.config.getCompThis().$set(app.store, index, {
...app.store[index],
x: Math.min(app.startPointer.x, app.pointer.x),
y: Math.min(app.startPointer.y, app.pointer.y),
width: Math.abs(app.pointer.x - app.startPointer.x),
height: Math.abs(app.pointer.y - app.startPointer.y)
})
},
mouseup: (event, app, tool) => {
if (!tool.instance) {
return
}
if (
app.startPointer.x == app.pointer.x &&
app.startPointer.y == app.pointer.y
) {
app.store = app.store.filter(v => v.id != tool.instance.id)
tool.instance = null
if (app.debug) {
console.log('矩形工具的起始位置相同,被销毁,从store里移除')
}
return
}
app.saveStoreState()
tool.instance = null
}
},
{
label: '椭圆',
icon: 'tool-icon-11',
config: {
sides: 4,
// radius: 0,
cornerRadius: 4,
stroke: 'red',
draggable: true,
component: 'v-ellipse'
},
instance: null,
onActive(app) {
app.cursor = 'crosshair'
//选择工具时,设置默认颜色和粗细
app.panelConfig.stroke = '#FA5151'
app.panelConfig.strokeWidth = 2
},
onInactive(app) {
app.cursor = ''
},
mousedown: (event, app, tool) => {
if (tool.instance) {
return
}
app.startPointer = clone(app.pointer)
tool.instance = {
time: dayjs().format('YYYY-MM-DD HH:mm'),
icon: tool.icon,
label: tool.label,
id: `v-ellipse-${new Date().getTime()}`,
name: `v-ellipse-${new Date().getTime()}`,
radiusX: 0,
radiusY: 0,
x: app.startPointer.x,
y: app.startPointer.y,
...tool.config,
...app.panelConfig
}
let ids = app.store.map(v => v.id)
if (!ids.includes(tool.instance.id)) {
if (app.debug) {
console.log('圆形工具push进入了store')
}
app.store.push(tool.instance)
}
},
mousemove: (event, app, tool) => {
if (!tool.instance) {
return
}
const dx = app.pointer.x - app.startPointer.x
const dy = app.pointer.y - app.startPointer.y
let index = app.store.findIndex(v => v.id == tool.instance.id)
app.config.getCompThis().$set(app.store, index, {
...app.store[index],
x: app.startPointer.x + dx / 2,
y: app.startPointer.y + dy / 2,
radiusX: Math.abs(dx) / 2,
radiusY: Math.abs(dy) / 2
})
},
mouseup: (event, app, tool) => {
if (!tool.instance) {
return
}
if (
app.startPointer.x == app.pointer.x &&
app.startPointer.y == app.pointer.y
) {
app.store = app.store.filter(v => v.id != tool.instance.id)
tool.instance = null
if (app.debug) {
console.log('圆形工具的起始位置相同,被销毁,从store里移除')
}
return
}
app.saveStoreState()
tool.instance = null
}
}
]
export const toolGroups3 = [
{
label: '画笔',
icon: 'tool-icon-12',
config: {
points: [], // 初始点为空
stroke: 'red',
strokeWidth: 2,
lineCap: 'round',
lineJoin: 'round',
tension: 0.5, // 使线条更光滑
draggable: true,
component: 'v-line'
},
instance: null,
onActive(app) {
app.cursor = 'crosshair'
//选择工具时,设置默认颜色和粗细
app.panelConfig.stroke = '#FA5151'
app.panelConfig.strokeWidth = 2
},
onInactive(app) {
app.cursor = ''
},
mousedown: (event, app, tool) => {
if (tool.instance) {
return
}
app.startPointer = clone(app.pointer)
tool.instance = {
time: dayjs().format('YYYY-MM-DD HH:mm'),
icon: tool.icon,
label: tool.label,
id: `v-line-${new Date().getTime()}`,
name: `v-line-${new Date().getTime()}`,
points: [app.startPointer.x, app.startPointer.y],
...tool.config,
...app.panelConfig
}
let ids = app.store.map(v => v.id)
if (!ids.includes(tool.instance.id)) {
if (app.debug) {
console.log('画笔工具push进入了store')
}
app.store.push(tool.instance)
}
},
mousemove: (event, app, tool) => {
if (!tool.instance) {
return
}
const newPoint = [app.pointer.x, app.pointer.y]
tool.instance.points = tool.instance.points.concat(newPoint)
let index = app.store.findIndex(v => v.id == tool.instance.id)
app.config.getCompThis().$set(app.store, index, {
...app.store[index],
points: tool.instance.points
})
},
mouseup: (event, app, tool) => {
if (!tool.instance) {
return
}
if (tool.instance.points.length <= 2) {
app.store = app.store.filter(v => v.id != tool.instance.id)
tool.instance = null
if (app.debug) {
console.log('画笔工具的起始位置相同或线段太短,被销毁,从store里移除')
}
return
}
app.saveStoreState()
tool.instance = null
}
},
{
label: '文字',
icon: 'tool-icon-13',
config: {
text: '',
fontSize: 18,
fontFamily: 'Arial',
fill: 'red',
padding: 0,
align: 'left',
draggable: true,
component: 'v-text'
},
instance: null,
onActive(app) {
app.cursor = 'crosshair'
//选择工具时,设置默认颜色和粗细
app.panelConfig.stroke = '#FA5151'
app.panelConfig.strokeWidth = 2
},
onInactive(app) {
app.cursor = ''
},
dblclick(event, app, tool) {
// if (tool.instance) {
// return
// }
// 保存起始位置
app.startPointer = clone(app.pointer)
// 创建新的文本节点实例
let instance = {
time: dayjs().format('YYYY-MM-DD HH:mm'),
icon: tool.icon,
label: tool.label,
id: `v-text-${new Date().getTime()}`,
name: `v-text-${new Date().getTime()}`,
x: app.startPointer.x,
y: app.startPointer.y,
// width: 80,
// height: 18,
...tool.config,
fill: app.panelConfig.stroke
// ...app.panelConfig
}
// 将文本节点添加到 store
let ids = app.store.map(v => v.id)
if (!ids.includes(instance.id)) {
if (app.debug) {
console.log('文字工具push进入了store')
}
app.store.push(instance)
// app.activeTool = null
app.setActiveTool(null)
setTimeout(() => {
let textNode = app.stage.findOne('#' + instance.id)
let index = app.store.findIndex(v => v.id == instance.id)
textNode.on('dblclick', ({ target }) => {
createTextarea(app, target, index, () => {
app.saveStoreState()
})
})
textNode.fire('dblclick')
})
}
}
}
]
export const createTextarea = (app, textNode, index, onUpdated) => {
textNode.hide()
const textPosition = textNode.getClientRect()
const areaPosition = {
x: app.stage.container().offsetLeft + textPosition.x,
y: app.stage.container().offsetTop + textPosition.y
}
const textarea = document.createElement('textarea')
app.stage.container().appendChild(textarea)
const transform = `translateY(-${Math.round(textNode.fontSize() / 20)}px)`
Object.assign(textarea.style, {
position: 'absolute',
display: 'inline-block',
minHeight: '1em',
minWidth: '8em',
backfaceVisibility: 'hidden',
resize: 'none',
background: 'transparent',
overflow: 'hidden',
overflowWrap: 'break-word',
boxSizing: 'content-box',
top: `${areaPosition.y}px`,
left: `${areaPosition.x}px`,
width: `${
textNode.width() * textNode.scaleX() - textNode.padding() * 2 + 10
}px`,
height: `${
textNode.height() * textNode.scaleY() - textNode.padding() * 2 + 5
}px`,
fontSize: `${textNode.fontSize() * textNode.scaleX()}px`,
border: 0,
padding: 0,
margin: 0,
outline: 0,
lineHeight: textNode.lineHeight().toString(),
fontFamily: textNode.fontFamily(),
textAlign: textNode.align(),
color: textNode.fill(),
caretColor: textNode.fill(),
zIndex: '99999',
transformOrigin: 'left top',
transform: transform
})
textarea.value = textNode.text()
textarea.style.height = `${textarea.scrollHeight + 3}px`
textarea.placeholder = '请填写'
textarea.focus()
function removeTextarea() {
textarea.parentNode?.removeChild(textarea)
window.removeEventListener('click', handleOutsideClick)
textNode.show()
}
textarea.addEventListener('keydown', e => {
e.stopPropagation()
if (e.key === 'Escape') {
removeTextarea()
}
})
textarea.addEventListener('input', () => {
const text = textarea.value.replace(/\n/g, '<br/>')
const tempDiv = document.createElement('div')
tempDiv.innerHTML = text
tempDiv.style.position = 'absolute'
tempDiv.style.visibility = 'hidden'
tempDiv.style.whiteSpace = 'pre-wrap'
document.body.appendChild(tempDiv)
const newWidth = tempDiv.offsetWidth + 10
textarea.style.width = `${newWidth}px`
document.body.removeChild(tempDiv)
// const lineHeight = parseFloat(getComputedStyle(textarea).lineHeight)
// const rows = textarea.value.split('\n').length
textarea.style.height = 'auto'
const newHeight = textarea.scrollHeight
textarea.style.height = `${newHeight}px`
textNode.width(newWidth / textNode.scaleX())
textNode.height(newHeight / textNode.scaleY())
})
function handleOutsideClick(e) {
if (e.target !== textarea) {
if (textarea.value.trim().length >= 1) {
// textNode.text(textarea.value)
app.config.getCompThis().$set(app.store, index, {
...app.store[index],
width: textarea.clientWidth / textNode.scaleX(),
height: textarea.clientHeight / textNode.scaleY(),
text: textarea.value
})
} else {
app.store = app.store.filter((v, i) => i != index)
}
removeTextarea()
onUpdated()
}
}
setTimeout(() => {
window.addEventListener('click', handleOutsideClick)
})
}
<!--
* @Author: zengzhe
* @Date: 2025-08-11 09:12:47
* @LastEditors: zengzhe
* @LastEditTime: 2025-10-28 09:23:15
* @Description:
-->
<template>
<div>
<h1>Leafer</h1>
......@@ -6,35 +13,37 @@
</template>
<script>
import { createLeaferAnnotate, } from './leafer'
import { createLeaferAnnotate } from './leafer'
import pageinfo from '../../api/pageinfo.json'
import markList from '../../api/marklist.json'
import pagePoints from '../../api/page-point.json'
export default {
name: 'c-leafer',
data() {
return {
instance: null
instance: null,
}
},
async mounted() {
this.init()
},
methods: {
async init(){
async init() {
this.instance = await createLeaferAnnotate({
view:'leafer-container',
pageUrl:pageinfo.url,
marks:markList
view: 'leafer-container',
pageUrl: pageinfo.url,
marks: markList,
points: pagePoints,
})
window.instance = this.instance
}
}
},
},
}
</script>
<style scoped>
.leafer-container {
width: 1000px;
height: 600px;
width: 90vw;
height: 90vh;
background: #333;
}
</style>
/*
* @Author: zengzhe
* @Date: 2025-08-18 09:29:26
* @LastEditors: zengzhe
* @LastEditTime: 2025-10-28 09:19:39
* @Description:
*/
import { Resource } from "leafer-ui";
export function loadImage(src) {
return new Promise((resolve, reject) => {
const image = new window.Image()
if (src.includes(';base64')) {
image.src = src
} else {
if (!src.includes('?')) {
src += '?time=' + new Date().valueOf()
} else {
src += '&time=' + new Date().valueOf()
}
image.src = src
image.crossOrigin = 'Anonymous'
}
image.onload = () => {
const { url } = Resource.setImage(`leafer://bg.png`, image)
resolve({
url: url,
width: image.width,
height: image.height
})
}
image.onerror = () => {
console.error('底图加载失败')
reject('file load iamge')
}
})
}
/**
* 直接从strokes字符串生成Path路径数据,按顺序连接所有点
* @param {string} strokesStr strokes字符串
* @param {number} scaleX X轴坐标换算比例
* @param {number} scaleY Y轴坐标换算比例
* @param {number} offsetX X轴出血偏移量
* @param {number} offsetY Y轴出血偏移量
* @returns {string} 路径字符串
*/
export function optimizedCreatePathsFromStrokes(strokesStr, scaleX, scaleY, offsetX = 0, offsetY = 0) {
if (!strokesStr || typeof strokesStr !== 'string') return ''
const strokePoints = strokesStr.split(';')
if (strokePoints.length === 0) return ''
const pathParts = []
let needMoveTo = true // 是否需要移动到新起点
// 一次遍历完成:解析、坐标转换、构建路径
for (let i = 0; i < strokePoints.length; i++) {
const pointStr = strokePoints[i].trim()
if (!pointStr) continue
const parts = pointStr.split(',')
if (parts.length < 6) continue
const x = parseFloat(parts[0])
const y = parseFloat(parts[1])
if (x <= 0 && y <= 0) continue
const strokeStart = parts[3] === 'true'
const strokeEnd = parts[4] === 'true'
// 如果是抬笔,跳过该点并重置状态
if (strokeEnd) {
needMoveTo = true
continue
}
// 直接使用比例换算坐标
const pxX = x * scaleX + offsetX
const pxY = y * scaleY + offsetY
// 构建路径字符串
if (needMoveTo || strokeStart) {
pathParts.push(`M ${pxX} ${pxY}`)
needMoveTo = false
} else {
pathParts.push(`L ${pxX} ${pxY}`)
}
}
return pathParts.join(' ')
}
\ No newline at end of file
/* eslint-disable no-unused-vars */
import { Leafer,App, Debug, Rect, Image, Frame, Platform, Box,Resource,Group } from "leafer-ui";
import { Leafer, App, Debug, Rect, Image, Frame, Platform, Box, Group,Path } from "leafer-ui";
import '@leafer-in/editor' // 导入交互状态插件
import '@leafer-in/viewport' // 导入视口插件
import '@leafer-in/view' // 导入视口插件
import '@leafer-in/state' // 导入交互状态插件
import '@leafer-in/resize' // 导入交互状态插件
import '@leafer-in/find'
import { cloneDeep } from 'lodash'
import { loadImage,optimizedCreatePathsFromStrokes } from './leafer-util'
class LeaferAnnotate {
constructor(config) {
this.config = cloneDeep(config)
}
changeMode(mode) {
this.mode = mode
if (mode === 'view') {
this.pageFrame.hitChildren = true
this.pageFrame.children.forEach(child => {
if (child.className === 'mark') {
child.hoverStyle = {
fill: 'rgba(50,205,121, 0.8)'
}
}
})
} else if (mode === 'edit') {
this.pageFrame.hitChildren = false
this.pageFrame.children.forEach(child => {
if (child.className === 'mark') {
child.hoverStyle = {
fill: ''
}
}
})
}
}
async init() {
let { view, pageUrl, marks } = this.config
let { view, pageUrl, marks,points } = this.config
this.leafer = new App({
view: view,
fill: '#333',
type: 'viewport',
// type: 'viewport',
zoom: { min: 0.1, max: 10 },
wheel: { zoomMode: true },
move: {drag: 'auto' },
editor: { }
editor: { rotateable: false }
})
let { url, width, height } = await loadImage(pageUrl)
this.pageFrame = new Frame({
......@@ -49,20 +28,14 @@ class LeaferAnnotate {
height: height,
})
// this.leafer.tree.zoomLayer = this.pageFrame
this.leafer.tree.add(this.pageFrame)
this.pageFrame.add(new Image({ url: url }, width, height))
this.leafer.zoom('fit', 0)
this.initMarks(marks)
this.changeMode('view')
this.initMarks(marks)
this.initPoints(points)
}
/**
* 基于世界坐标批量绘制标注矩形
* @param {Array<{top:{x:number,y:number},bottom:{x:number,y:number}}>} marks
*/
initMarks(marks) {
if (!Array.isArray(marks) || !this.pageFrame) return
const layer = this.pageFrame
......@@ -87,12 +60,46 @@ class LeaferAnnotate {
stroke: '#ff0000',
strokeWidth: 1,
cornerRadius: 0,
editable:false,
})
layer.add(rect)
}
}
initPoints(datas) {
if (!Array.isArray(datas) || datas.length === 0) return
datas.forEach((point, index) => {
if (!point.strokes) return
// 使用预计算的比例进行坐标换算,按需添加出血偏移
let offsetX = 0
let offsetY = 0
if (this.needBleed) {
const bleedMm = Number(import.meta.env.VITE_BLEED_SIZE ?? 3)
offsetX = bleedMm * this.coordinateScaleX
offsetY = bleedMm * this.coordinateScaleY
}
const path = optimizedCreatePathsFromStrokes(
point.strokes,
300 / 25.4,
300 / 25.4,
offsetX,
offsetY
)
const pathElement = new Path({
id: `point-${index}`,
path: path,
stroke: '#000000',
strokeWidth: 1 * 3,
fill: null,
closed: false,
strokeCap: 'round',
strokeJoin: 'round'
})
this.pageFrame.add(pathElement)
})
}
}
......@@ -102,27 +109,4 @@ export const createLeaferAnnotate = async (config) => {
return instance
};
export function loadImage(src) {
return new Promise((resolve, reject) => {
const image = new window.Image()
if (src.includes(';base64')) {
image.src = src // 直接使用 base64 字符串
} else {
image.src = src + '?time=' + new Date().valueOf()
image.crossOrigin = 'Anonymous'
}
image.onload = () => {
const { url } = Resource.setImage('leafer://test1.jpg', image)
resolve({
url: url,
width: image.width,
height: image.height
})
}
image.onerror = () => {
console.error('底图加载失败')
reject('file load iamge')
}
})
}
......@@ -3,10 +3,10 @@ module.exports = defineConfig({
transpileDependencies: true,
devServer: {
client: {
overlay: {
errors: false,
warnings: false,
},
// overlay: {
// errors: false,
// warnings: false,
// },
},
},
})
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论