feat: revamp editor UI and enhance about panel

- Updated markdown editor with modern toolbar design and improved visual hierarchy
- Added auto-save indicator and note color support in editor header
- Redesigned note list cards with hover effects and quick action buttons
- Added version, build date and copyright info to about panel
- Enhanced scrollbar styling and padding in editor container
- Improved responsive layout and dark mode support across components
This commit is contained in:
2025-10-27 10:11:20 -04:00
parent 917af04642
commit 11a58426d0
44 changed files with 1781 additions and 588 deletions
@@ -38,7 +38,7 @@ type CtxAction =
box-shadow: 0 10px 30px rgba(0,0,0,.25);
backdrop-filter: blur(6px);
animation: fadeIn .12s ease-out;
transform-origin: top left;
/* transform-origin is controlled inline based on opening direction */
user-select: none;
/* Theme-aware background and border */
background: var(--card, #ffffff);
@@ -90,6 +90,24 @@ type CtxAction =
outline: 2px solid color-mix(in oklab, var(--canvas, #ffffff) 70%, var(--fg, #111827) 15%);
outline-offset: 2px;
}
.clear-dot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in oklab, var(--surface-2, #e2e8f0) 75%, transparent 25%);
border: 2px solid color-mix(in oklab, var(--fg, #111827) 35%, transparent 65%);
}
.clear-dot::after {
content: '';
position: absolute;
width: 60%;
height: 2px;
border-radius: 9999px;
background: color-mix(in oklab, var(--canvas, #ffffff) 80%, var(--fg, #111827) 20%);
transform: rotate(-45deg);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--card, #0b1220) 65%, transparent 35%);
}
@keyframes fadeIn { from { opacity:0; transform: scale(.95);} to { opacity:1; transform: scale(1);} }
`],
template: `
@@ -101,7 +119,7 @@ type CtxAction =
<div
#menu
class="ctx"
[ngStyle]="{ left: left + 'px', top: top + 'px', position:'fixed' }"
[ngStyle]="{ left: left + 'px', top: top + 'px', position:'fixed', 'transform-origin': transformOrigin }"
role="menu"
(contextmenu)="$event.preventDefault()"
>
@@ -133,6 +151,13 @@ type CtxAction =
<div class="sep"></div>
<div class="row">
<div
class="dot clear-dot"
(click)="emitColor(null)"
aria-label="Remove folder color"
role="button"
title="Remove folder color"
></div>
<div *ngFor="let c of colors"
class="dot"
[style.background]="c"
@@ -154,7 +179,7 @@ export class ContextMenuComponent implements OnChanges, OnDestroy {
/** Actions/retours */
@Output() action = new EventEmitter<CtxAction>();
@Output() color = new EventEmitter<string>();
@Output() color = new EventEmitter<string | null>();
@Output() closed = new EventEmitter<void>();
/** Palette 8 couleurs */
@@ -163,16 +188,26 @@ export class ContextMenuComponent implements OnChanges, OnDestroy {
/** Position corrigée (anti overflow) */
left = 0;
top = 0;
transformOrigin: 'top left' | 'bottom left' = 'top left';
@ViewChild('menu') menuRef?: ElementRef<HTMLElement>;
private removeResize?: () => void;
private removeScroll?: () => void;
private repositionRaf: number | null = null;
constructor(private r2: Renderer2, private host: ElementRef<HTMLElement>) {
// listeners globaux qui ferment le menu
this.removeResize = this.r2.listen('window', 'resize', () => this.reposition());
this.removeScroll = this.r2.listen('window', 'scroll', () => this.reposition());
// Déplacer l'hôte directement sous <body> pour éviter les overflow/transform parents
try {
const el = this.host.nativeElement;
if (el && el.parentElement !== document.body) {
document.body.appendChild(el);
}
} catch {}
}
ngOnChanges(changes: SimpleChanges): void {
@@ -181,16 +216,20 @@ export class ContextMenuComponent implements OnChanges, OnDestroy {
this.left = this.x;
this.top = this.y;
// Then reposition for anti-overflow
queueMicrotask(() => this.reposition());
this.scheduleReposition();
}
if ((changes['x'] || changes['y']) && this.visible) {
queueMicrotask(() => this.reposition());
this.scheduleReposition();
}
}
ngOnDestroy(): void {
this.removeResize?.();
this.removeScroll?.();
if (this.repositionRaf != null) {
cancelAnimationFrame(this.repositionRaf);
this.repositionRaf = null;
}
}
/** Ferme le menu */
@@ -205,11 +244,28 @@ export class ContextMenuComponent implements OnChanges, OnDestroy {
this.close();
}
emitColor(c: string) {
emitColor(c: string | null) {
this.color.emit(c);
this.close();
}
/** Planifie une correction de positionnement sur la prochaine frame */
private scheduleReposition() {
if (!this.visible) return;
if (this.repositionRaf != null) {
cancelAnimationFrame(this.repositionRaf);
this.repositionRaf = null;
}
const el = this.menuRef?.nativeElement;
if (el) {
el.style.visibility = 'hidden';
}
this.repositionRaf = requestAnimationFrame(() => {
this.repositionRaf = null;
this.reposition();
});
}
/** Corrige la position si le menu sortirait du viewport */
private reposition() {
const el = this.menuRef?.nativeElement;
@@ -222,11 +278,26 @@ export class ContextMenuComponent implements OnChanges, OnDestroy {
let left = this.x;
let top = this.y;
// Horizontal anti-overflow
if (left + menuRect.width > vw - 8) left = Math.max(8, vw - menuRect.width - 8);
if (top + menuRect.height > vh - 8) top = Math.max(8, vh - menuRect.height - 8);
if (left < 8) left = 8;
// Vertical positioning: open upwards if it would overflow bottom
const wouldOverflowBottom = top + menuRect.height > vh - 8;
if (wouldOverflowBottom) {
top = Math.max(8, top - menuRect.height);
this.transformOrigin = 'bottom left';
} else {
if (top < 8) top = 8;
if (top + menuRect.height > vh - 8) {
top = Math.max(8, vh - menuRect.height - 8);
}
this.transformOrigin = 'top left';
}
this.left = left;
this.top = top;
el.style.visibility = 'visible';
}
/** Fermer avec ESC */
@@ -410,9 +410,17 @@ export class FileExplorerComponent {
}
}
onContextMenuColor(color: string) {
onContextMenuColor(color: string | null) {
if (!this.ctxTarget) return;
if (!color) {
this.folderColors.delete(this.ctxTarget.path);
this.persistFolderColors();
this.ctxVisible.set(false);
this.showNotification(`Folder color removed`, 'success');
return;
}
this.setFolderColor(this.ctxTarget.path, color);
this.persistFolderColors();
// Close context menu after color selection
this.ctxVisible.set(false);
this.showNotification(`Folder color updated`, 'success');
@@ -49,7 +49,7 @@ type NoteAction =
mix-blend-mode: normal;
overflow: hidden; /* clip inner cover to rounded corners */
animation: fadeIn .12s ease-out;
transform-origin: top left;
/* transform-origin is controlled inline based on opening direction */
user-select: none;
/* Use theme variable for solid background (themes define solid hex for --card) */
background: var(--card) !important;
@@ -127,6 +127,24 @@ type NoteAction =
box-shadow: 0 0 0 2px var(--fg, #111827);
transform: scale(1.1);
}
.color-clear-dot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in oklab, var(--surface-2, #e2e8f0) 75%, transparent 25%);
border: 2px solid color-mix(in oklab, var(--fg, #111827) 35%, transparent 65%);
}
.color-clear-dot::after {
content: '';
position: absolute;
width: 60%;
height: 2px;
border-radius: 9999px;
background: color-mix(in oklab, var(--canvas, #ffffff) 80%, var(--fg, #111827) 20%);
transform: rotate(-45deg);
box-shadow: 0 0 0 1px color-mix(in oklab, var(--card, #0b1220) 65%, transparent 35%);
}
.icon {
width: 1.125rem;
height: 1.125rem;
@@ -144,7 +162,7 @@ type NoteAction =
<div
#menu
class="ctx"
[ngStyle]="{ left: left + 'px', top: top + 'px', position:'fixed' }"
[ngStyle]="{ left: left + 'px', top: top + 'px', position:'fixed', 'transform-origin': transformOrigin }"
role="menu"
(contextmenu)="$event.preventDefault()"
>
@@ -239,18 +257,12 @@ type NoteAction =
role="button"
title="Définir la couleur de la note"></div>
<!-- Clear color option -->
<div class="color-dot"
<div class="color-dot color-clear-dot"
[class.active]="!note?.frontmatter?.color"
style="background: conic-gradient(from 45deg, #ef4444, #f59e0b, #22c55e, #3b82f6, #a855f7, #ef4444);"
(click)="emitColor('')"
attr.aria-label="Aucune couleur"
role="button"
title="Retirer la couleur">
<svg class="icon" style="width: 0.75rem; height: 0.75rem;" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</div>
title="Retirer la couleur"></div>
</div>
</div>
</ng-container>
@@ -276,11 +288,13 @@ export class NoteContextMenuComponent implements OnChanges, OnDestroy {
/** Position corrigée (anti overflow) */
left = 0;
top = 0;
transformOrigin: 'top left' | 'bottom left' = 'top left';
@ViewChild('menu') menuRef?: ElementRef<HTMLElement>;
private removeResize?: () => void;
private removeScroll?: () => void;
private repositionRaf: number | null = null;
private toastService = inject(ToastService);
constructor(private r2: Renderer2, private host: ElementRef<HTMLElement>) {
@@ -303,16 +317,20 @@ export class NoteContextMenuComponent implements OnChanges, OnDestroy {
this.left = this.x;
this.top = this.y;
// Then reposition for anti-overflow
queueMicrotask(() => this.reposition());
this.scheduleReposition();
}
if ((changes['x'] || changes['y']) && this.visible) {
queueMicrotask(() => this.reposition());
this.scheduleReposition();
}
}
ngOnDestroy(): void {
this.removeResize?.();
this.removeScroll?.();
if (this.repositionRaf != null) {
cancelAnimationFrame(this.repositionRaf);
this.repositionRaf = null;
}
}
/** Ferme le menu */
@@ -371,6 +389,23 @@ export class NoteContextMenuComponent implements OnChanges, OnDestroy {
return true; // Pour l'instant, on autorise toujours
}
/** Planifie une correction de positionnement sur la prochaine frame */
private scheduleReposition() {
if (!this.visible) return;
if (this.repositionRaf != null) {
cancelAnimationFrame(this.repositionRaf);
this.repositionRaf = null;
}
const el = this.menuRef?.nativeElement;
if (el) {
el.style.visibility = 'hidden';
}
this.repositionRaf = requestAnimationFrame(() => {
this.repositionRaf = null;
this.reposition();
});
}
/** Corrige la position si le menu sortirait du viewport */
private reposition() {
const el = this.menuRef?.nativeElement;
@@ -383,11 +418,29 @@ export class NoteContextMenuComponent implements OnChanges, OnDestroy {
let left = this.x;
let top = this.y;
// Horizontal anti-overflow
if (left + menuRect.width > vw - 8) left = Math.max(8, vw - menuRect.width - 8);
if (top + menuRect.height > vh - 8) top = Math.max(8, vh - menuRect.height - 8);
if (left < 8) left = 8;
// Vertical positioning: open upwards if it would overflow bottom
const wouldOverflowBottom = top + menuRect.height > vh - 8;
if (wouldOverflowBottom) {
// Open upwards from the click point
top = Math.max(8, top - menuRect.height);
this.transformOrigin = 'bottom left';
} else {
// Normal opening downwards; clamp if too close to top
if (top < 8) top = 8;
// Also clamp if still would overflow due to extremely small viewport
if (top + menuRect.height > vh - 8) {
top = Math.max(8, vh - menuRect.height - 8);
}
this.transformOrigin = 'top left';
}
this.left = left;
this.top = top;
el.style.visibility = 'visible';
}
/** Fermer avec ESC */
@@ -47,8 +47,28 @@ export interface WikiLinkActivation {
standalone: true,
imports: [CommonModule, NoteHeaderComponent, MarkdownEditorComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
styles: [`
.note-viewer-root { position: relative; border-radius: 0.5rem; overflow: visible; }
.note-viewer-root::before {
content: '';
position: absolute;
top: -8px; /* bridge any layout padding gap at the very top */
left: 50%;
transform: translateX(-50%);
width: 100dvw; /* full page width (dynamic viewport) */
height: 320px; /* extended to cover all header + metadata + edit toolbar */
background-image: var(--note-topband-gradient, none);
background-repeat: no-repeat;
background-size: 100% 100%;
-webkit-backdrop-filter: blur(4px);
backdrop-filter: blur(4px);
pointer-events: none;
z-index: 0;
}
.note-viewer-root > * { position: relative; z-index: 1; }
`],
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]">
<div class="note-viewer-root relative px-1 pb-1 pt-0 prose prose-lg dark:prose-invert max-w-none prose-p:leading-[1] prose-li:leading-[1] prose-blockquote:leading-[1] rounded-md">
<div class="sr-only" role="status" aria-live="polite">{{ copyStatus() }}</div>
<ng-container *ngIf="note() as note">
@@ -329,6 +349,7 @@ export class NoteViewerComponent implements OnDestroy {
showToc = output<void>();
directoryClicked = output<string>();
addTagRequested = output<void>();
searchRequested = output<void>();
fullScreenRequested = output<void>();
legacyRequested = output<void>();
parametersRequested = output<void>();
@@ -410,6 +431,7 @@ export class NoteViewerComponent implements OnDestroy {
this.scheduleAttachmentHandlers();
this.scheduleMathRender();
this.scheduleWikiLinkHoverHandlers();
this.updateTopBandGradient(this.note());
});
afterNextRender(() => {
@@ -470,6 +492,33 @@ export class NoteViewerComponent implements OnDestroy {
});
}
/**
* Apply full-width top band gradient based on frontmatter.color
*/
private updateTopBandGradient(n: Note | undefined): void {
try {
const host = this.elementRef.nativeElement as HTMLElement;
const root = host.querySelector('.note-viewer-root') as HTMLElement | null;
if (!root) return;
const raw = (n?.frontmatter as any)?.color as string | undefined;
let gradient = '';
if (raw) {
let topColor = raw;
const m = /^#([0-9a-fA-F]{6})$/.exec(raw);
if (m) {
const hex = m[1];
const r = parseInt(hex.slice(0, 2), 16);
const g = parseInt(hex.slice(2, 4), 16);
const b = parseInt(hex.slice(4, 6), 16);
topColor = `rgba(${r}, ${g}, ${b}, 0.34)`;
}
gradient = `linear-gradient(to bottom, ${topColor} 0%, rgba(0,0,0,0.0) 90%, rgba(0,0,0,0) 100%)`;
}
if (gradient) root.style.setProperty('--note-topband-gradient', gradient);
else root.style.removeProperty('--note-topband-gradient');
} catch {}
}
// La sauvegarde des tags est maintenant gérée automatiquement par TagsEditorComponent
// Cette méthode met simplement à jour l'état local après sauvegarde
onTagsChange(next: string[]): void {
@@ -748,21 +797,10 @@ export class NoteViewerComponent implements OnDestroy {
private ensureMermaid(): Promise<MermaidLib> {
if (this.mermaidLib) return Promise.resolve(this.mermaidLib);
if (this.mermaidLoader) return this.mermaidLoader;
if (typeof window === 'undefined') return Promise.reject(new Error('Mermaid is only available in the browser environment.'));
this.mermaidLoader = import('mermaid')
.then(module => {
const mermaidLib = (module.default ?? module) as MermaidLib;
const prefersDark = document.documentElement.classList.contains('dark');
mermaidLib.initialize({ startOnLoad: false, securityLevel: 'loose', theme: prefersDark ? 'dark' : 'default' });
this.mermaidLib = mermaidLib;
return mermaidLib;
})
.catch(error => {
this.mermaidLoader = null;
throw error;
});
return this.mermaidLoader;
const prefersDark = document.documentElement.classList.contains('dark');
mermaid.initialize({ startOnLoad: false, securityLevel: 'loose', theme: prefersDark ? 'dark' : 'default' });
this.mermaidLib = mermaid as unknown as MermaidLib;
return Promise.resolve(this.mermaidLib);
}
private renderMermaidDiagrams(): void {