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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user