Files
PR-Helper/static/js/note-editor.js
T
wonder 6c60b767df refactor: replace chromedp with browser native print for PDF export
Remove chromedp dependency, use window.print() instead. Docker image no longer needs Chromium (~200MB smaller).

- Delete services/pdf.go and templates/reports/review.html

- Remove PDF API route POST /api/repos/:id/review/pdf

- Add @media print CSS to review page

- Remove chromium from Dockerfile
2026-06-20 22:26:46 +08:00

259 lines
9.0 KiB
JavaScript

// 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 `
<div class="note-editor mt-2" data-note-key="${key}" data-scope="${scope}" data-scope-key="${scopeKey || ''}">
<div class="flex items-center justify-between mb-1">
<span class="text-xs text-gray-400">💬 备注 (Markdown)</span>
<span class="note-save-indicator text-xs"></span>
</div>
<textarea
class="note-textarea"
placeholder="${placeholder}"
rows="2"
oninput="NoteEditor.onInput(this, '${scope}', '${scopeKey || ''}')"
>${this._escapeHtml(content)}</textarea>
</div>`;
},
/**
* Render a standalone "overall" note editor section (for the review page).
*/
renderOverallEditor() {
if (!this.analysisId) return '';
return `
<div id="overall-note-section" class="bg-white rounded-lg shadow-md p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-900 mb-3">📝 整体备注</h2>
${this.createEditorHTML('overall', '')}
</div>`;
},
/**
* Render a "PDF Export" button.
*/
renderPDFButton() {
return `
<button onclick="NoteEditor.exportPDF()" id="btn-export-pdf"
class="px-4 py-2 bg-indigo-600 text-white rounded-md hover:bg-indigo-700 text-sm">
📄 导出 PDF 报告
</button>`;
},
/**
* 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;