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,177 @@
|
|
|
1
|
+
import type { ShadowContainer } from '../widget/shadow'
|
|
2
|
+
import type { I18nMessages } from '../i18n'
|
|
3
|
+
|
|
4
|
+
interface PopupOptions {
|
|
5
|
+
targetName: string
|
|
6
|
+
x: number
|
|
7
|
+
y: number
|
|
8
|
+
onSubmit: (description: string) => void
|
|
9
|
+
onClose: () => void
|
|
10
|
+
messages: I18nMessages
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class CommentPopup {
|
|
14
|
+
private el: HTMLDivElement
|
|
15
|
+
private textarea!: HTMLTextAreaElement
|
|
16
|
+
private submitBtn!: HTMLButtonElement
|
|
17
|
+
private errorEl!: HTMLDivElement
|
|
18
|
+
private onSubmit: (description: string) => void
|
|
19
|
+
private onClose: () => void
|
|
20
|
+
private submitLabel: string
|
|
21
|
+
private loading = false
|
|
22
|
+
|
|
23
|
+
constructor(shadow: ShadowContainer, opts: PopupOptions) {
|
|
24
|
+
this.onSubmit = opts.onSubmit
|
|
25
|
+
this.onClose = opts.onClose
|
|
26
|
+
this.submitLabel = opts.messages.popup.submit
|
|
27
|
+
|
|
28
|
+
this.el = shadow.el<HTMLDivElement>('div', 'mtb-popup')
|
|
29
|
+
this.el.innerHTML = this.buildHTML(opts)
|
|
30
|
+
shadow.append(this.el)
|
|
31
|
+
|
|
32
|
+
this.textarea = this.el.querySelector('.mtb-textarea')!
|
|
33
|
+
this.submitBtn = this.el.querySelector('.mtb-submit-btn')!
|
|
34
|
+
|
|
35
|
+
this.positionPopup(opts.x, opts.y)
|
|
36
|
+
this.bindEvents(opts.messages)
|
|
37
|
+
this.textarea.focus()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
private buildHTML(opts: PopupOptions): string {
|
|
41
|
+
const m = opts.messages
|
|
42
|
+
const quickOpts = m.popup.quickOptions
|
|
43
|
+
.map(
|
|
44
|
+
o => `
|
|
45
|
+
<button class="mtb-quick-option" data-quick="${escapeHtml(o.label)}">
|
|
46
|
+
<span class="mtb-quick-option-emoji">${o.emoji}</span>
|
|
47
|
+
<span>${escapeHtml(o.label)}</span>
|
|
48
|
+
</button>`,
|
|
49
|
+
)
|
|
50
|
+
.join('')
|
|
51
|
+
|
|
52
|
+
return `
|
|
53
|
+
<div class="mtb-popup-header">
|
|
54
|
+
<div class="mtb-popup-icon">
|
|
55
|
+
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="#059669" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
|
56
|
+
<path d="M21 10c0 7-9 13-9 13s-9-6-9-13a9 9 0 0 1 18 0z"/><circle cx="12" cy="10" r="3"/>
|
|
57
|
+
</svg>
|
|
58
|
+
</div>
|
|
59
|
+
<span class="mtb-popup-title">${m.popup.about} "${escapeHtml(opts.targetName)}"</span>
|
|
60
|
+
<button class="mtb-popup-close" aria-label="Close">
|
|
61
|
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
|
|
62
|
+
<path d="M18 6L6 18M6 6l12 12"/>
|
|
63
|
+
</svg>
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
<div class="mtb-popup-body">
|
|
67
|
+
<div class="mtb-quick-options">
|
|
68
|
+
${quickOpts}
|
|
69
|
+
</div>
|
|
70
|
+
<div class="mtb-textarea-wrap">
|
|
71
|
+
<textarea class="mtb-textarea" placeholder="${escapeHtml(m.popup.placeholder)}"></textarea>
|
|
72
|
+
</div>
|
|
73
|
+
<div class="mtb-popup-error" role="alert"></div>
|
|
74
|
+
<div class="mtb-popup-actions">
|
|
75
|
+
<button class="mtb-cancel-btn" type="button">${escapeHtml(m.popup.cancel)}</button>
|
|
76
|
+
<button class="mtb-submit-btn" type="button" disabled>${escapeHtml(m.popup.submit)}</button>
|
|
77
|
+
</div>
|
|
78
|
+
</div>
|
|
79
|
+
`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
private positionPopup(x: number, y: number): void {
|
|
83
|
+
const popupW = 320
|
|
84
|
+
const popupH = 260
|
|
85
|
+
const vw = window.innerWidth
|
|
86
|
+
const vh = window.innerHeight
|
|
87
|
+
const margin = 12
|
|
88
|
+
|
|
89
|
+
let left = x + 12
|
|
90
|
+
let top = y + 12
|
|
91
|
+
|
|
92
|
+
if (left + popupW > vw - margin) {
|
|
93
|
+
left = x - popupW - 12
|
|
94
|
+
}
|
|
95
|
+
if (left < margin) left = margin
|
|
96
|
+
|
|
97
|
+
if (top + popupH > vh - margin) {
|
|
98
|
+
top = vh - popupH - margin
|
|
99
|
+
}
|
|
100
|
+
if (top < margin) top = margin
|
|
101
|
+
|
|
102
|
+
this.el.style.left = `${left}px`
|
|
103
|
+
this.el.style.top = `${top}px`
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
private bindEvents(messages: I18nMessages): void {
|
|
107
|
+
this.el.querySelector('.mtb-popup-close')!.addEventListener('click', () => this.onClose())
|
|
108
|
+
this.el.querySelector('.mtb-cancel-btn')!.addEventListener('click', () => this.onClose())
|
|
109
|
+
this.errorEl = this.el.querySelector('.mtb-popup-error')!
|
|
110
|
+
|
|
111
|
+
this.el.querySelectorAll('.mtb-quick-option').forEach(btn => {
|
|
112
|
+
btn.addEventListener('click', () => {
|
|
113
|
+
const label = (btn as HTMLElement).dataset['quick'] ?? ''
|
|
114
|
+
this.textarea.value = label
|
|
115
|
+
this.updateSubmitState()
|
|
116
|
+
this.textarea.focus()
|
|
117
|
+
})
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
this.textarea.addEventListener('input', () => this.updateSubmitState())
|
|
121
|
+
|
|
122
|
+
this.textarea.addEventListener('keydown', (e: KeyboardEvent) => {
|
|
123
|
+
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
124
|
+
e.preventDefault()
|
|
125
|
+
this.submit()
|
|
126
|
+
}
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
this.submitBtn.addEventListener('click', () => this.submit())
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private updateSubmitState(): void {
|
|
133
|
+
const hasText = this.textarea.value.trim().length > 0
|
|
134
|
+
this.submitBtn.classList.toggle('ready', hasText && !this.loading)
|
|
135
|
+
this.submitBtn.disabled = !hasText || this.loading
|
|
136
|
+
this.errorEl.hidden = true
|
|
137
|
+
this.errorEl.textContent = ''
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private submit(): void {
|
|
141
|
+
const text = this.textarea.value.trim()
|
|
142
|
+
if (!text || this.loading) return
|
|
143
|
+
this.onSubmit(text)
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
setLoading(loading: boolean): void {
|
|
147
|
+
this.loading = loading
|
|
148
|
+
if (loading) {
|
|
149
|
+
this.submitBtn.classList.add('loading')
|
|
150
|
+
this.submitBtn.innerHTML = `<span class="mtb-dot"></span><span class="mtb-dot"></span><span class="mtb-dot"></span>`
|
|
151
|
+
this.textarea.disabled = true
|
|
152
|
+
} else {
|
|
153
|
+
this.submitBtn.classList.remove('loading')
|
|
154
|
+
this.submitBtn.textContent = this.submitLabel
|
|
155
|
+
this.textarea.disabled = false
|
|
156
|
+
}
|
|
157
|
+
this.updateSubmitState()
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
setError(message: string): void {
|
|
161
|
+
this.errorEl.textContent = message
|
|
162
|
+
this.errorEl.hidden = false
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
destroy(): void {
|
|
166
|
+
this.el.remove()
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function escapeHtml(text: string): string {
|
|
171
|
+
return text
|
|
172
|
+
.replace(/&/g, '&')
|
|
173
|
+
.replace(/</g, '<')
|
|
174
|
+
.replace(/>/g, '>')
|
|
175
|
+
.replace(/"/g, '"')
|
|
176
|
+
.replace(/'/g, ''')
|
|
177
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import { SuccessCard } from './success'
|
|
3
|
+
import { ShadowContainer } from '../widget/shadow'
|
|
4
|
+
import { getMessages } from '../i18n'
|
|
5
|
+
|
|
6
|
+
function setupCard() {
|
|
7
|
+
const shadow = new ShadowContainer()
|
|
8
|
+
const onClose = vi.fn()
|
|
9
|
+
const messages = getMessages('en')
|
|
10
|
+
const card = new SuccessCard(shadow, messages, onClose)
|
|
11
|
+
|
|
12
|
+
return { shadow, card, onClose, messages }
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('SuccessCard', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
vi.useFakeTimers()
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
vi.useRealTimers()
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders title, message, and close button', () => {
|
|
25
|
+
const { shadow, card, messages } = setupCard()
|
|
26
|
+
|
|
27
|
+
expect(shadow.root.querySelector('.mtb-success-title')?.textContent).toBe(messages.success.title)
|
|
28
|
+
expect(shadow.root.querySelector('.mtb-success-msg')?.textContent).toBe(messages.success.message)
|
|
29
|
+
expect(shadow.root.querySelector('.mtb-close-link')?.textContent).toBe(messages.success.close)
|
|
30
|
+
|
|
31
|
+
card.destroy()
|
|
32
|
+
shadow.destroy()
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('renders checkmark icon', () => {
|
|
36
|
+
const { shadow, card } = setupCard()
|
|
37
|
+
|
|
38
|
+
const icon = shadow.root.querySelector('.mtb-success-icon svg')
|
|
39
|
+
expect(icon).toBeTruthy()
|
|
40
|
+
|
|
41
|
+
card.destroy()
|
|
42
|
+
shadow.destroy()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('calls onClose when close button is clicked', () => {
|
|
46
|
+
const { shadow, card, onClose } = setupCard()
|
|
47
|
+
|
|
48
|
+
shadow.root.querySelector<HTMLButtonElement>('.mtb-close-link')!.click()
|
|
49
|
+
expect(onClose).toHaveBeenCalledOnce()
|
|
50
|
+
|
|
51
|
+
card.destroy()
|
|
52
|
+
shadow.destroy()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('auto-closes after 5 seconds', () => {
|
|
56
|
+
const { shadow, card, onClose } = setupCard()
|
|
57
|
+
|
|
58
|
+
expect(onClose).not.toHaveBeenCalled()
|
|
59
|
+
vi.advanceTimersByTime(5000)
|
|
60
|
+
expect(onClose).toHaveBeenCalled()
|
|
61
|
+
|
|
62
|
+
card.destroy()
|
|
63
|
+
shadow.destroy()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('destroy removes element from shadow root', () => {
|
|
67
|
+
const { shadow, card } = setupCard()
|
|
68
|
+
|
|
69
|
+
expect(shadow.root.querySelector('.mtb-success')).toBeTruthy()
|
|
70
|
+
card.destroy()
|
|
71
|
+
expect(shadow.root.querySelector('.mtb-success')).toBeNull()
|
|
72
|
+
|
|
73
|
+
shadow.destroy()
|
|
74
|
+
})
|
|
75
|
+
})
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { ShadowContainer } from '../widget/shadow'
|
|
2
|
+
import type { I18nMessages } from '../i18n'
|
|
3
|
+
import { BRAND_COLOR } from '../styles'
|
|
4
|
+
|
|
5
|
+
const SUCCESS_AUTO_CLOSE_MS = 5000
|
|
6
|
+
|
|
7
|
+
export class SuccessCard {
|
|
8
|
+
private el: HTMLDivElement
|
|
9
|
+
|
|
10
|
+
constructor(shadow: ShadowContainer, messages: I18nMessages, onClose: () => void) {
|
|
11
|
+
this.el = shadow.el<HTMLDivElement>('div', 'mtb-success')
|
|
12
|
+
this.el.style.right = '20px'
|
|
13
|
+
this.el.style.bottom = '20px'
|
|
14
|
+
this.el.innerHTML = `
|
|
15
|
+
<div class="mtb-success-icon">
|
|
16
|
+
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="${BRAND_COLOR}" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
|
17
|
+
<polyline points="20 6 9 17 4 12"/>
|
|
18
|
+
</svg>
|
|
19
|
+
</div>
|
|
20
|
+
<div class="mtb-success-title">${messages.success.title}</div>
|
|
21
|
+
<div class="mtb-success-msg">${messages.success.message}</div>
|
|
22
|
+
<button class="mtb-close-link">${messages.success.close}</button>
|
|
23
|
+
`
|
|
24
|
+
shadow.append(this.el)
|
|
25
|
+
this.el.querySelector('.mtb-close-link')!.addEventListener('click', onClose)
|
|
26
|
+
|
|
27
|
+
setTimeout(() => onClose(), SUCCESS_AUTO_CLOSE_MS)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
destroy(): void {
|
|
31
|
+
this.el.remove()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { RecordControlBar } from './control-bar'
|
|
3
|
+
import type { ShadowContainer } from '../widget/shadow'
|
|
4
|
+
import type { I18nMessages } from '../i18n'
|
|
5
|
+
|
|
6
|
+
function createMockShadow(): ShadowContainer & { appended: Node[] } {
|
|
7
|
+
const appended: Node[] = []
|
|
8
|
+
return {
|
|
9
|
+
appended,
|
|
10
|
+
el: <T extends HTMLElement>(tag: string, className?: string): T => {
|
|
11
|
+
const el = document.createElement(tag) as T
|
|
12
|
+
if (className) el.className = className
|
|
13
|
+
return el
|
|
14
|
+
},
|
|
15
|
+
append: (...nodes: Node[]) => {
|
|
16
|
+
nodes.forEach((n) => {
|
|
17
|
+
appended.push(n)
|
|
18
|
+
document.body.appendChild(n)
|
|
19
|
+
})
|
|
20
|
+
},
|
|
21
|
+
remove: vi.fn(),
|
|
22
|
+
root: document.createElement('div') as unknown as ShadowRoot,
|
|
23
|
+
destroy: vi.fn(),
|
|
24
|
+
} as unknown as ShadowContainer & { appended: Node[] }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function createMessages(): I18nMessages {
|
|
28
|
+
return {
|
|
29
|
+
record: {
|
|
30
|
+
stop: 'Stop',
|
|
31
|
+
timer_label: 'Recording',
|
|
32
|
+
max_reached: 'Maximum recording time reached',
|
|
33
|
+
},
|
|
34
|
+
} as unknown as I18nMessages
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('RecordControlBar', () => {
|
|
38
|
+
let shadow: ReturnType<typeof createMockShadow>
|
|
39
|
+
let bar: RecordControlBar
|
|
40
|
+
|
|
41
|
+
beforeEach(() => {
|
|
42
|
+
vi.useFakeTimers()
|
|
43
|
+
shadow = createMockShadow()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
bar?.destroy()
|
|
48
|
+
document.body.innerHTML = ''
|
|
49
|
+
vi.useRealTimers()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('constructor creates DOM with timer and stop button', () => {
|
|
53
|
+
bar = new RecordControlBar(shadow, createMessages(), () => 0, () => {})
|
|
54
|
+
|
|
55
|
+
expect(shadow.appended).toHaveLength(1)
|
|
56
|
+
const container = shadow.appended[0] as HTMLElement
|
|
57
|
+
|
|
58
|
+
expect(container.querySelector('.mtb-record-dot')).toBeTruthy()
|
|
59
|
+
expect(container.querySelector('.mtb-record-timer')).toBeTruthy()
|
|
60
|
+
expect(container.querySelector('.mtb-record-stop')).toBeTruthy()
|
|
61
|
+
expect(container.querySelector('.mtb-record-stop')!.textContent).toBe('Stop')
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('stop button fires the onStop callback', () => {
|
|
65
|
+
const onStop = vi.fn()
|
|
66
|
+
bar = new RecordControlBar(shadow, createMessages(), () => 0, onStop)
|
|
67
|
+
|
|
68
|
+
const stopBtn = (shadow.appended[0] as HTMLElement).querySelector('.mtb-record-stop')!
|
|
69
|
+
stopBtn.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
70
|
+
|
|
71
|
+
expect(onStop).toHaveBeenCalledOnce()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('timer updates display every second', () => {
|
|
75
|
+
let seconds = 0
|
|
76
|
+
bar = new RecordControlBar(shadow, createMessages(), () => seconds, () => {})
|
|
77
|
+
|
|
78
|
+
const timerEl = (shadow.appended[0] as HTMLElement).querySelector('.mtb-record-timer')!
|
|
79
|
+
expect(timerEl.textContent).toBe('00:00')
|
|
80
|
+
|
|
81
|
+
seconds = 5
|
|
82
|
+
vi.advanceTimersByTime(1000)
|
|
83
|
+
expect(timerEl.textContent).toBe('00:05')
|
|
84
|
+
|
|
85
|
+
seconds = 65
|
|
86
|
+
vi.advanceTimersByTime(1000)
|
|
87
|
+
expect(timerEl.textContent).toBe('01:05')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('destroy removes DOM element and stops interval', () => {
|
|
91
|
+
let seconds = 0
|
|
92
|
+
bar = new RecordControlBar(shadow, createMessages(), () => seconds, () => {})
|
|
93
|
+
|
|
94
|
+
const container = shadow.appended[0] as HTMLElement
|
|
95
|
+
expect(document.body.contains(container)).toBe(true)
|
|
96
|
+
|
|
97
|
+
bar.destroy()
|
|
98
|
+
expect(document.body.contains(container)).toBe(false)
|
|
99
|
+
|
|
100
|
+
seconds = 99
|
|
101
|
+
vi.advanceTimersByTime(5000)
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('initial timer text is 00:00', () => {
|
|
105
|
+
bar = new RecordControlBar(shadow, createMessages(), () => 0, () => {})
|
|
106
|
+
|
|
107
|
+
const timerEl = (shadow.appended[0] as HTMLElement).querySelector('.mtb-record-timer')!
|
|
108
|
+
expect(timerEl.textContent).toBe('00:00')
|
|
109
|
+
})
|
|
110
|
+
})
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ShadowContainer } from '../widget/shadow'
|
|
2
|
+
import type { I18nMessages } from '../i18n'
|
|
3
|
+
|
|
4
|
+
export class RecordControlBar {
|
|
5
|
+
private el: HTMLDivElement
|
|
6
|
+
private timerEl!: HTMLSpanElement
|
|
7
|
+
private intervalId: ReturnType<typeof setInterval> | null = null
|
|
8
|
+
private getDuration: () => number
|
|
9
|
+
|
|
10
|
+
constructor(
|
|
11
|
+
shadow: ShadowContainer,
|
|
12
|
+
messages: I18nMessages,
|
|
13
|
+
getDuration: () => number,
|
|
14
|
+
onStop: () => void,
|
|
15
|
+
) {
|
|
16
|
+
this.getDuration = getDuration
|
|
17
|
+
|
|
18
|
+
this.el = shadow.el<HTMLDivElement>('div', 'mtb-record-bar')
|
|
19
|
+
this.el.innerHTML = `
|
|
20
|
+
<span class="mtb-record-dot"></span>
|
|
21
|
+
<span class="mtb-record-timer">00:00</span>
|
|
22
|
+
<button class="mtb-record-stop" type="button">${messages.record.stop}</button>
|
|
23
|
+
`
|
|
24
|
+
shadow.append(this.el)
|
|
25
|
+
|
|
26
|
+
this.timerEl = this.el.querySelector('.mtb-record-timer')!
|
|
27
|
+
this.el.querySelector('.mtb-record-stop')!.addEventListener('click', onStop)
|
|
28
|
+
|
|
29
|
+
this.intervalId = setInterval(() => this.updateTimer(), 1000)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
private updateTimer(): void {
|
|
33
|
+
const secs = this.getDuration()
|
|
34
|
+
const mm = String(Math.floor(secs / 60)).padStart(2, '0')
|
|
35
|
+
const ss = String(secs % 60).padStart(2, '0')
|
|
36
|
+
this.timerEl.textContent = `${mm}:${ss}`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
destroy(): void {
|
|
40
|
+
if (this.intervalId) {
|
|
41
|
+
clearInterval(this.intervalId)
|
|
42
|
+
this.intervalId = null
|
|
43
|
+
}
|
|
44
|
+
this.el.remove()
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
|
|
2
|
+
import { RecordSession, type RecordingResult } from './session'
|
|
3
|
+
import type { ShadowContainer } from '../widget/shadow'
|
|
4
|
+
|
|
5
|
+
const mockStopFn = vi.fn()
|
|
6
|
+
const mockAddCustomEvent = vi.fn()
|
|
7
|
+
const mockRecord = Object.assign(
|
|
8
|
+
vi.fn(({ emit }: { emit: (event: Record<string, unknown>) => void }) => {
|
|
9
|
+
emit({ type: 2, data: 'snapshot' })
|
|
10
|
+
return mockStopFn
|
|
11
|
+
}),
|
|
12
|
+
{ addCustomEvent: mockAddCustomEvent },
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
vi.mock('rrweb', () => ({ record: mockRecord }))
|
|
16
|
+
|
|
17
|
+
function createMockShadow(): ShadowContainer {
|
|
18
|
+
const elements: HTMLElement[] = []
|
|
19
|
+
return {
|
|
20
|
+
el: <T extends HTMLElement>(tag: string, className?: string): T => {
|
|
21
|
+
const el = document.createElement(tag) as T
|
|
22
|
+
if (className) el.className = className
|
|
23
|
+
return el
|
|
24
|
+
},
|
|
25
|
+
append: (...nodes: Node[]) => {
|
|
26
|
+
nodes.forEach((n) => elements.push(n as HTMLElement))
|
|
27
|
+
},
|
|
28
|
+
remove: vi.fn(),
|
|
29
|
+
root: document.createElement('div') as unknown as ShadowRoot,
|
|
30
|
+
destroy: vi.fn(),
|
|
31
|
+
} as unknown as ShadowContainer
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('RecordSession', () => {
|
|
35
|
+
let session: RecordSession
|
|
36
|
+
let shadow: ShadowContainer
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
vi.useFakeTimers()
|
|
40
|
+
vi.clearAllMocks()
|
|
41
|
+
shadow = createMockShadow()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
session?.destroy()
|
|
46
|
+
vi.useRealTimers()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('start() initializes recording via rrweb', async () => {
|
|
50
|
+
session = new RecordSession(shadow)
|
|
51
|
+
await session.start()
|
|
52
|
+
|
|
53
|
+
expect(mockRecord).toHaveBeenCalledOnce()
|
|
54
|
+
expect(mockRecord).toHaveBeenCalledWith(
|
|
55
|
+
expect.objectContaining({ emit: expect.any(Function) }),
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('stop() returns captured events and duration', async () => {
|
|
60
|
+
session = new RecordSession(shadow)
|
|
61
|
+
await session.start()
|
|
62
|
+
|
|
63
|
+
vi.advanceTimersByTime(5000)
|
|
64
|
+
|
|
65
|
+
const result: RecordingResult = session.stop()
|
|
66
|
+
|
|
67
|
+
expect(result.events).toHaveLength(1)
|
|
68
|
+
expect(result.events[0]).toEqual({ type: 2, data: 'snapshot' })
|
|
69
|
+
expect(result.duration).toBe(5)
|
|
70
|
+
expect(mockStopFn).toHaveBeenCalledOnce()
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('getDuration() returns elapsed time in seconds', async () => {
|
|
74
|
+
session = new RecordSession(shadow)
|
|
75
|
+
await session.start()
|
|
76
|
+
|
|
77
|
+
expect(session.getDuration()).toBe(0)
|
|
78
|
+
|
|
79
|
+
vi.advanceTimersByTime(3000)
|
|
80
|
+
expect(session.getDuration()).toBe(3)
|
|
81
|
+
|
|
82
|
+
vi.advanceTimersByTime(7000)
|
|
83
|
+
expect(session.getDuration()).toBe(10)
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('getDuration() returns 0 before start', () => {
|
|
87
|
+
session = new RecordSession(shadow)
|
|
88
|
+
expect(session.getDuration()).toBe(0)
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('auto-fires onMaxReached at MAX_DURATION (60s)', async () => {
|
|
92
|
+
const onMaxReached = vi.fn()
|
|
93
|
+
session = new RecordSession(shadow, onMaxReached)
|
|
94
|
+
await session.start()
|
|
95
|
+
|
|
96
|
+
vi.advanceTimersByTime(59_999)
|
|
97
|
+
expect(onMaxReached).not.toHaveBeenCalled()
|
|
98
|
+
|
|
99
|
+
vi.advanceTimersByTime(1)
|
|
100
|
+
expect(onMaxReached).toHaveBeenCalledOnce()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('stop() clears the max-duration timer', async () => {
|
|
104
|
+
const onMaxReached = vi.fn()
|
|
105
|
+
session = new RecordSession(shadow, onMaxReached)
|
|
106
|
+
await session.start()
|
|
107
|
+
|
|
108
|
+
session.stop()
|
|
109
|
+
vi.advanceTimersByTime(120_000)
|
|
110
|
+
|
|
111
|
+
expect(onMaxReached).not.toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('click events create ripple elements and add custom rrweb events', async () => {
|
|
115
|
+
session = new RecordSession(shadow)
|
|
116
|
+
await session.start()
|
|
117
|
+
|
|
118
|
+
const clickEvent = new MouseEvent('click', {
|
|
119
|
+
clientX: 100,
|
|
120
|
+
clientY: 200,
|
|
121
|
+
bubbles: true,
|
|
122
|
+
})
|
|
123
|
+
document.dispatchEvent(clickEvent)
|
|
124
|
+
|
|
125
|
+
expect(mockAddCustomEvent).toHaveBeenCalledWith('mtb-click', {
|
|
126
|
+
x: 100,
|
|
127
|
+
y: 200,
|
|
128
|
+
target: expect.any(String),
|
|
129
|
+
})
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('stop() removes click handler', async () => {
|
|
133
|
+
session = new RecordSession(shadow)
|
|
134
|
+
await session.start()
|
|
135
|
+
|
|
136
|
+
session.stop()
|
|
137
|
+
vi.clearAllMocks()
|
|
138
|
+
|
|
139
|
+
document.dispatchEvent(new MouseEvent('click', { bubbles: true }))
|
|
140
|
+
|
|
141
|
+
expect(mockAddCustomEvent).not.toHaveBeenCalled()
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('destroy() clears all state', async () => {
|
|
145
|
+
session = new RecordSession(shadow)
|
|
146
|
+
await session.start()
|
|
147
|
+
|
|
148
|
+
session.destroy()
|
|
149
|
+
|
|
150
|
+
expect(mockStopFn).toHaveBeenCalled()
|
|
151
|
+
expect(session.getDuration()).toBe(0)
|
|
152
|
+
})
|
|
153
|
+
})
|