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.
- package/LICENSE +62 -0
- package/README.md +113 -0
- package/dist/annotate/draw.d.ts +21 -0
- package/dist/annotate/draw.d.ts.map +1 -0
- package/dist/annotate/draw.test.d.ts +2 -0
- package/dist/annotate/draw.test.d.ts.map +1 -0
- package/dist/annotate/highlighter.d.ts +11 -0
- package/dist/annotate/highlighter.d.ts.map +1 -0
- package/dist/annotate/highlighter.test.d.ts +2 -0
- package/dist/annotate/highlighter.test.d.ts.map +1 -0
- package/dist/annotate/overlay.d.ts +17 -0
- package/dist/annotate/overlay.d.ts.map +1 -0
- package/dist/annotate/overlay.test.d.ts +2 -0
- package/dist/annotate/overlay.test.d.ts.map +1 -0
- package/dist/annotate/pin.d.ts +10 -0
- package/dist/annotate/pin.d.ts.map +1 -0
- package/dist/annotate/pin.test.d.ts +2 -0
- package/dist/annotate/pin.test.d.ts.map +1 -0
- package/dist/annotate/session.d.ts +20 -0
- package/dist/annotate/session.d.ts.map +1 -0
- package/dist/annotate/session.test.d.ts +2 -0
- package/dist/annotate/session.test.d.ts.map +1 -0
- package/dist/annotate/toolbar.d.ts +17 -0
- package/dist/annotate/toolbar.d.ts.map +1 -0
- package/dist/annotate/toolbar.test.d.ts +2 -0
- package/dist/annotate/toolbar.test.d.ts.map +1 -0
- package/dist/api/client.d.ts +10 -0
- package/dist/api/client.d.ts.map +1 -0
- package/dist/api/client.test.d.ts +2 -0
- package/dist/api/client.test.d.ts.map +1 -0
- package/dist/context/collector.d.ts +10 -0
- package/dist/context/collector.d.ts.map +1 -0
- package/dist/context/collector.test.d.ts +2 -0
- package/dist/context/collector.test.d.ts.map +1 -0
- package/dist/context/console.d.ts +10 -0
- package/dist/context/console.d.ts.map +1 -0
- package/dist/context/console.test.d.ts +2 -0
- package/dist/context/console.test.d.ts.map +1 -0
- package/dist/context/error-source.d.ts +21 -0
- package/dist/context/error-source.d.ts.map +1 -0
- package/dist/context/error-source.test.d.ts +2 -0
- package/dist/context/error-source.test.d.ts.map +1 -0
- package/dist/context/frustration.d.ts +27 -0
- package/dist/context/frustration.d.ts.map +1 -0
- package/dist/context/frustration.test.d.ts +2 -0
- package/dist/context/frustration.test.d.ts.map +1 -0
- package/dist/global.d.ts +3 -0
- package/dist/global.d.ts.map +1 -0
- package/dist/i18n/en.d.ts +47 -0
- package/dist/i18n/en.d.ts.map +1 -0
- package/dist/i18n/index.d.ts +4 -0
- package/dist/i18n/index.d.ts.map +1 -0
- package/dist/i18n/index.test.d.ts +2 -0
- package/dist/i18n/index.test.d.ts.map +1 -0
- package/dist/i18n/zh-CN.d.ts +47 -0
- package/dist/i18n/zh-CN.d.ts.map +1 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/makethisbetter.cjs.js +84 -0
- package/dist/makethisbetter.cjs.js.map +1 -0
- package/dist/makethisbetter.esm.js +1433 -0
- package/dist/makethisbetter.esm.js.map +1 -0
- package/dist/makethisbetter.js +84 -0
- package/dist/makethisbetter.js.map +1 -0
- package/dist/popup/comment.d.ts +31 -0
- package/dist/popup/comment.d.ts.map +1 -0
- package/dist/popup/comment.test.d.ts +2 -0
- package/dist/popup/comment.test.d.ts.map +1 -0
- package/dist/popup/success.d.ts +8 -0
- package/dist/popup/success.d.ts.map +1 -0
- package/dist/popup/success.test.d.ts +2 -0
- package/dist/popup/success.test.d.ts.map +1 -0
- package/dist/record/control-bar.d.ts +12 -0
- package/dist/record/control-bar.d.ts.map +1 -0
- package/dist/record/control-bar.test.d.ts +2 -0
- package/dist/record/control-bar.test.d.ts.map +1 -0
- package/dist/record/session.d.ts +26 -0
- package/dist/record/session.d.ts.map +1 -0
- package/dist/record/session.test.d.ts +2 -0
- package/dist/record/session.test.d.ts.map +1 -0
- package/dist/screenshot/capture.d.ts +3 -0
- package/dist/screenshot/capture.d.ts.map +1 -0
- package/dist/screenshot/capture.test.d.ts +2 -0
- package/dist/screenshot/capture.test.d.ts.map +1 -0
- package/dist/styles.d.ts +9 -0
- package/dist/styles.d.ts.map +1 -0
- package/dist/types.d.ts +45 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/widget/controller.d.ts +37 -0
- package/dist/widget/controller.d.ts.map +1 -0
- package/dist/widget/controller.test.d.ts +2 -0
- package/dist/widget/controller.test.d.ts.map +1 -0
- package/dist/widget/frustration-prompt.d.ts +11 -0
- package/dist/widget/frustration-prompt.d.ts.map +1 -0
- package/dist/widget/frustration-prompt.test.d.ts +2 -0
- package/dist/widget/frustration-prompt.test.d.ts.map +1 -0
- package/dist/widget/payload.d.ts +5 -0
- package/dist/widget/payload.d.ts.map +1 -0
- package/dist/widget/payload.test.d.ts +2 -0
- package/dist/widget/payload.test.d.ts.map +1 -0
- package/dist/widget/pet.d.ts +12 -0
- package/dist/widget/pet.d.ts.map +1 -0
- package/dist/widget/pet.test.d.ts +2 -0
- package/dist/widget/pet.test.d.ts.map +1 -0
- package/dist/widget/shadow.d.ts +11 -0
- package/dist/widget/shadow.d.ts.map +1 -0
- package/dist/widget/shadow.test.d.ts +2 -0
- package/dist/widget/shadow.test.d.ts.map +1 -0
- package/dist/widget/tab.d.ts +10 -0
- package/dist/widget/tab.d.ts.map +1 -0
- package/dist/widget/tab.test.d.ts +2 -0
- package/dist/widget/tab.test.d.ts.map +1 -0
- package/package.json +64 -0
- package/src/annotate/draw.test.ts +168 -0
- package/src/annotate/draw.ts +108 -0
- package/src/annotate/highlighter.test.ts +47 -0
- package/src/annotate/highlighter.ts +84 -0
- package/src/annotate/overlay.test.ts +91 -0
- package/src/annotate/overlay.ts +44 -0
- package/src/annotate/pin.test.ts +87 -0
- package/src/annotate/pin.ts +28 -0
- package/src/annotate/session.test.ts +219 -0
- package/src/annotate/session.ts +104 -0
- package/src/annotate/toolbar.test.ts +132 -0
- package/src/annotate/toolbar.ts +81 -0
- package/src/api/client.test.ts +75 -0
- package/src/api/client.ts +77 -0
- package/src/context/collector.test.ts +14 -0
- package/src/context/collector.ts +41 -0
- package/src/context/console.test.ts +44 -0
- package/src/context/console.ts +34 -0
- package/src/context/error-source.test.ts +115 -0
- package/src/context/error-source.ts +62 -0
- package/src/context/frustration.test.ts +134 -0
- package/src/context/frustration.ts +145 -0
- package/src/global.ts +3 -0
- package/src/i18n/en.ts +47 -0
- package/src/i18n/index.test.ts +24 -0
- package/src/i18n/index.ts +16 -0
- package/src/i18n/zh-CN.ts +47 -0
- package/src/index.ts +32 -0
- package/src/popup/comment.test.ts +73 -0
- package/src/popup/comment.ts +177 -0
- package/src/popup/success.test.ts +75 -0
- package/src/popup/success.ts +33 -0
- package/src/record/control-bar.test.ts +110 -0
- package/src/record/control-bar.ts +46 -0
- package/src/record/session.test.ts +153 -0
- package/src/record/session.ts +139 -0
- package/src/screenshot/capture.test.ts +68 -0
- package/src/screenshot/capture.ts +96 -0
- package/src/styles/widget.css +847 -0
- package/src/styles.ts +9 -0
- package/src/types.ts +46 -0
- package/src/vite-env.d.ts +6 -0
- package/src/widget/controller.test.ts +62 -0
- package/src/widget/controller.ts +249 -0
- package/src/widget/frustration-prompt.test.ts +86 -0
- package/src/widget/frustration-prompt.ts +34 -0
- package/src/widget/payload.test.ts +167 -0
- package/src/widget/payload.ts +31 -0
- package/src/widget/pet.test.ts +63 -0
- package/src/widget/pet.ts +67 -0
- package/src/widget/shadow.test.ts +25 -0
- package/src/widget/shadow.ts +41 -0
- package/src/widget/tab.test.ts +92 -0
- 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
|
+
}
|