makethisbetter 1.0.0

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.
Files changed (168) hide show
  1. package/LICENSE +62 -0
  2. package/README.md +113 -0
  3. package/dist/annotate/draw.d.ts +21 -0
  4. package/dist/annotate/draw.d.ts.map +1 -0
  5. package/dist/annotate/draw.test.d.ts +2 -0
  6. package/dist/annotate/draw.test.d.ts.map +1 -0
  7. package/dist/annotate/highlighter.d.ts +11 -0
  8. package/dist/annotate/highlighter.d.ts.map +1 -0
  9. package/dist/annotate/highlighter.test.d.ts +2 -0
  10. package/dist/annotate/highlighter.test.d.ts.map +1 -0
  11. package/dist/annotate/overlay.d.ts +17 -0
  12. package/dist/annotate/overlay.d.ts.map +1 -0
  13. package/dist/annotate/overlay.test.d.ts +2 -0
  14. package/dist/annotate/overlay.test.d.ts.map +1 -0
  15. package/dist/annotate/pin.d.ts +10 -0
  16. package/dist/annotate/pin.d.ts.map +1 -0
  17. package/dist/annotate/pin.test.d.ts +2 -0
  18. package/dist/annotate/pin.test.d.ts.map +1 -0
  19. package/dist/annotate/session.d.ts +20 -0
  20. package/dist/annotate/session.d.ts.map +1 -0
  21. package/dist/annotate/session.test.d.ts +2 -0
  22. package/dist/annotate/session.test.d.ts.map +1 -0
  23. package/dist/annotate/toolbar.d.ts +17 -0
  24. package/dist/annotate/toolbar.d.ts.map +1 -0
  25. package/dist/annotate/toolbar.test.d.ts +2 -0
  26. package/dist/annotate/toolbar.test.d.ts.map +1 -0
  27. package/dist/api/client.d.ts +10 -0
  28. package/dist/api/client.d.ts.map +1 -0
  29. package/dist/api/client.test.d.ts +2 -0
  30. package/dist/api/client.test.d.ts.map +1 -0
  31. package/dist/context/collector.d.ts +10 -0
  32. package/dist/context/collector.d.ts.map +1 -0
  33. package/dist/context/collector.test.d.ts +2 -0
  34. package/dist/context/collector.test.d.ts.map +1 -0
  35. package/dist/context/console.d.ts +10 -0
  36. package/dist/context/console.d.ts.map +1 -0
  37. package/dist/context/console.test.d.ts +2 -0
  38. package/dist/context/console.test.d.ts.map +1 -0
  39. package/dist/context/error-source.d.ts +21 -0
  40. package/dist/context/error-source.d.ts.map +1 -0
  41. package/dist/context/error-source.test.d.ts +2 -0
  42. package/dist/context/error-source.test.d.ts.map +1 -0
  43. package/dist/context/frustration.d.ts +27 -0
  44. package/dist/context/frustration.d.ts.map +1 -0
  45. package/dist/context/frustration.test.d.ts +2 -0
  46. package/dist/context/frustration.test.d.ts.map +1 -0
  47. package/dist/global.d.ts +3 -0
  48. package/dist/global.d.ts.map +1 -0
  49. package/dist/i18n/en.d.ts +47 -0
  50. package/dist/i18n/en.d.ts.map +1 -0
  51. package/dist/i18n/index.d.ts +4 -0
  52. package/dist/i18n/index.d.ts.map +1 -0
  53. package/dist/i18n/index.test.d.ts +2 -0
  54. package/dist/i18n/index.test.d.ts.map +1 -0
  55. package/dist/i18n/zh-CN.d.ts +47 -0
  56. package/dist/i18n/zh-CN.d.ts.map +1 -0
  57. package/dist/index.d.mts +8 -0
  58. package/dist/index.d.ts +8 -0
  59. package/dist/index.d.ts.map +1 -0
  60. package/dist/makethisbetter.cjs.js +84 -0
  61. package/dist/makethisbetter.cjs.js.map +1 -0
  62. package/dist/makethisbetter.esm.js +1433 -0
  63. package/dist/makethisbetter.esm.js.map +1 -0
  64. package/dist/makethisbetter.js +84 -0
  65. package/dist/makethisbetter.js.map +1 -0
  66. package/dist/popup/comment.d.ts +31 -0
  67. package/dist/popup/comment.d.ts.map +1 -0
  68. package/dist/popup/comment.test.d.ts +2 -0
  69. package/dist/popup/comment.test.d.ts.map +1 -0
  70. package/dist/popup/success.d.ts +8 -0
  71. package/dist/popup/success.d.ts.map +1 -0
  72. package/dist/popup/success.test.d.ts +2 -0
  73. package/dist/popup/success.test.d.ts.map +1 -0
  74. package/dist/record/control-bar.d.ts +12 -0
  75. package/dist/record/control-bar.d.ts.map +1 -0
  76. package/dist/record/control-bar.test.d.ts +2 -0
  77. package/dist/record/control-bar.test.d.ts.map +1 -0
  78. package/dist/record/session.d.ts +26 -0
  79. package/dist/record/session.d.ts.map +1 -0
  80. package/dist/record/session.test.d.ts +2 -0
  81. package/dist/record/session.test.d.ts.map +1 -0
  82. package/dist/screenshot/capture.d.ts +3 -0
  83. package/dist/screenshot/capture.d.ts.map +1 -0
  84. package/dist/screenshot/capture.test.d.ts +2 -0
  85. package/dist/screenshot/capture.test.d.ts.map +1 -0
  86. package/dist/styles.d.ts +9 -0
  87. package/dist/styles.d.ts.map +1 -0
  88. package/dist/types.d.ts +45 -0
  89. package/dist/types.d.ts.map +1 -0
  90. package/dist/widget/controller.d.ts +37 -0
  91. package/dist/widget/controller.d.ts.map +1 -0
  92. package/dist/widget/controller.test.d.ts +2 -0
  93. package/dist/widget/controller.test.d.ts.map +1 -0
  94. package/dist/widget/frustration-prompt.d.ts +11 -0
  95. package/dist/widget/frustration-prompt.d.ts.map +1 -0
  96. package/dist/widget/frustration-prompt.test.d.ts +2 -0
  97. package/dist/widget/frustration-prompt.test.d.ts.map +1 -0
  98. package/dist/widget/payload.d.ts +5 -0
  99. package/dist/widget/payload.d.ts.map +1 -0
  100. package/dist/widget/payload.test.d.ts +2 -0
  101. package/dist/widget/payload.test.d.ts.map +1 -0
  102. package/dist/widget/pet.d.ts +12 -0
  103. package/dist/widget/pet.d.ts.map +1 -0
  104. package/dist/widget/pet.test.d.ts +2 -0
  105. package/dist/widget/pet.test.d.ts.map +1 -0
  106. package/dist/widget/shadow.d.ts +11 -0
  107. package/dist/widget/shadow.d.ts.map +1 -0
  108. package/dist/widget/shadow.test.d.ts +2 -0
  109. package/dist/widget/shadow.test.d.ts.map +1 -0
  110. package/dist/widget/tab.d.ts +10 -0
  111. package/dist/widget/tab.d.ts.map +1 -0
  112. package/dist/widget/tab.test.d.ts +2 -0
  113. package/dist/widget/tab.test.d.ts.map +1 -0
  114. package/package.json +64 -0
  115. package/src/annotate/draw.test.ts +168 -0
  116. package/src/annotate/draw.ts +108 -0
  117. package/src/annotate/highlighter.test.ts +47 -0
  118. package/src/annotate/highlighter.ts +84 -0
  119. package/src/annotate/overlay.test.ts +91 -0
  120. package/src/annotate/overlay.ts +44 -0
  121. package/src/annotate/pin.test.ts +87 -0
  122. package/src/annotate/pin.ts +28 -0
  123. package/src/annotate/session.test.ts +219 -0
  124. package/src/annotate/session.ts +104 -0
  125. package/src/annotate/toolbar.test.ts +132 -0
  126. package/src/annotate/toolbar.ts +81 -0
  127. package/src/api/client.test.ts +75 -0
  128. package/src/api/client.ts +77 -0
  129. package/src/context/collector.test.ts +14 -0
  130. package/src/context/collector.ts +41 -0
  131. package/src/context/console.test.ts +44 -0
  132. package/src/context/console.ts +34 -0
  133. package/src/context/error-source.test.ts +115 -0
  134. package/src/context/error-source.ts +62 -0
  135. package/src/context/frustration.test.ts +134 -0
  136. package/src/context/frustration.ts +145 -0
  137. package/src/global.ts +3 -0
  138. package/src/i18n/en.ts +47 -0
  139. package/src/i18n/index.test.ts +24 -0
  140. package/src/i18n/index.ts +16 -0
  141. package/src/i18n/zh-CN.ts +47 -0
  142. package/src/index.ts +32 -0
  143. package/src/popup/comment.test.ts +73 -0
  144. package/src/popup/comment.ts +177 -0
  145. package/src/popup/success.test.ts +75 -0
  146. package/src/popup/success.ts +33 -0
  147. package/src/record/control-bar.test.ts +110 -0
  148. package/src/record/control-bar.ts +46 -0
  149. package/src/record/session.test.ts +153 -0
  150. package/src/record/session.ts +139 -0
  151. package/src/screenshot/capture.test.ts +68 -0
  152. package/src/screenshot/capture.ts +96 -0
  153. package/src/styles/widget.css +847 -0
  154. package/src/styles.ts +9 -0
  155. package/src/types.ts +46 -0
  156. package/src/vite-env.d.ts +6 -0
  157. package/src/widget/controller.test.ts +62 -0
  158. package/src/widget/controller.ts +249 -0
  159. package/src/widget/frustration-prompt.test.ts +86 -0
  160. package/src/widget/frustration-prompt.ts +34 -0
  161. package/src/widget/payload.test.ts +167 -0
  162. package/src/widget/payload.ts +31 -0
  163. package/src/widget/pet.test.ts +63 -0
  164. package/src/widget/pet.ts +67 -0
  165. package/src/widget/shadow.test.ts +25 -0
  166. package/src/widget/shadow.ts +41 -0
  167. package/src/widget/tab.test.ts +92 -0
  168. package/src/widget/tab.ts +31 -0
@@ -0,0 +1,91 @@
1
+ import { afterEach, describe, it, expect, vi } from 'vitest'
2
+ import { ShadowContainer } from '../widget/shadow'
3
+ import { AnnotationOverlay } from './overlay'
4
+
5
+ describe('AnnotationOverlay', () => {
6
+ let shadow: ShadowContainer
7
+ let handlers: {
8
+ onMouseDown: ReturnType<typeof vi.fn>
9
+ onMouseMove: ReturnType<typeof vi.fn>
10
+ onMouseUp: ReturnType<typeof vi.fn>
11
+ onMouseLeave: ReturnType<typeof vi.fn>
12
+ }
13
+ let overlay: AnnotationOverlay
14
+
15
+ function setup() {
16
+ shadow = new ShadowContainer()
17
+ handlers = {
18
+ onMouseDown: vi.fn(),
19
+ onMouseMove: vi.fn(),
20
+ onMouseUp: vi.fn(),
21
+ onMouseLeave: vi.fn(),
22
+ }
23
+ overlay = new AnnotationOverlay(shadow, handlers)
24
+ }
25
+
26
+ afterEach(() => {
27
+ document.body.innerHTML = ''
28
+ })
29
+
30
+ describe('constructor', () => {
31
+ it('creates overlay and dim elements in the shadow root', () => {
32
+ setup()
33
+ expect(shadow.root.querySelector('.mtb-overlay')).toBeInstanceOf(HTMLDivElement)
34
+ expect(shadow.root.querySelector('.mtb-dim')).toBeInstanceOf(HTMLDivElement)
35
+ overlay.destroy()
36
+ })
37
+ })
38
+
39
+ describe('mouse event handlers', () => {
40
+ it('dispatches mousedown to the handler', () => {
41
+ setup()
42
+ const overlayEl = shadow.root.querySelector('.mtb-overlay')!
43
+ overlayEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
44
+ expect(handlers.onMouseDown).toHaveBeenCalledOnce()
45
+ overlay.destroy()
46
+ })
47
+
48
+ it('dispatches mousemove to the handler', () => {
49
+ setup()
50
+ const overlayEl = shadow.root.querySelector('.mtb-overlay')!
51
+ overlayEl.dispatchEvent(new MouseEvent('mousemove', { bubbles: true }))
52
+ expect(handlers.onMouseMove).toHaveBeenCalledOnce()
53
+ overlay.destroy()
54
+ })
55
+
56
+ it('dispatches mouseup to the handler', () => {
57
+ setup()
58
+ const overlayEl = shadow.root.querySelector('.mtb-overlay')!
59
+ overlayEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
60
+ expect(handlers.onMouseUp).toHaveBeenCalledOnce()
61
+ overlay.destroy()
62
+ })
63
+
64
+ it('dispatches mouseleave to the handler', () => {
65
+ setup()
66
+ const overlayEl = shadow.root.querySelector('.mtb-overlay')!
67
+ overlayEl.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }))
68
+ expect(handlers.onMouseLeave).toHaveBeenCalledOnce()
69
+ overlay.destroy()
70
+ })
71
+ })
72
+
73
+ describe('destroy', () => {
74
+ it('removes overlay and dim elements from the shadow root', () => {
75
+ setup()
76
+ overlay.destroy()
77
+ expect(shadow.root.querySelector('.mtb-overlay')).toBeNull()
78
+ expect(shadow.root.querySelector('.mtb-dim')).toBeNull()
79
+ })
80
+
81
+ it('removes event listeners so handlers are not called after destroy', () => {
82
+ setup()
83
+ const overlayEl = shadow.root.querySelector('.mtb-overlay')!
84
+ overlay.destroy()
85
+
86
+ handlers.onMouseDown.mockClear()
87
+ overlayEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
88
+ expect(handlers.onMouseDown).not.toHaveBeenCalled()
89
+ })
90
+ })
91
+ })
@@ -0,0 +1,44 @@
1
+ import type { ShadowContainer } from '../widget/shadow'
2
+
3
+ export class AnnotationOverlay {
4
+ private overlayEl: HTMLDivElement
5
+ private dimEl: HTMLDivElement
6
+ private onMouseDown: (e: MouseEvent) => void
7
+ private onMouseMove: (e: MouseEvent) => void
8
+ private onMouseUp: (e: MouseEvent) => void
9
+ private onMouseLeave: (e: MouseEvent) => void
10
+
11
+ constructor(
12
+ shadow: ShadowContainer,
13
+ handlers: {
14
+ onMouseDown: (e: MouseEvent) => void
15
+ onMouseMove: (e: MouseEvent) => void
16
+ onMouseUp: (e: MouseEvent) => void
17
+ onMouseLeave: (e: MouseEvent) => void
18
+ },
19
+ ) {
20
+ this.onMouseDown = handlers.onMouseDown
21
+ this.onMouseMove = handlers.onMouseMove
22
+ this.onMouseUp = handlers.onMouseUp
23
+ this.onMouseLeave = handlers.onMouseLeave
24
+
25
+ this.overlayEl = shadow.el<HTMLDivElement>('div', 'mtb-overlay')
26
+ this.overlayEl.addEventListener('mousedown', this.onMouseDown)
27
+ this.overlayEl.addEventListener('mousemove', this.onMouseMove)
28
+ this.overlayEl.addEventListener('mouseup', this.onMouseUp)
29
+ this.overlayEl.addEventListener('mouseleave', this.onMouseLeave)
30
+ shadow.append(this.overlayEl)
31
+
32
+ this.dimEl = shadow.el<HTMLDivElement>('div', 'mtb-dim')
33
+ shadow.append(this.dimEl)
34
+ }
35
+
36
+ destroy(): void {
37
+ this.overlayEl.removeEventListener('mousedown', this.onMouseDown)
38
+ this.overlayEl.removeEventListener('mousemove', this.onMouseMove)
39
+ this.overlayEl.removeEventListener('mouseup', this.onMouseUp)
40
+ this.overlayEl.removeEventListener('mouseleave', this.onMouseLeave)
41
+ this.overlayEl.remove()
42
+ this.dimEl.remove()
43
+ }
44
+ }
@@ -0,0 +1,87 @@
1
+ import { afterEach, describe, it, expect } from 'vitest'
2
+ import { ShadowContainer } from '../widget/shadow'
3
+ import { PinMarker } from './pin'
4
+
5
+ describe('PinMarker', () => {
6
+ let shadow: ShadowContainer
7
+ let pin: PinMarker
8
+
9
+ function setup() {
10
+ shadow = new ShadowContainer()
11
+ pin = new PinMarker(shadow)
12
+ }
13
+
14
+ afterEach(() => {
15
+ document.body.innerHTML = ''
16
+ })
17
+
18
+ describe('constructor', () => {
19
+ it('creates an instance without adding any pins', () => {
20
+ setup()
21
+ expect(shadow.root.querySelectorAll('.mtb-pin').length).toBe(0)
22
+ pin.destroy()
23
+ })
24
+ })
25
+
26
+ describe('addPin', () => {
27
+ it('adds a pin element to the shadow root', () => {
28
+ setup()
29
+ pin.addPin(100, 200, 1)
30
+ const pinEl = shadow.root.querySelector('.mtb-pin')
31
+ expect(pinEl).toBeInstanceOf(HTMLDivElement)
32
+ expect(pinEl!.textContent).toBe('1')
33
+ expect((pinEl as HTMLDivElement).style.top).toBe('200px')
34
+ expect((pinEl as HTMLDivElement).style.left).toBe('100px')
35
+ pin.destroy()
36
+ })
37
+
38
+ it('adds multiple pins with sequential indices', () => {
39
+ setup()
40
+ pin.addPin(10, 20, 1)
41
+ pin.addPin(30, 40, 2)
42
+ pin.addPin(50, 60, 3)
43
+ const pins = shadow.root.querySelectorAll('.mtb-pin')
44
+ expect(pins.length).toBe(3)
45
+ expect(pins[0].textContent).toBe('1')
46
+ expect(pins[1].textContent).toBe('2')
47
+ expect(pins[2].textContent).toBe('3')
48
+ pin.destroy()
49
+ })
50
+ })
51
+
52
+ describe('clearAll', () => {
53
+ it('removes all pin elements from the shadow root', () => {
54
+ setup()
55
+ pin.addPin(10, 20, 1)
56
+ pin.addPin(30, 40, 2)
57
+ pin.clearAll()
58
+ expect(shadow.root.querySelectorAll('.mtb-pin').length).toBe(0)
59
+ pin.destroy()
60
+ })
61
+
62
+ it('is safe to call when no pins exist', () => {
63
+ setup()
64
+ expect(() => pin.clearAll()).not.toThrow()
65
+ pin.destroy()
66
+ })
67
+
68
+ it('allows adding new pins after clearing', () => {
69
+ setup()
70
+ pin.addPin(10, 20, 1)
71
+ pin.clearAll()
72
+ pin.addPin(50, 60, 1)
73
+ expect(shadow.root.querySelectorAll('.mtb-pin').length).toBe(1)
74
+ pin.destroy()
75
+ })
76
+ })
77
+
78
+ describe('destroy', () => {
79
+ it('removes all pins via clearAll', () => {
80
+ setup()
81
+ pin.addPin(10, 20, 1)
82
+ pin.addPin(30, 40, 2)
83
+ pin.destroy()
84
+ expect(shadow.root.querySelectorAll('.mtb-pin').length).toBe(0)
85
+ })
86
+ })
87
+ })
@@ -0,0 +1,28 @@
1
+ import type { ShadowContainer } from '../widget/shadow'
2
+
3
+ export class PinMarker {
4
+ private pins: HTMLDivElement[] = []
5
+ private shadow: ShadowContainer
6
+
7
+ constructor(shadow: ShadowContainer) {
8
+ this.shadow = shadow
9
+ }
10
+
11
+ addPin(x: number, y: number, index: number): void {
12
+ const pin = this.shadow.el<HTMLDivElement>('div', 'mtb-pin')
13
+ pin.textContent = String(index)
14
+ pin.style.top = `${y}px`
15
+ pin.style.left = `${x}px`
16
+ this.shadow.append(pin)
17
+ this.pins.push(pin)
18
+ }
19
+
20
+ clearAll(): void {
21
+ for (const pin of this.pins) pin.remove()
22
+ this.pins = []
23
+ }
24
+
25
+ destroy(): void {
26
+ this.clearAll()
27
+ }
28
+ }
@@ -0,0 +1,219 @@
1
+ import { afterEach, beforeEach, describe, it, expect, vi } from 'vitest'
2
+ import { ShadowContainer } from '../widget/shadow'
3
+ import { AnnotationSession } from './session'
4
+ import { getMessages } from '../i18n'
5
+
6
+ const SVG_SEL = 'svg[class="mtb-draw-svg"]'
7
+
8
+ describe('AnnotationSession', () => {
9
+ let shadow: ShadowContainer
10
+ let session: AnnotationSession
11
+ let onReady: ReturnType<typeof vi.fn>
12
+ const messages = getMessages('en')
13
+
14
+ beforeEach(() => {
15
+ // jsdom does not implement elementFromPoint — stub it
16
+ document.elementFromPoint = vi.fn().mockReturnValue(null)
17
+ })
18
+
19
+ function setup() {
20
+ shadow = new ShadowContainer()
21
+ onReady = vi.fn()
22
+ session = new AnnotationSession(shadow, messages, onReady)
23
+ }
24
+
25
+ afterEach(() => {
26
+ document.body.innerHTML = ''
27
+ vi.restoreAllMocks()
28
+ })
29
+
30
+ describe('constructor', () => {
31
+ it('creates overlay, highlighter, pin, and draw elements in shadow root', () => {
32
+ setup()
33
+ expect(shadow.root.querySelector('.mtb-overlay')).toBeInstanceOf(HTMLDivElement)
34
+ expect(shadow.root.querySelector('.mtb-highlight')).toBeInstanceOf(HTMLDivElement)
35
+ expect(shadow.root.querySelector(SVG_SEL)).toBeTruthy()
36
+ expect(shadow.root.querySelector('.mtb-dim')).toBeInstanceOf(HTMLDivElement)
37
+ session.destroy()
38
+ })
39
+ })
40
+
41
+ describe('pin annotation via short click', () => {
42
+ it('creates a pin annotation on mousedown + mouseup with fewer than 5 points', () => {
43
+ setup()
44
+ const overlayEl = shadow.root.querySelector('.mtb-overlay')!
45
+
46
+ overlayEl.dispatchEvent(
47
+ new MouseEvent('mousedown', { clientX: 100, clientY: 200, bubbles: true }),
48
+ )
49
+ overlayEl.dispatchEvent(
50
+ new MouseEvent('mouseup', { clientX: 100, clientY: 200, bubbles: true }),
51
+ )
52
+
53
+ expect(onReady).toHaveBeenCalledOnce()
54
+ const [annotation] = onReady.mock.calls[0]
55
+ expect(annotation.type).toBe('pin')
56
+ expect(annotation.x).toBe(100)
57
+ expect(annotation.y).toBe(200)
58
+ session.destroy()
59
+ })
60
+
61
+ it('adds a pin marker to the shadow root', () => {
62
+ setup()
63
+ const overlayEl = shadow.root.querySelector('.mtb-overlay')!
64
+
65
+ overlayEl.dispatchEvent(
66
+ new MouseEvent('mousedown', { clientX: 50, clientY: 50, bubbles: true }),
67
+ )
68
+ overlayEl.dispatchEvent(
69
+ new MouseEvent('mouseup', { clientX: 50, clientY: 50, bubbles: true }),
70
+ )
71
+
72
+ const pin = shadow.root.querySelector('.mtb-pin')
73
+ expect(pin).toBeInstanceOf(HTMLDivElement)
74
+ expect(pin!.textContent).toBe('1')
75
+ session.destroy()
76
+ })
77
+
78
+ it('increments pin count on successive clicks', () => {
79
+ setup()
80
+ const overlayEl = shadow.root.querySelector('.mtb-overlay')!
81
+
82
+ for (let i = 0; i < 3; i++) {
83
+ overlayEl.dispatchEvent(
84
+ new MouseEvent('mousedown', { clientX: 10 * i, clientY: 10 * i, bubbles: true }),
85
+ )
86
+ overlayEl.dispatchEvent(
87
+ new MouseEvent('mouseup', { clientX: 10 * i, clientY: 10 * i, bubbles: true }),
88
+ )
89
+ }
90
+
91
+ const pins = shadow.root.querySelectorAll('.mtb-pin')
92
+ expect(pins.length).toBe(3)
93
+ expect(pins[0].textContent).toBe('1')
94
+ expect(pins[1].textContent).toBe('2')
95
+ expect(pins[2].textContent).toBe('3')
96
+ session.destroy()
97
+ })
98
+
99
+ it('uses element name from elementFromPoint when an element is found', () => {
100
+ setup()
101
+ const btn = document.createElement('button')
102
+ btn.setAttribute('aria-label', 'Save')
103
+ ;(document.elementFromPoint as ReturnType<typeof vi.fn>).mockReturnValue(btn)
104
+
105
+ const overlayEl = shadow.root.querySelector('.mtb-overlay')!
106
+ overlayEl.dispatchEvent(
107
+ new MouseEvent('mousedown', { clientX: 50, clientY: 50, bubbles: true }),
108
+ )
109
+ overlayEl.dispatchEvent(
110
+ new MouseEvent('mouseup', { clientX: 50, clientY: 50, bubbles: true }),
111
+ )
112
+
113
+ const [annotation, targetName] = onReady.mock.calls[0]
114
+ expect(targetName).toBe('Save')
115
+ expect(annotation.targetName).toBe('Save')
116
+ session.destroy()
117
+ })
118
+
119
+ it('falls back to messages.annotation.element when no element under cursor', () => {
120
+ setup()
121
+ ;(document.elementFromPoint as ReturnType<typeof vi.fn>).mockReturnValue(null)
122
+
123
+ const overlayEl = shadow.root.querySelector('.mtb-overlay')!
124
+ overlayEl.dispatchEvent(
125
+ new MouseEvent('mousedown', { clientX: 50, clientY: 50, bubbles: true }),
126
+ )
127
+ overlayEl.dispatchEvent(
128
+ new MouseEvent('mouseup', { clientX: 50, clientY: 50, bubbles: true }),
129
+ )
130
+
131
+ const [, targetName] = onReady.mock.calls[0]
132
+ expect(targetName).toBe(messages.annotation.element)
133
+ session.destroy()
134
+ })
135
+ })
136
+
137
+ describe('draw annotation via drag', () => {
138
+ it('creates a draw annotation when enough points are collected', () => {
139
+ setup()
140
+ const overlayEl = shadow.root.querySelector('.mtb-overlay')!
141
+
142
+ overlayEl.dispatchEvent(
143
+ new MouseEvent('mousedown', { clientX: 0, clientY: 0, bubbles: true }),
144
+ )
145
+ for (let i = 1; i <= 5; i++) {
146
+ overlayEl.dispatchEvent(
147
+ new MouseEvent('mousemove', { clientX: i * 10, clientY: i * 10, bubbles: true }),
148
+ )
149
+ }
150
+ overlayEl.dispatchEvent(
151
+ new MouseEvent('mouseup', { clientX: 50, clientY: 50, bubbles: true }),
152
+ )
153
+
154
+ expect(onReady).toHaveBeenCalledOnce()
155
+ const [annotation, targetName] = onReady.mock.calls[0]
156
+ expect(annotation.type).toBe('draw')
157
+ expect(annotation.drawPath).toBeDefined()
158
+ expect(targetName).toBe(messages.annotation.drawing)
159
+ session.destroy()
160
+ })
161
+ })
162
+
163
+ describe('mouseleave', () => {
164
+ it('ends drawing when mouse leaves the overlay', () => {
165
+ setup()
166
+ const overlayEl = shadow.root.querySelector('.mtb-overlay')!
167
+
168
+ overlayEl.dispatchEvent(
169
+ new MouseEvent('mousedown', { clientX: 0, clientY: 0, bubbles: true }),
170
+ )
171
+ for (let i = 1; i <= 5; i++) {
172
+ overlayEl.dispatchEvent(
173
+ new MouseEvent('mousemove', { clientX: i * 10, clientY: i * 10, bubbles: true }),
174
+ )
175
+ }
176
+ overlayEl.dispatchEvent(new MouseEvent('mouseleave', { bubbles: true }))
177
+
178
+ expect(onReady).toHaveBeenCalledOnce()
179
+ session.destroy()
180
+ })
181
+ })
182
+
183
+ describe('dismissInteraction', () => {
184
+ it('removes overlay and highlighter but keeps pins and draw layer', () => {
185
+ setup()
186
+ session.dismissInteraction()
187
+
188
+ expect(shadow.root.querySelector('.mtb-overlay')).toBeNull()
189
+ expect(shadow.root.querySelector('.mtb-highlight')).toBeNull()
190
+ expect(shadow.root.querySelector(SVG_SEL)).toBeTruthy()
191
+ session.destroy()
192
+ })
193
+
194
+ it('is idempotent — second call does not throw', () => {
195
+ setup()
196
+ session.dismissInteraction()
197
+ expect(() => session.dismissInteraction()).not.toThrow()
198
+ session.destroy()
199
+ })
200
+ })
201
+
202
+ describe('destroy', () => {
203
+ it('removes all elements from the shadow root', () => {
204
+ setup()
205
+ session.destroy()
206
+
207
+ expect(shadow.root.querySelector('.mtb-overlay')).toBeNull()
208
+ expect(shadow.root.querySelector('.mtb-highlight')).toBeNull()
209
+ expect(shadow.root.querySelector('.mtb-pin')).toBeNull()
210
+ expect(shadow.root.querySelector(SVG_SEL)).toBeNull()
211
+ })
212
+
213
+ it('is safe to call after dismissInteraction', () => {
214
+ setup()
215
+ session.dismissInteraction()
216
+ expect(() => session.destroy()).not.toThrow()
217
+ })
218
+ })
219
+ })
@@ -0,0 +1,104 @@
1
+ import type { ShadowContainer } from '../widget/shadow'
2
+ import type { I18nMessages } from '../i18n'
3
+ import type { Annotation } from '../types'
4
+ import { AnnotationOverlay } from './overlay'
5
+ import { ElementHighlighter, getElementName, getCssSelector } from './highlighter'
6
+ import { PinMarker } from './pin'
7
+ import { DrawLayer } from './draw'
8
+
9
+ const DRAW_MIN_POINTS = 5
10
+
11
+ export class AnnotationSession {
12
+ private overlay: AnnotationOverlay
13
+ private highlighter: ElementHighlighter
14
+ private pinMarker: PinMarker
15
+ private drawLayer: DrawLayer
16
+ private pinCount = 0
17
+ private interactionActive = true
18
+
19
+ constructor(
20
+ shadow: ShadowContainer,
21
+ private messages: I18nMessages,
22
+ private onReady: (annotation: Annotation, targetName: string) => void,
23
+ ) {
24
+ this.highlighter = new ElementHighlighter(shadow)
25
+ this.pinMarker = new PinMarker(shadow)
26
+ this.drawLayer = new DrawLayer(shadow, (annotation) => {
27
+ this.onReady(annotation, this.messages.annotation.drawing)
28
+ })
29
+ this.overlay = new AnnotationOverlay(shadow, {
30
+ onMouseDown: (e) => this.handleMouseDown(e),
31
+ onMouseMove: (e) => this.handleMouseMove(e),
32
+ onMouseUp: (e) => this.handleMouseUp(e),
33
+ onMouseLeave: () => {
34
+ if (this.drawLayer.isCurrentlyDrawing()) this.drawLayer.endDraw()
35
+ this.highlighter.hide()
36
+ },
37
+ })
38
+ }
39
+
40
+ private handleMouseDown(e: MouseEvent): void {
41
+ e.preventDefault()
42
+ this.drawLayer.startDraw(e.clientX, e.clientY)
43
+ }
44
+
45
+ private handleMouseMove(e: MouseEvent): void {
46
+ if (this.drawLayer.isCurrentlyDrawing()) {
47
+ this.drawLayer.continueDraw(e.clientX, e.clientY)
48
+ this.highlighter.hide()
49
+ return
50
+ }
51
+
52
+ const underEl = getElementUnder(e.clientX, e.clientY)
53
+ if (underEl) this.highlighter.highlight(underEl)
54
+ else this.highlighter.hide()
55
+ }
56
+
57
+ private handleMouseUp(e: MouseEvent): void {
58
+ if (!this.drawLayer.isCurrentlyDrawing()) return
59
+
60
+ if (this.drawLayer.getPointCount() >= DRAW_MIN_POINTS) {
61
+ this.drawLayer.endDraw()
62
+ return
63
+ }
64
+
65
+ this.drawLayer.cancelCurrentDraw()
66
+ const underEl = getElementUnder(e.clientX, e.clientY)
67
+ const targetName = underEl ? getElementName(underEl) : this.messages.annotation.element
68
+ const annotation: Annotation = {
69
+ type: 'pin',
70
+ x: e.clientX,
71
+ y: e.clientY,
72
+ targetSelector: underEl ? getCssSelector(underEl) : undefined,
73
+ targetText: underEl?.textContent?.trim().slice(0, 200) ?? '',
74
+ targetName,
75
+ }
76
+ this.pinMarker.addPin(e.clientX, e.clientY, ++this.pinCount)
77
+ this.drawLayer.clearAll()
78
+ this.highlighter.hide()
79
+ this.onReady(annotation, targetName)
80
+ }
81
+
82
+ dismissInteraction(): void {
83
+ if (!this.interactionActive) return
84
+ this.interactionActive = false
85
+ this.overlay.destroy()
86
+ this.highlighter.destroy()
87
+ }
88
+
89
+ destroy(): void {
90
+ this.dismissInteraction()
91
+ this.pinMarker.destroy()
92
+ this.drawLayer.destroy()
93
+ }
94
+ }
95
+
96
+ function getElementUnder(x: number, y: number): Element | null {
97
+ const host = document.getElementById('mtb-widget-host')
98
+ if (host) host.style.display = 'none'
99
+
100
+ const el = document.elementFromPoint(x, y)
101
+
102
+ if (host) host.style.display = ''
103
+ return el
104
+ }