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,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
|
+
}
|