@operato/image-marker 8.0.0-alpha.33 → 8.0.0-alpha.38

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,15 @@
1
- import { LitElement, css, html, svg, nothing } from 'lit'
1
+ import { LitElement, css, html, svg, nothing, PropertyValues } from 'lit'
2
2
  import { property, state, query } from 'lit/decorators.js'
3
+ import { debounce } from 'lodash-es'
3
4
 
4
- import { Shape } from './types'
5
+ import {
6
+ GestureEventDoubleTap,
7
+ GestureEventDrag,
8
+ GestureEventPinch,
9
+ GestureHelper
10
+ } from '@operato/utils/gesture-helper.js'
11
+ import { ImageProvider, Shape } from './types'
12
+ import { DefaultImageProvider } from './default-image-provider'
5
13
 
6
14
  export class BaseImageMarker extends LitElement {
7
15
  static styles = [
@@ -22,6 +30,11 @@ export class BaseImageMarker extends LitElement {
22
30
  cursor: pointer;
23
31
  width: 100%;
24
32
  height: 100%;
33
+ touch-action: none;
34
+ }
35
+
36
+ .link:hover {
37
+ cursor: pointer;
25
38
  }
26
39
  `
27
40
  ]
@@ -29,217 +42,174 @@ export class BaseImageMarker extends LitElement {
29
42
  @property({ type: String }) imageUrl: string = ''
30
43
  @property({ type: Array }) shapes: Shape[] = []
31
44
 
45
+ @state() protected currentImage?: string
32
46
  @state() protected viewBox = '0 0 800 600'
33
47
  @state() protected zoomLevel = 1
48
+ @state() private imageProvider?: ImageProvider = new DefaultImageProvider()
34
49
 
50
+ protected debouncedUpdateImage = debounce(this.updateImage.bind(this), 300)
35
51
  protected zoomFactor = 0.1
36
52
  protected zoomThreshold = 0.05
37
- protected isPanning = false
38
- protected panStart = { x: 0, y: 0 }
39
- protected panInitiated = false
40
- protected panThreshold = 20
41
53
 
42
- protected initialViewBox = '0 0 800 600'
43
- private initialPinchDistance: number | null = null
44
- private initialZoomLevel: number = 1
45
- private lastTouchEnd: number = 0
54
+ protected initialViewBox: string | null = null
46
55
 
47
56
  @query('svg') protected svg!: SVGSVGElement
57
+ @query('image') protected image!: SVGImageElement
48
58
 
49
- protected onImageLoad(event: Event) {
50
- const img = new Image()
51
- img.onload = () => {
52
- const width = img.naturalWidth
53
- const height = img.naturalHeight
54
- this.viewBox = `0 0 ${width} ${height}`
55
- this.initialViewBox = `0 0 ${width} ${height}`
56
- }
57
- img.src = this.imageUrl
58
- }
59
-
60
- protected onSvgWheel(event: WheelEvent) {
61
- event.preventDefault()
59
+ private gestureHelper: GestureHelper | null = null
62
60
 
63
- const { offsetX, offsetY, deltaY } = event
64
- const scale = deltaY > 0 ? 1 - this.zoomFactor : 1 + this.zoomFactor
65
- if (Math.abs(scale - 1) > this.zoomThreshold) {
66
- this.zoomLevel *= scale
67
-
68
- const [vbX, vbY, vbWidth, vbHeight] = this.viewBox.split(' ').map(Number)
69
- const mouseX = vbX + (offsetX / this.svg.clientWidth) * vbWidth
70
- const mouseY = vbY + (offsetY / this.svg.clientHeight) * vbHeight
61
+ connectedCallback(): void {
62
+ super.connectedCallback()
63
+ requestAnimationFrame(() => {
64
+ this.gestureHelper = new GestureHelper(this.svg)
65
+ })
66
+ }
71
67
 
72
- const newVbWidth = vbWidth / scale
73
- const newVbHeight = vbHeight / scale
68
+ disconnectedCallback(): void {
69
+ super.disconnectedCallback()
74
70
 
75
- const newVbX = mouseX - (offsetX / this.svg.clientWidth) * newVbWidth
76
- const newVbY = mouseY - (offsetY / this.svg.clientHeight) * newVbHeight
71
+ this.gestureHelper?.dispose()
72
+ this.gestureHelper = null
73
+ }
77
74
 
78
- this.viewBox = `${newVbX} ${newVbY} ${newVbWidth} ${newVbHeight}`
75
+ protected updated(changes: PropertyValues): void {
76
+ if (changes.has('imageUrl') || changes.has('imageProvider')) {
77
+ this.initialViewBox = null
78
+ this.updateImage()
79
79
  }
80
80
  }
81
81
 
82
- protected onSvgMouseDown(event: MouseEvent) {
83
- event.preventDefault()
84
-
85
- this.startPanning(event)
86
-
87
- this.svg.addEventListener('mousemove', this.onPanMouseMove)
88
- this.svg.addEventListener('mouseup', this.onPanMouseUp)
82
+ public setImageProvider(provider: ImageProvider) {
83
+ this.imageProvider = provider
89
84
  }
90
85
 
91
- protected isDoubleTouch(event: TouchEvent) {
92
- const currentTime = new Date().getTime()
93
- const tapLength = currentTime - this.lastTouchEnd
94
-
95
- const result = tapLength < 300 && event.touches.length == 1
96
- this.lastTouchEnd = currentTime
86
+ protected onImageLoad(event: Event) {
87
+ const element = event.target as SVGImageElement
88
+ const img = new Image()
97
89
 
98
- return result
99
- }
90
+ img.onload = () => {
91
+ const width = img.naturalWidth
92
+ const height = img.naturalHeight
100
93
 
101
- protected onSvgTouchStart = (event: TouchEvent) => {
102
- event.preventDefault()
94
+ if (!this.initialViewBox) {
95
+ this.initialViewBox = `0 0 ${width} ${height}`
96
+ this.viewBox = this.initialViewBox
103
97
 
104
- if (this.isDoubleTouch(event)) {
105
- this.resetZoom()
106
- }
98
+ element.setAttribute('width', width.toString())
99
+ element.setAttribute('height', height.toString())
100
+ element.setAttribute('preserveAspectRatio', 'xMidYMid meet')
101
+ } else {
102
+ const [initialX, initialY, initialWidth, initialHeight] = this.initialViewBox.split(' ').map(Number)
103
+ const scale = initialWidth / width
107
104
 
108
- if (event.touches.length === 1) {
109
- this.startPanning(event)
110
- } else if (event.touches.length === 2) {
111
- this.startPinchZooming(event)
105
+ element.setAttribute('width', (width * scale).toString())
106
+ element.setAttribute('height', (height * scale).toString())
107
+ }
112
108
  }
113
- }
114
-
115
- protected startPinchZooming(event: TouchEvent) {
116
- const touch1 = event.touches[0]
117
- const touch2 = event.touches[1]
118
-
119
- const dx = touch2.clientX - touch1.clientX
120
- const dy = touch2.clientY - touch1.clientY
121
109
 
122
- this.initialPinchDistance = Math.sqrt(dx * dx + dy * dy)
123
- this.initialZoomLevel = this.zoomLevel
124
-
125
- this.svg.addEventListener('touchmove', this.onZoomTouchMove)
126
- this.svg.addEventListener('touchend', this.onZoomTouchEnd)
110
+ img.src = this.currentImage!
127
111
  }
128
112
 
129
- protected onZoomTouchMove = (event: TouchEvent) => {
130
- event.preventDefault()
131
-
132
- if (event.touches.length === 2 && this.initialPinchDistance) {
133
- const touch1 = event.touches[0]
134
- const touch2 = event.touches[1]
135
- const dx = touch2.clientX - touch1.clientX
136
- const dy = touch2.clientY - touch1.clientY
137
- const currentPinchDistance = Math.sqrt(dx * dx + dy * dy)
138
-
139
- const scaleFactor = currentPinchDistance / this.initialPinchDistance
140
-
141
- // 줌 threshold 확인 후 줌 적용
142
- if (Math.abs(scaleFactor - 1) > this.zoomThreshold) {
143
- this.zoomLevel = this.initialZoomLevel * scaleFactor
144
- this.applyZoom()
145
- }
113
+ protected async updateImage() {
114
+ if (!this.imageProvider) {
115
+ throw new Error('ImageProvider is not set')
146
116
  }
147
- }
148
117
 
149
- protected onZoomTouchEnd = (event: TouchEvent) => {
150
- event.preventDefault()
118
+ if (!this.imageUrl) {
119
+ return
120
+ }
151
121
 
152
- if (event.touches.length < 2) {
153
- this.initialPinchDistance = null
122
+ const options = {
123
+ id: this.imageUrl, // 이미지 ID는 imageUrl 속성을 사용
124
+ scale: this.zoomLevel, // scale은 zoomLevel 속성을 사용
125
+ offsetX: 0, // offsetX는 0으로 설정
126
+ offsetY: 0, // offsetY는 0으로 설정
127
+ currentImage: this.currentImage // 기존 이미지 객체 전달
154
128
  }
155
129
 
156
- this.svg.removeEventListener('touchmove', this.onZoomTouchMove)
157
- this.svg.removeEventListener('touchend', this.onZoomTouchEnd)
130
+ this.currentImage = await this.imageProvider.getImage(options)
158
131
  }
159
132
 
160
- protected applyZoom() {
161
- const [vbX, vbY, vbWidth, vbHeight] = this.viewBox.split(' ').map(Number)
162
- const initialViewBoxNumbers = this.initialViewBox.split(' ').map(Number)
163
- const newVbWidth = initialViewBoxNumbers[2] / this.zoomLevel
164
- const newVbHeight = initialViewBoxNumbers[3] / this.zoomLevel
165
- const newVbX = vbX + (vbWidth - newVbWidth) / 2
166
- const newVbY = vbY + (vbHeight - newVbHeight) / 2
133
+ protected onSvgWheel(event: WheelEvent) {
134
+ event.preventDefault()
167
135
 
168
- this.viewBox = `${newVbX} ${newVbY} ${newVbWidth} ${newVbHeight}`
169
- }
136
+ const { offsetX, offsetY, deltaY } = event
137
+ const scale = deltaY > 0 ? 1 - this.zoomFactor : 1 + this.zoomFactor
170
138
 
171
- protected onSvgDoubleClick() {
172
- this.resetZoom()
139
+ this.zoom(scale, offsetX, offsetY)
173
140
  }
174
141
 
175
- protected resetZoom() {
176
- this.viewBox = this.initialViewBox
177
- this.zoomLevel = 1
178
- }
142
+ protected onSvgPinch(e: GestureEventPinch) {
143
+ e.stopPropagation()
179
144
 
180
- protected startPanning(event: MouseEvent | TouchEvent) {
181
- const { x, y } = this.getPointFromEvent(event)
145
+ const { scale, centerX, centerY } = e.detail
182
146
 
183
- this.isPanning = true
184
- this.panStart = { x, y }
185
- this.panInitiated = false // 팬 동작이 아직 시작되지 않았음을 나타냄
186
- this.svg.classList.add('dragging')
147
+ console.log('pinch', e.detail)
187
148
 
188
- if ('touches' in event) {
189
- this.svg.addEventListener('touchmove', this.onPanMouseMove)
190
- this.svg.addEventListener('touchend', this.onPanMouseUp)
191
- } else {
192
- this.svg.addEventListener('mousemove', this.onPanMouseMove)
193
- this.svg.addEventListener('mouseup', this.onPanMouseUp)
194
- }
149
+ this.zoom(scale, centerX, centerY)
195
150
  }
196
151
 
197
- protected onPanMouseMove = (event: MouseEvent | TouchEvent) => {
198
- if (!this.isPanning) {
199
- return
200
- }
201
- event.preventDefault()
202
-
203
- const { x, y } = this.getPointFromEvent(event)
152
+ protected onSvgDoubleTap(e: GestureEventDoubleTap) {
153
+ e.stopPropagation()
204
154
 
205
- const dx = x - this.panStart.x
206
- const dy = y - this.panStart.y
207
-
208
- if (!this.panInitiated && Math.sqrt(dx * dx + dy * dy) < this.panThreshold) {
209
- return
210
- }
155
+ this.reset()
156
+ }
211
157
 
212
- this.panInitiated = true
158
+ protected onSvgDrag(e: GestureEventDrag) {
159
+ const { deltaX, deltaY } = e.detail
160
+ console.log('drag', e.detail)
213
161
 
214
162
  const [vbX, vbY, vbWidth, vbHeight] = this.viewBox.split(' ').map(Number)
163
+
215
164
  const scaleX = vbWidth / this.svg.clientWidth
216
165
  const scaleY = vbHeight / this.svg.clientHeight
217
166
  const scale = Math.max(scaleX, scaleY)
218
167
 
219
- const newVbX = vbX - dx * scale
220
- const newVbY = vbY - dy * scale
168
+ const newVbX = vbX - deltaX * scale
169
+ const newVbY = vbY - deltaY * scale
221
170
 
222
171
  this.viewBox = `${newVbX} ${newVbY} ${vbWidth} ${vbHeight}`
223
- this.panStart = { x, y }
172
+
173
+ this.debouncedUpdateImage()
224
174
  }
225
175
 
226
- protected onPanMouseUp = (event: MouseEvent | TouchEvent) => {
227
- event.preventDefault()
176
+ zoom(scale: number, offsetX: number, offsetY: number) {
177
+ if (Math.abs(scale) > this.zoomThreshold) {
178
+ this.zoomLevel *= scale
179
+
180
+ const [vbX, vbY, vbWidth, vbHeight] = this.viewBox.split(' ').map(Number)
181
+ const mouseX = vbX + (offsetX / this.svg.clientWidth) * vbWidth
182
+ const mouseY = vbY + (offsetY / this.svg.clientHeight) * vbHeight
228
183
 
229
- const svg = this.svg
184
+ const newVbWidth = vbWidth / scale
185
+ const newVbHeight = vbHeight / scale
230
186
 
231
- this.isPanning = false
232
- svg.classList.remove('dragging')
187
+ const newVbX = mouseX - (offsetX / this.svg.clientWidth) * newVbWidth
188
+ const newVbY = mouseY - (offsetY / this.svg.clientHeight) * newVbHeight
233
189
 
234
- if ('touches' in event) {
235
- svg.removeEventListener('touchmove', this.onPanMouseMove)
236
- svg.removeEventListener('touchend', this.onPanMouseUp)
237
- } else {
238
- svg.removeEventListener('mousemove', this.onPanMouseMove)
239
- svg.removeEventListener('mouseup', this.onPanMouseUp)
190
+ this.viewBox = `${newVbX} ${newVbY} ${newVbWidth} ${newVbHeight}`
191
+
192
+ this.debouncedUpdateImage()
240
193
  }
241
194
  }
242
195
 
196
+ protected reset() {
197
+ this.viewBox = this.initialViewBox!
198
+ this.zoomLevel = 1
199
+
200
+ this.debouncedUpdateImage()
201
+ }
202
+
203
+ protected handleLinkClick(shape: Shape) {
204
+ this.dispatchEvent(
205
+ new CustomEvent('link-clicked', {
206
+ detail: { shapeId: shape.id, link: shape.link },
207
+ bubbles: true,
208
+ composed: true
209
+ })
210
+ )
211
+ }
212
+
243
213
  protected renderShape(shape: Shape) {
244
214
  const commonAttrs = {
245
215
  stroke: 'red',
@@ -264,21 +234,28 @@ export class BaseImageMarker extends LitElement {
264
234
  </marker>
265
235
  </defs>
266
236
  <line x1="${shape.x1}" y1="${shape.y1}" x2="${shape.x2}" y2="${shape.y2}" stroke="${commonAttrs.stroke}" stroke-width="${commonAttrs['stroke-width']}" marker-end="url(#arrowhead-${shape.id})"></line>`
237
+ case 'link':
238
+ return svg`
239
+ <rect class="link" x="${shape.x}" y="${shape.y}" width="${shape.width}" height="${shape.height}"
240
+ stroke="${commonAttrs.stroke}" stroke-width="${commonAttrs['stroke-width']}"
241
+ fill="${commonAttrs.fill}" fill-opacity="0.5" @click="${() => this.handleLinkClick(shape)}">
242
+ </rect>`
267
243
  default:
268
244
  return nothing
269
245
  }
270
246
  }
271
247
 
272
- protected getPointFromEvent(event: MouseEvent | TouchEvent) {
273
- if ('touches' in event) {
248
+ protected getPointFromEvent(event: MouseEvent | PointerEvent | GestureEventDrag) {
249
+ if ('clientX' in event) {
274
250
  return {
275
- x: event.touches[0].clientX,
276
- y: event.touches[0].clientY
251
+ x: event.clientX,
252
+ y: event.clientY
277
253
  }
278
254
  } else {
255
+ const { clientX, clientY } = event.detail
279
256
  return {
280
- x: event.clientX,
281
- y: event.clientY
257
+ x: clientX,
258
+ y: clientY
282
259
  }
283
260
  }
284
261
  }
@@ -0,0 +1,7 @@
1
+ import { ImageOptions, ImageProvider } from './types'
2
+
3
+ export class DefaultImageProvider implements ImageProvider {
4
+ async getImage(options: ImageOptions): Promise<string> {
5
+ return options.id
6
+ }
7
+ }
package/src/index.ts CHANGED
@@ -0,0 +1,6 @@
1
+ export * from './types.js'
2
+ export * from './base-image-marker.js'
3
+
4
+ export * from './ox-image-marker.js'
5
+ export * from './ox-image-marker-view.js'
6
+ export * from './ox-image-marker-mode.js'
@@ -10,12 +10,12 @@ export class OxImageMarkerView extends BaseImageMarker {
10
10
  <div class="container">
11
11
  <svg
12
12
  @wheel=${this.onSvgWheel}
13
- @mousedown=${this.onSvgMouseDown}
14
- @touchstart=${this.onSvgTouchStart}
15
- @dblclick=${this.onSvgDoubleClick}
13
+ @doubletap=${this.onSvgDoubleTap}
14
+ @pinch=${this.onSvgPinch}
15
+ @drag=${this.onSvgDrag}
16
16
  viewBox=${this.viewBox}
17
17
  >
18
- <image href=${this.imageUrl} @load=${this.onImageLoad}></image>
18
+ <image href=${this.currentImage} @load=${this.onImageLoad}></image>
19
19
  ${this.shapes.map(shape => this.renderShape(shape))}
20
20
  </svg>
21
21
  </div>