import { ref, type Ref } from 'vue' import { useApi } from '~/composables/useApi' import { useAuthStore } from '~/stores/auth' import useCommunicationsEngine from '../../shared/utils/communicationsEngine' export type BugReportArrow = { fx: number; fy: number; tx: number; ty: number } /** Which part of the app the report is filed from — reported in the mail. */ export type BugReportSource = 'live-atc' | 'classroom' export interface BugReportOptions { source: BugReportSource /** * Live ATC hands over its communications engine so the report carries the * flow state. Classroom has no such engine — the snapshot is simply omitted. */ engine?: ReturnType /** Scenario the report is captured in — stored alongside the state snapshot. */ activeScenario?: Ref<{ id?: string; startFlow?: string } | null | undefined> } export function useBugReport(options: BugReportOptions) { const api = useApi() const auth = useAuthStore() const { source, engine, activeScenario } = options const showBugReportDialog = ref(false) const bugReportComment = ref('') const bugReportContact = ref('') const bugReportScreenshot = ref(null) const bugReportArrows = ref([]) const bugReportLoading = ref(false) const bugReportCapturing = ref(false) const bugReportError = ref('') const bugReportSuccess = ref(false) const bugReportCode = ref('') const bugReportCanvasRef = ref(null) const bugReportImgRef = ref(null) let _arrowDrawing = false let _arrowStart = { x: 0, y: 0 } function setupAnnotationCanvas() { const canvas = bugReportCanvasRef.value const img = bugReportImgRef.value if (!canvas || !img) return canvas.width = img.clientWidth canvas.height = img.clientHeight } function _drawArrow(ctx: CanvasRenderingContext2D, fx: number, fy: number, tx: number, ty: number) { const headLen = 14 const angle = Math.atan2(ty - fy, tx - fx) ctx.strokeStyle = '#ef4444' ctx.fillStyle = '#ef4444' ctx.lineWidth = 3 ctx.lineCap = 'round' ctx.lineJoin = 'round' ctx.beginPath() ctx.moveTo(fx, fy) ctx.lineTo(tx, ty) ctx.stroke() ctx.beginPath() ctx.moveTo(tx, ty) ctx.lineTo(tx - headLen * Math.cos(angle - Math.PI / 6), ty - headLen * Math.sin(angle - Math.PI / 6)) ctx.lineTo(tx - headLen * Math.cos(angle + Math.PI / 6), ty - headLen * Math.sin(angle + Math.PI / 6)) ctx.closePath() ctx.fill() } function _redrawAnnotations(preview?: BugReportArrow) { const canvas = bugReportCanvasRef.value if (!canvas) return const ctx = canvas.getContext('2d') if (!ctx) return ctx.clearRect(0, 0, canvas.width, canvas.height) for (const a of bugReportArrows.value) _drawArrow(ctx, a.fx, a.fy, a.tx, a.ty) if (preview) { _drawArrow(ctx, preview.fx, preview.fy, preview.tx, preview.ty) } } function _canvasCoords(e: MouseEvent) { const canvas = bugReportCanvasRef.value! const rect = canvas.getBoundingClientRect() return { x: e.clientX - rect.left, y: e.clientY - rect.top } } function onCanvasMouseDown(e: MouseEvent) { _arrowStart = _canvasCoords(e) _arrowDrawing = true } function onCanvasMouseMove(e: MouseEvent) { if (!_arrowDrawing) return const { x, y } = _canvasCoords(e) _redrawAnnotations({ fx: _arrowStart.x, fy: _arrowStart.y, tx: x, ty: y }) } function onCanvasMouseUp(e: MouseEvent) { if (!_arrowDrawing) return _arrowDrawing = false const { x, y } = _canvasCoords(e) const dx = x - _arrowStart.x const dy = y - _arrowStart.y if (Math.sqrt(dx * dx + dy * dy) < 8) { _redrawAnnotations(); return } bugReportArrows.value = [...bugReportArrows.value, { fx: _arrowStart.x, fy: _arrowStart.y, tx: x, ty: y }] _redrawAnnotations() } function onCanvasMouseLeave() { if (!_arrowDrawing) return _arrowDrawing = false _redrawAnnotations() } function undoLastArrow() { bugReportArrows.value = bugReportArrows.value.slice(0, -1) _redrawAnnotations() } async function openBugReport() { bugReportError.value = '' bugReportSuccess.value = false bugReportCode.value = '' bugReportComment.value = '' bugReportArrows.value = [] bugReportScreenshot.value = null bugReportContact.value = [auth.user?.name, auth.user?.email].filter(Boolean).join(' — ') // Capture the screenshot BEFORE opening the dialog, otherwise the open // dialog overlay would appear in the shot instead of the actual bug state. bugReportCapturing.value = true try { // modern-screenshot renders via a native SVG , so modern CSS // such as color-mix()/oklch() (used throughout the app) is supported. // html2canvas could not parse those and silently produced no screenshot. const { domToJpeg } = await import('modern-screenshot') bugReportScreenshot.value = await domToJpeg(document.body, { quality: 0.75, scale: 0.55, // Skip assets we cannot read (cross-origin tiles/avatars) instead of failing. filter: (node) => !(node instanceof Element && node.getAttribute?.('data-no-screenshot') === 'true'), }) } catch (err) { // Screenshot is optional — keep the report flow usable, but surface why. console.warn('[PM] Bug report screenshot capture failed', err) bugReportScreenshot.value = null bugReportError.value = 'Screenshot konnte nicht erstellt werden – Bug-Report ohne Bild möglich.' } finally { bugReportCapturing.value = false } showBugReportDialog.value = true } async function submitBugReport() { if (!bugReportComment.value.trim()) { bugReportError.value = 'Bitte eine Fehlerbeschreibung eingeben.'; return } bugReportLoading.value = true bugReportError.value = '' try { let finalScreenshot: string | undefined if (bugReportScreenshot.value) { const img = bugReportImgRef.value const src = new Image() await new Promise((res) => { src.onload = () => res(); src.src = bugReportScreenshot.value! }) const out = document.createElement('canvas') out.width = src.naturalWidth; out.height = src.naturalHeight const ctx = out.getContext('2d')! ctx.drawImage(src, 0, 0) if (bugReportArrows.value.length > 0 && img) { const sx = src.naturalWidth / img.clientWidth const sy = src.naturalHeight / img.clientHeight for (const a of bugReportArrows.value) _drawArrow(ctx, a.fx * sx, a.fy * sy, a.tx * sx, a.ty * sy) } finalScreenshot = out.toDataURL('image/jpeg', 0.8) } const pmState = engine ? { flowSlug: activeScenario?.value?.startFlow || '', scenarioId: activeScenario?.value?.id || '', currentStateId: (engine.currentState.value as any)?.id || '', variables: (engine.variables as any)?.value || {}, flags: (engine.flags as any)?.value || {}, flightContext: (engine.flightContext as any)?.value || {}, communicationLog: ((engine.communicationLog as any)?.value || [] as any[]).slice(-20), } : undefined const res = await api.post<{ code?: string }>('/api/bug-reports', { source, comment: bugReportComment.value.trim(), contact: bugReportContact.value.trim(), screenshot: finalScreenshot, pmState, }) bugReportCode.value = res?.code || '' bugReportSuccess.value = true setTimeout(() => { showBugReportDialog.value = false; bugReportSuccess.value = false }, 5000) } catch (err: any) { bugReportError.value = err?.data?.statusMessage || err?.message || 'Fehler beim Senden.' } finally { bugReportLoading.value = false } } return { showBugReportDialog, bugReportComment, bugReportContact, bugReportScreenshot, bugReportArrows, bugReportLoading, bugReportCapturing, bugReportError, bugReportSuccess, bugReportCode, bugReportCanvasRef, bugReportImgRef, setupAnnotationCanvas, onCanvasMouseDown, onCanvasMouseMove, onCanvasMouseUp, onCanvasMouseLeave, undoLastArrow, openBugReport, submitBugReport, } }