@operato/image-marker 8.0.0-beta.0 → 8.0.0-beta.1

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,857 +0,0 @@
1
- import './ox-image-marker-mode'
2
-
3
- import { html, css, svg, nothing } from 'lit'
4
- import { query, state, customElement } from 'lit/decorators.js'
5
- import { ifDefined } from 'lit/directives/if-defined.js'
6
- import { keyed } from 'lit/directives/keyed.js'
7
-
8
- import { GestureEventDrag, GestureEventPinch } from '@operato/utils/gesture-helper.js'
9
-
10
- import { ImageMarkerMode, Shape, ShapeType } from './types'
11
- import { BaseImageMarker } from './base-image-marker'
12
- import { OxImageMarkerMode } from './ox-image-marker-mode'
13
-
14
- @customElement('ox-image-marker')
15
- export class OxImageMarker extends BaseImageMarker {
16
- static styles = [
17
- ...BaseImageMarker.styles,
18
- css`
19
- :host {
20
- position: relative;
21
- display: flex;
22
- width: 100%;
23
- height: 100%;
24
- }
25
-
26
- ox-image-marker-mode {
27
- position: absolute;
28
- top: 0;
29
- right: 0;
30
- }
31
-
32
- svg {
33
- cursor: default;
34
- }
35
-
36
- svg[drawing] {
37
- cursor: crosshair;
38
- }
39
-
40
- .resize-handle {
41
- cursor: nwse-resize;
42
- }
43
- `
44
- ]
45
-
46
- @state() private currentShapeType?: ShapeType
47
- @state() private currentMode: ImageMarkerMode = 'edit'
48
- @state() private selectedShape: Shape | null = null
49
-
50
- @query('ox-image-marker-mode') imageMarkerMode!: OxImageMarkerMode
51
-
52
- // private drawingStarted: boolean = false
53
- private drawing: boolean = false
54
- private startX: number = 0
55
- private startY: number = 0
56
- private offsetX: number = 0
57
- private offsetY: number = 0
58
- private shapeMoveThreshold: number = 10
59
- private shapeMoveInitiated = false
60
-
61
- connectedCallback() {
62
- super.connectedCallback()
63
- this.setAttribute('tabindex', '0')
64
- this.addEventListener('focus', this.onFocus)
65
- this.addEventListener('blur', this.onBlur)
66
- }
67
-
68
- disconnectedCallback() {
69
- super.disconnectedCallback()
70
- this.removeEventListener('focus', this.onFocus)
71
- this.removeEventListener('blur', this.onBlur)
72
- }
73
-
74
- render() {
75
- return html`
76
- <div class="container">
77
- <svg
78
- @wheel=${this.onSvgWheel}
79
- @doubletap=${this.onSvgDoubleTap}
80
- @pinch=${this.onSvgPinch}
81
- @drag=${this.onSvgDrag}
82
- @click=${this.onSvgClick}
83
- viewBox=${this.viewBox}
84
- ?drawing=${this.currentMode == 'shape'}
85
- >
86
- <image href=${this.currentImage} @load=${this.onImageLoad}></image>
87
- ${this.shapes.map(shape => keyed(shape.id, this.renderShape(shape)))}
88
- </svg>
89
- </div>
90
-
91
- <ox-image-marker-mode
92
- currentMode=${this.currentMode}
93
- currentShapeType=${ifDefined(this.currentShapeType)}
94
- @mode-changed=${(e: CustomEvent) => {
95
- const { mode, shapeType } = e.detail
96
- this.setMode(mode, shapeType)
97
- }}
98
- @tap-fit-size=${() => this.reset()}
99
- @tap-delete=${() => this.deleteSelectedShape()}
100
- @tap-save=${() => {
101
- this.dispatchEvent(
102
- new CustomEvent('shapes-changed', {
103
- detail: this.shapes,
104
- bubbles: true,
105
- composed: true
106
- })
107
- )
108
- }}
109
- ></ox-image-marker-mode>
110
- `
111
- }
112
-
113
- private setMode(mode: ImageMarkerMode, shapeType: ShapeType) {
114
- if (mode == 'view') {
115
- this.setSelectedShape(null)
116
- }
117
-
118
- this.currentMode = mode
119
- this.currentShapeType = shapeType
120
- }
121
-
122
- protected onSvgPinch(e: GestureEventPinch) {
123
- e.stopPropagation()
124
-
125
- const { scale, centerX, centerY } = e.detail
126
-
127
- this.zoom(scale, centerX, centerY)
128
- }
129
-
130
- protected onSvgDrag(e: GestureEventDrag) {
131
- const { deltaX, deltaY, end } = e.detail
132
-
133
- if (this.currentMode === 'shape') {
134
- this.drawShape(e)
135
- } else {
136
- const [vbX, vbY, vbWidth, vbHeight] = this.viewBox.split(' ').map(Number)
137
-
138
- const scaleX = vbWidth / this.svg.clientWidth
139
- const scaleY = vbHeight / this.svg.clientHeight
140
- const scale = Math.max(scaleX, scaleY)
141
-
142
- const newVbX = vbX - deltaX * scale
143
- const newVbY = vbY - deltaY * scale
144
-
145
- this.viewBox = `${newVbX} ${newVbY} ${vbWidth} ${vbHeight}`
146
-
147
- this.debouncedUpdateImage()
148
- }
149
- }
150
-
151
- protected onSvgClick(e: MouseEvent) {
152
- if (this.currentMode === 'shape' && this.currentShapeType === 'text') {
153
- const { x, y } = this.getSvgPoint(e)
154
-
155
- const textElement = document.createElementNS('http://www.w3.org/2000/svg', 'text')
156
-
157
- textElement.setAttribute('x', x.toString())
158
- textElement.setAttribute('y', y.toString())
159
- textElement.textContent = 'ABC'
160
-
161
- this.svg.appendChild(textElement)
162
-
163
- /* 강제 지연은 iOS에서 input focus를 받지 못하는 현상이 있다. */
164
- this.showTextInput(textElement, null, { x, y })
165
-
166
- this.imageMarkerMode.setMode('edit')
167
- }
168
- }
169
-
170
- private drawShape(e: GestureEventDrag) {
171
- const { end } = e.detail
172
- const { x, y } = this.getSvgPoint(e)
173
-
174
- if (!this.drawing) {
175
- this.startX = x
176
- this.startY = y
177
- this.setSelectedShape(null)
178
-
179
- this.drawing = true
180
- } else if (this.drawing) {
181
- const minDistance = 5
182
- const distance = Math.sqrt(Math.pow(x - this.startX, 2) + Math.pow(y - this.startY, 2))
183
-
184
- if (!this.selectedShape && distance >= minDistance) {
185
- let newShape: Shape
186
-
187
- switch (this.currentShapeType) {
188
- case 'rectangle':
189
- newShape = {
190
- id: Date.now().toString(),
191
- type: 'rectangle',
192
- x: this.startX,
193
- y: this.startY,
194
- width: 0,
195
- height: 0
196
- }
197
- break
198
- case 'circle':
199
- newShape = {
200
- id: Date.now().toString(),
201
- type: 'circle',
202
- cx: this.startX,
203
- cy: this.startY,
204
- r: 0
205
- }
206
- break
207
- case 'triangle':
208
- newShape = {
209
- id: Date.now().toString(),
210
- type: 'triangle',
211
- x1: this.startX,
212
- y1: this.startY,
213
- width: 0,
214
- height: 0,
215
- points: `${this.startX},${this.startY} ${this.startX},${this.startY} ${this.startX},${this.startY}`
216
- }
217
- break
218
- case 'line':
219
- newShape = {
220
- id: Date.now().toString(),
221
- type: 'line',
222
- x1: this.startX,
223
- y1: this.startY,
224
- width: 0,
225
- height: 0
226
- }
227
- break
228
- case 'arrow':
229
- newShape = {
230
- id: Date.now().toString(),
231
- type: 'arrow',
232
- x1: this.startX,
233
- y1: this.startY,
234
- width: 0,
235
- height: 0
236
- }
237
- break
238
- default:
239
- return
240
- }
241
-
242
- this.shapes = [...this.shapes, newShape]
243
- this.setSelectedShape(newShape)
244
- }
245
-
246
- if (this.selectedShape) {
247
- switch (this.selectedShape.type) {
248
- case 'rectangle':
249
- this.selectedShape.x = Math.min(x, this.startX)
250
- this.selectedShape.y = Math.min(y, this.startY)
251
- this.selectedShape.width = Math.abs(x - this.startX)
252
- this.selectedShape.height = Math.abs(y - this.startY)
253
- break
254
- case 'circle':
255
- const dx = x - this.selectedShape.cx!
256
- const dy = y - this.selectedShape.cy!
257
- this.selectedShape.r = Math.sqrt(dx * dx + dy * dy)
258
- break
259
- case 'triangle':
260
- const x2 = x
261
- const y2 = y
262
- const x3 = this.startX - (x - this.startX)
263
- const y3 = y
264
- this.selectedShape.points = `${this.startX},${this.startY} ${x2},${y2} ${x3},${y3}`
265
- break
266
- case 'line':
267
- case 'arrow':
268
- this.selectedShape.x2 = x
269
- this.selectedShape.y2 = y
270
- break
271
- }
272
-
273
- this.requestUpdate()
274
- }
275
-
276
- if (end) {
277
- this.drawing = false
278
- this.imageMarkerMode.setMode('edit')
279
-
280
- this.requestUpdate()
281
- }
282
- }
283
- }
284
-
285
- private setSelectedShape(shape: Shape | null) {
286
- this.selectedShape = shape
287
-
288
- if (!shape) {
289
- this.shapes = this.shapes.map(s => {
290
- return { ...s, selected: false }
291
- })
292
- } else {
293
- this.selectedShape!.selected = true
294
- }
295
- }
296
-
297
- private getSvgPoint(event: MouseEvent | PointerEvent | GestureEventDrag) {
298
- const { x, y } = this.getPointFromEvent(event)
299
-
300
- const svg = this.svg
301
- const point = svg.createSVGPoint()
302
- point.x = x
303
- point.y = y
304
- const ctm = svg.getScreenCTM()!.inverse()
305
-
306
- return point.matrixTransform(ctm)
307
- }
308
-
309
- protected renderShape(shape: Shape) {
310
- const commonAttrs = {
311
- stroke: shape.selected ? 'blue' : 'red',
312
- 'stroke-width': 1,
313
- fill: 'transparent',
314
- cursor: 'pointer'
315
- }
316
-
317
- switch (shape.type) {
318
- case 'text':
319
- return svg`
320
- <text
321
- x="${shape.x!}"
322
- y="${shape.y!}"
323
- fill="${commonAttrs.stroke}"
324
- style="cursor: ${commonAttrs.cursor}; font-size: 16px;"
325
- @pointerdown="${(e: PointerEvent) => this.onPointerDownOnShape(e, shape)}"
326
- @dblclick="${(e: MouseEvent) => this.onTextDoubleClick(e, shape)}"
327
- >${shape.text}</text>
328
- `
329
-
330
- case 'rectangle':
331
- return svg`
332
- <rect
333
- x="${shape.x!}"
334
- y="${shape.y!}"
335
- width="${shape.width!}"
336
- height="${shape.height!}"
337
- stroke="${commonAttrs.stroke}"
338
- stroke-width="${commonAttrs['stroke-width']}"
339
- fill="${commonAttrs.fill}"
340
- style="cursor: ${commonAttrs.cursor}"
341
- @pointerdown="${(e: PointerEvent) => this.onPointerDownOnShape(e, shape)}"
342
- ></rect>
343
- ${shape.selected ? this.renderResizeHandles(shape) : ''}
344
- `
345
-
346
- case 'circle':
347
- return svg`
348
- <circle
349
- cx="${shape.cx!}"
350
- cy="${shape.cy!}"
351
- r="${shape.r!}"
352
- stroke="${commonAttrs.stroke}"
353
- stroke-width="${commonAttrs['stroke-width']}"
354
- fill="${commonAttrs.fill}"
355
- style="cursor: ${commonAttrs.cursor}"
356
- @pointerdown="${(e: PointerEvent) => this.onPointerDownOnShape(e, shape)}"
357
- ></circle>
358
- ${shape.selected ? this.renderResizeHandles(shape) : ''}
359
- `
360
-
361
- case 'triangle':
362
- return svg`
363
- <polygon
364
- points="${shape.points!}"
365
- stroke="${commonAttrs.stroke}"
366
- stroke-width="${commonAttrs['stroke-width']}"
367
- fill="${commonAttrs.fill}"
368
- style="cursor: ${commonAttrs.cursor}"
369
- @pointerdown="${(e: PointerEvent) => this.onPointerDownOnShape(e, shape)}"
370
- ></polygon>
371
- ${shape.selected ? this.renderResizeHandles(shape) : ''}
372
- `
373
-
374
- case 'line':
375
- return svg`
376
- <line
377
- x1="${shape.x1!}"
378
- y1="${shape.y1!}"
379
- x2="${shape.x2 ?? shape.x1}"
380
- y2="${shape.y2 ?? shape.y1}"
381
- stroke="${commonAttrs.stroke}"
382
- stroke-width="${commonAttrs['stroke-width']}"
383
- style="cursor: ${commonAttrs.cursor}"
384
- ></line>
385
- <line
386
- x1="${shape.x1!}"
387
- y1="${shape.y1!}"
388
- x2="${shape.x2 ?? shape.x1}"
389
- y2="${shape.y2 ?? shape.y1}"
390
- stroke="${commonAttrs.stroke}"
391
- stroke-width="25"
392
- stroke-opacity="0"
393
- pointer-events="stroke"
394
- @pointerdown="${(e: PointerEvent) => this.onPointerDownOnShape(e, shape)}"
395
- @mouseenter="${() => (this.svg.style.cursor = 'pointer')}"
396
- @mouseleave="${() => (this.svg.style.cursor = 'default')}"
397
- ></line>
398
- ${shape.selected ? this.renderResizeHandles(shape) : ''}
399
- `
400
-
401
- case 'arrow':
402
- return svg`
403
- <defs>
404
- <marker id="arrowhead-${shape.id}" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
405
- <polygon points="0 0, 10 3.5, 0 7" fill="${commonAttrs.stroke}"></polygon>
406
- </marker>
407
- </defs>
408
- <line
409
- x1="${shape.x1!}"
410
- y1="${shape.y1!}"
411
- x2="${shape.x2 ?? shape.x1}"
412
- y2="${shape.y2 ?? shape.y1}"
413
- stroke="${commonAttrs.stroke}"
414
- stroke-width="${commonAttrs['stroke-width']}"
415
- marker-end="url(#arrowhead-${shape.id})"
416
- style="cursor: ${commonAttrs.cursor}"
417
- ></line>
418
- <line
419
- x1="${shape.x1!}"
420
- y1="${shape.y1!}"
421
- x2="${shape.x2 ?? shape.x1}"
422
- y2="${shape.y2 ?? shape.y1}"
423
- stroke="${commonAttrs.stroke}"
424
- stroke-width="25"
425
- stroke-opacity="0"
426
- pointer-events="stroke"
427
- @mousedown="${(e: PointerEvent) => this.onPointerDownOnShape(e, shape)}"
428
- @mouseenter="${() => (this.svg.style.cursor = 'pointer')}"
429
- @mouseleave="${() => (this.svg.style.cursor = 'default')}"
430
- @pointerdown="${(e: PointerEvent) => this.currentMode == 'edit' && e.stopPropagation()}"
431
- ></line>
432
- ${shape.selected ? this.renderResizeHandles(shape) : ''}
433
- `
434
-
435
- case 'link':
436
- return svg`
437
- <rect class="link" x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}"
438
- stroke="${commonAttrs.stroke}" stroke-width="0"
439
- fill="${commonAttrs.stroke}" fill-opacity="0.3" @click="${() => this.handleLinkClick(shape)}">
440
- </rect>
441
- `
442
- default:
443
- return nothing
444
- }
445
- }
446
-
447
- private onPointerDownOnShape(event: PointerEvent, shape: Shape) {
448
- if (this.currentMode !== 'edit') {
449
- return
450
- }
451
-
452
- // TODO check if single touch ... (멀티터치를 제외해야한다).
453
- // if ('touches' in event && event.touches.length > 1) {
454
- // return
455
- // }
456
-
457
- event.stopPropagation()
458
- event.preventDefault()
459
-
460
- this.shapes = this.shapes.map(s => ({
461
- ...s,
462
- selected: s.id === shape.id
463
- }))
464
-
465
- this.shapeMoveInitiated = false
466
- this.setSelectedShape(this.shapes.find(s => s.id == shape.id)!)
467
-
468
- const { x, y } = this.getSvgPoint(event)
469
-
470
- this.offsetX = x
471
- this.offsetY = y
472
-
473
- document.addEventListener('pointermove', this.onPointerMoveOnShape)
474
- document.addEventListener('pointerup', this.onPointerUpOnShape)
475
- }
476
-
477
- private onPointerMoveOnShape = (event: PointerEvent) => {
478
- if (!this.selectedShape) {
479
- return
480
- }
481
-
482
- // TODO check if single touch ... (멀티터치를 제외해야한다).
483
- // if ('touches' in event && event.touches.length > 1) {
484
- // return
485
- // }
486
-
487
- event.preventDefault()
488
-
489
- const { x, y } = this.getSvgPoint(event)
490
-
491
- const dx = x - this.offsetX
492
- const dy = y - this.offsetY
493
-
494
- if (!this.shapeMoveInitiated) {
495
- const distance = Math.sqrt(dx * dx + dy * dy)
496
-
497
- if (distance < this.shapeMoveThreshold) {
498
- return
499
- }
500
-
501
- this.shapeMoveInitiated = true
502
- }
503
-
504
- this.offsetX = x
505
- this.offsetY = y
506
-
507
- switch (this.selectedShape.type) {
508
- case 'text':
509
- case 'rectangle':
510
- this.selectedShape.x! += dx
511
- this.selectedShape.y! += dy
512
- break
513
- case 'circle':
514
- this.selectedShape.cx! += dx
515
- this.selectedShape.cy! += dy
516
- break
517
- case 'triangle':
518
- const points = this.selectedShape.points!.split(' ').map(p => p.split(',').map(Number))
519
-
520
- this.selectedShape.points = points.map(([px, py]) => [px + dx, py + dy].join(',')).join(' ')
521
- break
522
- case 'line':
523
- case 'arrow':
524
- this.selectedShape.x1! += dx
525
- this.selectedShape.x2! += dx
526
- this.selectedShape.y1! += dy
527
- this.selectedShape.y2! += dy
528
- break
529
- }
530
-
531
- this.requestUpdate()
532
- }
533
-
534
- private onPointerUpOnShape = (event: PointerEvent) => {
535
- if (!this.selectedShape) {
536
- return
537
- }
538
-
539
- // TODO
540
- // if ('touches' in event && event.touches.length > 1) {
541
- // return
542
- // }
543
-
544
- event.preventDefault()
545
-
546
- document.removeEventListener('pointermove', this.onPointerMoveOnShape)
547
- document.removeEventListener('pointerup', this.onPointerUpOnShape)
548
- }
549
-
550
- private renderResizeHandles(shape: Shape) {
551
- let handles: Array<{ x: number; y: number; cursor: string }> = []
552
-
553
- switch (shape.type) {
554
- case 'text':
555
- case 'rectangle':
556
- handles = [
557
- { x: shape.x!, y: shape.y!, cursor: 'nwse-resize' },
558
- { x: shape.x! + shape.width!, y: shape.y!, cursor: 'nesw-resize' },
559
- { x: shape.x!, y: shape.y! + shape.height!, cursor: 'nesw-resize' },
560
- { x: shape.x! + shape.width!, y: shape.y! + shape.height!, cursor: 'nwse-resize' }
561
- ]
562
- break
563
- case 'circle':
564
- handles = [
565
- { x: shape.cx! + shape.r!, y: shape.cy!, cursor: 'ew-resize' },
566
- { x: shape.cx! - shape.r!, y: shape.cy!, cursor: 'ew-resize' },
567
- { x: shape.cx!, y: shape.cy! + shape.r!, cursor: 'ns-resize' },
568
- { x: shape.cx!, y: shape.cy! - shape.r!, cursor: 'ns-resize' }
569
- ]
570
- break
571
- case 'triangle':
572
- const points = shape.points!.split(' ').map(p => p.split(',').map(Number))
573
- handles = points.map(([x, y]) => ({ x, y, cursor: 'move' }))
574
- break
575
- case 'line':
576
- case 'arrow':
577
- handles = [
578
- { x: shape.x1!, y: shape.y1!, cursor: 'move' },
579
- { x: shape.x2!, y: shape.y2!, cursor: 'move' }
580
- ]
581
- break
582
- }
583
-
584
- return svg`
585
- ${handles.map(
586
- handle => svg`
587
- <g
588
- class="resize-handle-group"
589
- style="transform: translate(${handle.x}px, ${handle.y}px) scale(${1 / this.zoomLevel});"
590
- >
591
- <rect
592
- x="-5"
593
- y="-5"
594
- width="10"
595
- height="10"
596
- fill="white"
597
- stroke="black"
598
- class="resize-handle"
599
- style="cursor: ${handle.cursor};"
600
- @pointerdown="${(e: PointerEvent) => this.onResizeHandlePointerDown(e, shape, handle)}"
601
- ></rect>
602
- </g>
603
- `
604
- )}
605
- `
606
- }
607
-
608
- private onResizeHandlePointerDown(event: PointerEvent, shape: Shape, handle: any) {
609
- event.stopPropagation()
610
- event.preventDefault()
611
-
612
- this.setSelectedShape(shape)
613
-
614
- this.offsetX = handle.x
615
- this.offsetY = handle.y
616
-
617
- document.addEventListener('pointermove', this.onResizeHandlePointerMove)
618
- document.addEventListener('pointerup', this.onResizeHandlePointerUp)
619
- }
620
-
621
- private onResizeHandlePointerMove = (event: PointerEvent) => {
622
- if (!this.selectedShape) {
623
- return
624
- }
625
-
626
- event.preventDefault()
627
-
628
- const { x, y } = this.getSvgPoint(event)
629
-
630
- switch (this.selectedShape.type) {
631
- case 'rectangle':
632
- const startX = this.selectedShape.x!
633
- const startY = this.selectedShape.y!
634
- const width = this.selectedShape.width!
635
- const height = this.selectedShape.height!
636
-
637
- // 핸들의 좌표에 따라 너비 및 높이 조정
638
- if (this.offsetX === startX && this.offsetY === startY) {
639
- // 상좌 핸들 (왼쪽 위)
640
- this.selectedShape.x = x
641
- this.selectedShape.y = y
642
- this.selectedShape.width = width + (startX - x)
643
- this.selectedShape.height = height + (startY - y)
644
- } else if (this.offsetX === startX + width && this.offsetY === startY) {
645
- // 상우 핸들 (오른쪽 위)
646
- this.selectedShape.y = y
647
- this.selectedShape.width = Math.abs(x - startX)
648
- this.selectedShape.height = height + (startY - y)
649
- } else if (this.offsetX === startX && this.offsetY === startY + height) {
650
- // 하좌 핸들 (왼쪽 아래)
651
- this.selectedShape.x = x
652
- this.selectedShape.width = width + (startX - x)
653
- this.selectedShape.height = Math.abs(y - startY)
654
- } else if (this.offsetX === startX + width && this.offsetY === startY + height) {
655
- // 하우 핸들 (오른쪽 아래)
656
- this.selectedShape.width = Math.abs(x - startX)
657
- this.selectedShape.height = Math.abs(y - startY)
658
- }
659
-
660
- // 최소 너비 및 높이 유지
661
- this.selectedShape.width = Math.max(10, this.selectedShape.width!)
662
- this.selectedShape.height = Math.max(10, this.selectedShape.height!)
663
-
664
- this.offsetX = x
665
- this.offsetY = y
666
-
667
- break
668
- case 'circle':
669
- const dx = x - this.selectedShape.cx!
670
- const dy = y - this.selectedShape.cy!
671
- this.selectedShape.r = Math.sqrt(dx * dx + dy * dy)
672
- break
673
- case 'triangle':
674
- const points = this.selectedShape.points!.split(' ').map(p => p.split(',').map(Number))
675
- const index = points.findIndex(([px, py]) => px === this.offsetX && py === this.offsetY)
676
- if (index !== -1) {
677
- points[index] = [x, y]
678
- this.selectedShape.points = points.map(p => p.join(',')).join(' ')
679
- this.offsetX = x
680
- this.offsetY = y
681
- }
682
- break
683
- case 'line':
684
- case 'arrow':
685
- if (this.offsetX === this.selectedShape.x1 && this.offsetY === this.selectedShape.y1) {
686
- this.selectedShape.x1 = x
687
- this.selectedShape.y1 = y
688
- } else if (this.offsetX === this.selectedShape.x2 && this.offsetY === this.selectedShape.y2) {
689
- this.selectedShape.x2 = x
690
- this.selectedShape.y2 = y
691
- }
692
- this.offsetX = x
693
- this.offsetY = y
694
- break
695
- }
696
-
697
- this.requestUpdate()
698
- }
699
-
700
- private onResizeHandlePointerUp = (event: PointerEvent) => {
701
- event.preventDefault()
702
-
703
- document.removeEventListener('pointermove', this.onResizeHandlePointerMove)
704
- document.removeEventListener('pointerup', this.onResizeHandlePointerUp)
705
- }
706
-
707
- private onTextDoubleClick(event: MouseEvent, shape: Shape) {
708
- event.stopPropagation()
709
- event.preventDefault()
710
-
711
- const textElement = event.target as SVGTextElement
712
-
713
- this.showTextInput(textElement, shape)
714
- }
715
-
716
- private showTextInput(textElement: SVGTextElement, shape: Shape | null = null, options?: { x: number; y: number }) {
717
- const input: HTMLInputElement = document.createElement('input')
718
- input.style.position = 'absolute'
719
-
720
- const ctm = textElement.getScreenCTM() || { a: 1, e: 0, d: 1, f: 0 }
721
- const bbox = textElement.getBBox()
722
-
723
- const screenX = bbox.x * ctm.a + ctm.e
724
- const screenY = bbox.y * ctm.d + ctm.f
725
-
726
- input.style.left = `${screenX}px`
727
- input.style.top = `${screenY}px`
728
-
729
- const computedStyle = window.getComputedStyle(textElement)
730
- const fontSize = parseFloat(computedStyle.fontSize) * ctm.a
731
- const fontFamily = computedStyle.fontFamily
732
- const fontWeight = computedStyle.fontWeight
733
- const textColor = computedStyle.fill || computedStyle.color
734
- const lineHeight = parseFloat(computedStyle.lineHeight) * ctm.d
735
-
736
- input.style.fontSize = `${fontSize}px`
737
- input.style.fontFamily = fontFamily
738
- input.style.fontWeight = fontWeight
739
- input.style.color = textColor
740
- input.style.lineHeight = `${lineHeight}px`
741
- input.style.padding = '0'
742
- input.style.margin = '0'
743
- input.style.border = 'none'
744
- input.style.outline = 'none'
745
- input.style.background = 'transparent'
746
-
747
- if (shape) {
748
- input.value = shape.text || ''
749
- }
750
-
751
- textElement.style.visibility = 'hidden'
752
-
753
- document.body.appendChild(input)
754
- this.adjustInputWidth(input)
755
-
756
- /* iOS에서는 딜레이를 주었을 때 focus()를 받지 못하는 현상이 있으므로, delay 없이 바로 focus를 설정함. */
757
- input.focus()
758
-
759
- input.addEventListener('input', () => {
760
- this.adjustInputWidth(input)
761
- })
762
-
763
- input.addEventListener('keydown', e => {
764
- if (e.key === 'Enter') {
765
- const textValue = input.value.trim()
766
-
767
- if (textValue) {
768
- if (shape) {
769
- shape.text = textValue
770
- } else {
771
- const newShape: Shape = {
772
- id: Date.now().toString(),
773
- type: 'text',
774
- x: options!.x,
775
- y: options!.y,
776
- text: textValue
777
- }
778
-
779
- this.shapes = [...this.shapes, newShape]
780
- }
781
- } else if (shape) {
782
- this.shapes = this.shapes.filter(s => s.id !== shape.id)
783
- }
784
-
785
- document.body.removeChild(input)
786
-
787
- if (!shape) {
788
- this.svg.removeChild(textElement)
789
- } else {
790
- textElement.style.visibility = 'visible'
791
- }
792
-
793
- this.requestUpdate()
794
- } else if (e.key === 'Escape') {
795
- document.body.removeChild(input)
796
-
797
- if (!shape) {
798
- this.svg.removeChild(textElement)
799
- } else {
800
- textElement.style.visibility = 'visible'
801
- }
802
- }
803
- })
804
-
805
- input.addEventListener('blur', () => {
806
- requestAnimationFrame(() => {
807
- if (!shape) {
808
- this.svg.removeChild(textElement)
809
- } else {
810
- textElement.style.visibility = 'visible'
811
- }
812
-
813
- if (input.parentElement) {
814
- document.body.removeChild(input)
815
- }
816
- })
817
- })
818
- }
819
-
820
- private adjustInputWidth(input: HTMLInputElement) {
821
- const minWidth = 50
822
- const textWidth = this.measureTextWidth(input.value || input.placeholder, input)
823
- input.style.width = `${Math.max(textWidth + 10, minWidth)}px`
824
- }
825
-
826
- private measureTextWidth(text: string, input: HTMLInputElement): number {
827
- const canvas = document.createElement('canvas')
828
- const context = canvas.getContext('2d')!
829
- context.font = window.getComputedStyle(input).font
830
-
831
- return context.measureText(text).width
832
- }
833
-
834
- private onFocus = () => {
835
- this.addEventListener('keydown', this.handleKeyDown)
836
- }
837
-
838
- private onBlur = () => {
839
- this.removeEventListener('keydown', this.handleKeyDown)
840
- }
841
-
842
- private handleKeyDown = (event: KeyboardEvent) => {
843
- if ((event.key === 'Delete' || event.key === 'Backspace') && this.selectedShape) {
844
- this.deleteSelectedShape()
845
- }
846
- }
847
-
848
- private deleteSelectedShape() {
849
- if (this.selectedShape) {
850
- this.shapes = this.shapes.filter(s => s.id !== this.selectedShape!.id)
851
-
852
- this.setSelectedShape(null)
853
-
854
- this.requestUpdate()
855
- }
856
- }
857
- }