feat: add CodeMirror dependencies and implement markdown editor UI
This commit is contained in:
@@ -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 { }
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Component, Input, OnChanges, SimpleChanges, signal, computed, inject, effect, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { Component, Input, OnChanges, SimpleChanges, signal, computed, inject, effect, CUSTOM_ELEMENTS_SCHEMA, Output, EventEmitter } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { MarkdownService } from '../../services/markdown.service';
|
||||
import { Note } from '../../types';
|
||||
import { DrawingsEditorComponent } from '../../app/features/drawings/drawings-editor.component';
|
||||
import { EditorStateService } from '../../services/editor-state.service';
|
||||
|
||||
/**
|
||||
* Composant réutilisable pour afficher du contenu Markdown
|
||||
@@ -45,6 +46,19 @@ import { DrawingsEditorComponent } from '../../app/features/drawings/drawings-ed
|
||||
</span>
|
||||
</div>
|
||||
<div class="markdown-viewer__toolbar-right">
|
||||
<!-- Edit Button -->
|
||||
<button
|
||||
*ngIf="!isExcalidrawFile()"
|
||||
type="button"
|
||||
class="btn-standard-icon"
|
||||
(click)="toggleEditMode()"
|
||||
[attr.aria-label]="'Edit markdown'">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<!-- Fullscreen Button -->
|
||||
<button
|
||||
*ngIf="fullscreenMode"
|
||||
type="button"
|
||||
@@ -176,6 +190,7 @@ import { DrawingsEditorComponent } from '../../app/features/drawings/drawings-ed
|
||||
export class MarkdownViewerComponent implements OnChanges {
|
||||
private markdownService = inject(MarkdownService);
|
||||
private sanitizer = inject(DomSanitizer);
|
||||
private editorState = inject(EditorStateService);
|
||||
|
||||
/** Contenu markdown brut à afficher */
|
||||
@Input() content: string = '';
|
||||
@@ -194,6 +209,9 @@ export class MarkdownViewerComponent implements OnChanges {
|
||||
|
||||
/** Chemin du fichier (pour détecter les .excalidraw.md) */
|
||||
@Input() filePath: string = '';
|
||||
|
||||
/** Event émis quand on veut passer en mode édition */
|
||||
@Output() editModeRequested = new EventEmitter<{ path: string; content: string }>();
|
||||
|
||||
// Signals
|
||||
isLoading = signal<boolean>(false);
|
||||
@@ -246,6 +264,19 @@ export class MarkdownViewerComponent implements OnChanges {
|
||||
this.isFullscreen.update(v => !v);
|
||||
}
|
||||
|
||||
toggleEditMode(): void {
|
||||
if (!this.filePath) {
|
||||
console.warn('[MarkdownViewer] Cannot edit: no file path');
|
||||
return;
|
||||
}
|
||||
|
||||
// Émettre l'événement avec le chemin et le contenu
|
||||
this.editModeRequested.emit({
|
||||
path: this.filePath,
|
||||
content: this.content
|
||||
});
|
||||
}
|
||||
|
||||
private setupLazyLoading(): void {
|
||||
// Wait for next tick to ensure DOM is updated
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { Component, Input, OnChanges, SimpleChanges, signal, computed, inject, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
|
||||
import { Component, Input, OnChanges, SimpleChanges, signal, computed, inject, CUSTOM_ELEMENTS_SCHEMA, ViewContainerRef, ComponentRef, ViewChild, effect } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MarkdownViewerComponent } from '../markdown-viewer/markdown-viewer.component';
|
||||
import { FileTypeDetectorService } from '../../services/file-type-detector.service';
|
||||
import { Note } from '../../types';
|
||||
import { EditorStateService } from '../../services/editor-state.service';
|
||||
|
||||
/**
|
||||
* Composant intelligent qui détecte automatiquement le type de fichier
|
||||
@@ -25,15 +26,19 @@ import { Note } from '../../types';
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
template: `
|
||||
<div class="smart-file-viewer" [attr.data-viewer-type]="viewerType()">
|
||||
<!-- Markdown/Excalidraw Viewer -->
|
||||
<!-- Markdown Editor (Edit Mode) -->
|
||||
<div #editorContainer *ngIf="isEditMode()" class="smart-file-viewer__editor"></div>
|
||||
|
||||
<!-- Markdown/Excalidraw Viewer (Read Mode) -->
|
||||
<app-markdown-viewer
|
||||
*ngIf="viewerType() === 'markdown' || viewerType() === 'excalidraw'"
|
||||
*ngIf="!isEditMode() && (viewerType() === 'markdown' || viewerType() === 'excalidraw')"
|
||||
[content]="content"
|
||||
[allNotes]="allNotes"
|
||||
[currentNote]="currentNote"
|
||||
[showToolbar]="showToolbar"
|
||||
[fullscreenMode]="fullscreenMode"
|
||||
[filePath]="filePath">
|
||||
[filePath]="filePath"
|
||||
(editModeRequested)="onEditModeRequested($event)">
|
||||
</app-markdown-viewer>
|
||||
|
||||
<!-- Image Viewer -->
|
||||
@@ -83,6 +88,11 @@ import { Note } from '../../types';
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.smart-file-viewer__editor {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.smart-file-viewer__image,
|
||||
.smart-file-viewer__pdf,
|
||||
.smart-file-viewer__text,
|
||||
@@ -124,7 +134,11 @@ import { Note } from '../../types';
|
||||
`]
|
||||
})
|
||||
export class SmartFileViewerComponent implements OnChanges {
|
||||
@ViewChild('editorContainer', { read: ViewContainerRef }) editorContainer?: ViewContainerRef;
|
||||
|
||||
private fileTypeDetector = inject(FileTypeDetectorService);
|
||||
private editorStateService = inject(EditorStateService);
|
||||
private editorComponentRef?: ComponentRef<any>;
|
||||
|
||||
@Input() filePath: string = '';
|
||||
@Input() content: string = '';
|
||||
@@ -134,6 +148,7 @@ export class SmartFileViewerComponent implements OnChanges {
|
||||
@Input() fullscreenMode: boolean = true;
|
||||
|
||||
viewerType = signal<'markdown' | 'excalidraw' | 'image' | 'pdf' | 'text' | 'unknown'>('unknown');
|
||||
isEditMode = computed(() => this.editorStateService.isEditMode());
|
||||
|
||||
fileName = computed(() => {
|
||||
return this.filePath.split('/').pop() || this.filePath.split('\\').pop() || 'Unknown file';
|
||||
@@ -166,4 +181,64 @@ export class SmartFileViewerComponent implements OnChanges {
|
||||
const type = this.fileTypeDetector.getViewerType(this.filePath, this.content);
|
||||
this.viewerType.set(type);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
// Observer le changement de mode édition
|
||||
effect(() => {
|
||||
const editMode = this.editorStateService.isEditMode();
|
||||
if (editMode && !this.editorComponentRef) {
|
||||
this.loadEditor();
|
||||
} else if (!editMode && this.editorComponentRef) {
|
||||
this.unloadEditor();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async onEditModeRequested(event: { path: string; content: string }): Promise<void> {
|
||||
this.editorStateService.enterEditMode(event.path, event.content);
|
||||
}
|
||||
|
||||
private async loadEditor(): Promise<void> {
|
||||
if (!this.editorContainer) {
|
||||
console.warn('[SmartFileViewer] Editor container not available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Lazy load the editor module
|
||||
const { MarkdownEditorComponent } = await import('../../app/features/editor/markdown-editor.component');
|
||||
|
||||
// Clear any existing component
|
||||
this.editorContainer.clear();
|
||||
|
||||
// Create the editor component
|
||||
this.editorComponentRef = this.editorContainer.createComponent(MarkdownEditorComponent);
|
||||
|
||||
// Set inputs
|
||||
const path = this.editorStateService.currentPath();
|
||||
const content = this.editorStateService.content();
|
||||
|
||||
if (path && content !== null) {
|
||||
this.editorComponentRef.setInput('initialPath', path);
|
||||
this.editorComponentRef.setInput('initialContent', content);
|
||||
}
|
||||
|
||||
console.log('[SmartFileViewer] Editor loaded successfully');
|
||||
} catch (error) {
|
||||
console.error('[SmartFileViewer] Failed to load editor:', error);
|
||||
}
|
||||
}
|
||||
|
||||
private unloadEditor(): void {
|
||||
if (this.editorComponentRef) {
|
||||
this.editorComponentRef.destroy();
|
||||
this.editorComponentRef = undefined;
|
||||
}
|
||||
|
||||
if (this.editorContainer) {
|
||||
this.editorContainer.clear();
|
||||
}
|
||||
|
||||
console.log('[SmartFileViewer] Editor unloaded');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { Note } from '../../../types';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { NotePreviewService, PreviewData } from '../../../services/note-preview.service';
|
||||
import { NoteHeaderComponent } from '../../../app/features/note/components/note-header/note-header.component';
|
||||
import { MarkdownEditorComponent } from '../../../app/features/editor/markdown-editor.component';
|
||||
import { EditorStateService } from '../../../services/editor-state.service';
|
||||
import { ClipboardService } from '../../../app/shared/services/clipboard.service';
|
||||
import { ToastService } from '../../../app/shared/toast/toast.service';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
@@ -65,7 +67,7 @@ interface MetadataEntry {
|
||||
@Component({
|
||||
selector: 'app-note-viewer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, NoteHeaderComponent],
|
||||
imports: [CommonModule, NoteHeaderComponent, MarkdownEditorComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<div class="relative p-1 prose prose-lg dark:prose-invert max-w-none prose-p:leading-[1] prose-li:leading-[1] prose-blockquote:leading-[1]">
|
||||
@@ -82,6 +84,18 @@ interface MetadataEntry {
|
||||
></app-note-header>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
(click)="enterEdit()"
|
||||
title="Éditer"
|
||||
aria-label="Éditer"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
|
||||
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="note-toolbar-icon"
|
||||
@@ -132,6 +146,13 @@ interface MetadataEntry {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (isEditMode()) {
|
||||
<app-markdown-editor
|
||||
[initialPath]="note().filePath"
|
||||
[initialContent]="note().rawContent ?? note().content"
|
||||
/>
|
||||
} @else {
|
||||
|
||||
@if (frontmatterTags().length > 0) {
|
||||
<div class="mb-6 md-tag-group not-prose">
|
||||
@for (tag of frontmatterTags(); track tag) {
|
||||
@@ -320,6 +341,8 @@ interface MetadataEntry {
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
}
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
@@ -344,6 +367,7 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
private readonly clipboard = inject(ClipboardService);
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly vault = inject(VaultService);
|
||||
private readonly editorState = inject(EditorStateService);
|
||||
private readonly tagPaletteSize = 12;
|
||||
private readonly tagColorCache = new Map<string, number>();
|
||||
private readonly copyFeedbackTimers = new Map<HTMLElement, number>();
|
||||
@@ -365,6 +389,9 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
readonly maxMetadataPreviewItems = 3;
|
||||
readonly copyStatus = signal('');
|
||||
|
||||
// Edition state
|
||||
readonly isEditMode = this.editorState.isEditMode;
|
||||
|
||||
readonly sanitizedHtmlContent = computed<SafeHtml>(() =>
|
||||
this.sanitizer.bypassSecurityTrustHtml(this.noteHtmlContent())
|
||||
);
|
||||
@@ -536,6 +563,13 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
this.menuOpen.set(false);
|
||||
}
|
||||
|
||||
enterEdit(): void {
|
||||
const n = this.note();
|
||||
if (!n) return;
|
||||
const content = n.rawContent ?? n.content ?? '';
|
||||
this.editorState.enterEditMode(n.filePath, content);
|
||||
}
|
||||
|
||||
getAuthorFromFrontmatter(): string | null {
|
||||
const frontmatter = this.note().frontmatter ?? {};
|
||||
const authorValue = frontmatter['author'] ?? frontmatter['auteur'];
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { Injectable, signal, computed } from '@angular/core';
|
||||
|
||||
/**
|
||||
* Service de gestion d'état pour le mode édition Markdown
|
||||
* Gère le passage entre mode lecture et mode édition
|
||||
*/
|
||||
export interface EditorState {
|
||||
mode: 'view' | 'edit';
|
||||
currentPath: string | null;
|
||||
isDirty: boolean;
|
||||
content: string | null;
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class EditorStateService {
|
||||
// État privé
|
||||
private state = signal<EditorState>({
|
||||
mode: 'view',
|
||||
currentPath: null,
|
||||
isDirty: false,
|
||||
content: null
|
||||
});
|
||||
|
||||
// Signaux publics (lecture seule)
|
||||
readonly mode = computed(() => this.state().mode);
|
||||
readonly currentPath = computed(() => this.state().currentPath);
|
||||
readonly isDirty = computed(() => this.state().isDirty);
|
||||
readonly content = computed(() => this.state().content);
|
||||
readonly isEditMode = computed(() => this.state().mode === 'edit');
|
||||
|
||||
/**
|
||||
* Active le mode édition pour un fichier donné
|
||||
*/
|
||||
enterEditMode(filePath: string, content: string): void {
|
||||
this.state.set({
|
||||
mode: 'edit',
|
||||
currentPath: filePath,
|
||||
isDirty: false,
|
||||
content
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Quitte le mode édition
|
||||
*/
|
||||
exitEditMode(): void {
|
||||
this.state.set({
|
||||
mode: 'view',
|
||||
currentPath: null,
|
||||
isDirty: false,
|
||||
content: null
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Marque le contenu comme modifié
|
||||
*/
|
||||
setDirty(isDirty: boolean): void {
|
||||
this.state.update(current => ({
|
||||
...current,
|
||||
isDirty
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Met à jour le contenu en cours d'édition
|
||||
*/
|
||||
updateContent(content: string): void {
|
||||
this.state.update(current => ({
|
||||
...current,
|
||||
content,
|
||||
isDirty: true
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Réinitialise le flag dirty après sauvegarde
|
||||
*/
|
||||
markAsSaved(): void {
|
||||
this.state.update(current => ({
|
||||
...current,
|
||||
isDirty: false
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Vérifie si on peut quitter sans perdre de données
|
||||
*/
|
||||
canExit(): boolean {
|
||||
return !this.state().isDirty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Force la sortie (abandon des modifications)
|
||||
*/
|
||||
forceExit(): void {
|
||||
this.exitEditMode();
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
@import './styles-test.css';
|
||||
@import './styles/_overlay-scrollbar.css';
|
||||
@import './styles/codemirror.css';
|
||||
|
||||
/* Excalidraw CSS variables (thème sombre) */
|
||||
/* .excalidraw {
|
||||
|
||||
@@ -0,0 +1,307 @@
|
||||
/**
|
||||
* CodeMirror 6 Custom Styles
|
||||
* Styles globaux pour l'éditeur Markdown CodeMirror 6
|
||||
* Thèmes Light & Dark synchronisés avec le reste de l'application
|
||||
*/
|
||||
|
||||
/* ============================================
|
||||
Variables CSS pour les thèmes
|
||||
============================================ */
|
||||
:root {
|
||||
--cm-font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', 'Monaco', 'Courier New', monospace;
|
||||
--cm-font-size: 14px;
|
||||
--cm-line-height: 1.6;
|
||||
|
||||
/* Light theme colors */
|
||||
--cm-bg: #ffffff;
|
||||
--cm-text: #1e293b;
|
||||
--cm-cursor: #3b82f6;
|
||||
--cm-selection-bg: #bfdbfe;
|
||||
--cm-active-line-bg: #f1f5f9;
|
||||
--cm-gutter-bg: #f8fafc;
|
||||
--cm-gutter-text: #94a3b8;
|
||||
--cm-gutter-active-bg: #e2e8f0;
|
||||
|
||||
/* Syntax highlighting - Light */
|
||||
--cm-keyword: #d73a49;
|
||||
--cm-string: #032f62;
|
||||
--cm-comment: #6a737d;
|
||||
--cm-variable: #e36209;
|
||||
--cm-number: #005cc5;
|
||||
--cm-tag: #22863a;
|
||||
--cm-attribute: #6f42c1;
|
||||
--cm-link: #0366d6;
|
||||
}
|
||||
|
||||
.dark {
|
||||
/* Dark theme colors */
|
||||
--cm-bg: #1e293b;
|
||||
--cm-text: #e2e8f0;
|
||||
--cm-cursor: #60a5fa;
|
||||
--cm-selection-bg: #1e40af;
|
||||
--cm-active-line-bg: #334155;
|
||||
--cm-gutter-bg: #1e293b;
|
||||
--cm-gutter-text: #64748b;
|
||||
--cm-gutter-active-bg: #334155;
|
||||
|
||||
/* Syntax highlighting - Dark */
|
||||
--cm-keyword: #ff7b72;
|
||||
--cm-string: #a5d6ff;
|
||||
--cm-comment: #8b949e;
|
||||
--cm-variable: #ffa657;
|
||||
--cm-number: #79c0ff;
|
||||
--cm-tag: #7ee787;
|
||||
--cm-attribute: #d2a8ff;
|
||||
--cm-link: #58a6ff;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Base Editor Styles
|
||||
============================================ */
|
||||
.cm-editor {
|
||||
font-family: var(--cm-font-family) !important;
|
||||
font-size: var(--cm-font-size) !important;
|
||||
line-height: var(--cm-line-height) !important;
|
||||
}
|
||||
|
||||
.cm-scroller {
|
||||
font-family: inherit;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
padding: 1rem;
|
||||
caret-color: var(--cm-cursor);
|
||||
color: var(--cm-text);
|
||||
}
|
||||
|
||||
.cm-line {
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Cursor & Selection
|
||||
============================================ */
|
||||
.cm-cursor,
|
||||
.cm-cursor-primary {
|
||||
border-left-color: var(--cm-cursor) !important;
|
||||
border-left-width: 2px;
|
||||
}
|
||||
|
||||
.cm-selectionBackground,
|
||||
.cm-focused .cm-selectionBackground {
|
||||
background-color: var(--cm-selection-bg) !important;
|
||||
}
|
||||
|
||||
.cm-activeLine {
|
||||
background-color: var(--cm-active-line-bg);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Gutters (Line Numbers)
|
||||
============================================ */
|
||||
.cm-gutters {
|
||||
background-color: var(--cm-gutter-bg);
|
||||
color: var(--cm-gutter-text);
|
||||
border: none;
|
||||
padding-right: 0.5rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.cm-activeLineGutter {
|
||||
background-color: var(--cm-gutter-active-bg);
|
||||
color: var(--cm-text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cm-lineNumbers .cm-gutterElement {
|
||||
padding: 0 0.5rem;
|
||||
min-width: 2.5rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Syntax Highlighting
|
||||
============================================ */
|
||||
.cm-content .cm-keyword {
|
||||
color: var(--cm-keyword);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.cm-content .cm-string {
|
||||
color: var(--cm-string);
|
||||
}
|
||||
|
||||
.cm-content .cm-comment {
|
||||
color: var(--cm-comment);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cm-content .cm-variableName {
|
||||
color: var(--cm-variable);
|
||||
}
|
||||
|
||||
.cm-content .cm-number {
|
||||
color: var(--cm-number);
|
||||
}
|
||||
|
||||
.cm-content .cm-propertyName {
|
||||
color: var(--cm-attribute);
|
||||
}
|
||||
|
||||
.cm-content .cm-link {
|
||||
color: var(--cm-link);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Markdown specific */
|
||||
.cm-content .cm-header,
|
||||
.cm-content .cm-heading {
|
||||
color: var(--cm-keyword);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cm-content .cm-strong {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.cm-content .cm-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.cm-content .cm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.cm-content .cm-code,
|
||||
.cm-content .cm-monospace {
|
||||
font-family: var(--cm-font-family);
|
||||
background-color: var(--cm-active-line-bg);
|
||||
padding: 0.125rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Fold Gutter
|
||||
============================================ */
|
||||
.cm-foldGutter .cm-gutterElement {
|
||||
padding: 0 0.25rem;
|
||||
cursor: pointer;
|
||||
opacity: 0.5;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
|
||||
.cm-foldGutter .cm-gutterElement:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Search & Replace
|
||||
============================================ */
|
||||
.cm-searchMatch {
|
||||
background-color: rgba(255, 213, 0, 0.3);
|
||||
outline: 1px solid rgba(255, 213, 0, 0.6);
|
||||
}
|
||||
|
||||
.cm-searchMatch-selected {
|
||||
background-color: rgba(255, 165, 0, 0.5);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Autocomplete
|
||||
============================================ */
|
||||
.cm-tooltip-autocomplete {
|
||||
background-color: var(--cm-bg);
|
||||
border: 1px solid var(--cm-gutter-text);
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1);
|
||||
}
|
||||
|
||||
.cm-tooltip-autocomplete > ul > li[aria-selected] {
|
||||
background-color: var(--cm-active-line-bg);
|
||||
color: var(--cm-text);
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Focus Styles (Accessibility)
|
||||
============================================ */
|
||||
.cm-editor.cm-focused {
|
||||
outline: 2px solid var(--cm-cursor);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Responsive Mobile Styles
|
||||
============================================ */
|
||||
@media (max-width: 640px) {
|
||||
.cm-editor {
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
padding: 0.75rem;
|
||||
}
|
||||
|
||||
.cm-lineNumbers .cm-gutterElement {
|
||||
min-width: 2rem;
|
||||
padding: 0 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Print Styles
|
||||
============================================ */
|
||||
@media print {
|
||||
.cm-gutters {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.cm-content {
|
||||
color: #000 !important;
|
||||
background: #fff !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Animations
|
||||
============================================ */
|
||||
.cm-line {
|
||||
animation: fadeIn 0.15s ease-in;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0.8;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Scrollbar Styling
|
||||
============================================ */
|
||||
.cm-scroller::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
}
|
||||
|
||||
.cm-scroller::-webkit-scrollbar-track {
|
||||
background: var(--cm-gutter-bg);
|
||||
}
|
||||
|
||||
.cm-scroller::-webkit-scrollbar-thumb {
|
||||
background: var(--cm-gutter-text);
|
||||
border-radius: 6px;
|
||||
border: 3px solid var(--cm-gutter-bg);
|
||||
}
|
||||
|
||||
.cm-scroller::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--cm-text);
|
||||
}
|
||||
|
||||
/* Firefox scrollbar */
|
||||
.cm-scroller {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--cm-gutter-text) var(--cm-gutter-bg);
|
||||
}
|
||||
Reference in New Issue
Block a user