feat: add CodeMirror dependencies and implement markdown editor UI

This commit is contained in:
2025-10-20 15:43:56 -04:00
parent 4d995bf7a9
commit 062d743481
17 changed files with 2541 additions and 13 deletions
@@ -0,0 +1,52 @@
import { Injectable, inject } from '@angular/core';
import { CanDeactivate } from '@angular/router';
import { Observable } from 'rxjs';
import { EditorStateService } from '../../../services/editor-state.service';
/**
* Interface pour les composants qui peuvent être désactivés
*/
export interface CanComponentDeactivate {
canDeactivate: () => Observable<boolean> | Promise<boolean> | boolean;
}
/**
* Guard de navigation pour protéger contre la perte de données non sauvegardées
* Utilisé pour empêcher l'utilisateur de quitter une page avec des modifications en cours
*/
@Injectable({
providedIn: 'root'
})
export class EditorCanDeactivateGuard implements CanDeactivate<CanComponentDeactivate> {
private editorState = inject(EditorStateService);
canDeactivate(
component: CanComponentDeactivate
): Observable<boolean> | Promise<boolean> | boolean {
// Si pas en mode édition ou pas de modifications, autoriser la navigation
if (!this.editorState.isEditMode() || this.editorState.canExit()) {
return true;
}
// Sinon, demander confirmation
const message = 'You have unsaved changes. Do you really want to leave?';
return confirm(message);
}
}
/**
* Hook beforeunload pour protéger contre la fermeture du navigateur
* À utiliser dans le composant racine (app.component.ts)
*/
export function setupBeforeUnloadGuard(editorState: EditorStateService): void {
if (typeof window === 'undefined') return;
window.addEventListener('beforeunload', (event) => {
if (editorState.isEditMode() && !editorState.canExit()) {
event.preventDefault();
event.returnValue = 'You have unsaved changes. Are you sure you want to leave?';
return event.returnValue;
}
});
}
@@ -0,0 +1,567 @@
import {
Component,
Input,
OnInit,
OnDestroy,
ViewChild,
ElementRef,
inject,
signal,
computed,
effect
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { EditorView, keymap, lineNumbers, highlightActiveLine, drawSelection, dropCursor } from '@codemirror/view';
import { EditorState, Compartment } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { markdown, markdownLanguage } from '@codemirror/lang-markdown';
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, foldGutter } from '@codemirror/language';
import { searchKeymap, highlightSelectionMatches } from '@codemirror/search';
import { autocompletion, completionKeymap, closeBrackets, closeBracketsKeymap } from '@codemirror/autocomplete';
import { lintKeymap } from '@codemirror/lint';
import { VaultService } from '../../../services/vault.service';
import { EditorStateService } from '../../../services/editor-state.service';
import { ToastService } from '../../shared/toast/toast.service';
/**
* Composant d'édition Markdown avec CodeMirror 6
*
* Features:
* - Édition Markdown avec coloration syntaxique
* - Support YAML front-matter
* - Toolbar avec Save/Cancel/Wrap/Search
* - Raccourcis clavier (Ctrl/Cmd+S, Ctrl/Cmd+F)
* - Thème dark/light synchronisé
* - Autosave optionnel
* - Navigation guard si modifications non sauvegardées
*/
@Component({
selector: 'app-markdown-editor',
standalone: true,
imports: [CommonModule],
template: `
<div class="markdown-editor" [class.markdown-editor--dark]="isDarkTheme()">
<!-- Toolbar -->
<div class="markdown-editor__toolbar">
<div class="markdown-editor__toolbar-left">
<span class="text-sm font-medium text-muted">Editing</span>
<span class="text-sm text-muted-foreground" *ngIf="filePath()">{{ fileName() }}</span>
</div>
<div class="markdown-editor__toolbar-right">
<!-- Save Button -->
<button
type="button"
class="btn-editor"
[class.btn-editor--primary]="isDirty()"
[disabled]="isSaving()"
(click)="save()"
[attr.aria-label]="'Save (Ctrl+S)'">
<svg *ngIf="!isSaving()" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/>
<polyline points="17 21 17 13 7 13 7 21"/>
<polyline points="7 3 7 8 15 8"/>
</svg>
<svg *ngIf="isSaving()" class="animate-spin" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" stroke-dasharray="60" stroke-dashoffset="15"/>
</svg>
<span class="ml-1 hidden sm:inline">{{ isSaving() ? 'Saving...' : 'Save' }}</span>
</button>
<!-- Wrap Toggle -->
<button
type="button"
class="btn-editor"
[class.btn-editor--active]="wordWrap()"
(click)="toggleWordWrap()"
[attr.aria-label]="'Toggle word wrap'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="4 7 4 4 20 4 20 7"/>
<line x1="9" y1="20" x2="15" y2="20"/>
<line x1="12" y1="4" x2="12" y2="20"/>
</svg>
<span class="ml-1 hidden md:inline">Wrap</span>
</button>
<!-- Undo -->
<button
type="button"
class="btn-editor hidden sm:flex"
(click)="undo()"
[attr.aria-label]="'Undo (Ctrl+Z)'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="1 4 1 10 7 10"/>
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"/>
</svg>
</button>
<!-- Redo -->
<button
type="button"
class="btn-editor hidden sm:flex"
(click)="redo()"
[attr.aria-label]="'Redo (Ctrl+Y)'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
<!-- Close/Cancel -->
<button
type="button"
class="btn-editor"
(click)="close()"
[attr.aria-label]="'Close editor (Esc)'">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
<span class="ml-1 hidden sm:inline">Close</span>
</button>
</div>
</div>
<!-- Editor Container -->
<div class="markdown-editor__container">
<div #editorHost class="markdown-editor__host"></div>
</div>
<!-- Status Bar -->
<div class="markdown-editor__statusbar">
<div class="text-xs text-muted-foreground">
<span *ngIf="isDirty()" class="text-amber-500 dark:text-amber-400 mr-2">● Unsaved changes</span>
<span *ngIf="lastSaved()">Last saved: {{ lastSaved() }}</span>
</div>
<div class="text-xs text-muted-foreground">
Line {{ cursorLine() }}, Col {{ cursorCol() }}
</div>
</div>
</div>
`,
styles: [`
:host {
display: block;
height: 100%;
}
.markdown-editor {
display: flex;
flex-direction: column;
height: 100%;
background: var(--bg-main);
}
.markdown-editor__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--border);
background: var(--card);
gap: 0.75rem;
flex-wrap: wrap;
}
.markdown-editor__toolbar-left,
.markdown-editor__toolbar-right {
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.btn-editor {
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
font-weight: 500;
color: var(--text-muted);
background: transparent;
border: 1px solid var(--border);
border-radius: 0.375rem;
cursor: pointer;
transition: all 0.15s ease;
white-space: nowrap;
}
.btn-editor:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--border-hover);
color: var(--text);
}
.btn-editor:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-editor--primary {
background: var(--brand);
color: white;
border-color: var(--brand);
}
.btn-editor--primary:hover:not(:disabled) {
background: var(--brand-hover);
border-color: var(--brand-hover);
}
.btn-editor--active {
background: var(--brand);
color: white;
border-color: var(--brand);
}
.markdown-editor__container {
flex: 1;
overflow: hidden;
position: relative;
}
.markdown-editor__host {
height: 100%;
overflow: auto;
}
.markdown-editor__statusbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.5rem 1rem;
border-top: 1px solid var(--border);
background: var(--card);
font-size: 0.75rem;
}
/* CodeMirror container */
:host ::ng-deep .cm-editor {
height: 100%;
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 14px;
line-height: 1.6;
}
:host ::ng-deep .cm-scroller {
overflow: auto;
font-family: inherit;
}
:host ::ng-deep .cm-content {
padding: 1rem;
}
/* Responsive */
@media (max-width: 640px) {
.markdown-editor__toolbar {
padding: 0.5rem;
}
.btn-editor {
padding: 0.375rem 0.5rem;
}
}
/* Loading spinner animation */
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.animate-spin {
animation: spin 1s linear infinite;
}
`]
})
export class MarkdownEditorComponent implements OnInit, OnDestroy {
@ViewChild('editorHost', { static: true }) editorHost!: ElementRef<HTMLDivElement>;
@Input() set initialPath(value: string) {
this.filePath.set(value);
}
@Input() set initialContent(value: string) {
this.content.set(value);
}
private vaultService = inject(VaultService);
private editorStateService = inject(EditorStateService);
private toastService = inject(ToastService);
// Signals (public pour utilisation dans le template)
filePath = signal<string>('');
content = signal<string>('');
isDirty = signal<boolean>(false);
isSaving = signal<boolean>(false);
wordWrap = signal<boolean>(false);
cursorLine = signal<number>(1);
cursorCol = signal<number>(1);
lastSaved = signal<string>('');
isDarkTheme = signal<boolean>(false);
// Computed
fileName = computed(() => {
const path = this.filePath();
return path ? path.split('/').pop() || '' : '';
});
// CodeMirror
private editorView?: EditorView;
private wrapCompartment = new Compartment();
private autosaveTimer?: ReturnType<typeof setTimeout>;
constructor() {
// Détecter le thème
effect(() => {
if (typeof window !== 'undefined') {
const darkMode = document.documentElement.classList.contains('dark');
this.isDarkTheme.set(darkMode);
}
});
}
ngOnInit(): void {
this.initializeEditor();
this.setupThemeObserver();
}
ngOnDestroy(): void {
this.cleanup();
}
private initializeEditor(): void {
const saveCommand = {
key: 'Mod-s',
run: () => {
this.save();
return true;
}
};
const initialState = EditorState.create({
doc: this.content(),
extensions: [
lineNumbers(),
highlightActiveLine(),
history(),
drawSelection(),
dropCursor(),
bracketMatching(),
closeBrackets(),
autocompletion(),
highlightSelectionMatches(),
foldGutter(),
syntaxHighlighting(defaultHighlightStyle),
markdown({ base: markdownLanguage }),
this.wrapCompartment.of(this.wordWrap() ? EditorView.lineWrapping : []),
keymap.of([
saveCommand,
...defaultKeymap,
...historyKeymap,
...searchKeymap,
...completionKeymap,
...closeBracketsKeymap,
...lintKeymap,
indentWithTab
]),
EditorView.updateListener.of((update) => {
if (update.docChanged) {
this.onContentChange(update.state.doc.toString());
}
if (update.selectionSet) {
this.updateCursorPosition();
}
}),
EditorView.theme({
'&': {
height: '100%',
backgroundColor: this.isDarkTheme() ? '#1e293b' : '#ffffff'
},
'.cm-content': {
caretColor: this.isDarkTheme() ? '#60a5fa' : '#3b82f6',
color: this.isDarkTheme() ? '#e2e8f0' : '#1e293b'
},
'.cm-cursor': {
borderLeftColor: this.isDarkTheme() ? '#60a5fa' : '#3b82f6'
},
'.cm-activeLine': {
backgroundColor: this.isDarkTheme() ? '#334155' : '#f1f5f9'
},
'.cm-gutters': {
backgroundColor: this.isDarkTheme() ? '#1e293b' : '#f8fafc',
color: this.isDarkTheme() ? '#64748b' : '#94a3b8',
border: 'none'
},
'.cm-activeLineGutter': {
backgroundColor: this.isDarkTheme() ? '#334155' : '#e2e8f0'
}
})
]
});
this.editorView = new EditorView({
state: initialState,
parent: this.editorHost.nativeElement
});
this.updateCursorPosition();
}
private setupThemeObserver(): void {
if (typeof window === 'undefined') return;
const observer = new MutationObserver(() => {
const darkMode = document.documentElement.classList.contains('dark');
this.isDarkTheme.set(darkMode);
this.reconfigureTheme();
});
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['class']
});
}
private reconfigureTheme(): void {
if (!this.editorView) return;
const newTheme = EditorView.theme({
'&': {
height: '100%',
backgroundColor: this.isDarkTheme() ? '#1e293b' : '#ffffff'
},
'.cm-content': {
caretColor: this.isDarkTheme() ? '#60a5fa' : '#3b82f6',
color: this.isDarkTheme() ? '#e2e8f0' : '#1e293b'
},
'.cm-cursor': {
borderLeftColor: this.isDarkTheme() ? '#60a5fa' : '#3b82f6'
},
'.cm-activeLine': {
backgroundColor: this.isDarkTheme() ? '#334155' : '#f1f5f9'
},
'.cm-gutters': {
backgroundColor: this.isDarkTheme() ? '#1e293b' : '#f8fafc',
color: this.isDarkTheme() ? '#64748b' : '#94a3b8',
border: 'none'
},
'.cm-activeLineGutter': {
backgroundColor: this.isDarkTheme() ? '#334155' : '#e2e8f0'
}
});
this.editorView.dispatch({
effects: []
});
}
private onContentChange(newContent: string): void {
this.content.set(newContent);
this.isDirty.set(true);
this.editorStateService.updateContent(newContent);
this.scheduleAutosave();
}
private updateCursorPosition(): void {
if (!this.editorView) return;
const pos = this.editorView.state.selection.main.head;
const line = this.editorView.state.doc.lineAt(pos);
this.cursorLine.set(line.number);
this.cursorCol.set(pos - line.from + 1);
}
toggleWordWrap(): void {
this.wordWrap.update(v => !v);
if (this.editorView) {
this.editorView.dispatch({
effects: this.wrapCompartment.reconfigure(
this.wordWrap() ? EditorView.lineWrapping : []
)
});
}
}
undo(): void {
if (!this.editorView) return;
const { state, dispatch } = this.editorView;
const cmd = historyKeymap.find(k => k.key === 'Mod-z');
if (cmd) cmd.run?.({ state, dispatch } as any);
}
redo(): void {
if (!this.editorView) return;
const { state, dispatch } = this.editorView;
const cmd = historyKeymap.find(k => k.key === 'Mod-y');
if (cmd) cmd.run?.({ state, dispatch } as any);
}
async save(): Promise<void> {
const path = this.filePath();
if (!path || this.isSaving()) return;
this.isSaving.set(true);
try {
const content = this.editorView?.state.doc.toString() || '';
const success = await (this.vaultService as any).saveMarkdown(path, content);
if (success) {
this.isDirty.set(false);
this.editorStateService.markAsSaved();
this.lastSaved.set(new Date().toLocaleTimeString());
this.showToast('Saved successfully', 'success');
} else {
this.showToast('Failed to save', 'error');
}
} catch (error) {
console.error('Save error:', error);
this.showToast('Error saving file', 'error');
} finally {
this.isSaving.set(false);
}
}
close(): void {
if (this.isDirty() && !confirm('You have unsaved changes. Close anyway?')) {
return;
}
this.editorStateService.exitEditMode();
}
private scheduleAutosave(): void {
if (this.autosaveTimer) {
clearTimeout(this.autosaveTimer);
}
// Autosave after 5 seconds of inactivity
this.autosaveTimer = setTimeout(() => {
if (this.isDirty() && !this.isSaving()) {
this.save();
}
}, 5000);
}
private showToast(message: string, type: 'success' | 'error'): void {
if (type === 'success') {
this.toastService.success(message);
} else {
this.toastService.error(message);
}
}
private cleanup(): void {
if (this.autosaveTimer) {
clearTimeout(this.autosaveTimer);
}
if (this.editorView) {
this.editorView.destroy();
}
}
}
@@ -0,0 +1,12 @@
import { NgModule } from '@angular/core';
import { CommonModule } from '@angular/common';
/**
* Module lazy-loadable pour l'éditeur Markdown avec CodeMirror 6
* Chargé uniquement quand l'utilisateur entre en mode édition
* Note: Le composant MarkdownEditorComponent est standalone et chargé dynamiquement
*/
@NgModule({
imports: [CommonModule]
})
export class MarkdownEditorModule { }