@operato/image-marker 8.0.0-alpha.10 → 8.0.0-alpha.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,7 +1,7 @@
1
1
  import './ox-image-marker-mode'
2
2
 
3
3
  import { html, css, svg, nothing } from 'lit'
4
- import { property, state, customElement, query } from 'lit/decorators.js'
4
+ import { state, customElement } from 'lit/decorators.js'
5
5
  import { ifDefined } from 'lit/directives/if-defined.js'
6
6
  import { keyed } from 'lit/directives/keyed.js'
7
7
 
@@ -50,6 +50,32 @@ export class OxImageMarker extends BaseImageMarker {
50
50
  private offsetX = 0
51
51
  private offsetY = 0
52
52
 
53
+ connectedCallback() {
54
+ super.connectedCallback()
55
+ // 키다운 이벤트 리스너 추가
56
+ window.addEventListener('keydown', this.handleKeyDown)
57
+ }
58
+
59
+ disconnectedCallback() {
60
+ super.disconnectedCallback()
61
+ // 키다운 이벤트 리스너 제거
62
+ window.removeEventListener('keydown', this.handleKeyDown)
63
+ }
64
+
65
+ private handleKeyDown = (event: KeyboardEvent) => {
66
+ if ((event.key === 'Delete' || event.key === 'Backspace') && this.selectedShape) {
67
+ this.deleteSelectedShape()
68
+ }
69
+ }
70
+
71
+ private deleteSelectedShape() {
72
+ if (this.selectedShape) {
73
+ this.shapes = this.shapes.filter(s => s.id !== this.selectedShape!.id)
74
+ this.selectedShape = null
75
+ this.requestUpdate()
76
+ }
77
+ }
78
+
53
79
  render() {
54
80
  return html`
55
81
  <div class="container">
@@ -71,12 +97,7 @@ export class OxImageMarker extends BaseImageMarker {
71
97
  const { mode, shapeType } = e.detail
72
98
  this.setMode(mode, shapeType)
73
99
  }}
74
- @clicked-delete=${() => {
75
- if (this.selectedShape) {
76
- this.shapes = this.shapes.filter(s => s.id !== this.selectedShape!.id)
77
- this.requestUpdate()
78
- }
79
- }}
100
+ @clicked-delete=${() => this.deleteSelectedShape()}
80
101
  @clicked-save=${() => {
81
102
  this.dispatchEvent(
82
103
  new CustomEvent('shapes-changed', {
@@ -125,78 +146,25 @@ export class OxImageMarker extends BaseImageMarker {
125
146
  }
126
147
 
127
148
  private startDrawing(event: MouseEvent) {
128
- this.drawing = true
129
149
  const { x, y } = this.getSvgPoint(event)
130
- this.startX = x
131
- this.startY = y
132
150
 
133
- let newShape: Shape
134
- switch (this.currentShapeType) {
135
- case 'text':
136
- newShape = {
137
- id: Date.now().toString(),
138
- type: 'text',
139
- x: this.startX,
140
- y: this.startY,
141
- text: 'ABC'
142
- }
143
- break
144
- case 'rectangle':
145
- newShape = {
146
- id: Date.now().toString(),
147
- type: 'rectangle',
148
- x: this.startX,
149
- y: this.startY,
150
- width: 0,
151
- height: 0
152
- }
153
- break
154
- case 'circle':
155
- newShape = {
156
- id: Date.now().toString(),
157
- type: 'circle',
158
- cx: this.startX,
159
- cy: this.startY,
160
- r: 0
161
- }
162
- break
163
- case 'triangle':
164
- newShape = {
165
- id: Date.now().toString(),
166
- type: 'triangle',
167
- x1: this.startX,
168
- y1: this.startY,
169
- width: 0,
170
- height: 0,
171
- points: `${this.startX},${this.startY} ${this.startX},${this.startY} ${this.startX},${this.startY}`
172
- }
173
- break
174
- case 'line':
175
- newShape = {
176
- id: Date.now().toString(),
177
- type: 'line',
178
- x1: this.startX,
179
- y1: this.startY,
180
- width: 0,
181
- height: 0
182
- }
183
- break
184
- case 'arrow':
185
- newShape = {
186
- id: Date.now().toString(),
187
- type: 'arrow',
188
- x1: this.startX,
189
- y1: this.startY,
190
- width: 0,
191
- height: 0
192
- }
193
- break
194
- default:
195
- return
196
- }
151
+ if (this.currentShapeType === 'text') {
152
+ const textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text')
153
+
154
+ textElement.setAttribute('x', x.toString())
155
+ textElement.setAttribute('y', y.toString())
156
+ textElement.textContent = 'ABC'
157
+
158
+ this.svg.appendChild(textElement)
197
159
 
198
- this.selectedShape = newShape
199
- this.shapes = [...this.shapes, newShape]
160
+ requestAnimationFrame(() => {
161
+ this.showTextInput(textElement, null, { x, y })
162
+ })
163
+ } else {
164
+ this.startX = x
165
+ this.startY = y
166
+ this.drawing = true
167
+ }
200
168
  }
201
169
 
202
170
  private setMode(mode: ImageMarkerMode, shapeType: ShapeType) {
@@ -204,52 +172,120 @@ export class OxImageMarker extends BaseImageMarker {
204
172
  this.currentShapeType = shapeType
205
173
  }
206
174
 
207
- // 마우스 이동 이벤트 핸들러 (도형 그리기 중)
208
175
  private onMouseMove = (event: MouseEvent) => {
209
- if (!this.drawing || !this.selectedShape) return
176
+ if (!this.drawing) return
177
+
210
178
  const { x, y } = this.getSvgPoint(event)
211
179
 
212
- switch (this.selectedShape.type) {
213
- case 'text':
214
- break
215
- case 'rectangle':
216
- this.selectedShape.width = x - this.startX
217
- this.selectedShape.height = y - this.startY
218
- break
219
- case 'circle':
220
- const dx = x - this.selectedShape.cx!
221
- const dy = y - this.selectedShape.cy!
222
- this.selectedShape.r = Math.sqrt(dx * dx + dy * dy)
223
- break
224
- case 'triangle':
225
- const x2 = x
226
- const y2 = y
227
- const x3 = this.startX - (x - this.startX)
228
- const y3 = y
229
- this.selectedShape.points = `${this.startX},${this.startY} ${x2},${y2} ${x3},${y3}`
230
- break
231
- case 'line':
232
- case 'arrow':
233
- this.selectedShape.x2 = x
234
- this.selectedShape.y2 = y
235
- break
180
+ const minDistance = 5
181
+ const distance = Math.sqrt(Math.pow(x - this.startX, 2) + Math.pow(y - this.startY, 2))
182
+
183
+ if (!this.selectedShape && distance >= minDistance) {
184
+ let newShape: Shape
185
+
186
+ switch (this.currentShapeType) {
187
+ case 'rectangle':
188
+ newShape = {
189
+ id: Date.now().toString(),
190
+ type: 'rectangle',
191
+ x: this.startX,
192
+ y: this.startY,
193
+ width: 0,
194
+ height: 0
195
+ }
196
+ break
197
+ case 'circle':
198
+ newShape = {
199
+ id: Date.now().toString(),
200
+ type: 'circle',
201
+ cx: this.startX,
202
+ cy: this.startY,
203
+ r: 0
204
+ }
205
+ break
206
+ case 'triangle':
207
+ newShape = {
208
+ id: Date.now().toString(),
209
+ type: 'triangle',
210
+ x1: this.startX,
211
+ y1: this.startY,
212
+ width: 0,
213
+ height: 0,
214
+ points: `${this.startX},${this.startY} ${this.startX},${this.startY} ${this.startX},${this.startY}`
215
+ }
216
+ break
217
+ case 'line':
218
+ newShape = {
219
+ id: Date.now().toString(),
220
+ type: 'line',
221
+ x1: this.startX,
222
+ y1: this.startY,
223
+ width: 0,
224
+ height: 0
225
+ }
226
+ break
227
+ case 'arrow':
228
+ newShape = {
229
+ id: Date.now().toString(),
230
+ type: 'arrow',
231
+ x1: this.startX,
232
+ y1: this.startY,
233
+ width: 0,
234
+ height: 0
235
+ }
236
+ break
237
+ default:
238
+ return
239
+ }
240
+
241
+ this.selectedShape = newShape
242
+ this.shapes = [...this.shapes, newShape]
236
243
  }
237
244
 
238
- this.requestUpdate()
245
+ if (this.selectedShape) {
246
+ switch (this.selectedShape.type) {
247
+ case 'rectangle':
248
+ this.selectedShape.x = Math.min(x, this.startX)
249
+ this.selectedShape.y = Math.min(y, this.startY)
250
+ this.selectedShape.width = Math.abs(x - this.startX)
251
+ this.selectedShape.height = Math.abs(y - this.startY)
252
+ break
253
+ case 'circle':
254
+ const dx = x - this.selectedShape.cx!
255
+ const dy = y - this.selectedShape.cy!
256
+ this.selectedShape.r = Math.sqrt(dx * dx + dy * dy)
257
+ break
258
+ case 'triangle':
259
+ const x2 = x
260
+ const y2 = y
261
+ const x3 = this.startX - (x - this.startX)
262
+ const y3 = y
263
+ this.selectedShape.points = `${this.startX},${this.startY} ${x2},${y2} ${x3},${y3}`
264
+ break
265
+ case 'line':
266
+ case 'arrow':
267
+ this.selectedShape.x2 = x
268
+ this.selectedShape.y2 = y
269
+ break
270
+ }
271
+
272
+ this.requestUpdate()
273
+ }
239
274
  }
240
275
 
241
- // 마우스 업 이벤트 핸들러 (도형 그리기 종료)
242
276
  private onMouseUp = () => {
243
277
  this.drawing = false
244
278
  const svg = this.svg as SVGSVGElement
245
279
  svg.removeEventListener('mousemove', this.onMouseMove)
246
280
  svg.removeEventListener('mouseup', this.onMouseUp)
247
281
 
248
- this.selectedShape!.selected = true
282
+ if (this.selectedShape) {
283
+ this.selectedShape.selected = true
284
+ }
285
+
249
286
  this.requestUpdate()
250
287
  }
251
288
 
252
- // SVG 좌표계로 마우스 좌표 변환
253
289
  private getSvgPoint(event: MouseEvent) {
254
290
  const svg = this.svg
255
291
  const point = svg.createSVGPoint()
@@ -259,11 +295,10 @@ export class OxImageMarker extends BaseImageMarker {
259
295
  return point.matrixTransform(ctm)
260
296
  }
261
297
 
262
- // 도형 렌더링 함수
263
298
  protected renderShape(shape: Shape) {
264
299
  const commonAttrs = {
265
300
  stroke: shape.selected ? 'blue' : 'red',
266
- 'stroke-width': shape.selected ? 2 : 1,
301
+ 'stroke-width': 1,
267
302
  fill: 'transparent',
268
303
  cursor: 'pointer'
269
304
  }
@@ -271,12 +306,11 @@ export class OxImageMarker extends BaseImageMarker {
271
306
  switch (shape.type) {
272
307
  case 'text':
273
308
  return svg`
274
-
275
309
  <text
276
310
  x="${shape.x!}"
277
311
  y="${shape.y!}"
278
312
  fill="${commonAttrs.stroke}"
279
- style="cursor: ${commonAttrs.cursor}; font-size: 16px; text-decoration: ${shape.selected ? 'underline' : 'none'};"
313
+ style="cursor: ${commonAttrs.cursor}; font-size: 16px;"
280
314
  @mousedown="${(e: MouseEvent) => this.onShapeMouseDown(e, shape)}"
281
315
  @dblclick="${(e: MouseEvent) => this.onTextDoubleClick(e, shape)}"
282
316
  >${shape.text}</text>
@@ -377,7 +411,6 @@ export class OxImageMarker extends BaseImageMarker {
377
411
 
378
412
  this.selectedShape = this.shapes.find(s => s.id == shape.id)!
379
413
 
380
- // 마우스 클릭 좌표와 도형의 현재 좌표 차이 계산
381
414
  const { x, y } = this.getSvgPoint(event)
382
415
 
383
416
  switch (shape.type) {
@@ -404,20 +437,16 @@ export class OxImageMarker extends BaseImageMarker {
404
437
  break
405
438
  }
406
439
 
407
- // 드래그 이벤트 리스너 추가
408
440
  const svg = this.svg as SVGSVGElement
409
441
  svg.addEventListener('mousemove', this.onShapeMouseMove)
410
442
  svg.addEventListener('mouseup', this.onShapeMouseUp)
411
443
  }
412
444
 
413
445
  private onShapeMouseMove = (event: MouseEvent) => {
414
- if (!this.selectedShape) {
415
- return
416
- }
446
+ if (!this.selectedShape) return
417
447
 
418
448
  const { x, y } = this.getSvgPoint(event)
419
449
 
420
- // 마우스 이동에 따라 도형의 위치 업데이트
421
450
  switch (this.selectedShape.type) {
422
451
  case 'text':
423
452
  case 'rectangle':
@@ -449,7 +478,7 @@ export class OxImageMarker extends BaseImageMarker {
449
478
  break
450
479
  }
451
480
 
452
- this.requestUpdate() // 화면 업데이트
481
+ this.requestUpdate()
453
482
  }
454
483
 
455
484
  private onShapeMouseUp = () => {
@@ -459,9 +488,7 @@ export class OxImageMarker extends BaseImageMarker {
459
488
  }
460
489
 
461
490
  private getScale(): number {
462
- if (!this.svg) {
463
- return 1
464
- }
491
+ if (!this.svg) return 1
465
492
 
466
493
  const [vbWidth, vbHeight] = this.viewBox.split(' ').map(Number)
467
494
 
@@ -593,34 +620,124 @@ export class OxImageMarker extends BaseImageMarker {
593
620
  private onTextDoubleClick(event: MouseEvent, shape: Shape) {
594
621
  event.stopPropagation()
595
622
 
596
- var input: HTMLInputElement | null = document.createElement('input')
623
+ const textElement = event.target as SVGTextElement
624
+
625
+ this.showTextInput(textElement, shape)
626
+ }
627
+
628
+ private showTextInput(textElement: SVGTextElement, shape: Shape | null = null, options?: { x: number; y: number }) {
629
+ const input: HTMLInputElement = document.createElement('input')
597
630
  input.style.position = 'absolute'
598
- input.style.left = `${event.clientX}px`
599
- input.style.top = `${event.clientY}px`
600
- input.value = shape.text || ''
631
+
632
+ const ctm = textElement.getScreenCTM() || { a: 1, e: 0, d: 1, f: 0 }
633
+ const bbox = textElement.getBBox()
634
+
635
+ const screenX = bbox.x * ctm.a + ctm.e
636
+ const screenY = bbox.y * ctm.d + ctm.f
637
+
638
+ input.style.left = `${screenX}px`
639
+ input.style.top = `${screenY}px`
640
+
641
+ const computedStyle = window.getComputedStyle(textElement)
642
+ const fontSize = parseFloat(computedStyle.fontSize) * ctm.a
643
+ const fontFamily = computedStyle.fontFamily
644
+ const fontWeight = computedStyle.fontWeight
645
+ const textColor = computedStyle.fill || computedStyle.color
646
+ const lineHeight = parseFloat(computedStyle.lineHeight) * ctm.d
647
+
648
+ input.style.fontSize = `${fontSize}px`
649
+ input.style.fontFamily = fontFamily
650
+ input.style.fontWeight = fontWeight
651
+ input.style.color = textColor
652
+ input.style.lineHeight = `${lineHeight}px`
653
+ input.style.padding = '0'
654
+ input.style.margin = '0'
655
+ input.style.border = 'none'
656
+ input.style.outline = 'none'
657
+ input.style.background = 'transparent'
658
+
659
+ if (shape) {
660
+ input.value = shape.text || ''
661
+ }
662
+
663
+ textElement.style.visibility = 'hidden'
664
+
601
665
  document.body.appendChild(input)
602
- input.focus()
666
+ this.adjustInputWidth(input)
667
+ requestAnimationFrame(() => input.focus())
668
+
669
+ input.addEventListener('input', () => {
670
+ this.adjustInputWidth(input)
671
+ })
603
672
 
604
673
  input.addEventListener('keydown', e => {
605
674
  if (e.key === 'Enter') {
606
- shape.text = input!.value
607
- document.body.removeChild(input!)
608
- input = null
675
+ const textValue = input.value.trim()
676
+
677
+ if (textValue) {
678
+ if (shape) {
679
+ shape.text = textValue
680
+ } else {
681
+ const newShape: Shape = {
682
+ id: Date.now().toString(),
683
+ type: 'text',
684
+ x: options!.x,
685
+ y: options!.y,
686
+ text: textValue
687
+ }
688
+
689
+ this.shapes = [...this.shapes, newShape]
690
+ }
691
+ } else if (shape) {
692
+ this.shapes = this.shapes.filter(s => s.id !== shape.id)
693
+ }
694
+
695
+ document.body.removeChild(input)
696
+
697
+ if (!shape) {
698
+ this.svg.removeChild(textElement)
699
+ } else {
700
+ textElement.style.visibility = 'visible'
701
+ }
609
702
 
610
703
  this.requestUpdate()
611
704
  } else if (e.key === 'Escape') {
612
- document.body.removeChild(input!)
613
- input = null
705
+ document.body.removeChild(input)
614
706
 
615
- this.requestUpdate()
707
+ if (!shape) {
708
+ this.svg.removeChild(textElement)
709
+ } else {
710
+ textElement.style.visibility = 'visible'
711
+ }
616
712
  }
617
713
  })
618
714
 
619
715
  input.addEventListener('blur', () => {
620
716
  requestAnimationFrame(() => {
621
- input && document.body.removeChild(input)
622
- input = null
717
+ if (!shape) {
718
+ this.svg.removeChild(textElement)
719
+ } else {
720
+ textElement.style.visibility = 'visible'
721
+ }
722
+
723
+ if (input.parentElement) {
724
+ document.body.removeChild(input)
725
+ }
623
726
  })
624
727
  })
625
728
  }
729
+
730
+ private adjustInputWidth(input: HTMLInputElement) {
731
+ const minWidth = 50
732
+ const textWidth = this.measureTextWidth(input.value || input.placeholder, input)
733
+ input.style.width = `${Math.max(textWidth + 10, minWidth)}px`
734
+ }
735
+
736
+ private measureTextWidth(text: string, input: HTMLInputElement): number {
737
+ const canvas = document.createElement('canvas')
738
+ const context = canvas.getContext('2d')!
739
+ context.font = window.getComputedStyle(input).font
740
+
741
+ return context.measureText(text).width
742
+ }
626
743
  }