## Enhanced Mode v2 - Smart Section-Based Editing (routes/ai.js) ### Server-Side Section Replacement Architecture - Add cleanMarkdownFromTitle() to normalize section titles for matching - Add extractHeaders() to list all document sections for debugging - Add replaceSection() to surgically replace only modified sections - AI now returns ONLY the modified section, not entire document - Server automatically replaces section in original document - Automatic header level correction if AI changes ## to ### or vice versa - Section boundary detection based on header hierarchy ### Enhanced Prompt and Response Format - Modified prompt to explicitly request ONLY modified section - New closing format: document (mandatory) - Added fallback regex for old format with warnings - Explicit rules: keep exact header level (## stays ##) - Clear section boundary definition in prompt - Examples with proper formatting guidelines ### Comprehensive Logging System - Log all API requests with method, endpoint, payload size - Log AI responses with length and preview - Log section matching and replacement operations - Log header level corrections - Log section not found errors with available sections list - Track modified sections across iterations ## AI Button Mutex and Preview Mode Controls (assets/js/app.js) ### AI Button Mutex (Prevent API Overload) - Add disableAIButtons() to disable all AI buttons during operations - Add enableAIButtons() to re-enable after completion or error - Disable all AI buttons at start of any AI operation - Re-enable in finally blocks to ensure cleanup even on errors - Re-enable on validation failures (e.g., no text selected for rephrase) - Re-enable when user clicks Apply/Cancel in rephrase mode ### Preview Mode Button Restrictions - Disable Preview button during Enhanced Mode operation - Disable all AI buttons in preview mode (rephrase, inconsistencies, duplications, advice, liberty) - Disable Save and Load buttons in preview mode - Re-enable all buttons when returning to edit mode - Proper cleanup with finally blocks ## Mermaid Auto-Fix System - Complete Removal ### Removed from assets/js/app.js - Remove mermaidFixAttempts Set from constructor - Remove setupMessageListener() and postMessage handler - Remove fixMermaidDiagramBackground() function - Simplify Mermaid error display to inline messages only - Remove hash-based tracking mechanism ### Removed from routes/index.js (Present Mode) - Remove entire auto-fix fetch and retry logic - Remove status div updates and fix notifications - Remove postMessage to parent window - Simplify to display styled error message only ### Current Behavior - Preview mode: Shows inline error with simple message - Present mode: Shows styled error box with instructions - No automatic fix attempts - manual correction only ## Additional Improvements - Clean markdown formatting (##, **, etc.) from section titles in UI badges - Proper section title matching ignoring markdown syntax - Enhanced error handling with detailed logging - Better user feedback during Enhanced Mode iterations This release improves Enhanced Mode reliability, prevents API overload through button mutex, simplifies Mermaid error handling, and adds comprehensive logging for debugging.
1677 lines
54 KiB
JavaScript
1677 lines
54 KiB
JavaScript
// Main application
|
|
class ConceptionAssistant {
|
|
constructor() {
|
|
this.currentJournalId = null;
|
|
this.editor = null;
|
|
this.undoStack = [];
|
|
this.redoStack = [];
|
|
this.tocTimer = null;
|
|
this.isPreviewMode = false;
|
|
this.originalContent = '';
|
|
this.init();
|
|
}
|
|
|
|
async init() {
|
|
this.setupEditor();
|
|
this.setupEventListeners();
|
|
await this.loadJournalList();
|
|
this.generateTOC();
|
|
this.updateStatistics();
|
|
}
|
|
|
|
setupEditor() {
|
|
this.editor = document.getElementById('journal-editor');
|
|
|
|
// Generate TOC with debounce to avoid interrupting typing
|
|
this.editor.addEventListener('input', () => {
|
|
this.debounceTOC();
|
|
this.updateStatistics();
|
|
});
|
|
|
|
// Keyboard shortcut handling
|
|
this.editor.addEventListener('keydown', (e) => {
|
|
// Ctrl+S to save
|
|
if (e.ctrlKey && e.key === 's') {
|
|
e.preventDefault();
|
|
this.saveJournal();
|
|
}
|
|
|
|
// Ctrl+Z to undo
|
|
if (e.ctrlKey && e.key === 'z' && !e.shiftKey) {
|
|
e.preventDefault();
|
|
this.undo();
|
|
}
|
|
|
|
// Ctrl+Y or Ctrl+Shift+Z to redo
|
|
if (e.ctrlKey && (e.key === 'y' || (e.shiftKey && e.key === 'Z'))) {
|
|
e.preventDefault();
|
|
this.redo();
|
|
}
|
|
|
|
// Tab for indentation
|
|
if (e.key === 'Tab') {
|
|
e.preventDefault();
|
|
document.execCommand('insertText', false, ' ');
|
|
}
|
|
});
|
|
|
|
// Save state for undo/redo before important modifications
|
|
this.editor.addEventListener('input', () => {
|
|
this.saveState();
|
|
});
|
|
}
|
|
|
|
showEditorPlaceholder() {
|
|
// Placeholder is now managed via CSS ::before
|
|
// This function can be removed or left empty for compatibility
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Journal control buttons
|
|
document.getElementById('save-journal')?.addEventListener('click', () => this.saveJournal());
|
|
document.getElementById('load-journal')?.addEventListener('click', () => this.showJournalSelector());
|
|
document.getElementById('preview-toggle')?.addEventListener('click', async () => await this.togglePreview());
|
|
document.getElementById('new-blank-document')?.addEventListener('click', () => this.createBlankDocument());
|
|
|
|
// Table of contents
|
|
document.getElementById('refresh-toc')?.addEventListener('click', () => this.generateTOC());
|
|
|
|
// Export/Import
|
|
document.getElementById('export-md')?.addEventListener('click', () => this.exportMarkdown());
|
|
document.getElementById('export-web')?.addEventListener('click', () => this.exportWeb());
|
|
document.getElementById('import-md')?.addEventListener('change', (e) => this.importMarkdown(e));
|
|
|
|
// Theme
|
|
document.getElementById('theme-toggle')?.addEventListener('click', () => this.toggleTheme());
|
|
|
|
// AI Assistant
|
|
document.getElementById('activate-rephrase')?.addEventListener('click', () => this.handleAI('rephrase'));
|
|
document.getElementById('check-inconsistencies')?.addEventListener('click', () => this.handleAI('inconsistencies'));
|
|
document.getElementById('check-duplications')?.addEventListener('click', () => this.handleAI('duplications'));
|
|
document.getElementById('give-advice')?.addEventListener('click', () => this.handleAI('advice'));
|
|
document.getElementById('liberty-mode')?.addEventListener('click', () => this.handleAI('liberty'));
|
|
|
|
// Present mode
|
|
document.getElementById('present-mode')?.addEventListener('click', () => this.openPresentMode());
|
|
|
|
// Shortcuts help
|
|
document.getElementById('show-shortcuts')?.addEventListener('click', () => this.showShortcutsModal());
|
|
|
|
// Loading modal
|
|
document.getElementById('close-journal-modal')?.addEventListener('click', () => this.closeModal());
|
|
|
|
// Close modal by clicking on overlay
|
|
document.getElementById('journal-modal')?.addEventListener('click', (e) => {
|
|
if (e.target.id === 'journal-modal') {
|
|
this.closeModal();
|
|
}
|
|
});
|
|
|
|
// Scroll to top button
|
|
document.getElementById('scroll-to-top')?.addEventListener('click', () => this.scrollToTop());
|
|
|
|
// Manage scroll to top button display
|
|
this.setupScrollToTop();
|
|
}
|
|
|
|
setupScrollToTop() {
|
|
const scrollButton = document.getElementById('scroll-to-top');
|
|
if (!scrollButton) return;
|
|
|
|
window.addEventListener('scroll', () => {
|
|
if (window.scrollY > 300) {
|
|
scrollButton.classList.add('visible');
|
|
} else {
|
|
scrollButton.classList.remove('visible');
|
|
}
|
|
});
|
|
}
|
|
|
|
scrollToTop() {
|
|
window.scrollTo({
|
|
top: 0,
|
|
behavior: 'smooth'
|
|
});
|
|
this.showNotification('Back to top', 'success');
|
|
}
|
|
|
|
saveState(immediate = false) {
|
|
// If immediate is true, save immediately without debounce
|
|
if (immediate) {
|
|
this.performSaveState();
|
|
return;
|
|
}
|
|
|
|
// Avoid saving too often (debounce)
|
|
if (this.saveStateTimer) {
|
|
clearTimeout(this.saveStateTimer);
|
|
}
|
|
|
|
this.saveStateTimer = setTimeout(() => {
|
|
this.performSaveState();
|
|
}, 1000); // Save after 1 second of inactivity
|
|
}
|
|
|
|
performSaveState() {
|
|
const currentContent = this.editor.innerText;
|
|
|
|
// Don't save if content hasn't changed
|
|
if (this.undoStack.length > 0 && this.undoStack[this.undoStack.length - 1] === currentContent) {
|
|
return;
|
|
}
|
|
|
|
this.undoStack.push(currentContent);
|
|
|
|
// Limit undo stack to 50 elements
|
|
if (this.undoStack.length > 50) {
|
|
this.undoStack.shift();
|
|
}
|
|
|
|
// Clear redo stack because we've done a new action
|
|
this.redoStack = [];
|
|
}
|
|
|
|
undo() {
|
|
if (this.undoStack.length > 1) {
|
|
const currentContent = this.undoStack.pop();
|
|
this.redoStack.push(currentContent);
|
|
|
|
const previousContent = this.undoStack[this.undoStack.length - 1];
|
|
this.editor.innerText = previousContent;
|
|
|
|
// Regenerate table of contents
|
|
this.generateTOC();
|
|
|
|
this.showNotification('Undo completed', 'success');
|
|
} else {
|
|
this.showNotification('Nothing to undo', 'warning');
|
|
}
|
|
}
|
|
|
|
redo() {
|
|
if (this.redoStack.length > 0) {
|
|
const nextContent = this.redoStack.pop();
|
|
this.undoStack.push(nextContent);
|
|
|
|
this.editor.innerText = nextContent;
|
|
|
|
// Regenerate table of contents
|
|
this.generateTOC();
|
|
|
|
this.showNotification('Redo completed', 'success');
|
|
} else {
|
|
this.showNotification('Nothing to redo', 'warning');
|
|
}
|
|
}
|
|
|
|
|
|
async saveJournal() {
|
|
const content = this.editor.innerText;
|
|
if (!content) return;
|
|
|
|
const statusEl = document.getElementById('save-status');
|
|
const saveBtn = document.getElementById('save-journal');
|
|
|
|
saveBtn.classList.add('loading');
|
|
statusEl.textContent = 'Saving...';
|
|
|
|
try {
|
|
let response;
|
|
|
|
if (this.currentJournalId) {
|
|
// Update
|
|
response = await fetch(`/api/journals/${this.currentJournalId}`, {
|
|
method: 'PUT',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content })
|
|
});
|
|
} else {
|
|
// Create
|
|
response = await fetch('/api/journals', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content })
|
|
});
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
if (!this.currentJournalId) {
|
|
this.currentJournalId = result.data.id;
|
|
}
|
|
|
|
statusEl.textContent = 'Saved';
|
|
this.showNotification('Journal saved successfully', 'success');
|
|
setTimeout(() => statusEl.textContent = '', 3000);
|
|
} else {
|
|
throw new Error(result.error || 'Save error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
statusEl.textContent = 'Error';
|
|
this.showNotification('Error while saving: ' + error.message, 'error');
|
|
setTimeout(() => statusEl.textContent = '', 3000);
|
|
} finally {
|
|
saveBtn.classList.remove('loading');
|
|
}
|
|
}
|
|
|
|
async loadJournalList() {
|
|
try {
|
|
const response = await fetch('/api/journals');
|
|
const result = await response.json();
|
|
|
|
if (result.success && result.data.length > 0) {
|
|
// Load the last journal automatically
|
|
const lastJournal = result.data[result.data.length - 1];
|
|
await this.loadJournal(lastJournal.id);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading list:', error);
|
|
}
|
|
}
|
|
|
|
async loadJournal(id) {
|
|
try {
|
|
const response = await fetch(`/api/journals/${id}`);
|
|
const result = await response.json();
|
|
|
|
if (result.success && result.data.length > 0) {
|
|
const journal = result.data[0];
|
|
this.currentJournalId = id;
|
|
this.editor.innerText = journal.markdownContent;
|
|
this.generateTOC();
|
|
|
|
// Reset history for the new journal
|
|
this.undoStack = [journal.markdownContent];
|
|
this.redoStack = [];
|
|
|
|
// Ensure editor is in edit mode
|
|
this.ensureEditMode();
|
|
|
|
this.showNotification('Journal loaded', 'success');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error loading journal:', error);
|
|
this.showNotification('Error while loading', 'error');
|
|
}
|
|
}
|
|
|
|
async showJournalSelector() {
|
|
try {
|
|
const response = await fetch('/api/journals');
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
const journalList = result.data.map(journal => {
|
|
// Extract first lines for preview
|
|
const lines = journal.markdownContent.split('\n');
|
|
const firstLines = lines.slice(0, 3).join('\n');
|
|
const preview = firstLines.length > 150 ? firstLines.substring(0, 150) + '...' : firstLines;
|
|
|
|
return `
|
|
<div class="journal-item-container">
|
|
<button class="journal-item btn secondary" data-id="${journal.id}">
|
|
<div class="journal-preview">
|
|
<strong>Journal ${journal.id}</strong>
|
|
<div>${preview}</div>
|
|
</div>
|
|
</button>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
|
|
const modalBody = document.getElementById('journal-modal-body');
|
|
modalBody.innerHTML = `
|
|
<div class="journal-list">
|
|
<button class="btn success mb-2" onclick="app.createNewJournal(); app.closeModal();" style="width: 100%;">
|
|
+ New Journal
|
|
</button>
|
|
${journalList}
|
|
</div>
|
|
`;
|
|
|
|
// Show modal with animation
|
|
const modal = document.getElementById('journal-modal');
|
|
modal.style.display = 'flex';
|
|
setTimeout(() => modal.classList.add('show'), 10);
|
|
|
|
// Add event listeners
|
|
document.querySelectorAll('.journal-item').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
this.loadJournal(btn.dataset.id);
|
|
this.closeModal();
|
|
});
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
this.showNotification('Error loading list', 'error');
|
|
}
|
|
}
|
|
|
|
closeModal() {
|
|
const modal = document.getElementById('journal-modal');
|
|
modal.classList.remove('show');
|
|
setTimeout(() => {
|
|
modal.style.display = 'none';
|
|
}, 300);
|
|
}
|
|
|
|
createNewJournal() {
|
|
this.currentJournalId = null;
|
|
this.editor.innerText = '';
|
|
this.generateTOC();
|
|
this.clearFeedback();
|
|
|
|
// Reset history for new journal
|
|
this.undoStack = [''];
|
|
this.redoStack = [];
|
|
|
|
// Ensure editor is in edit mode
|
|
this.ensureEditMode();
|
|
|
|
this.showNotification('New journal created', 'success');
|
|
}
|
|
|
|
createBlankDocument() {
|
|
// Clear the editor completely
|
|
this.editor.innerText = '';
|
|
|
|
// Reset currentJournalId to null
|
|
this.currentJournalId = null;
|
|
|
|
// Reset undo/redo stacks
|
|
this.undoStack = [''];
|
|
this.redoStack = [];
|
|
|
|
// Generate TOC
|
|
this.generateTOC();
|
|
|
|
// Update statistics
|
|
this.updateStatistics();
|
|
|
|
// Show notification
|
|
this.showNotification('New blank document created', 'success');
|
|
|
|
// Close all panels
|
|
closeAllPanels();
|
|
|
|
// Ensure editor is in edit mode
|
|
this.ensureEditMode();
|
|
}
|
|
|
|
debounceTOC() {
|
|
if (this.tocTimer) {
|
|
clearTimeout(this.tocTimer);
|
|
}
|
|
this.tocTimer = setTimeout(() => {
|
|
this.generateTOC();
|
|
}, 500); // Wait 500ms after last keystroke
|
|
}
|
|
|
|
saveSelection() {
|
|
const selection = window.getSelection();
|
|
if (selection.rangeCount > 0) {
|
|
return selection.getRangeAt(0).cloneRange();
|
|
}
|
|
return null;
|
|
}
|
|
|
|
restoreSelection(range) {
|
|
if (range) {
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
}
|
|
}
|
|
|
|
cleanMarkdownFromTitle(title) {
|
|
// Remove all markdown formatting markers from title
|
|
return title
|
|
.replace(/\*\*/g, '') // Remove bold **
|
|
.replace(/\*/g, '') // Remove italic *
|
|
.replace(/__/g, '') // Remove bold __
|
|
.replace(/_/g, '') // Remove italic _
|
|
.replace(/`/g, '') // Remove code `
|
|
.replace(/~~/g, '') // Remove strikethrough ~~
|
|
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links, keep text
|
|
.trim();
|
|
}
|
|
|
|
generateTOC() {
|
|
// Save cursor position
|
|
const savedRange = this.saveSelection();
|
|
|
|
const content = this.editor.innerText;
|
|
const lines = content.split('\n');
|
|
const toc = [];
|
|
|
|
// Remove existing anchors
|
|
const existingAnchors = this.editor.querySelectorAll('.heading-anchor');
|
|
existingAnchors.forEach(anchor => anchor.remove());
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
if (line.startsWith('#')) {
|
|
const level = line.match(/^#+/)[0].length;
|
|
const title = line.replace(/^#+\s*/, '');
|
|
const id = 'heading-' + i;
|
|
|
|
toc.push({ level, title, id, lineIndex: i });
|
|
}
|
|
}
|
|
|
|
let tocHtml = '';
|
|
if (toc.length > 0) {
|
|
// Nouvelle génération de TOC avec indentation correcte
|
|
let previousLevel = 0;
|
|
|
|
for (let i = 0; i < toc.length; i++) {
|
|
const item = toc[i];
|
|
|
|
// Ouvrir les listes nécessaires
|
|
if (item.level > previousLevel) {
|
|
for (let j = previousLevel; j < item.level; j++) {
|
|
tocHtml += '<ul>';
|
|
}
|
|
}
|
|
// Fermer les listes nécessaires
|
|
else if (item.level < previousLevel) {
|
|
for (let j = item.level; j < previousLevel; j++) {
|
|
tocHtml += '</ul></li>';
|
|
}
|
|
}
|
|
// Même niveau, fermer le li précédent
|
|
else if (i > 0) {
|
|
tocHtml += '</li>';
|
|
}
|
|
|
|
// Ajouter l'élément avec indentation CSS
|
|
const indent = (item.level - 1) * 1; // 1rem par niveau
|
|
const cleanTitle = this.cleanMarkdownFromTitle(item.title);
|
|
tocHtml += `<li style="margin-left: ${indent}rem;"><a href="#${item.id}" onclick="app.scrollToHeading('${item.title.replace(/'/g, "\\'")}'); return false;">${cleanTitle}</a>`;
|
|
|
|
previousLevel = item.level;
|
|
}
|
|
|
|
// Fermer toutes les listes restantes
|
|
for (let j = 0; j < previousLevel; j++) {
|
|
tocHtml += '</li></ul>';
|
|
}
|
|
} else {
|
|
tocHtml = '<div class="toc-placeholder"><p>Add headings (# ## ###) to your journal to generate the table of contents.</p></div>';
|
|
}
|
|
|
|
document.getElementById('toc-nav').innerHTML = tocHtml;
|
|
|
|
// Restore cursor position
|
|
this.restoreSelection(savedRange);
|
|
}
|
|
|
|
addHeadingAnchors(lines, toc) {
|
|
const editorLines = this.editor.innerHTML.split('<br>');
|
|
let newContent = '';
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const tocItem = toc.find(item => item.lineIndex === i);
|
|
|
|
if (tocItem) {
|
|
newContent += `<span class="heading-anchor" id="${tocItem.id}" style="position: absolute; margin-top: -100px; visibility: hidden;"></span>`;
|
|
}
|
|
|
|
newContent += line;
|
|
if (i < lines.length - 1) newContent += '<br>';
|
|
}
|
|
|
|
this.editor.innerHTML = newContent;
|
|
}
|
|
|
|
scrollToHeading(title) {
|
|
try {
|
|
const content = this.editor.innerText;
|
|
const lines = content.split('\n');
|
|
|
|
// Find the line index and calculate character position
|
|
let targetLineIndex = -1;
|
|
let charPosition = 0;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
if (line.startsWith('#') && line.replace(/^#+\s*/, '') === title) {
|
|
targetLineIndex = i;
|
|
break;
|
|
}
|
|
// Count characters including newlines
|
|
charPosition += lines[i].length + 1; // +1 for \n
|
|
}
|
|
|
|
if (targetLineIndex === -1) {
|
|
this.showNotification('Section not found', 'warning');
|
|
return;
|
|
}
|
|
|
|
// Create a range at the target position to get accurate coordinates
|
|
const selection = window.getSelection();
|
|
const range = document.createRange();
|
|
|
|
// Find the text node at the character position
|
|
const walker = document.createTreeWalker(
|
|
this.editor,
|
|
NodeFilter.SHOW_TEXT,
|
|
null
|
|
);
|
|
|
|
let currentChar = 0;
|
|
let targetNode = null;
|
|
let offsetInNode = 0;
|
|
|
|
while (walker.nextNode()) {
|
|
const node = walker.currentNode;
|
|
const nodeLength = node.textContent.length;
|
|
|
|
if (currentChar + nodeLength >= charPosition) {
|
|
targetNode = node;
|
|
offsetInNode = charPosition - currentChar;
|
|
break;
|
|
}
|
|
currentChar += nodeLength;
|
|
}
|
|
|
|
if (targetNode) {
|
|
// Set range at the target position
|
|
range.setStart(targetNode, Math.min(offsetInNode, targetNode.textContent.length));
|
|
range.setEnd(targetNode, Math.min(offsetInNode, targetNode.textContent.length));
|
|
|
|
// Get the bounding rectangle of the range
|
|
const rect = range.getBoundingClientRect();
|
|
|
|
// Scroll to the element - use the parent container (#design-journal)
|
|
const journalSection = document.getElementById('design-journal');
|
|
const sectionRect = journalSection.getBoundingClientRect();
|
|
const scrollOffset = rect.top - sectionRect.top + journalSection.scrollTop - 100;
|
|
|
|
journalSection.scrollTo({
|
|
top: Math.max(0, scrollOffset),
|
|
behavior: 'smooth'
|
|
});
|
|
|
|
// Temporarily highlight the heading
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
setTimeout(() => selection.removeAllRanges(), 1000);
|
|
}
|
|
|
|
this.showNotification(`Navigating to: ${title}`, 'success');
|
|
|
|
} catch (error) {
|
|
console.error('Scroll error:', error);
|
|
this.showNotification('Error during navigation', 'error');
|
|
}
|
|
}
|
|
|
|
highlightHeading(title) {
|
|
// Function to temporarily highlight the found title
|
|
try {
|
|
const content = this.editor.innerText;
|
|
const lines = content.split('\n');
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i].trim();
|
|
if (line.startsWith('#') && line.replace(/^#+\s*/, '') === title) {
|
|
// Create a range to select the line
|
|
const selection = window.getSelection();
|
|
const range = document.createRange();
|
|
|
|
// Find the text node and position
|
|
const walker = document.createTreeWalker(
|
|
this.editor,
|
|
NodeFilter.SHOW_TEXT,
|
|
null,
|
|
false
|
|
);
|
|
|
|
let currentLine = 0;
|
|
let textNode = walker.nextNode();
|
|
|
|
while (textNode && currentLine < i) {
|
|
const nodeText = textNode.textContent;
|
|
const newLines = (nodeText.match(/\n/g) || []).length;
|
|
currentLine += newLines;
|
|
|
|
if (currentLine < i) {
|
|
textNode = walker.nextNode();
|
|
}
|
|
}
|
|
|
|
if (textNode) {
|
|
// Find the start of the line in this node
|
|
const nodeText = textNode.textContent;
|
|
const linesInNode = nodeText.split('\n');
|
|
const targetLineInNode = i - (currentLine - linesInNode.length + 1);
|
|
|
|
if (targetLineInNode >= 0 && targetLineInNode < linesInNode.length) {
|
|
let startPos = 0;
|
|
for (let j = 0; j < targetLineInNode; j++) {
|
|
startPos += linesInNode[j].length + 1;
|
|
}
|
|
|
|
const endPos = startPos + linesInNode[targetLineInNode].length;
|
|
|
|
range.setStart(textNode, startPos);
|
|
range.setEnd(textNode, endPos);
|
|
|
|
// Select temporarily
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
|
|
// Remove selection after a short delay
|
|
setTimeout(() => {
|
|
selection.removeAllRanges();
|
|
}, 1000);
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Ignore highlight errors, not critical
|
|
console.log('Highlight error:', error);
|
|
}
|
|
}
|
|
|
|
exportMarkdown() {
|
|
const content = this.editor.innerText;
|
|
if (!content.trim()) {
|
|
this.showNotification('No content to export', 'warning');
|
|
return;
|
|
}
|
|
|
|
const blob = new Blob([content], { type: 'text/markdown' });
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'design-journal.md';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
this.showNotification('Markdown file exported', 'success');
|
|
}
|
|
|
|
async exportWeb() {
|
|
const content = this.editor.innerText;
|
|
if (!content.trim()) {
|
|
this.showNotification('No content to export', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.showNotification('Generating web export...', 'success');
|
|
|
|
const response = await fetch('/api/export/web', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ content, title: 'Design Journal' })
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error('Export failed');
|
|
}
|
|
|
|
const blob = await response.blob();
|
|
const url = URL.createObjectURL(blob);
|
|
const a = document.createElement('a');
|
|
a.href = url;
|
|
a.download = 'design-journal-web.zip';
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
|
|
this.showNotification('Web export (ZIP) downloaded', 'success');
|
|
} catch (error) {
|
|
console.error('Export error:', error);
|
|
this.showNotification('Error exporting web: ' + error.message, 'error');
|
|
}
|
|
}
|
|
|
|
|
|
|
|
importMarkdown(event) {
|
|
const file = event.target.files[0];
|
|
if (!file) return;
|
|
|
|
const reader = new FileReader();
|
|
reader.onload = (e) => {
|
|
const importedContent = e.target.result;
|
|
this.editor.innerText = importedContent;
|
|
this.generateTOC();
|
|
this.currentJournalId = null; // New journal
|
|
|
|
// Reset history for imported file
|
|
this.undoStack = [importedContent];
|
|
this.redoStack = [];
|
|
|
|
// Ensure editor is in edit mode
|
|
this.ensureEditMode();
|
|
|
|
this.showNotification('Markdown file imported', 'success');
|
|
};
|
|
reader.readAsText(file);
|
|
}
|
|
|
|
toggleTheme() {
|
|
document.body.classList.toggle('dark-theme');
|
|
const isDark = document.body.classList.contains('dark-theme');
|
|
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
|
this.showNotification(`${isDark ? 'Dark' : 'Light'} mode activated`, 'success');
|
|
}
|
|
|
|
updateStatistics() {
|
|
const content = this.editor.innerText.trim();
|
|
|
|
// Count words
|
|
const words = content.split(/\s+/).filter(word => word.length > 0);
|
|
const wordCount = words.length;
|
|
|
|
// Count characters
|
|
const charCount = content.length;
|
|
|
|
// Calculate reading time (200 words per minute)
|
|
const readingTime = Math.ceil(wordCount / 200);
|
|
|
|
// Update DOM elements
|
|
const wordCountEl = document.getElementById('word-count');
|
|
const charCountEl = document.getElementById('char-count');
|
|
const readingTimeEl = document.getElementById('reading-time');
|
|
|
|
if (wordCountEl) wordCountEl.textContent = wordCount;
|
|
if (charCountEl) charCountEl.textContent = charCount;
|
|
if (readingTimeEl) readingTimeEl.textContent = `${readingTime} min`;
|
|
}
|
|
|
|
async openPresentMode() {
|
|
const content = this.editor.innerText;
|
|
|
|
if (!content.trim()) {
|
|
this.showNotification('No content to present', 'warning');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
this.showNotification('Opening presentation mode...', 'success');
|
|
|
|
// Create a form to POST the content
|
|
const form = document.createElement('form');
|
|
form.method = 'POST';
|
|
form.action = '/present';
|
|
form.target = '_blank';
|
|
|
|
const input = document.createElement('input');
|
|
input.type = 'hidden';
|
|
input.name = 'content';
|
|
input.value = content;
|
|
|
|
form.appendChild(input);
|
|
document.body.appendChild(form);
|
|
form.submit();
|
|
document.body.removeChild(form);
|
|
|
|
} catch (error) {
|
|
console.error('Present mode error:', error);
|
|
this.showNotification('Error opening presentation mode', 'error');
|
|
}
|
|
}
|
|
|
|
showShortcutsModal() {
|
|
const modalBody = document.getElementById('journal-modal-body');
|
|
const modalHeader = document.querySelector('#journal-modal .modal-header h3');
|
|
|
|
if (modalHeader) {
|
|
modalHeader.textContent = 'Keyboard Shortcuts';
|
|
}
|
|
|
|
modalBody.innerHTML = `
|
|
<div style="padding: 1rem;">
|
|
<div style="margin-bottom: 1.5rem;">
|
|
<h4 style="color: var(--primary-color); margin-bottom: 0.75rem;">Document Management</h4>
|
|
<table style="width: 100%; border-collapse: collapse;">
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 0.5rem; font-weight: bold;">Ctrl+S</td>
|
|
<td style="padding: 0.5rem;">Save journal</td>
|
|
</tr>
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 0.5rem; font-weight: bold;">Ctrl+Z</td>
|
|
<td style="padding: 0.5rem;">Undo</td>
|
|
</tr>
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 0.5rem; font-weight: bold;">Ctrl+Y</td>
|
|
<td style="padding: 0.5rem;">Redo</td>
|
|
</tr>
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 0.5rem; font-weight: bold;">Tab</td>
|
|
<td style="padding: 0.5rem;">Indent text</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 0.5rem; font-weight: bold;">Esc</td>
|
|
<td style="padding: 0.5rem;">Close side panels</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
|
|
<div style="margin-top: 1.5rem;">
|
|
<h4 style="color: var(--primary-color); margin-bottom: 0.75rem;">Markdown Formatting</h4>
|
|
<table style="width: 100%; border-collapse: collapse;">
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 0.5rem; font-family: monospace;"># Title</td>
|
|
<td style="padding: 0.5rem;">Heading level 1</td>
|
|
</tr>
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 0.5rem; font-family: monospace;">## Title</td>
|
|
<td style="padding: 0.5rem;">Heading level 2</td>
|
|
</tr>
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 0.5rem; font-family: monospace;">**bold**</td>
|
|
<td style="padding: 0.5rem;">Bold text</td>
|
|
</tr>
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 0.5rem; font-family: monospace;">*italic*</td>
|
|
<td style="padding: 0.5rem;">Italic text</td>
|
|
</tr>
|
|
<tr style="border-bottom: 1px solid var(--border-color);">
|
|
<td style="padding: 0.5rem; font-family: monospace;">- item</td>
|
|
<td style="padding: 0.5rem;">Bullet list</td>
|
|
</tr>
|
|
<tr>
|
|
<td style="padding: 0.5rem; font-family: monospace;">\`code\`</td>
|
|
<td style="padding: 0.5rem;">Inline code</td>
|
|
</tr>
|
|
</table>
|
|
</div>
|
|
|
|
<div style="text-align: center; margin-top: 2rem;">
|
|
<button class="btn primary" onclick="app.closeModal()">Close</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Show modal
|
|
const modal = document.getElementById('journal-modal');
|
|
modal.style.display = 'flex';
|
|
setTimeout(() => modal.classList.add('show'), 10);
|
|
}
|
|
|
|
async handleAI(action) {
|
|
const selection = window.getSelection().toString().trim();
|
|
const fullContent = this.editor.innerText;
|
|
|
|
// Disable all AI buttons to prevent API overload
|
|
this.disableAIButtons();
|
|
|
|
// Check conditions based on action
|
|
if (action === 'rephrase' && !selection) {
|
|
this.showAIFeedback('Please select text to rephrase');
|
|
this.enableAIButtons();
|
|
return;
|
|
}
|
|
|
|
// Determine content to use based on action
|
|
let contentToUse;
|
|
switch (action) {
|
|
case 'rephrase':
|
|
contentToUse = selection;
|
|
break;
|
|
case 'inconsistencies':
|
|
case 'duplications':
|
|
case 'advice':
|
|
case 'liberty':
|
|
contentToUse = selection || fullContent;
|
|
break;
|
|
default:
|
|
contentToUse = fullContent;
|
|
}
|
|
|
|
// Show loading message
|
|
this.showAIFeedback('<div class="feedback-message loading">Processing...</div>');
|
|
|
|
try {
|
|
let result;
|
|
|
|
switch (action) {
|
|
case 'rephrase':
|
|
result = await this.callAI('/api/ai/rephrase', { text: selection.trim(), context: fullContent.substring(0, 500) });
|
|
|
|
// Get rephrased text directly
|
|
const rephrasedText = result.rephrased || result.data || result;
|
|
|
|
// Store suggestion for validation
|
|
this.lastRephraseData = {
|
|
original: selection.trim(),
|
|
rephrased: rephrasedText,
|
|
selection: window.getSelection()
|
|
};
|
|
|
|
this.showAIFeedback(`
|
|
<strong>Rephrasing</strong><br><br>
|
|
|
|
<div style="background: var(--background-color); padding: 1rem; border-radius: 8px; margin: 0.5rem 0;">
|
|
<strong>Original text:</strong><br>
|
|
<em style="color: var(--text-light);">${selection.substring(0, 200)}${selection.length > 200 ? '...' : ''}</em>
|
|
</div>
|
|
|
|
<div style="background: var(--surface-color); border: 2px solid var(--success-color); padding: 1rem; border-radius: 8px; margin-bottom: 1rem;">
|
|
<strong>Improved version:</strong><br>
|
|
${rephrasedText}
|
|
</div>
|
|
<div style="text-align: center;">
|
|
<button id="validate-rephrase" class="btn success" style="margin-right: 0.5rem;">
|
|
Apply rephrasing
|
|
</button>
|
|
<button id="cancel-rephrase" class="btn secondary">
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
`);
|
|
|
|
// Add event listeners for buttons
|
|
document.getElementById('validate-rephrase')?.addEventListener('click', () => {
|
|
this.validateRephrase();
|
|
this.enableAIButtons();
|
|
});
|
|
document.getElementById('cancel-rephrase')?.addEventListener('click', () => {
|
|
this.clearFeedback();
|
|
this.enableAIButtons();
|
|
});
|
|
break;
|
|
|
|
case 'inconsistencies':
|
|
result = await this.callAI('/api/ai/check-inconsistencies', { content: contentToUse });
|
|
this.showAIFeedback(`<strong>Inconsistency Analysis</strong><br><br>${this.formatAIResponse(result.analysis)}`);
|
|
break;
|
|
|
|
case 'duplications':
|
|
result = await this.callAI('/api/ai/check-duplications', { content: contentToUse });
|
|
this.showAIFeedback(`<strong>Duplication Check</strong><br><br>${this.formatAIResponse(result.analysis)}`);
|
|
break;
|
|
|
|
case 'advice':
|
|
result = await this.callAI('/api/ai/give-advice', { content: contentToUse, domain: 'design' });
|
|
this.showAIFeedback(`<strong>Improvement Advice</strong><br><br>${this.formatAIResponse(result.advice)}`);
|
|
break;
|
|
|
|
case 'liberty':
|
|
// Save state before AI modifications
|
|
this.saveState(true);
|
|
|
|
// Disable preview button during enhanced mode
|
|
const previewBtn = document.getElementById('preview-toggle');
|
|
if (previewBtn) {
|
|
previewBtn.disabled = true;
|
|
previewBtn.style.opacity = '0.5';
|
|
previewBtn.style.cursor = 'not-allowed';
|
|
}
|
|
|
|
const count = document.getElementById('liberty-repeat-count')?.value || 3;
|
|
const precision = document.getElementById('liberty-precision')?.value || 70;
|
|
|
|
// Initialize progress display
|
|
this.showAIFeedback(`
|
|
<div id="liberty-progress">
|
|
<strong>Enhanced Mode</strong><br>
|
|
<div style="margin: 0.5rem 0; font-size: 0.9rem; color: var(--text-light);">
|
|
${count} iterations - Precision: ${precision}% - Focus: design
|
|
</div>
|
|
<div class="progress-bar" style="width: 100%; height: 4px; background: var(--background-color); border-radius: 2px; margin: 1rem 0;">
|
|
<div id="liberty-progress-fill" style="height: 100%; background: var(--primary-color); border-radius: 2px; width: 0%; transition: width 0.3s ease;"></div>
|
|
</div>
|
|
<div id="liberty-iterations"></div>
|
|
</div>
|
|
`);
|
|
|
|
// Use EventSource for streaming
|
|
try {
|
|
await this.handleLibertyModeStreaming(fullContent, count, precision);
|
|
} finally {
|
|
// Re-enable preview button after completion (even on error)
|
|
if (previewBtn) {
|
|
previewBtn.disabled = false;
|
|
previewBtn.style.opacity = '1';
|
|
previewBtn.style.cursor = 'pointer';
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
} catch (error) {
|
|
console.error('AI Error:', error);
|
|
this.showAIFeedback(`<strong>Error</strong><br><br>An error occurred: ${error.message}<br><br>Check your connection and API configuration.`);
|
|
} finally {
|
|
// Re-enable all AI buttons after completion (even on error)
|
|
this.enableAIButtons();
|
|
}
|
|
}
|
|
|
|
async handleLibertyModeStreaming(content, iterations, precision) {
|
|
return new Promise((resolve, reject) => {
|
|
// Prepare data to send
|
|
const requestData = {
|
|
content: content,
|
|
iterations: iterations,
|
|
precision: precision,
|
|
focus: 'design'
|
|
};
|
|
|
|
// Create fetch request for streaming
|
|
fetch('/api/ai/liberty-mode', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(requestData)
|
|
})
|
|
.then(response => {
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP Error: ${response.status}`);
|
|
}
|
|
|
|
const reader = response.body.getReader();
|
|
const decoder = new TextDecoder();
|
|
let buffer = '';
|
|
|
|
const processStream = () => {
|
|
return reader.read().then(({ done, value }) => {
|
|
if (done) {
|
|
resolve();
|
|
return;
|
|
}
|
|
|
|
// Decode received data
|
|
buffer += decoder.decode(value, { stream: true });
|
|
|
|
// Process each received line
|
|
const lines = buffer.split('\n');
|
|
buffer = lines.pop() || ''; // Keep incomplete last line
|
|
|
|
for (const line of lines) {
|
|
if (line.trim().startsWith('data: ')) {
|
|
try {
|
|
const data = JSON.parse(line.slice(6)); // Remove "data: "
|
|
this.handleLibertyStreamData(data);
|
|
} catch (e) {
|
|
console.error('JSON parsing error:', e);
|
|
}
|
|
}
|
|
}
|
|
|
|
return processStream();
|
|
});
|
|
};
|
|
|
|
return processStream();
|
|
})
|
|
.catch(error => {
|
|
console.error('Streaming error:', error);
|
|
this.showAIFeedback(`<strong>Error</strong><br><br>Streaming error: ${error.message}`);
|
|
reject(error);
|
|
});
|
|
});
|
|
}
|
|
|
|
handleLibertyStreamData(data) {
|
|
const progressFill = document.getElementById('liberty-progress-fill');
|
|
const iterationsDiv = document.getElementById('liberty-iterations');
|
|
|
|
if (data.error) {
|
|
// Display error
|
|
const errorHTML = `
|
|
<div style="background: var(--danger-color); color: white; padding: 1rem; border-radius: 8px; margin: 0.5rem 0;">
|
|
<strong>Error:</strong> ${data.error}
|
|
</div>
|
|
`;
|
|
iterationsDiv.innerHTML += errorHTML;
|
|
return;
|
|
}
|
|
|
|
if (data.iteration) {
|
|
// Update progress bar
|
|
const totalIterations = parseInt(document.getElementById('liberty-repeat-count')?.value || 3);
|
|
const progressPercent = (data.iteration / totalIterations) * 100;
|
|
|
|
if (progressFill) {
|
|
progressFill.style.width = `${progressPercent}%`;
|
|
}
|
|
|
|
// Display this iteration's explanation with selected section
|
|
const sectionBadge = data.selectedSection
|
|
? `<span style="display: inline-block; background: #2563eb; color: #ffffff; padding: 0.25rem 0.75rem; border-radius: 12px; font-size: 0.85rem; margin-left: 0.5rem; font-weight: 500;">${data.selectedSection}</span>`
|
|
: '';
|
|
|
|
const iterationHTML = `
|
|
<div style="background: var(--surface-color); border-left: 4px solid var(--primary-color); padding: 1rem; border-radius: 8px; margin: 0.5rem 0;">
|
|
<strong>Iteration ${data.iteration}</strong>${sectionBadge}<br><br>
|
|
${this.formatAIResponse(data.explanation)}
|
|
</div>
|
|
`;
|
|
|
|
iterationsDiv.innerHTML += iterationHTML;
|
|
|
|
// Update editor with new markdown if available
|
|
if (data.markdown && data.markdown !== this.editor.innerText) {
|
|
this.editor.innerText = data.markdown;
|
|
this.generateTOC();
|
|
}
|
|
|
|
// Scroll to bottom of feedback to see new iteration
|
|
const feedback = document.getElementById('ai-assistant-feedback');
|
|
feedback.scrollTop = feedback.scrollHeight;
|
|
}
|
|
|
|
if (data.completed) {
|
|
// Finalize display
|
|
if (progressFill) {
|
|
progressFill.style.width = '100%';
|
|
}
|
|
|
|
if (data.finalMarkdown) {
|
|
// Ensure final content is in editor
|
|
this.editor.innerText = data.finalMarkdown;
|
|
this.generateTOC();
|
|
// Save final state
|
|
this.saveState(true);
|
|
}
|
|
|
|
// Completion message
|
|
const completedHTML = `
|
|
<div style="background: var(--success-color); color: white; padding: 1rem; border-radius: 8px; margin: 1rem 0; text-align: center;">
|
|
<strong>Enhanced Mode completed!</strong><br>
|
|
${data.totalIterations || 'All'} iteration(s) completed
|
|
</div>
|
|
`;
|
|
|
|
iterationsDiv.innerHTML += completedHTML;
|
|
|
|
this.showNotification('Enhanced Mode completed', 'success');
|
|
}
|
|
}
|
|
|
|
async callAI(endpoint, data) {
|
|
const response = await fetch(endpoint, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify(data)
|
|
});
|
|
|
|
const result = await response.json();
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.error || 'Unknown error');
|
|
}
|
|
|
|
return result.data;
|
|
}
|
|
|
|
formatAIResponse(text) {
|
|
if (!text) return '';
|
|
|
|
// Parser la réponse pour séparer le raisonnement du contenu principal
|
|
const sections = text.split('##');
|
|
let formattedHTML = '';
|
|
|
|
const self = this;
|
|
sections.forEach((section, index) => {
|
|
if (index === 0 && section.trim()) {
|
|
// Première section sans titre
|
|
formattedHTML += `<div style="margin-bottom: 1rem;">${self.parseMarkdown(section.trim())}</div>`;
|
|
} else if (section.trim()) {
|
|
const lines = section.split('\n');
|
|
const title = lines[0].trim();
|
|
const content = lines.slice(1).join('\n').trim();
|
|
|
|
// Sections normales - avec markdown
|
|
formattedHTML += `
|
|
<div style="margin: 1rem 0;">
|
|
<strong style="color: var(--primary-color); font-size: 1.1em;">${title}</strong><br><br>
|
|
<div style="margin-top: 0.5rem;">${self.parseMarkdown(content)}</div>
|
|
</div>
|
|
`;
|
|
}
|
|
});
|
|
|
|
return formattedHTML || text;
|
|
}
|
|
|
|
parseMarkdown(text) {
|
|
if (!text) return '';
|
|
|
|
return text
|
|
// Titres H1-H6 (#, ##, ###, etc.)
|
|
.replace(/^(#{1,6})\s+(.+)$/gm, (match, hashes, title) => {
|
|
const level = hashes.length;
|
|
return `<h${level} style="color: var(--primary-color); margin: 1rem 0 0.5rem 0; font-weight: bold;">${title}</h${level}>`;
|
|
})
|
|
// Listes à puces (- ou *)
|
|
.replace(/^[\s]*[-\*]\s+(.+)$/gm, '<li style="margin: 0.25rem 0;">$1</li>')
|
|
// Listes numérotées
|
|
.replace(/^[\s]*\d+\.\s+(.+)$/gm, '<li style="margin: 0.25rem 0;">$1</li>')
|
|
// Code blocks avec ```
|
|
.replace(/```([\s\S]*?)```/g, '<pre style="background: var(--background-color); padding: 1rem; border-radius: 6px; border-left: 4px solid var(--primary-color); margin: 1rem 0; overflow-x: auto;"><code>$1</code></pre>')
|
|
// Citations avec >
|
|
.replace(/^>\s+(.+)$/gm, '<blockquote style="border-left: 4px solid var(--primary-color); padding-left: 1rem; margin: 1rem 0; font-style: italic; color: var(--text-light);">$1</blockquote>')
|
|
// Gras **texte**
|
|
.replace(/\*\*(.*?)\*\*/g, '<strong style="color: var(--primary-color);">$1</strong>')
|
|
// Italique *texte*
|
|
.replace(/\*(.*?)\*/g, '<em style="color: var(--text-light);">$1</em>')
|
|
// Code inline `code`
|
|
.replace(/`([^`]+)`/g, '<code style="background: var(--surface-color); padding: 0.2rem 0.4rem; border-radius: 3px; font-family: monospace; font-size: 0.9em;">$1</code>')
|
|
// Liens [texte](url)
|
|
.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" style="color: var(--primary-color); text-decoration: underline;">$1</a>')
|
|
// Séparer les listes en blocs <ul> ou <ol>
|
|
.replace(/(<li[^>]*>.*<\/li>)/gs, (match) => {
|
|
if (match.includes('<li')) {
|
|
return `<ul style="margin: 0.5rem 0; padding-left: 1.5rem;">${match}</ul>`;
|
|
}
|
|
return match;
|
|
})
|
|
// Sauts de ligne doubles pour paragraphes
|
|
.replace(/\n\n+/g, '\n\n')
|
|
.replace(/\n\n/g, '</p><p style="margin: 0.75rem 0; line-height: 1.6;">')
|
|
// Sauts de ligne simples
|
|
.replace(/\n/g, '<br>')
|
|
// Encapsuler dans un paragraphe si pas déjà fait
|
|
.replace(/^(?!<[h1-6|ul|ol|pre|blockquote])/i, '<p style="margin: 0.75rem 0; line-height: 1.6;">')
|
|
.replace(/$/i, '</p>');
|
|
}
|
|
|
|
showAIFeedback(message) {
|
|
const feedback = document.getElementById('ai-assistant-feedback');
|
|
feedback.innerHTML = `<div class="feedback-message">${message}</div>`;
|
|
// Scroll to top of feedback to see result
|
|
feedback.scrollTop = 0;
|
|
}
|
|
|
|
validateRephrase() {
|
|
if (!this.lastRephraseData) return;
|
|
|
|
try {
|
|
// Save state before rephrasing to allow undo
|
|
this.saveState(true);
|
|
|
|
// Replace text in editor
|
|
const range = this.lastRephraseData.selection.getRangeAt(0);
|
|
range.deleteContents();
|
|
range.insertNode(document.createTextNode(this.lastRephraseData.rephrased));
|
|
|
|
// Clear selection and regenerate TOC
|
|
window.getSelection().removeAllRanges();
|
|
this.generateTOC();
|
|
|
|
// Save state after rephrasing
|
|
this.saveState(true);
|
|
|
|
// Show success message
|
|
this.showNotification('Rephrasing applied successfully', 'success');
|
|
this.clearFeedback();
|
|
|
|
// Clean up data
|
|
this.lastRephraseData = null;
|
|
} catch (error) {
|
|
console.error('Error applying rephrasing:', error);
|
|
this.showNotification('Error applying rephrasing', 'error');
|
|
}
|
|
}
|
|
|
|
clearFeedback() {
|
|
const feedback = document.getElementById('ai-assistant-feedback');
|
|
feedback.innerHTML = `
|
|
<div class="feedback-message" style="text-align: center; color: var(--text-light);">
|
|
<strong>AI Assistant ready</strong><br>
|
|
Select text in the editor and click an action to begin.
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
disableAIButtons() {
|
|
// Disable all AI buttons to prevent API overload
|
|
const aiButtons = [
|
|
'activate-rephrase',
|
|
'check-inconsistencies',
|
|
'check-duplications',
|
|
'give-advice',
|
|
'liberty-mode'
|
|
];
|
|
aiButtons.forEach(buttonId => {
|
|
const btn = document.getElementById(buttonId);
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.style.opacity = '0.5';
|
|
btn.style.cursor = 'not-allowed';
|
|
}
|
|
});
|
|
}
|
|
|
|
enableAIButtons() {
|
|
// Re-enable all AI buttons
|
|
const aiButtons = [
|
|
'activate-rephrase',
|
|
'check-inconsistencies',
|
|
'check-duplications',
|
|
'give-advice',
|
|
'liberty-mode'
|
|
];
|
|
aiButtons.forEach(buttonId => {
|
|
const btn = document.getElementById(buttonId);
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.style.opacity = '1';
|
|
btn.style.cursor = 'pointer';
|
|
}
|
|
});
|
|
}
|
|
|
|
ensureEditMode() {
|
|
// If in preview mode, force return to edit mode
|
|
if (this.isPreviewMode) {
|
|
const previewBtn = document.getElementById('preview-toggle');
|
|
|
|
// Return to edit mode without using originalContent because we want new content
|
|
this.editor.contentEditable = true;
|
|
this.editor.style.background = '';
|
|
this.editor.style.border = '';
|
|
this.editor.style.borderRadius = '';
|
|
|
|
// Change button
|
|
if (previewBtn) {
|
|
previewBtn.innerHTML = 'Preview';
|
|
previewBtn.classList.remove('secondary');
|
|
previewBtn.classList.add('primary');
|
|
}
|
|
|
|
this.isPreviewMode = false;
|
|
}
|
|
|
|
// Ensure editor is always editable
|
|
this.editor.contentEditable = true;
|
|
}
|
|
|
|
showNotification(message, type = 'success') {
|
|
const notification = document.createElement('div');
|
|
notification.className = `notification ${type}`;
|
|
notification.textContent = message;
|
|
|
|
document.body.appendChild(notification);
|
|
|
|
setTimeout(() => notification.classList.add('show'), 100);
|
|
setTimeout(() => {
|
|
notification.classList.remove('show');
|
|
setTimeout(() => document.body.removeChild(notification), 300);
|
|
}, 3000);
|
|
}
|
|
|
|
async togglePreview() {
|
|
const previewBtn = document.getElementById('preview-toggle');
|
|
const mainElement = document.querySelector('main');
|
|
|
|
if (!this.isPreviewMode) {
|
|
// Switch to preview mode - Save innerText instead of innerHTML
|
|
this.originalContent = this.editor.innerText;
|
|
const markdownContent = this.editor.innerText;
|
|
|
|
// Add preview-mode class to main to adjust grid layout
|
|
if (mainElement) {
|
|
mainElement.classList.add('preview-mode');
|
|
}
|
|
|
|
// Configure Marked for GitHub-compatible rendering
|
|
if (typeof marked !== 'undefined') {
|
|
marked.setOptions({
|
|
breaks: true,
|
|
gfm: true,
|
|
headerIds: true,
|
|
sanitize: false,
|
|
smartypants: false
|
|
});
|
|
}
|
|
|
|
// Convert Markdown to HTML with Marked (GitHub-compatible)
|
|
let previewHTML = '';
|
|
if (typeof marked !== 'undefined') {
|
|
previewHTML = marked.parse(markdownContent);
|
|
} else {
|
|
// Fallback to our custom parser if Marked is not loaded
|
|
previewHTML = this.parseMarkdown(markdownContent);
|
|
}
|
|
|
|
// Disable editing and apply GitHub preview style
|
|
this.editor.contentEditable = false;
|
|
this.editor.innerHTML = `<div class="markdown-preview">${previewHTML}</div>`;
|
|
this.editor.style.background = '';
|
|
this.editor.style.border = '';
|
|
this.editor.style.borderRadius = '';
|
|
|
|
// Disable AI buttons and save/load buttons in preview mode
|
|
const disabledButtons = [
|
|
'activate-rephrase',
|
|
'check-inconsistencies',
|
|
'check-duplications',
|
|
'give-advice',
|
|
'liberty-mode',
|
|
'save-journal',
|
|
'load-journal'
|
|
];
|
|
disabledButtons.forEach(buttonId => {
|
|
const btn = document.getElementById(buttonId);
|
|
if (btn) {
|
|
btn.disabled = true;
|
|
btn.style.opacity = '0.5';
|
|
btn.style.cursor = 'not-allowed';
|
|
}
|
|
});
|
|
|
|
// Process Mermaid diagrams after rendering
|
|
if (typeof mermaid !== 'undefined') {
|
|
try {
|
|
mermaid.initialize({
|
|
startOnLoad: false,
|
|
theme: document.body.classList.contains('dark-theme') ? 'dark' : 'default',
|
|
securityLevel: 'loose',
|
|
fontFamily: 'system-ui, -apple-system, "Segoe UI", Roboto, sans-serif'
|
|
});
|
|
|
|
// Wait for DOM to be ready to process Mermaid diagrams
|
|
setTimeout(() => {
|
|
const preview = this.editor.querySelector('.markdown-preview');
|
|
if (preview) {
|
|
// Find all code blocks with 'mermaid' language
|
|
const mermaidBlocks = preview.querySelectorAll('code.language-mermaid, pre code.language-mermaid');
|
|
|
|
mermaidBlocks.forEach((block, index) => {
|
|
try {
|
|
const mermaidCode = block.textContent;
|
|
const uniqueId = `mermaid-${Date.now()}-${index}`;
|
|
|
|
// Create div with unique ID for Mermaid
|
|
const mermaidDiv = document.createElement('div');
|
|
mermaidDiv.id = uniqueId;
|
|
mermaidDiv.className = 'mermaid';
|
|
mermaidDiv.textContent = mermaidCode;
|
|
|
|
// Replace code block with Mermaid div
|
|
const pre = block.closest('pre') || block;
|
|
pre.parentNode.replaceChild(mermaidDiv, pre);
|
|
|
|
// Render diagram
|
|
mermaid.render(uniqueId + '-svg', mermaidCode).then(({ svg }) => {
|
|
mermaidDiv.innerHTML = svg;
|
|
}).catch((err) => {
|
|
console.warn('Mermaid rendering error:', err);
|
|
|
|
// Show error inline
|
|
mermaidDiv.innerHTML = `<pre style="color: var(--accent-color); padding: 1rem; background: var(--background-color); border-radius: 6px; border-left: 4px solid var(--accent-color);">Mermaid rendering error: ${err.message}\n\nPlease check the diagram syntax in the editor.</pre>`;
|
|
});
|
|
} catch (error) {
|
|
console.warn('Mermaid processing error:', error);
|
|
}
|
|
});
|
|
}
|
|
}, 200);
|
|
} catch (error) {
|
|
console.warn('Mermaid initialization error:', error);
|
|
}
|
|
}
|
|
|
|
// Change button
|
|
previewBtn.innerHTML = 'Edit';
|
|
previewBtn.classList.remove('primary');
|
|
previewBtn.classList.add('secondary');
|
|
|
|
this.isPreviewMode = true;
|
|
this.showNotification('Preview mode activated', 'success');
|
|
|
|
} else {
|
|
// Return to edit mode - Restore innerText instead of innerHTML
|
|
this.editor.contentEditable = true;
|
|
this.editor.innerText = this.originalContent;
|
|
this.editor.style.background = '';
|
|
this.editor.style.border = '';
|
|
this.editor.style.borderRadius = '';
|
|
|
|
// Remove preview-mode class to restore normal grid layout
|
|
if (mainElement) {
|
|
mainElement.classList.remove('preview-mode');
|
|
}
|
|
|
|
// Re-enable AI buttons and save/load buttons
|
|
const enabledButtons = [
|
|
'activate-rephrase',
|
|
'check-inconsistencies',
|
|
'check-duplications',
|
|
'give-advice',
|
|
'liberty-mode',
|
|
'save-journal',
|
|
'load-journal'
|
|
];
|
|
enabledButtons.forEach(buttonId => {
|
|
const btn = document.getElementById(buttonId);
|
|
if (btn) {
|
|
btn.disabled = false;
|
|
btn.style.opacity = '1';
|
|
btn.style.cursor = 'pointer';
|
|
}
|
|
});
|
|
|
|
// Change button
|
|
previewBtn.innerHTML = 'Preview';
|
|
previewBtn.classList.remove('secondary');
|
|
previewBtn.classList.add('primary');
|
|
|
|
this.isPreviewMode = false;
|
|
this.showNotification('Edit mode activated', 'success');
|
|
}
|
|
}
|
|
}
|
|
|
|
// Side panel management
|
|
function togglePanel(side) {
|
|
const panel = document.getElementById(`${side}-panel`);
|
|
const overlay = document.getElementById('panel-overlay');
|
|
const otherPanel = document.getElementById(side === 'left' ? 'right-panel' : 'left-panel');
|
|
|
|
// Close the other panel if it's open
|
|
if (otherPanel && otherPanel.classList.contains('open')) {
|
|
otherPanel.classList.remove('open');
|
|
}
|
|
|
|
// Toggle current panel
|
|
if (panel.classList.contains('open')) {
|
|
panel.classList.remove('open');
|
|
overlay.classList.remove('active');
|
|
} else {
|
|
panel.classList.add('open');
|
|
overlay.classList.add('active');
|
|
}
|
|
}
|
|
|
|
function closeAllPanels() {
|
|
const leftPanel = document.getElementById('left-panel');
|
|
const rightPanel = document.getElementById('right-panel');
|
|
const overlay = document.getElementById('panel-overlay');
|
|
|
|
if (leftPanel) leftPanel.classList.remove('open');
|
|
if (rightPanel) rightPanel.classList.remove('open');
|
|
if (overlay) overlay.classList.remove('active');
|
|
}
|
|
|
|
// Close panels with Escape
|
|
document.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Escape') {
|
|
closeAllPanels();
|
|
}
|
|
});
|
|
|
|
// Application initialization
|
|
let app;
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
app = new ConceptionAssistant();
|
|
|
|
// Load saved theme
|
|
const savedTheme = localStorage.getItem('theme');
|
|
if (savedTheme === 'dark') {
|
|
document.body.classList.add('dark-theme');
|
|
}
|
|
|
|
// Initialize panels
|
|
initializePanels();
|
|
});
|
|
|
|
function initializePanels() {
|
|
// Initialize template management
|
|
initializeTemplateForm();
|
|
}
|
|
|
|
function initializeTemplateForm() {
|
|
const loadTemplateBtn = document.getElementById('load-template');
|
|
|
|
// Handle template loading
|
|
if (loadTemplateBtn) {
|
|
loadTemplateBtn.addEventListener('click', async () => {
|
|
try {
|
|
loadTemplateBtn.classList.add('loading');
|
|
|
|
const response = await fetch('/api/templates/default');
|
|
const result = await response.json();
|
|
|
|
if (result.success) {
|
|
// Load template into editor
|
|
app.editor.innerText = result.data.content;
|
|
app.generateTOC();
|
|
app.currentJournalId = null; // New journal
|
|
|
|
// Reset history for new template
|
|
app.undoStack = [result.data.content];
|
|
app.redoStack = [];
|
|
|
|
// Ensure editor is in edit mode
|
|
app.ensureEditMode();
|
|
|
|
app.showNotification('Template loaded successfully', 'success');
|
|
closeAllPanels();
|
|
} else {
|
|
app.showNotification('Error loading template', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Error:', error);
|
|
app.showNotification('Error loading template', 'error');
|
|
} finally {
|
|
loadTemplateBtn.classList.remove('loading');
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Ensure togglePanel is globally accessible
|
|
window.togglePanel = togglePanel; |