// Note Editor: manages review notes with auto-save and three-level scoping // Scopes: overall (page-level), file (per-file), suggestion (per-suggestion) const NoteEditor = { repoId: null, analysisId: null, _saveTimers: {}, // debounce timers by key _notes: {}, // loaded notes keyed by "scope:scopeKey" /** * Initialize the note editor for a review page. * @param {number} repoId - Repository ID * @param {number} analysisId - Analysis ID (from the most recent review) */ init(repoId, analysisId) { this.repoId = repoId; this.analysisId = analysisId; this._notes = {}; this._saveTimers = {}; // Bind existing note editor placeholders (from diff-viewer suggestion cards) this._bindPlaceholders(); // Watch for new placeholders added dynamically (from SSE streaming) this._observeNewPlaceholders(); }, /** * Load all notes for the current analysis and populate editors. */ async loadNotes() { if (!this.analysisId) return; try { const resp = await fetch(`/api/repos/${this.repoId}/review/notes?analysis_id=${this.analysisId}`); if (!resp.ok) return; const notes = await resp.json(); // Index notes notes.forEach(n => { const key = n.scope + ':' + (n.scope_key || ''); this._notes[key] = n; }); // Populate all existing editors this._populateAllEditors(); } catch (err) { console.error('Failed to load notes:', err); } }, /** * Save a note (upsert). Debounced to avoid rapid-fire requests. */ saveNote(scope, scopeKey, content) { const key = scope + ':' + (scopeKey || ''); // Clear existing timer for this key if (this._saveTimers[key]) { clearTimeout(this._saveTimers[key]); } // Debounce: wait 800ms after last keystroke this._saveTimers[key] = setTimeout(async () => { delete this._saveTimers[key]; try { const resp = await fetch(`/api/repos/${this.repoId}/review/notes`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ analysis_id: this.analysisId, scope: scope, scope_key: scopeKey || '', content: content, }), }); if (resp.ok) { const note = await resp.json(); this._notes[key] = note; // Update save indicator const indicator = document.querySelector(`[data-note-key="${key}"] .note-save-indicator`); if (indicator) { indicator.textContent = '已保存'; indicator.classList.remove('text-gray-400'); indicator.classList.add('text-green-500'); setTimeout(() => { indicator.textContent = ''; indicator.classList.remove('text-green-500'); }, 2000); } } } catch (err) { console.error('Failed to save note:', err); } }, 800); }, /** * Create a note editor HTML for a given scope. * @param {string} scope - 'overall', 'file', or 'suggestion' * @param {string} scopeKey - empty for overall, filename for file, suggestionId for suggestion * @returns {string} HTML string */ createEditorHTML(scope, scopeKey) { const key = scope + ':' + (scopeKey || ''); const existing = this._notes[key]; const content = existing ? existing.content : ''; const placeholder = this._getPlaceholder(scope); return `
💬 备注 (Markdown)
`; }, /** * Render a standalone "overall" note editor section (for the review page). */ renderOverallEditor() { if (!this.analysisId) return ''; return `

📝 整体备注

${this.createEditorHTML('overall', '')}
`; }, /** * Render a "PDF Export" button. */ renderPDFButton() { return ` `; }, /** * Trigger PDF export and download. */ async exportPDF() { if (!this.analysisId) { alert('请先完成审查再导出 PDF'); return; } const btn = document.getElementById('btn-export-pdf'); if (btn) { btn.disabled = true; btn.textContent = '⏳ 生成中...'; } try { const resp = await fetch(`/api/repos/${this.repoId}/review/pdf`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ analysis_id: this.analysisId, base: document.getElementById('base-ref')?.value || '', head: document.getElementById('head-ref')?.value || '', }), }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || 'PDF generation failed'); } // Download the PDF const blob = await resp.blob(); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = `pr-helper-review-${new Date().toISOString().slice(0, 10)}.pdf`; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } catch (err) { alert('PDF 导出失败: ' + err.message); } finally { if (btn) { btn.disabled = false; btn.textContent = '📄 导出 PDF 报告'; } } }, // ── Internal helpers ────────────────────────────────────────── _getPlaceholder(scope) { switch (scope) { case 'overall': return '添加整体备注...'; case 'file': return '添加文件级备注...'; case 'suggestion': return '添加备注...'; default: return '添加备注...'; } }, _escapeHtml(text) { const div = document.createElement('div'); div.textContent = text || ''; return div.innerHTML; }, onInput(textarea, scope, scopeKey) { // Update save indicator to "saving..." const key = scope + ':' + (scopeKey || ''); const indicator = textarea.closest('.note-editor')?.querySelector('.note-save-indicator'); if (indicator) { indicator.textContent = '保存中...'; indicator.classList.remove('text-green-500'); indicator.classList.add('text-gray-400'); } this.saveNote(scope, scopeKey, textarea.value); }, /** * Bind click/input handlers to existing note-editor-placeholder divs. */ _bindPlaceholders() { document.querySelectorAll('.note-editor-placeholder').forEach(placeholder => { if (placeholder.dataset.initialized) return; placeholder.dataset.initialized = 'true'; const scope = placeholder.dataset.scope || 'suggestion'; const scopeKey = placeholder.dataset.scopeKey || ''; placeholder.innerHTML = this.createEditorHTML(scope, scopeKey); }); }, /** * Populate all note editors with loaded content. */ _populateAllEditors() { document.querySelectorAll('.note-editor').forEach(editor => { const key = editor.dataset.noteKey; const note = this._notes[key]; if (note && note.content) { const textarea = editor.querySelector('.note-textarea'); if (textarea && !textarea.value) { textarea.value = note.content; } } }); }, /** * Observe DOM for new note-editor-placeholder elements (from SSE streaming). */ _observeNewPlaceholders() { const observer = new MutationObserver((mutations) => { let hasNew = false; for (const mutation of mutations) { for (const node of mutation.addedNodes) { if (node.nodeType !== 1) continue; if (node.classList?.contains('note-editor-placeholder') || node.querySelector?.('.note-editor-placeholder')) { hasNew = true; break; } } if (hasNew) break; } if (hasNew) { this._bindPlaceholders(); this._populateAllEditors(); } }); observer.observe(document.body, { childList: true, subtree: true }); }, /** * Set the analysis ID (called after a review completes and analysis is saved). */ setAnalysisId(id) { this.analysisId = id; }, }; window.NoteEditor = NoteEditor;