card-template.vue 9.4 KB

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