card-template.vue 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406
  1. <template>
  2. <canvas ref="canvasRef" style="border:1px solid #ccc; cursor: grab;"></canvas>
  3. </template>
  4. <script lang='ts' setup>
  5. import { onMounted, PropType, ref, watch } from 'vue'
  6. const props = defineProps({
  7. cardData: {
  8. type: Object as PropType<API.Card>,
  9. default: () => ({})
  10. },
  11. config: {
  12. type: Object as PropType<API.CardJson>,
  13. default: () => ({})
  14. }
  15. })
  16. const canvasRef = ref<HTMLCanvasElement | null>(null)
  17. const img = new window.Image()
  18. img.src = props.cardData.img
  19. type Rect = { id: number, name: string, x: number, y: number, width: number, height: number, music_name: string, selected: boolean }
  20. // 矩形参数
  21. const rectWidth = 180
  22. const rectHeight = 240
  23. const rects = [] as Rect[]
  24. const rectGapX = 127 // 矩形间横向间隔
  25. const rectGapY = 146 // 矩形间纵向间隔
  26. const startX = 112 // 图片内第一个矩形的x偏移
  27. const startY = -56 // 图片内第一个矩形的y偏移
  28. // 未选择 选中了但是没有音频 选中了有音频 三种模式的矩形样式
  29. const rectStyles = {
  30. unselected: {
  31. fillStyle: '007bff22',
  32. strokeStyle: '#007bff',
  33. lineWidth: 2,
  34. lineDash: []
  35. },
  36. selectedNoAudio: {
  37. fillStyle: '007bff22',
  38. strokeStyle: '#ff0000',
  39. lineWidth: 2,
  40. lineDash: [4, 6]
  41. },
  42. selectedWithAudio: {
  43. fillStyle: 'ff660033',
  44. strokeStyle: '#ff6600',
  45. lineWidth: 2,
  46. lineDash: []
  47. }
  48. }
  49. const generateRectStyle = (rect: Rect) => {
  50. if (rect.selected) {
  51. return rect.music_name ? rectStyles.selectedWithAudio : rectStyles.selectedNoAudio
  52. }
  53. return rectStyles.unselected
  54. }
  55. // 生成矩形数据
  56. function generateRects () {
  57. rects.length = 0
  58. const firstRowGapX = 96
  59. props.config.touch_key.forEach(item => {
  60. if (item.id < 3) {
  61. for (let i = 0; i < 3; i++) {
  62. rects.push({
  63. id: item.id,
  64. name: item.name,
  65. x: startX + item.id * (rectWidth - 40 + firstRowGapX) + 1166,
  66. y: startY + 116,
  67. width: rectWidth - 40,
  68. height: rectHeight - 100,
  69. music_name: item.music_name || '',
  70. selected: item.selected
  71. })
  72. }
  73. } else {
  74. rects.push({
  75. id: item.id,
  76. name: item.name,
  77. x: startX + item.col * (rectWidth + rectGapX),
  78. y: startY + item.row * (rectHeight + rectGapY),
  79. width: rectWidth,
  80. height: rectHeight,
  81. music_name: item.music_name || '',
  82. selected: item.selected
  83. })
  84. }
  85. })
  86. }
  87. // 图片状态
  88. const state = {
  89. scale: 0.3, // 初始缩放为0.5
  90. minScale: 0.2,
  91. maxScale: 5,
  92. offsetX: 0,
  93. offsetY: 0,
  94. dragging: false,
  95. lastX: 0,
  96. lastY: 0,
  97. selectedRectId: null as number | null
  98. }
  99. // 选择框状态
  100. const selection = {
  101. selecting: false,
  102. startX: 0,
  103. startY: 0,
  104. endX: 0,
  105. endY: 0
  106. }
  107. // 多选集合
  108. const selectedRectIds = ref<number[]>([])
  109. const emits = defineEmits(['operation'])
  110. // 框选操作 and 点选操作
  111. const handleOperation = (ids: number[]) => {
  112. emits('operation', { ids })
  113. }
  114. function centerImage () {
  115. const canvas = canvasRef.value
  116. if (!canvas) return
  117. const imgW = img.width * state.scale
  118. const imgH = img.height * state.scale
  119. state.offsetX = (canvas.width - imgW) / 2
  120. state.offsetY = (canvas.height - imgH) / 2
  121. }
  122. function resizeCanvas () {
  123. const canvas = canvasRef.value
  124. if (!canvas) return
  125. canvas.width = window.innerWidth
  126. canvas.height = window.innerHeight
  127. centerImage()
  128. draw()
  129. }
  130. // 绘制网格、图片和矩形
  131. function draw () {
  132. const canvas = canvasRef.value
  133. if (!canvas) return
  134. const ctx = canvas.getContext('2d')!
  135. ctx.clearRect(0, 0, canvas.width, canvas.height)
  136. // 画网格
  137. const gridSize = 20
  138. ctx.save()
  139. ctx.strokeStyle = '#eee'
  140. for (let x = 0; x < canvas.width; x += gridSize) {
  141. ctx.beginPath()
  142. ctx.moveTo(x, 0)
  143. ctx.lineTo(x, canvas.height)
  144. ctx.stroke()
  145. }
  146. for (let y = 0; y < canvas.height; y += gridSize) {
  147. ctx.beginPath()
  148. ctx.moveTo(0, y)
  149. ctx.lineTo(canvas.width, y)
  150. ctx.stroke()
  151. }
  152. ctx.restore()
  153. // 画图片
  154. if (img.complete) {
  155. const imgW = img.width * state.scale
  156. const imgH = img.height * state.scale
  157. ctx.drawImage(img, state.offsetX, state.offsetY, imgW, imgH)
  158. console.log('rects:', rects)
  159. // 画矩形(随图片缩放和移动)
  160. rects.forEach(rect => {
  161. const rx = state.offsetX + rect.x * state.scale
  162. const ry = state.offsetY + rect.y * state.scale
  163. const rw = rect.width * state.scale
  164. const rh = rect.height * state.scale
  165. ctx.save()
  166. ctx.lineWidth = 2
  167. const isSelected = rect.selected
  168. ctx.strokeStyle = isSelected ? '#ff6600' : '#007bff'
  169. ctx.globalAlpha = 0.7
  170. ctx.fillStyle = isSelected ? '#ff660033' : '#3374e5dd'
  171. ctx.fillRect(rx, ry, rw, rh)
  172. ctx.globalAlpha = 1
  173. ctx.setLineDash([]) // 确保边框为实线
  174. ctx.strokeRect(rx, ry, rw, rh)
  175. // 绘制序号
  176. ctx.font = `bold ${Math.floor(rh / 1.7)}px sans-serif`
  177. ctx.strokeStyle = isSelected ? '#ff6600' : '#333'
  178. ctx.lineWidth = 1
  179. ctx.textAlign = 'center'
  180. ctx.textBaseline = 'middle'
  181. // 根据选中状态设置线条样式
  182. ctx.setLineDash(isSelected ? [] : []) // 选中时使用虚线,否则实线
  183. if (rect.id < 3) {
  184. ctx.strokeText(rect.name, rx + rw / 2, ry + rh / 2)
  185. } else {
  186. ctx.strokeText(rect.name, rx + rw / 2, ry + rh / 4)
  187. }
  188. ctx.restore()
  189. if (rect.id >= 3) {
  190. ctx.font = `bold ${Math.floor(rh / 1.7)}px sans-serif`
  191. ctx.strokeStyle = '#333'
  192. // ctx.lineWidth = 1
  193. ctx.textAlign = 'center'
  194. ctx.textBaseline = 'middle'
  195. // ctx.setLineDash([6, 4]) // 设置为实线
  196. ctx.strokeText((rect.id + 1).toString(), rx + rw / 2, ry + rh / 1.3)
  197. ctx.restore()
  198. }
  199. })
  200. }
  201. }
  202. function onWheel (e: WheelEvent) {
  203. if (!e.ctrlKey) return
  204. e.preventDefault()
  205. const { offsetX, offsetY, deltaY } = e
  206. // 计算缩放中心
  207. const prevScale = state.scale
  208. if (deltaY < 0) {
  209. state.scale = Math.min(state.maxScale, state.scale * 1.1)
  210. } else {
  211. state.scale = Math.max(state.minScale, state.scale / 1.1)
  212. }
  213. // 缩放时保持鼠标点下的内容不动
  214. const scaleRatio = state.scale / prevScale
  215. state.offsetX = offsetX - (offsetX - state.offsetX) * scaleRatio
  216. state.offsetY = offsetY - (offsetY - state.offsetY) * scaleRatio
  217. draw()
  218. }
  219. function onMouseDown (e: MouseEvent) {
  220. // 判断是否按住shift进行多选框选
  221. if (e.button === 0 && e.shiftKey) {
  222. selection.selecting = true
  223. selection.startX = e.offsetX
  224. selection.startY = e.offsetY
  225. selection.endX = e.offsetX
  226. selection.endY = e.offsetY
  227. draw()
  228. return
  229. }
  230. // 鼠标左键直接拖拽图片(不需要ctrl)
  231. if (e.button === 0) {
  232. state.dragging = true
  233. state.lastX = e.clientX
  234. state.lastY = e.clientY
  235. const canvas = canvasRef.value
  236. if (canvas) canvas.style.cursor = 'grabbing'
  237. // 判断是否点在某个矩形内,若是则不做任何高亮处理
  238. const x = e.offsetX
  239. const y = e.offsetY
  240. for (const rect of rects) {
  241. const rx = state.offsetX + rect.x * state.scale
  242. const ry = state.offsetY + rect.y * state.scale
  243. const rw = rect.width * state.scale
  244. const rh = rect.height * state.scale
  245. if (x >= rx && x <= rx + rw && y >= ry && y <= ry + rh) {
  246. // 点在矩形内,直接return,不清空高亮
  247. return
  248. }
  249. }
  250. }
  251. }
  252. let rafId: number | null = null
  253. function scheduleDraw () {
  254. if (rafId !== null) return
  255. rafId = requestAnimationFrame(() => {
  256. draw()
  257. rafId = null
  258. })
  259. }
  260. function onMouseMove (e: MouseEvent) {
  261. if (selection.selecting) {
  262. selection.endX = e.offsetX
  263. selection.endY = e.offsetY
  264. scheduleDraw()
  265. return
  266. }
  267. if (!state.dragging) return
  268. const dx = e.clientX - state.lastX
  269. const dy = e.clientY - state.lastY
  270. if (dx === 0 && dy === 0) return
  271. state.offsetX += dx
  272. state.offsetY += dy
  273. state.lastX = e.clientX
  274. state.lastY = e.clientY
  275. scheduleDraw()
  276. }
  277. function onMouseUp (e?: MouseEvent) {
  278. if (selection.selecting) {
  279. // 计算选择框范围
  280. const x1 = Math.min(selection.startX, selection.endX)
  281. const y1 = Math.min(selection.startY, selection.endY)
  282. const x2 = Math.max(selection.startX, selection.endX)
  283. const y2 = Math.max(selection.startY, selection.endY)
  284. // 判断哪些rect被框选
  285. const selected: number[] = []
  286. rects.forEach(rect => {
  287. const rx = state.offsetX + rect.x * state.scale
  288. const ry = state.offsetY + rect.y * state.scale
  289. const rw = rect.width * state.scale
  290. const rh = rect.height * state.scale
  291. // 判断矩形中心点是否在选择框内
  292. const cx = rx + rw / 2
  293. const cy = ry + rh / 2
  294. if (cx >= x1 && cx <= x2 && cy >= y1 && cy <= y2) {
  295. selected.push(rect.id)
  296. }
  297. })
  298. selection.selecting = false
  299. draw()
  300. handleOperation(selected)
  301. return
  302. }
  303. state.dragging = false
  304. const canvas = canvasRef.value
  305. if (canvas) canvas.style.cursor = 'grab'
  306. }
  307. function onClick (e: MouseEvent) {
  308. // 计算点击点在图片坐标系下的位置
  309. const canvas = canvasRef.value
  310. if (!canvas) return
  311. // 如果正在批量框选(shift按下),不处理单选逻辑
  312. if (e.shiftKey) return
  313. const x = e.offsetX
  314. const y = e.offsetY
  315. let found: number | null = null
  316. for (const rect of rects) {
  317. const rx = state.offsetX + rect.x * state.scale
  318. const ry = state.offsetY + rect.y * state.scale
  319. const rw = rect.width * state.scale
  320. const rh = rect.height * state.scale
  321. if (x >= rx && x <= rx + rw && y >= ry && y <= ry + rh) {
  322. found = rect.id
  323. break
  324. }
  325. }
  326. handleOperation([found!])
  327. draw()
  328. }
  329. onMounted(() => {
  330. img.onload = () => {
  331. resizeCanvas()
  332. }
  333. const canvas = canvasRef.value
  334. if (!canvas) return
  335. resizeCanvas()
  336. window.addEventListener('resize', resizeCanvas)
  337. canvas.addEventListener('wheel', onWheel, { passive: false })
  338. canvas.addEventListener('mousedown', onMouseDown)
  339. window.addEventListener('mousemove', onMouseMove)
  340. window.addEventListener('mouseup', onMouseUp)
  341. // canvas.addEventListener('click', onClick)
  342. })
  343. const delSelectedRectIds = () => draw()
  344. const reload = () => draw()
  345. watch(
  346. () => props.config,
  347. () => {
  348. const ids = props.config.touch_key.filter(item => item.music_name).map(_ => _.id)
  349. selectedRectIds.value = ids
  350. generateRects()
  351. draw()
  352. }, {
  353. deep: true
  354. }
  355. )
  356. defineExpose({
  357. delSelectedRectIds,
  358. reload
  359. })
  360. </script>
  361. <style lang='less' scoped>
  362. canvas {
  363. background: #fff;
  364. display: block;
  365. position: fixed;
  366. left: 0;
  367. top: 0;
  368. width: 100vw;
  369. height: 100vh;
  370. z-index: 10;
  371. }
  372. </style>