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,139 @@
1
+ import type { ShadowContainer } from '../widget/shadow'
2
+
3
+ type RRWebEvent = Record<string, unknown>
4
+ type StopFn = () => void
5
+
6
+ interface RRWebRecordFn {
7
+ (options: { emit: (event: RRWebEvent) => void }): StopFn
8
+ addCustomEvent: (tag: string, payload: Record<string, unknown>) => void
9
+ }
10
+
11
+ export interface RecordingResult {
12
+ events: RRWebEvent[]
13
+ duration: number
14
+ }
15
+
16
+ const MAX_DURATION_S = 60
17
+ const RRWEB_CDN = 'https://cdn.jsdelivr.net/npm/rrweb@2/dist/rrweb.min.js'
18
+
19
+ let rrwebLoaded: Promise<RRWebRecordFn> | null = null
20
+
21
+ function loadRrweb(): Promise<RRWebRecordFn> {
22
+ if (rrwebLoaded) return rrwebLoaded
23
+ rrwebLoaded = loadViaImport().catch(() => loadViaCdn())
24
+ return rrwebLoaded
25
+ }
26
+
27
+ async function loadViaImport(): Promise<RRWebRecordFn> {
28
+ const mod = await import('rrweb')
29
+ return mod.record as unknown as RRWebRecordFn
30
+ }
31
+
32
+ function loadViaCdn(): Promise<RRWebRecordFn> {
33
+ return new Promise((resolve, reject) => {
34
+ const win = globalThis as unknown as Window & { rrweb?: { record: RRWebRecordFn } }
35
+ if (win.rrweb?.record) { resolve(win.rrweb.record); return }
36
+ const script = document.createElement('script')
37
+ script.src = RRWEB_CDN
38
+ script.onload = () => {
39
+ const w = globalThis as unknown as Window & { rrweb?: { record: RRWebRecordFn } }
40
+ if (w.rrweb?.record) resolve(w.rrweb.record)
41
+ else reject(new Error('rrweb global not found after script load'))
42
+ }
43
+ script.onerror = () => reject(new Error('Failed to load rrweb from CDN'))
44
+ document.head.appendChild(script)
45
+ })
46
+ }
47
+
48
+ export class RecordSession {
49
+ private events: RRWebEvent[] = []
50
+ private stopFn: StopFn | null = null
51
+ private startedAt = 0
52
+ private timer: ReturnType<typeof setTimeout> | null = null
53
+ private clickHandler: ((e: MouseEvent) => void) | null = null
54
+ private shadow: ShadowContainer
55
+ private onMaxReached?: () => void
56
+ private stopped = false
57
+
58
+ constructor(shadow: ShadowContainer, onMaxReached?: () => void) {
59
+ this.shadow = shadow
60
+ this.onMaxReached = onMaxReached
61
+ }
62
+
63
+ async start(): Promise<void> {
64
+ const record = await loadRrweb()
65
+
66
+ this.startedAt = Date.now()
67
+ this.stopped = false
68
+
69
+ this.stopFn = record({
70
+ emit: (event: RRWebEvent) => {
71
+ if (!this.stopped) this.events.push(event)
72
+ },
73
+ })
74
+
75
+ this.clickHandler = (e: MouseEvent) => {
76
+ this.showClickRipple(e.clientX, e.clientY)
77
+ record.addCustomEvent?.('mtb-click', {
78
+ x: e.clientX,
79
+ y: e.clientY,
80
+ target: e.target instanceof HTMLElement ? e.target.tagName : '',
81
+ })
82
+ }
83
+ document.addEventListener('click', this.clickHandler, true)
84
+
85
+ this.timer = setTimeout(() => {
86
+ this.onMaxReached?.()
87
+ }, MAX_DURATION_S * 1000)
88
+ }
89
+
90
+ stop(): RecordingResult {
91
+ this.stopped = true
92
+ this.stopFn?.()
93
+ this.stopFn = null
94
+ this.cleanupTimer()
95
+ this.cleanupClickHandler()
96
+
97
+ return {
98
+ events: this.events,
99
+ duration: this.getDuration(),
100
+ }
101
+ }
102
+
103
+ getDuration(): number {
104
+ if (this.startedAt === 0) return 0
105
+ return Math.round((Date.now() - this.startedAt) / 1000)
106
+ }
107
+
108
+ destroy(): void {
109
+ this.stopped = true
110
+ this.stopFn?.()
111
+ this.stopFn = null
112
+ this.cleanupTimer()
113
+ this.cleanupClickHandler()
114
+ this.events = []
115
+ this.startedAt = 0
116
+ }
117
+
118
+ private cleanupTimer(): void {
119
+ if (this.timer) {
120
+ clearTimeout(this.timer)
121
+ this.timer = null
122
+ }
123
+ }
124
+
125
+ private cleanupClickHandler(): void {
126
+ if (this.clickHandler) {
127
+ document.removeEventListener('click', this.clickHandler, true)
128
+ this.clickHandler = null
129
+ }
130
+ }
131
+
132
+ private showClickRipple(x: number, y: number): void {
133
+ const ripple = this.shadow.el<HTMLDivElement>('div', 'mtb-click-ripple')
134
+ ripple.style.left = `${x}px`
135
+ ripple.style.top = `${y}px`
136
+ this.shadow.append(ripple)
137
+ ripple.addEventListener('animationend', () => ripple.remove())
138
+ }
139
+ }
@@ -0,0 +1,68 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest'
2
+ import { toPng } from 'html-to-image'
3
+ import { captureScreenshot } from './capture'
4
+ import type { Annotation } from '../types'
5
+
6
+ vi.mock('html-to-image', () => ({
7
+ toPng: vi.fn(async () => 'data:image/png;base64,AA=='),
8
+ }))
9
+
10
+ const toPngMock = vi.mocked(toPng)
11
+
12
+ describe('captureScreenshot', () => {
13
+ afterEach(() => {
14
+ document.body.innerHTML = ''
15
+ toPngMock.mockClear()
16
+ })
17
+
18
+ it('captures a png blob', async () => {
19
+ const blob = await captureScreenshot()
20
+
21
+ expect(blob).toBeInstanceOf(Blob)
22
+ expect(blob?.type).toBe('image/png')
23
+ expect(toPngMock).toHaveBeenCalledWith(
24
+ document.body,
25
+ expect.objectContaining({
26
+ cacheBust: true,
27
+ skipAutoScale: false,
28
+ }),
29
+ )
30
+ })
31
+
32
+ it('mounts pin annotations during capture and removes them after capture', async () => {
33
+ let captureText = ''
34
+ toPngMock.mockImplementationOnce(async () => {
35
+ captureText = document.querySelector<HTMLElement>('[data-mtb-capture-annotations]')?.textContent ?? ''
36
+ return 'data:image/png;base64,AA=='
37
+ })
38
+
39
+ const annotation: Annotation = { type: 'pin', x: 40, y: 60 }
40
+ await captureScreenshot([annotation])
41
+
42
+ expect(captureText).toContain('1')
43
+ expect(document.querySelector('[data-mtb-capture-annotations]')).toBeNull()
44
+ })
45
+
46
+ it('mounts draw annotations during capture', async () => {
47
+ let drawPath: string | null = null
48
+ toPngMock.mockImplementationOnce(async () => {
49
+ drawPath = document.querySelector('[data-mtb-capture-annotations] path')?.getAttribute('d') ?? null
50
+ return 'data:image/png;base64,AA=='
51
+ })
52
+
53
+ const annotation: Annotation = { type: 'draw', x: 50, y: 50, drawPath: 'M 10 10 L 90 90' }
54
+ await captureScreenshot([annotation])
55
+
56
+ expect(drawPath).toBe('M 10 10 L 90 90')
57
+ })
58
+
59
+ it('returns null and cleans up if capture fails', async () => {
60
+ toPngMock.mockRejectedValueOnce(new Error('capture failed'))
61
+
62
+ const annotation: Annotation = { type: 'pin', x: 40, y: 60 }
63
+ const blob = await captureScreenshot([annotation])
64
+
65
+ expect(blob).toBeNull()
66
+ expect(document.querySelector('[data-mtb-capture-annotations]')).toBeNull()
67
+ })
68
+ })
@@ -0,0 +1,96 @@
1
+ import { toPng } from 'html-to-image'
2
+ import type { Annotation } from '../types'
3
+
4
+ export async function captureScreenshot(annotations: Annotation[] = []): Promise<Blob | null> {
5
+ const annotationLayer = mountCaptureAnnotations(annotations)
6
+
7
+ try {
8
+ const dataUrl = await toPng(document.body, {
9
+ cacheBust: true,
10
+ skipAutoScale: false,
11
+ filter: (node) => {
12
+ if (node instanceof HTMLElement && node.id === 'mtb-widget-host') return false
13
+ return true
14
+ },
15
+ })
16
+ return dataUrlToBlob(dataUrl)
17
+ } catch {
18
+ return null
19
+ } finally {
20
+ annotationLayer?.remove()
21
+ }
22
+ }
23
+
24
+ function mountCaptureAnnotations(annotations: Annotation[]): HTMLDivElement | null {
25
+ if (annotations.length === 0) return null
26
+
27
+ const layer = document.createElement('div')
28
+ layer.setAttribute('data-mtb-capture-annotations', 'true')
29
+ layer.setAttribute('aria-hidden', 'true')
30
+ layer.style.cssText = 'position:fixed;inset:0;pointer-events:none;z-index:2147483645;'
31
+
32
+ for (const [index, annotation] of annotations.entries()) {
33
+ if (annotation.type === 'pin') {
34
+ layer.appendChild(buildCapturePin(annotation, index + 1))
35
+ } else if (annotation.type === 'draw' && annotation.drawPath) {
36
+ layer.appendChild(buildCaptureDraw(annotation.drawPath))
37
+ }
38
+ }
39
+
40
+ document.body.appendChild(layer)
41
+ return layer
42
+ }
43
+
44
+ function buildCapturePin(annotation: Annotation, index: number): HTMLDivElement {
45
+ const pin = document.createElement('div')
46
+ pin.textContent = String(index)
47
+ pin.style.cssText = [
48
+ 'position:fixed',
49
+ `left:${annotation.x}px`,
50
+ `top:${annotation.y}px`,
51
+ 'width:22px',
52
+ 'height:22px',
53
+ 'border-radius:50% 50% 50% 2px',
54
+ 'background:#059669',
55
+ 'border:2px solid #fff',
56
+ 'display:flex',
57
+ 'align-items:center',
58
+ 'justify-content:center',
59
+ 'font:700 10px -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
60
+ 'color:#fff',
61
+ 'box-shadow:0 2px 8px rgba(5,150,105,0.45)',
62
+ 'transform:translate(-50%,-50%)',
63
+ ].join(';')
64
+ return pin
65
+ }
66
+
67
+ function buildCaptureDraw(drawPath: string): SVGSVGElement {
68
+ const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
69
+ svg.setAttribute('width', '100%')
70
+ svg.setAttribute('height', '100%')
71
+ svg.setAttribute('viewBox', `0 0 ${window.innerWidth} ${window.innerHeight}`)
72
+ svg.style.cssText = 'position:fixed;inset:0;width:100%;height:100%;overflow:visible;'
73
+
74
+ const path = document.createElementNS('http://www.w3.org/2000/svg', 'path')
75
+ path.setAttribute('d', drawPath)
76
+ path.setAttribute('fill', 'none')
77
+ path.setAttribute('stroke', '#059669')
78
+ path.setAttribute('stroke-width', '3.5')
79
+ path.setAttribute('stroke-linecap', 'round')
80
+ path.setAttribute('stroke-linejoin', 'round')
81
+ path.setAttribute('filter', 'drop-shadow(0 1px 2px rgba(5,150,105,0.35))')
82
+ svg.appendChild(path)
83
+
84
+ return svg
85
+ }
86
+
87
+ function dataUrlToBlob(dataUrl: string): Blob {
88
+ const [header, data] = dataUrl.split(',')
89
+ const mime = header.match(/:(.*?);/)?.[1] ?? 'image/png'
90
+ const binary = atob(data)
91
+ const bytes = new Uint8Array(binary.length)
92
+ for (let i = 0; i < binary.length; i++) {
93
+ bytes[i] = binary.charCodeAt(i)
94
+ }
95
+ return new Blob([bytes], { type: mime })
96
+ }