// 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}`, { credentials: 'same-origin' }); 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' }, credentials: 'same-origin', 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 via browser's native print dialog. * Users can select "Save as PDF" in the print dialog. */ exportPDF() { if (!this.analysisId) { showToast('请先完成审查再导出 PDF', 'warning'); return; } window.print(); }, // ── 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;