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:
parent
917af04642
commit
11a58426d0
@ -78,6 +78,10 @@ import { trigger, transition, style, animate } from '@angular/animations';
|
||||
<p class="text-sm text-muted">
|
||||
Créé par <span class="font-semibold text-main dark:text-white">Bruno Charest</span>
|
||||
</p>
|
||||
<div class="mt-1 text-xs text-muted">
|
||||
<div>Version: {{ version }}</div>
|
||||
<div>Date de compilation: {{ buildDate }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Credits -->
|
||||
@ -104,6 +108,7 @@ import { trigger, transition, style, animate } from '@angular/animations';
|
||||
</svg>
|
||||
Voir sur GitHub
|
||||
</a>
|
||||
<div class="mt-4 text-[11px] text-muted">© {{ currentYear }} ObsiViewer. Tous droits réservés.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@ -119,6 +124,8 @@ export class AboutPanelComponent {
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
readonly version = '1.0.0';
|
||||
readonly buildDate = new Date().toLocaleString();
|
||||
readonly currentYear = new Date().getFullYear();
|
||||
|
||||
@HostListener('document:keydown.escape')
|
||||
onEscapeKey(): void {
|
||||
|
||||
@ -43,84 +43,25 @@ import { EditorHighlightService } from '../../shared/editor/editor-highlight.ser
|
||||
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>
|
||||
<!-- Revamped Toolbar -->
|
||||
<header
|
||||
class="flex items-center justify-between px-4 py-2 bg-gradient-to-r from-[#0f172a] to-[#1e293b] border-b border-slate-700/50 shadow-sm backdrop-blur-sm relative"
|
||||
[style.border-top]="noteColor() ? '3px solid ' + noteColor() : 'none'"
|
||||
>
|
||||
<h1 class="text-slate-200 font-semibold text-base">{{ fileName() }}</h1>
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="relative">
|
||||
<button class="px-3 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-slate-200 transition" (click)="save()" [disabled]="isSaving()">
|
||||
{{ isSaving() ? 'Saving...' : 'Save' }}
|
||||
</button>
|
||||
<span *ngIf="isAutoSaving()" class="absolute top-0 right-0 -mt-1 -mr-1 w-3 h-3 bg-green-500 rounded-full animate-ping"></span>
|
||||
<span *ngIf="isAutoSaving()" class="absolute top-0 right-0 -mt-1 -mr-1 w-3 h-3 bg-green-500 rounded-full"></span>
|
||||
</div>
|
||||
<button class="px-3 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-slate-200 transition" (click)="toggleWordWrap()">Wrap</button>
|
||||
<button class="px-3 py-1 rounded-lg bg-white/10 hover:bg-white/20 text-slate-200 transition" (click)="undo()">Undo</button>
|
||||
<button class="px-3 py-1 rounded-lg bg-red-500/20 hover:bg-red-500/40 text-red-200 transition" (click)="close()">Close</button>
|
||||
</div>
|
||||
|
||||
<div class="markdown-editor__toolbar-right">
|
||||
<!-- Save Button -->
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn editor-btn--primary"
|
||||
[disabled]="isSaving()"
|
||||
(click)="save()"
|
||||
[attr.aria-label]="'Save (Ctrl+S)'">
|
||||
<svg *ngIf="!isSaving()" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="editor-btn__icon">
|
||||
<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 editor-btn__icon" 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="editor-btn__label hidden sm:inline">{{ isSaving() ? 'Saving...' : 'Save' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Wrap Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn editor-btn--neutral"
|
||||
(click)="toggleWordWrap()"
|
||||
[attr.aria-label]="'Toggle word wrap'">
|
||||
<svg class="editor-btn__icon" 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="editor-btn__label hidden md:inline">Wrap</span>
|
||||
</button>
|
||||
|
||||
<!-- Undo -->
|
||||
<button
|
||||
type="button"
|
||||
class="editor-btn editor-btn--ghost editor-btn--icon hidden sm:inline-flex"
|
||||
(click)="undo()"
|
||||
[attr.aria-label]="'Undo (Ctrl+Z)'">
|
||||
<svg class="editor-btn__icon" 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="editor-btn editor-btn--ghost editor-btn--icon hidden sm:inline-flex"
|
||||
(click)="redo()"
|
||||
[attr.aria-label]="'Redo (Ctrl+Y)'">
|
||||
<svg class="editor-btn__icon" 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="editor-btn editor-btn--neutral"
|
||||
(click)="close()"
|
||||
[attr.aria-label]="'Close editor (Esc)'">
|
||||
<svg class="editor-btn__icon" 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="editor-btn__label hidden sm:inline">Close</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Editor Container -->
|
||||
<div class="markdown-editor__container">
|
||||
@ -152,137 +93,41 @@ import { EditorHighlightService } from '../../shared/editor/editor-highlight.ser
|
||||
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;
|
||||
}
|
||||
|
||||
.editor-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.45rem;
|
||||
padding: 0.45rem 0.85rem;
|
||||
border-radius: 9999px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.01em;
|
||||
transition: transform 0.18s ease, box-shadow 0.18s ease, background 0.18s ease, border-color 0.18s ease, color 0.18s ease;
|
||||
border: 1px solid transparent;
|
||||
box-shadow: 0 6px 14px -12px color-mix(in oklab, var(--brand) 70%, transparent);
|
||||
}
|
||||
|
||||
.editor-btn:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px color-mix(in oklab, var(--brand) 35%, transparent);
|
||||
}
|
||||
|
||||
.editor-btn:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.editor-btn__icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.editor-btn__label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.editor-btn--icon {
|
||||
padding: 0.4rem;
|
||||
border-radius: 9999px;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.editor-btn--primary {
|
||||
color: #0f172a;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--brand) 55%, transparent) 0%,
|
||||
color-mix(in oklab, var(--brand-700) 45%, transparent) 100%);
|
||||
border-color: color-mix(in oklab, var(--brand-700) 40%, transparent);
|
||||
}
|
||||
|
||||
.editor-btn--primary:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 12px 22px -12px color-mix(in oklab, var(--brand) 80%, transparent);
|
||||
}
|
||||
|
||||
.editor-btn--neutral {
|
||||
color: inherit;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--surface-1, #e5e7eb) 65%, transparent) 0%,
|
||||
color-mix(in oklab, var(--surface-2, #d1d5db) 55%, transparent) 100%);
|
||||
border-color: color-mix(in oklab, var(--border, #cbd5e1) 55%, transparent);
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.editor-btn--neutral:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 8px 18px -14px color-mix(in oklab, var(--border, #cbd5e1) 65%, transparent);
|
||||
}
|
||||
|
||||
.editor-btn--ghost {
|
||||
color: var(--brand-500, var(--btn-bg));
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.editor-btn--ghost:hover:not(:disabled) {
|
||||
background: color-mix(in srgb, var(--brand) 10%, transparent);
|
||||
}
|
||||
|
||||
:host-context(.dark) .editor-btn--primary {
|
||||
color: #e2e8f0;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--brand-700) 55%, transparent) 0%,
|
||||
color-mix(in oklab, var(--brand-900, #1e1b4b) 55%, transparent) 100%);
|
||||
border-color: color-mix(in oklab, var(--brand-700) 45%, transparent);
|
||||
}
|
||||
|
||||
:host-context(.dark) .editor-btn--neutral {
|
||||
color: #e2e8f0;
|
||||
background: linear-gradient(135deg,
|
||||
color-mix(in oklab, var(--card, #111827) 65%, transparent) 0%,
|
||||
color-mix(in oklab, var(--surface2, #0b1220) 55%, transparent) 100%);
|
||||
border-color: color-mix(in oklab, var(--border, #334155) 60%, transparent);
|
||||
}
|
||||
|
||||
:host-context(.dark) .editor-btn--ghost {
|
||||
color: color-mix(in oklab, var(--brand-200, #c7d2fe) 75%, #93c5fd 25%);
|
||||
}
|
||||
|
||||
.editor-btn--ghost.editor-btn--icon:hover:not(:disabled) {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.markdown-editor__container {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
padding: 1rem; /* Add some space around the editor */
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.markdown-editor__host {
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 0 20px rgba(0,0,0,0.2);
|
||||
}
|
||||
|
||||
/* Custom Scrollbar for Webkit browsers */
|
||||
.markdown-editor__host::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
.markdown-editor__host::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.markdown-editor__host::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(100, 116, 139, 0.6);
|
||||
border-radius: 4px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
|
||||
.markdown-editor__host::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(100, 116, 139, 0.8);
|
||||
}
|
||||
|
||||
.markdown-editor__statusbar {
|
||||
@ -309,7 +154,7 @@ import { EditorHighlightService } from '../../shared/editor/editor-highlight.ser
|
||||
}
|
||||
|
||||
:host ::ng-deep .cm-content {
|
||||
padding: 1rem;
|
||||
padding: 1.5rem; /* p-6 */
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@ -355,6 +200,7 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
|
||||
content = signal<string>('');
|
||||
isDirty = signal<boolean>(false);
|
||||
isSaving = signal<boolean>(false);
|
||||
isAutoSaving = signal<boolean>(false);
|
||||
wordWrap = signal<boolean>(false);
|
||||
cursorLine = signal<number>(1);
|
||||
cursorCol = signal<number>(1);
|
||||
@ -362,6 +208,12 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
|
||||
isDarkTheme = signal<boolean>(false);
|
||||
|
||||
// Computed
|
||||
noteColor = computed(() => {
|
||||
const content = this.content();
|
||||
const match = content.match(/---\s*[\s\S]*?color:\s*['"]?(#[0-9a-fA-F]{6}|[a-zA-Z]+)['"]?[\s\S]*?---/);
|
||||
return match ? match[1] : null;
|
||||
});
|
||||
|
||||
fileName = computed(() => {
|
||||
const path = this.filePath();
|
||||
return path ? path.split('/').pop() || '' : '';
|
||||
@ -632,9 +484,11 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
// Autosave after 5 seconds of inactivity
|
||||
this.autosaveTimer = setTimeout(() => {
|
||||
this.autosaveTimer = setTimeout(async () => {
|
||||
if (this.isDirty() && !this.isSaving()) {
|
||||
this.save();
|
||||
this.isAutoSaving.set(true);
|
||||
await this.save();
|
||||
this.isAutoSaving.set(false);
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
@ -10,6 +10,8 @@ import { NoteContextMenuComponent } from '../../../components/note-context-menu/
|
||||
import { WarningPanelComponent } from '../../components/warning-panel/warning-panel.component';
|
||||
import { NoteContextMenuService } from '../../services/note-context-menu.service';
|
||||
import { UrlStateService } from '../../services/url-state.service';
|
||||
import { EditorStateService } from '../../../services/editor-state.service';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notes-list',
|
||||
@ -133,7 +135,7 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
|
||||
<ul class="notes-list">
|
||||
<li *ngFor="let n of filtered()"
|
||||
class="note-row cursor-pointer"
|
||||
class="note-row note-card group cursor-pointer relative"
|
||||
[class.active]="(selectedId() === n.id) || (pendingSelectId() === n.id)"
|
||||
[ngClass]="getListItemClasses()"
|
||||
[ngStyle]="getNoteGradientStyle(n)"
|
||||
@ -142,24 +144,50 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
tabindex="-1"
|
||||
(click)="openNote.emit(n.id)"
|
||||
(contextmenu)="openContextMenu($event, n)">
|
||||
|
||||
<!-- Action Buttons (hover reveal) -->
|
||||
<div class="note-card-actions absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10">
|
||||
<button
|
||||
type="button"
|
||||
class="action-btn edit inline-flex items-center justify-center w-7 h-7 rounded-lg bg-card/80 dark:bg-main/80 hover:bg-surface1 dark:hover:bg-surface2 transition-colors backdrop-blur-sm"
|
||||
title="Éditer la note"
|
||||
(click)="$event.stopPropagation(); editNote(n)">
|
||||
<svg class="w-4 h-4" 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="action-btn delete inline-flex items-center justify-center w-7 h-7 rounded-lg bg-card/80 dark:bg-main/80 hover:bg-red-100 dark:hover:bg-red-950 transition-colors backdrop-blur-sm"
|
||||
title="Supprimer la note"
|
||||
(click)="$event.stopPropagation(); openDeleteWarning(n)">
|
||||
<svg class="w-4 h-4 text-red-600 dark:text-red-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Compact View -->
|
||||
<div *ngIf="state.viewMode() === 'compact'" class="note-inner">
|
||||
<div *ngIf="state.viewMode() === 'compact'" class="note-inner flex items-center gap-2">
|
||||
<span class="note-color-dot flex-shrink-0" [style.backgroundColor]="getNoteColor(n)" aria-hidden="true"></span>
|
||||
<div class="title text-xs truncate">{{ n.title }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Comfortable View (default) -->
|
||||
<div *ngIf="state.viewMode() === 'comfortable'" class="note-inner">
|
||||
<div class="title text-sm truncate">{{ n.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ n.filePath }}</div>
|
||||
<div *ngIf="state.viewMode() === 'comfortable'" class="note-inner flex items-start gap-2">
|
||||
<span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColor(n)" aria-hidden="true"></span>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="title text-sm truncate">{{ n.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ n.filePath }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Detailed View -->
|
||||
<div *ngIf="state.viewMode() === 'detailed'" class="note-inner space-y-1.5">
|
||||
<div class="title text-sm truncate">{{ n.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ n.filePath }}</div>
|
||||
<div class="excerpt text-xs">
|
||||
<span *ngIf="n.frontmatter?.status">Status: {{ n.frontmatter.status }}</span>
|
||||
<span *ngIf="n.mtime" class="ml-2">{{ formatDate(n.mtime) }}</span>
|
||||
<div *ngIf="state.viewMode() === 'detailed'" class="note-inner flex items-start gap-2 space-y-0">
|
||||
<span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColor(n)" aria-hidden="true"></span>
|
||||
<div class="min-w-0 flex-1 space-y-1.5">
|
||||
<div class="title text-sm truncate">{{ n.title }}</div>
|
||||
<div class="meta text-xs truncate">{{ n.filePath }}</div>
|
||||
<div class="excerpt text-xs">
|
||||
<span *ngIf="n.frontmatter?.status">Status: {{ n.frontmatter.status }}</span>
|
||||
<span *ngIf="n.mtime" class="ml-2">{{ formatDate(n.mtime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@ -362,6 +390,73 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
color: var(--primary-foreground, #fafafa);
|
||||
border-color: color-mix(in oklab, var(--primary) 35%, white 65%);
|
||||
}
|
||||
|
||||
/* Enhanced note card with color indicator and action buttons */
|
||||
.note-card {
|
||||
/* Smooth hover lift effect */
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.note-card:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Gradient background positioning */
|
||||
.note-card {
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 120px;
|
||||
background-position: top center;
|
||||
}
|
||||
|
||||
/* Color dot indicator */
|
||||
.note-color-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px color-mix(in oklab, var(--text-main) 15%, transparent 85%);
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.note-row:hover .note-color-dot {
|
||||
box-shadow: 0 0 0 2px color-mix(in oklab, var(--text-main) 25%, transparent 75%);
|
||||
}
|
||||
|
||||
/* Action buttons container */
|
||||
.note-card-actions {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 0;
|
||||
}
|
||||
|
||||
.action-btn.edit {
|
||||
color: var(--primary, #3b82f6);
|
||||
}
|
||||
|
||||
.action-btn.delete {
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
:host-context(html.dark) .action-btn.delete {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Touch devices: keep actions visible (no hover) */
|
||||
@media (pointer: coarse) {
|
||||
.note-card .note-card-actions {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Ensure proper stacking context for action buttons */
|
||||
.note-row {
|
||||
overflow: visible;
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class NotesListComponent {
|
||||
@ -385,6 +480,8 @@ export class NotesListComponent {
|
||||
@ViewChild('listContainer') listContainer?: ElementRef<HTMLElement>;
|
||||
private urlState = inject(UrlStateService);
|
||||
private pendingSelectId = signal<string | null>(null);
|
||||
private editorState = inject(EditorStateService);
|
||||
private vault = inject(VaultService);
|
||||
|
||||
// Delete warning modal state
|
||||
deleteWarningOpen = signal<boolean>(false);
|
||||
@ -743,4 +840,25 @@ export class NotesListComponent {
|
||||
backgroundImage: `linear-gradient(to left, ${gradientColor} 0%, transparent 65%)`
|
||||
} as Record<string, string>;
|
||||
}
|
||||
|
||||
// Get note color for the indicator dot
|
||||
getNoteColor(note: Note): string {
|
||||
return note.frontmatter?.color || 'var(--text-muted)';
|
||||
}
|
||||
|
||||
// Edit note action
|
||||
editNote(note: Note): void {
|
||||
try {
|
||||
const full = this.vault.getNoteById(note.id);
|
||||
if (full?.filePath) {
|
||||
const content = (full as any).rawContent ?? full.content ?? '';
|
||||
this.editorState.enterEditMode(full.filePath, content);
|
||||
// also select the note in the list for visual sync
|
||||
this.openNote.emit(note.id);
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[NotesList] Failed to enter edit mode for note:', note?.id, e);
|
||||
this.openNote.emit(note.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
326
src/app/features/note-info/note-info-modal.component.ts
Normal file
326
src/app/features/note-info/note-info-modal.component.ts
Normal file
@ -0,0 +1,326 @@
|
||||
import { Component, EventEmitter, Input, Output, ChangeDetectionStrategy, HostListener, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import type { Note } from '../../../types';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { UrlStateService } from '../../services/url-state.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-note-info-modal',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<!-- Backdrop -->
|
||||
<div class="fixed inset-0 bg-black/60 backdrop-blur-sm z-[60] flex items-center justify-center p-4 animate-fadeIn" (click)="onBackdrop()">
|
||||
<!-- Panel -->
|
||||
<div class="bg-card dark:bg-main border border-border dark:border-gray-700 rounded-2xl shadow-2xl shadow-[var(--shadow-glow,0_0_24px_var(--primary))] w-full max-w-4xl p-5 md:p-6 relative scale-100 animate-[fadeIn_120ms_ease,scaleIn_140ms_ease] flex flex-col max-h-[min(92vh,880px)] overflow-hidden" (click)="$event.stopPropagation()">
|
||||
<!-- Decorative corner image/icon -->
|
||||
<div class="absolute -top-3 -right-3 opacity-20 select-none pointer-events-none">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" class="text-muted"><path d="M4 19.5A2.5 2.5 0 0 0 6.5 22h11A2.5 2.5 0 0 0 20 19.5v-15A2.5 2.5 0 0 0 17.5 2h-11A2.5 2.5 0 0 0 4 4.5Z"/><path d="M8 7h8"/><path d="M8 11h8"/><path d="M8 15h6"/></svg>
|
||||
</div>
|
||||
|
||||
<!-- Close button -->
|
||||
<button type="button" class="absolute top-4 right-4 w-8 h-8 rounded-full hover:bg-surface1 dark:hover:bg-card transition-colors flex items-center justify-center text-muted hover:text-main" (click)="close.emit()" aria-label="Fermer">
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M18 6 6 18" /><path d="m6 6 12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div class="flex-1 overflow-y-auto pr-1">
|
||||
<!-- Header -->
|
||||
<div class="flex items-start gap-3 mb-6">
|
||||
<div class="flex-shrink-0 w-10 h-10 rounded-xl bg-surface1 dark:bg-card flex items-center justify-center text-lg">
|
||||
<span>🧾</span>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h2 class="text-xl md:text-2xl font-bold truncate">{{ note?.title }}</h2>
|
||||
<div class="text-xs md:text-sm text-muted mt-1 break-all">{{ note?.filePath }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 pb-4">
|
||||
<!-- A. Informations de base -->
|
||||
<section class="rounded-2xl border border-border/80 dark:border-gray-700 bg-surface1/60 dark:bg-card/60 shadow-md p-5 md:p-6">
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2"><span>🧾</span> Informations de base</h3>
|
||||
<div class="text-sm space-y-1">
|
||||
<div><span class="text-muted">📄 Nom du fichier:</span> {{ base.fileName }}</div>
|
||||
<div><span class="text-muted">📁 Dossier parent:</span> {{ base.parent }}</div>
|
||||
<div><span class="text-muted">🧩 Type:</span> {{ base.type }}</div>
|
||||
<div><span class="text-muted">💾 Taille:</span> {{ tech.sizeExact }}</div>
|
||||
<div><span class="text-muted">🔡 Encodage:</span> UTF-8</div>
|
||||
<div><span class="text-muted">🧠 MIME:</span> {{ tech.mime }}</div>
|
||||
<div><span class="text-muted">🧭 Emplacement serveur:</span> /vault/{{ note?.filePath }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- B. Métadonnées (Front-matter) -->
|
||||
<section class="rounded-2xl border border-border/80 dark:border-gray-700 bg-surface1/60 dark:bg-card/60 shadow-md p-5 md:p-6">
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2"><span>🧠</span> Métadonnées</h3>
|
||||
<div class="text-sm space-y-1">
|
||||
<div><span class="text-muted">👤 Auteur:</span> {{ fm.auteur || note?.author || '—' }}</div>
|
||||
<div><span class="text-muted">🕒 Création:</span> {{ note?.createdAt || '—' }}</div>
|
||||
<div><span class="text-muted">🕒 Modification:</span> {{ note?.updatedAt || (note?.mtime ? (note?.mtime | date:'medium') : '—') }}</div>
|
||||
<div><span class="text-muted">📜 Tags:</span> {{ (fm.tags || note?.tags || []).join(', ') || '—' }}</div>
|
||||
<div><span class="text-muted">🗂️ Catégorie:</span> {{ fm['catégorie'] || fm.categorie || '—' }}</div>
|
||||
<div><span class="text-muted">🪪 Alias:</span> {{ (fm.aliases || []).join(', ') || '—' }}</div>
|
||||
<div><span class="text-muted">🔖 Statut:</span> {{ fm.status || '—' }}</div>
|
||||
<div><span class="text-muted">⭐ Favoris:</span> {{ fm.favoris ? 'true' : 'false' }}</div>
|
||||
<div><span class="text-muted">🔒 Lecture seule:</span> {{ fm.readOnly ? 'true' : 'false' }}</div>
|
||||
<div *ngIf="hash" class="mt-2"><span class="text-muted">🔐 Hash (SHA-1):</span> {{ hash }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- C. Statistiques de contenu -->
|
||||
<section class="rounded-2xl border border-border/80 dark:border-gray-700 bg-surface1/60 dark:bg-card/60 shadow-md p-5 md:p-6">
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2"><span>📊</span> Statistiques de contenu</h3>
|
||||
<div class="text-sm grid grid-cols-2 gap-x-3 gap-y-1">
|
||||
<div><span class="text-muted">🔤 Caractères:</span> {{ stats.chars }}</div>
|
||||
<div><span class="text-muted">📝 Mots:</span> {{ stats.words }}</div>
|
||||
<div><span class="text-muted">✉️ Phrases:</span> {{ stats.sentences }}</div>
|
||||
<div><span class="text-muted">📑 Paragraphes:</span> {{ stats.paragraphs }}</div>
|
||||
<div><span class="text-muted">📏 Longueur moyenne:</span> {{ stats.avgSentenceLen }}</div>
|
||||
<div><span class="text-muted">#️⃣ Titres H1–H6:</span> {{ stats.headings }}</div>
|
||||
<div><span class="text-muted">✅ Listes:</span> {{ stats.lists }}</div>
|
||||
<div><span class="text-muted">🧩 Code:</span> {{ stats.codeBlocks }}</div>
|
||||
<div><span class="text-muted">🖼️ Images:</span> {{ stats.images }}</div>
|
||||
<div><span class="text-muted">📊 Tableaux:</span> {{ stats.tables }}</div>
|
||||
<div><span class="text-muted">💬 Citations:</span> {{ stats.quotes }}</div>
|
||||
<div><span class="text-muted">🧠 Lisibilité (Flesch approx):</span> {{ stats.readability }}</div>
|
||||
<div><span class="text-muted">🌍 Langue (naive):</span> {{ stats.lang }}</div>
|
||||
<div><span class="text-muted">📌 Qualité contenu:</span> {{ stats.sizeClass }}</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- D. Liens -->
|
||||
<section class="rounded-2xl border border-border/80 dark:border-gray-700 bg-surface1/60 dark:bg-card/60 shadow-md p-5 md:p-6">
|
||||
<h3 class="text-sm font-semibold mb-3 flex items-center gap-2"><span>🔗</span> Liens</h3>
|
||||
<div class="text-sm space-y-2">
|
||||
<div class="flex items-center gap-3 flex-wrap">
|
||||
<span class="text-muted">Internes ({{ links.internalCount }}):</span>
|
||||
<ng-container *ngFor="let l of links.internalList; let i = index">
|
||||
<button type="button" class="px-2 py-0.5 rounded-full text-xs bg-surface1 dark:bg-card hover:bg-surface2 transition border border-border/70" (click)="openInternalLink(l)">[[{{ l }}]]</button>
|
||||
</ng-container>
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<div class="text-muted">Externes ({{ links.externalCount }}):</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<a *ngFor="let u of links.externalList" class="inline-flex items-center gap-1 text-xs px-2 py-1 rounded border border-border/70 hover:bg-surface1 dark:hover:bg-card transition" [href]="u" target="_blank" rel="noopener noreferrer">🔗 {{ toHost(u) }}</a>
|
||||
</div>
|
||||
</div>
|
||||
<div><span class="text-muted">Cassés (internes):</span> {{ links.brokenInternal }}</div>
|
||||
<div *ngIf="links.uniqueExternalDomains.length"><span class="text-muted">Domaines:</span> {{ links.uniqueExternalDomains.join(', ') }}</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer buttons -->
|
||||
<div class="mt-6 flex flex-wrap gap-2 justify-between items-center">
|
||||
<div class="flex gap-2">
|
||||
<button type="button" class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-medium transition-colors" (click)="openFile()">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m18 13-5 5-5-5"/><path d="m18 6-5 5-5-5"/></svg>
|
||||
Ouvrir le fichier
|
||||
</button>
|
||||
<button type="button" class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-surface1 dark:bg-card hover:bg-surface2 text-sm transition-colors" (click)="exportMetadata()">📤 Exporter les métadonnées</button>
|
||||
<button type="button" class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-surface1 dark:bg-card hover:bg-surface2 text-sm transition-colors" (click)="copyAll()">📄 Copier toutes les infos</button>
|
||||
</div>
|
||||
<div>
|
||||
<button type="button" class="inline-flex items-center gap-1.5 px-3 py-2 rounded bg-primary hover:bg-primary/90 text-primary-foreground text-sm font-medium transition-colors" (click)="close.emit()">
|
||||
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
})
|
||||
export class NoteInfoModalComponent {
|
||||
@Input() note: Note | null = null;
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
private vault = inject(VaultService);
|
||||
private urlState = inject(UrlStateService);
|
||||
|
||||
hash: string | null = null;
|
||||
constructor() {
|
||||
// Precompute hash asynchronously when opened
|
||||
setTimeout(() => this.computeHash().catch(() => {}), 0);
|
||||
}
|
||||
|
||||
get fm() { return this.note?.frontmatter || ({} as any); }
|
||||
|
||||
get base() {
|
||||
const fileName = this.note?.fileName || '';
|
||||
const parent = (this.note?.filePath || '').split('/').slice(0, -1).join('/') || '/';
|
||||
const pathLower = (this.note?.filePath || '').toLowerCase();
|
||||
const type = pathLower.endsWith('.excalidraw.md') ? 'Excalidraw' : (pathLower.endsWith('.json') ? 'JSON' : 'Markdown');
|
||||
const sizeKb = Math.max(1, Math.round(((this.note?.content || '').length) / 1024));
|
||||
return { fileName, parent, type, sizeKb };
|
||||
}
|
||||
|
||||
get tech() {
|
||||
const content = this.note?.content || '';
|
||||
const bytes = new Blob([content]).size;
|
||||
const sizeExact = this.formatBytes(bytes);
|
||||
const mime = this.guessMime(this.note?.filePath || '');
|
||||
return { sizeExact, mime };
|
||||
}
|
||||
|
||||
get stats() {
|
||||
const content = this.note?.content || '';
|
||||
const chars = content.length;
|
||||
const words = (content.trim().match(/\S+/g) || []).length;
|
||||
const sentences = (content.match(/[\.!?]+\s|$/g) || []).length;
|
||||
const paragraphs = content.split(/\n\s*\n/).filter(Boolean).length;
|
||||
const headings = (content.match(/^#{1,6}\s+/gm) || []).length;
|
||||
const lists = (content.match(/^(?:\s*[-*+]\s+|\s*\d+\.\s+)/gm) || []).length;
|
||||
const codeBlocks = Math.floor(((content.match(/```/g) || []).length) / 2);
|
||||
const images = (content.match(/!\[[^\]]*\]\([^\)]+\)/g) || []).length;
|
||||
const tables = (content.match(/^\s*\|.*\|\s*$/gm) || []).length;
|
||||
const quotes = (content.match(/^>\s+/gm) || []).length;
|
||||
const avgSentenceLen = sentences ? Math.round((words / sentences) * 10) / 10 : 0;
|
||||
const syllables = this.estimateSyllables(content);
|
||||
const flesch = this.fleschReadingEase(words, sentences, syllables);
|
||||
const readability = isFinite(flesch) ? Math.round(flesch) : 0;
|
||||
const lang = this.detectLanguage(content);
|
||||
const sizeClass = chars < 800 ? 'Note courte' : (chars < 2500 ? 'Note moyenne' : 'Note longue');
|
||||
return { chars, words, sentences, paragraphs, headings, lists, codeBlocks, images, tables, quotes, avgSentenceLen, readability, lang, sizeClass };
|
||||
}
|
||||
|
||||
get links() {
|
||||
const content = this.note?.content || '';
|
||||
const internal = Array.from(content.matchAll(/\[\[([^\]]+)\]\]/g)).map(m => m[1]);
|
||||
const external = Array.from(content.matchAll(/https?:\/\/[^\s)]+/g)).map(m => m[0]);
|
||||
|
||||
const allNotes = this.vault.allNotes() || [];
|
||||
const paths = new Set(allNotes.map(n => (n.filePath || '').toLowerCase()));
|
||||
|
||||
const brokenInternal = internal.filter(link => {
|
||||
// Try to resolve to a markdown path: allow [[name]] or [[path/name]] (without .md)
|
||||
const normalized = (link || '').replace(/\\/g, '/').replace(/\s/g, '%20');
|
||||
// Check with and without .md
|
||||
const withMd = (normalized.endsWith('.md') ? normalized : normalized + '.md').toLowerCase();
|
||||
return !paths.has(withMd);
|
||||
}).length;
|
||||
|
||||
const uniqueExternalDomains = Array.from(new Set(external.map(url => {
|
||||
try { return new URL(url).host; } catch { return null; }
|
||||
}).filter(Boolean) as string[]));
|
||||
|
||||
return { internalCount: internal.length, externalCount: external.length, brokenInternal, uniqueExternalDomains, internalList: internal, externalList: external };
|
||||
}
|
||||
|
||||
@HostListener('document:keydown.escape') onEsc() { this.close.emit(); }
|
||||
onBackdrop() { this.close.emit(); }
|
||||
|
||||
openFile() {
|
||||
const path = this.note?.filePath;
|
||||
if (!path) return;
|
||||
try { this.urlState.openNote(path); } catch {}
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
exportMetadata() {
|
||||
const payload = {
|
||||
title: this.note?.title,
|
||||
path: this.note?.filePath,
|
||||
base: this.base,
|
||||
frontmatter: this.fm,
|
||||
stats: this.stats,
|
||||
links: this.links,
|
||||
tech: this.tech,
|
||||
hash: this.hash
|
||||
};
|
||||
const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `${(this.note?.fileName || 'note')}.info.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async copyAll() {
|
||||
try {
|
||||
const text = [
|
||||
`Titre: ${this.note?.title}`,
|
||||
`Chemin: ${this.note?.filePath}`,
|
||||
`Nom fichier: ${this.base.fileName}`,
|
||||
`Dossier: ${this.base.parent}`,
|
||||
`Type: ${this.base.type}`,
|
||||
`Taille: ${this.tech.sizeExact}`,
|
||||
`MIME: ${this.tech.mime}`,
|
||||
`Hash: ${this.hash ?? ''}`,
|
||||
`Tags: ${(this.fm.tags || this.note?.tags || []).join(', ')}`,
|
||||
].join('\n');
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch {}
|
||||
}
|
||||
|
||||
openInternalLink(label: string) {
|
||||
const normalized = (label || '').replace(/\\/g, '/');
|
||||
const target = normalized.endsWith('.md') ? normalized : `${normalized}.md`;
|
||||
const all = this.vault.allNotes() || [];
|
||||
const found = all.find(n => (n.filePath || '').toLowerCase() === target.toLowerCase());
|
||||
if (found?.filePath) {
|
||||
try { this.urlState.openNote(found.filePath); } catch {}
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
|
||||
toHost(url: string): string {
|
||||
try { return new URL(url).host; } catch { return url; }
|
||||
}
|
||||
|
||||
private guessMime(p: string): string {
|
||||
const low = p.toLowerCase();
|
||||
if (low.endsWith('.md')) return 'text/markdown';
|
||||
if (low.endsWith('.json')) return 'application/json';
|
||||
if (low.endsWith('.excalidraw') || low.endsWith('.excalidraw.md')) return 'application/vnd.excalidraw+json';
|
||||
return 'text/plain';
|
||||
}
|
||||
|
||||
private formatBytes(bytes: number): string {
|
||||
if (bytes === 0) return '0 B';
|
||||
const k = 1024;
|
||||
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return `${(bytes / Math.pow(k, i)).toFixed(2)} ${sizes[i]}`;
|
||||
}
|
||||
|
||||
private async computeHash() {
|
||||
try {
|
||||
const content = this.note?.content || '';
|
||||
const enc = new TextEncoder();
|
||||
const data = enc.encode(content);
|
||||
const digest = await crypto.subtle.digest('SHA-1', data);
|
||||
this.hash = Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
} catch {
|
||||
this.hash = null;
|
||||
}
|
||||
}
|
||||
|
||||
private estimateSyllables(text: string): number {
|
||||
const vowels = text.match(/[aeiouyàâäéèêëîïôöùûü]/gi) || [];
|
||||
return Math.max(1, Math.round(vowels.length / 3));
|
||||
}
|
||||
|
||||
private fleschReadingEase(words: number, sentences: number, syllables: number): number {
|
||||
if (!words || !sentences || !syllables) return 0;
|
||||
// Flesch Reading Ease (approx)
|
||||
const ASL = words / sentences; // average sentence length
|
||||
const ASW = syllables / words; // average syllables per word
|
||||
return 206.835 - (1.015 * ASL) - (84.6 * ASW);
|
||||
}
|
||||
|
||||
private detectLanguage(text: string): string {
|
||||
const hasFrenchChars = /[àâäçéèêëîïôöùûüÿœ]/i.test(text);
|
||||
const hasEnglishWords = /\b(the|and|of|to|in|is|that|it|for|on)\b/i.test(text);
|
||||
if (hasFrenchChars) return 'fr (naif)';
|
||||
if (hasEnglishWords) return 'en (naive)';
|
||||
return '—';
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,196 @@
|
||||
:host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
font-family: var(--font-ui);
|
||||
}
|
||||
|
||||
.move-menu-trigger {
|
||||
padding: 0.3rem 0.5rem;
|
||||
border-radius: 0.75rem;
|
||||
border: 1px solid color-mix(in srgb, var(--border) 65%, transparent);
|
||||
background: color-mix(in srgb, var(--surface-1) 60%, transparent);
|
||||
color: var(--text-muted);
|
||||
transition: background var(--transition-base), border-color var(--transition-base), color var(--transition-base), box-shadow var(--transition-base);
|
||||
}
|
||||
|
||||
.move-menu-trigger:hover,
|
||||
.move-menu-trigger:focus-visible {
|
||||
color: var(--text-main);
|
||||
border-color: color-mix(in srgb, var(--border) 90%, transparent);
|
||||
background: color-mix(in srgb, var(--surface-2) 80%, transparent);
|
||||
box-shadow: 0 6px 18px rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.move-menu-trigger:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--primary) 30%, transparent);
|
||||
}
|
||||
|
||||
.move-menu-panel {
|
||||
/* Solid, non-transparent panel aligned with site menus */
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 1rem;
|
||||
box-shadow:
|
||||
0 16px 42px var(--shadow-color),
|
||||
0 4px 10px color-mix(in srgb, var(--shadow-color) 55%, transparent);
|
||||
overflow: hidden;
|
||||
backdrop-filter: none;
|
||||
opacity: 1;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.move-menu-header {
|
||||
padding: 1rem 1.1rem 0.7rem;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
|
||||
}
|
||||
|
||||
.move-menu-title {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.18em;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.move-menu-link {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
color: var(--link);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
.move-menu-link:hover {
|
||||
color: var(--link-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.move-menu-body {
|
||||
padding: 0.9rem 1.1rem 1.1rem;
|
||||
}
|
||||
|
||||
.move-menu-search {
|
||||
padding: 0.6rem 0.75rem;
|
||||
border-radius: 0.9rem;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.move-menu-input {
|
||||
background: transparent;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.move-menu-input::placeholder {
|
||||
color: color-mix(in srgb, var(--text-muted) 70%, transparent);
|
||||
}
|
||||
|
||||
.move-menu-clear {
|
||||
padding: 0.2rem 0.45rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: color-mix(in srgb, var(--surface-1) 80%, transparent);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: background var(--transition-base), color var(--transition-base);
|
||||
}
|
||||
|
||||
.move-menu-clear:hover {
|
||||
background: var(--surface-2);
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.move-menu-empty {
|
||||
font-size: 0.75rem;
|
||||
color: color-mix(in srgb, var(--text-muted) 85%, transparent);
|
||||
padding: 0.25rem 0.1rem;
|
||||
}
|
||||
|
||||
.move-menu-section-title {
|
||||
font-size: 0.65rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: color-mix(in srgb, var(--text-muted) 75%, transparent);
|
||||
}
|
||||
|
||||
.move-menu-breadcrumb {
|
||||
font-size: 0.78rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.move-menu-crumb {
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
padding: 0.15rem 0.25rem;
|
||||
border-radius: 0.45rem;
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-base), background var(--transition-base);
|
||||
}
|
||||
|
||||
.move-menu-crumb:hover {
|
||||
color: var(--text-main);
|
||||
background: var(--surface-1);
|
||||
}
|
||||
|
||||
.move-menu-crumb-separator {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.move-menu-list,
|
||||
.move-menu-search-results {
|
||||
max-height: 14rem;
|
||||
border-radius: 0.9rem;
|
||||
overflow: auto;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.move-folder-button,
|
||||
.move-search-result {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border: none;
|
||||
background: none;
|
||||
border-radius: 0.9rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
color: var(--text-main);
|
||||
transition: background var(--transition-base), color var(--transition-base), transform var(--transition-base);
|
||||
}
|
||||
|
||||
.move-folder-button:hover,
|
||||
.move-search-result:hover {
|
||||
background: var(--surface-2);
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.move-folder-expand {
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
.move-folder-expand:hover {
|
||||
color: var(--text-main);
|
||||
}
|
||||
|
||||
.move-menu-footer {
|
||||
margin-top: 1rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
|
||||
|
||||
@media (max-width: 540px) {
|
||||
.move-menu-panel {
|
||||
width: calc(100vw - 2rem);
|
||||
left: 1rem !important;
|
||||
right: 1rem;
|
||||
}
|
||||
}
|
||||
@ -2,7 +2,7 @@
|
||||
<button
|
||||
#trigger
|
||||
type="button"
|
||||
class="inline-flex items-center gap-1 text-sm text-muted hover:text-main transition-color"
|
||||
class="move-menu-trigger inline-flex items-center gap-1 text-sm text-muted hover:text-main transition-color"
|
||||
(click)="toggleMenu()"
|
||||
>
|
||||
<span class="truncate" [title]="currentPath">
|
||||
@ -12,15 +12,15 @@
|
||||
</button>
|
||||
|
||||
@if (showMenu()) {
|
||||
<div class="fixed z-50 w-80 rounded-xl border border-border bg-card shadow-lg"
|
||||
<div class="fixed z-[1000] w-80 move-menu-panel"
|
||||
[style.top.px]="menuTop()" [style.left.px]="menuLeft()">
|
||||
<div class="flex items-center justify-between px-3 py-2 border-b border-border/60">
|
||||
<div class="text-xs uppercase tracking-wide text-muted">Move note to folder</div>
|
||||
<button class="text-xs text-accent hover:underline" (click)="openFolderInSidebar()">Open sidebar</button>
|
||||
<div class="move-menu-header flex items-center justify-between">
|
||||
<div class="move-menu-title">Move note to folder</div>
|
||||
<button class="move-menu-link" (click)="openFolderInSidebar()">Open sidebar</button>
|
||||
</div>
|
||||
|
||||
<div class="p-3 space-y-3">
|
||||
<div class="flex items-center gap-2 bg-surface1 rounded-lg px-2 py-1.5">
|
||||
<div class="move-menu-body space-y-3">
|
||||
<div class="move-menu-search flex items-center gap-2">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<path d="m21 21-4.35-4.35"></path>
|
||||
@ -28,38 +28,38 @@
|
||||
<input
|
||||
#searchField
|
||||
type="text"
|
||||
class="bg-transparent text-sm flex-1 outline-none"
|
||||
class="move-menu-input text-sm flex-1"
|
||||
placeholder="Search folders"
|
||||
[value]="searchQuery()"
|
||||
(input)="onSearchChange($any($event.target).value)"
|
||||
/>
|
||||
@if (searchQuery()) {
|
||||
<button type="button" class="text-muted hover:text-main" (click)="clearSearch()">✕</button>
|
||||
<button type="button" class="move-menu-clear" (click)="clearSearch()">✕</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (isSearching() && searchResults().length === 0) {
|
||||
<div class="text-xs text-muted px-1">No matching folders</div>
|
||||
<div class="move-menu-empty">No matching folders</div>
|
||||
}
|
||||
|
||||
@if (!isSearching()) {
|
||||
<div class="text-xs text-muted px-1">All my folders</div>
|
||||
<div class="flex items-center gap-1 text-xs text-muted/80">
|
||||
<button class="hover:text-main" (click)="goToRoot()">All my folders</button>
|
||||
<div class="move-menu-section-title">All my folders</div>
|
||||
<div class="move-menu-breadcrumb flex items-center gap-1">
|
||||
<button class="move-menu-crumb" (click)="goToRoot()">All my folders</button>
|
||||
@for (crumb of breadcrumb(); track crumb.path; let i = $index) {
|
||||
<span>›</span>
|
||||
<button class="hover:text-main" (click)="navigateTo(i + 1)">{{ crumb.name }}</button>
|
||||
<span class="move-menu-crumb-separator">›</span>
|
||||
<button class="move-menu-crumb" (click)="navigateTo(i + 1)">{{ crumb.name }}</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="max-h-64 overflow-y-auto space-y-1">
|
||||
<div class="move-menu-list">
|
||||
@if (loading()) {
|
||||
<div class="text-xs text-muted px-2 py-3">Loading folders…</div>
|
||||
<div class="move-menu-empty">Loading folders…</div>
|
||||
} @else {
|
||||
@for (folder of currentLevelFolders(); track folder.path) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex items-center justify-between px-2 py-1.5 rounded-lg text-sm hover:bg-surface1"
|
||||
class="move-folder-button"
|
||||
(click)="onFolderSelected(folder, $event)"
|
||||
>
|
||||
<span class="flex items-center gap-2">
|
||||
@ -69,7 +69,7 @@
|
||||
<span class="truncate">{{ folder.name }}</span>
|
||||
</span>
|
||||
@if (folder.children?.length) {
|
||||
<button type="button" class="text-xs text-muted hover:text-main" (click)="navigateInto(folder, $event)">
|
||||
<button type="button" class="move-folder-expand" (click)="navigateInto(folder, $event)">
|
||||
▶ {{ folder.children.length }} subfolder{{ folder.children.length > 1 ? 's' : '' }}
|
||||
</button>
|
||||
}
|
||||
@ -80,11 +80,11 @@
|
||||
}
|
||||
|
||||
@if (isSearching()) {
|
||||
<div class="max-h-64 overflow-y-auto space-y-1">
|
||||
<div class="move-menu-search-results">
|
||||
@for (result of searchResults(); track result.path) {
|
||||
<button
|
||||
type="button"
|
||||
class="w-full flex flex-col items-start px-2 py-1.5 rounded-lg text-sm hover:bg-surface1 text-left"
|
||||
class="move-search-result"
|
||||
(click)="onSearchResultSelected(result)"
|
||||
>
|
||||
<span class="font-medium">{{ result.name }}</span>
|
||||
@ -94,9 +94,9 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="flex items-center justify-between pt-2 border-t border-border/60">
|
||||
<button class="text-xs text-muted hover:text-main" (click)="moveToCurrentFolder()">Move here</button>
|
||||
<button class="text-xs text-muted hover:text-main" (click)="closeMenu()">Cancel</button>
|
||||
<div class="move-menu-footer flex items-center justify-between">
|
||||
<button class="btn btn-standard-primary btn-standard-sm" (click)="moveToCurrentFolder()">Move here</button>
|
||||
<button class="btn btn-standard-sm" (click)="closeMenu()">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -33,7 +33,8 @@ interface FlattenedFolder {
|
||||
selector: 'app-move-note-to-folder',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './move-note-to-folder.component.html'
|
||||
templateUrl: './move-note-to-folder.component.html',
|
||||
styleUrls: ['./move-note-to-folder.component.css']
|
||||
})
|
||||
export class MoveNoteToFolderComponent {
|
||||
@Input()
|
||||
@ -322,11 +323,13 @@ export class MoveNoteToFolderComponent {
|
||||
const t = this.trigger?.nativeElement;
|
||||
if (!t) return;
|
||||
const rect = t.getBoundingClientRect();
|
||||
// Place menu just under the trigger, with small margin
|
||||
this.menuTop.set(Math.round(rect.bottom + window.scrollY + 8));
|
||||
// Try to align left edges; ensure not off-screen (simple clamp)
|
||||
const left = Math.round(rect.left + window.scrollX);
|
||||
this.menuLeft.set(Math.max(8, left));
|
||||
// Place menu just under the trigger (fixed panel -> viewport coords)
|
||||
this.menuTop.set(Math.round(rect.bottom + 8));
|
||||
// Align left edge; clamp to viewport
|
||||
const vw = Math.max(document.documentElement.clientWidth || 0, window.innerWidth || 0);
|
||||
const desiredLeft = Math.round(rect.left);
|
||||
const clampedLeft = Math.min(Math.max(8, desiredLeft), vw - 8 - 320); // ~w-80
|
||||
this.menuLeft.set(clampedLeft);
|
||||
}
|
||||
|
||||
private attachGlobalListeners(): void {
|
||||
|
||||
@ -2,6 +2,28 @@
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Full-width top gradient background layer */
|
||||
.note-header::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 120px; /* covers the whole top area visually */
|
||||
background-image: var(--note-accent-gradient, none);
|
||||
background-repeat: no-repeat;
|
||||
background-size: 100% 100%;
|
||||
pointer-events: none;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Ensure content renders above the gradient layer */
|
||||
.note-header > * {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.path-wrap {
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { Component, EventEmitter, HostListener, Input, Output, inject, effect } from '@angular/core';
|
||||
import { Component, EventEmitter, HostListener, Input, Output, inject, effect, AfterViewInit, ViewChild, ElementRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { UiModeService } from '../../shared/services/ui-mode.service';
|
||||
import { ResponsiveService } from '../../shared/services/responsive.service';
|
||||
@ -20,13 +20,17 @@ import { TestsPanelComponent } from '../../features/tests/tests-panel.component'
|
||||
import { ParametersPage } from '../../features/parameters/parameters.page';
|
||||
import { AboutPanelComponent } from '../../features/about/about-panel.component';
|
||||
import { UrlStateService } from '../../services/url-state.service';
|
||||
import { NoteInfoModalComponent } from '../../features/note-info/note-info-modal.component';
|
||||
import { NoteInfoModalService } from '../../services/note-info-modal.service';
|
||||
import { InPageSearchService } from '../../shared/search/in-page-search.service';
|
||||
import { InPageSearchOverlayComponent } from '../../shared/search/in-page-search-overlay.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell-nimbus-layout',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, ParametersPage, AboutPanelComponent],
|
||||
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent, TestsPanelComponent, ParametersPage, AboutPanelComponent, NoteInfoModalComponent, InPageSearchOverlayComponent],
|
||||
template: `
|
||||
<div class="relative h-screen flex flex-col bg-card dark:bg-main text-main dark:text-gray-100">
|
||||
<div class="relative h-screen flex flex-col bg-card dark:bg-main text-main dark:text-gray-100" [style.--sidebar-width.px]="isSidebarOpen ? leftSidebarWidth : 64">
|
||||
|
||||
<!-- Fullscreen overlay for note -->
|
||||
<div *ngIf="noteFullScreen && selectedNote && activeView !== 'markdown-playground'" class="absolute inset-0 z-50 flex flex-col bg-card dark:bg-main">
|
||||
@ -42,10 +46,12 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
(fullScreenRequested)="toggleNoteFullScreen()"
|
||||
(legacyRequested)="ui.toggleUIMode()"
|
||||
(parametersRequested)="onParametersOpen()"
|
||||
(searchRequested)="openInPageSearch()"
|
||||
(showToc)="toggleOutlineRequest.emit()"
|
||||
(directoryClicked)="onFolderSelected($event)"
|
||||
[tocOpen]="isOutlineOpen"
|
||||
></app-note-viewer>
|
||||
<app-in-page-search-overlay></app-in-page-search-overlay>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -148,26 +154,6 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Tests section (visible when sidebar is open) -->
|
||||
<div class="rounded-2xl border border-border/70 bg-card/85 px-3 py-3 shadow-subtle">
|
||||
<div class="grid grid-cols-3 gap-2 sm:grid-cols-5">
|
||||
<div class="text-xs font-semibold uppercase tracking-wide text-text-muted mb-1">Tests</div>
|
||||
<div class="space-y-1">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main"
|
||||
(click)="onMarkdownPlaygroundSelected()">
|
||||
📝 Markdown Playground
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left block text-sm px-2 py-1.5 rounded hover:bg-surface1 dark:hover:bg-card text-main dark:text-main"
|
||||
(click)="onTestsPanelSelected()">
|
||||
🔬 API Tests Panel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Left Resizer -->
|
||||
@ -197,7 +183,7 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
|
||||
<!-- Note View + ToC -->
|
||||
<section class="flex-1 relative min-w-0 flex">
|
||||
<div class="note-content-area flex-1 overflow-y-auto px-4 py-4 lg:px-8" appScrollableOverlay>
|
||||
<div #pageRoot class="note-content-area flex-1 overflow-y-auto px-4 py-4 lg:px-8" appScrollableOverlay>
|
||||
<app-markdown-playground *ngIf="activeView === 'markdown-playground'"></app-markdown-playground>
|
||||
<app-parameters *ngIf="activeView === 'parameters'"></app-parameters>
|
||||
<app-tests-panel *ngIf="activeView === 'tests-panel'"></app-tests-panel>
|
||||
@ -208,14 +194,14 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
(noteLinkClicked)="noteSelected.emit($event)"
|
||||
(tagClicked)="onTagSelected($event)"
|
||||
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
|
||||
[fullScreenActive]="noteFullScreen"
|
||||
(searchRequested)="openInPageSearch()"
|
||||
(fullScreenRequested)="toggleNoteFullScreen()"
|
||||
(legacyRequested)="ui.toggleUIMode()"
|
||||
(parametersRequested)="onParametersOpen()"
|
||||
(showToc)="toggleOutlineRequest.emit()"
|
||||
(directoryClicked)="onFolderSelected($event)"
|
||||
[tocOpen]="isOutlineOpen"
|
||||
></app-note-viewer>
|
||||
<app-in-page-search-overlay></app-in-page-search-overlay>
|
||||
</div>
|
||||
<aside class="hidden xl:block border-l border-border dark:border-gray-800 overflow-y-auto transition-all duration-300 ease-in-out" appScrollableOverlay [style.width.px]="isOutlineOpen ? rightSidebarWidth : 0" [class.opacity-0]="!isOutlineOpen" [class.pointer-events-none]="!isOutlineOpen">
|
||||
<div class="p-3 space-y-3">
|
||||
@ -314,7 +300,7 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
} @else {
|
||||
<div class="note-content-area h-full px-3 py-3 animate-fadeIn" style="overflow-y: auto !important;" appScrollableOverlay>
|
||||
@if (selectedNote) {
|
||||
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="onTagSelected($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" (fullScreenRequested)="toggleNoteFullScreen()" (parametersRequested)="onParametersOpen()"></app-note-viewer>
|
||||
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="onTagSelected($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" (searchRequested)="openInPageSearch()" (fullScreenRequested)="toggleNoteFullScreen()" (parametersRequested)="onParametersOpen()"></app-note-viewer>
|
||||
} @else {
|
||||
<div class="mt-10 text-center text-sm text-muted dark:text-muted">
|
||||
<div class="text-4xl mb-3">📄</div>
|
||||
@ -332,15 +318,20 @@ import { UrlStateService } from '../../services/url-state.service';
|
||||
|
||||
<!-- About Panel Overlay -->
|
||||
<app-about-panel *ngIf="showAboutPanel" (close)="showAboutPanel = false"></app-about-panel>
|
||||
|
||||
<!-- Note Info Modal -->
|
||||
<app-note-info-modal *ngIf="noteInfo.visible()" [note]="noteInfo.note()!" (close)="noteInfo.close()"></app-note-info-modal>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class AppShellNimbusLayoutComponent {
|
||||
export class AppShellNimbusLayoutComponent implements AfterViewInit {
|
||||
ui = inject(UiModeService);
|
||||
vault = inject(VaultService);
|
||||
responsive = inject(ResponsiveService);
|
||||
mobileNav = inject(MobileNavService);
|
||||
urlState = inject(UrlStateService);
|
||||
noteInfo = inject(NoteInfoModalService);
|
||||
inPageSearch = inject(InPageSearchService);
|
||||
|
||||
noteFullScreen = false;
|
||||
showAboutPanel = false;
|
||||
@ -360,6 +351,8 @@ export class AppShellNimbusLayoutComponent {
|
||||
@Input() tags: TagInfo[] = [];
|
||||
@Input() activeView: string = 'files';
|
||||
|
||||
@ViewChild('pageRoot', { static: false }) pageRoot?: ElementRef<HTMLElement>;
|
||||
|
||||
@Output() noteSelected = new EventEmitter<string>();
|
||||
@Output() tagClicked = new EventEmitter<string>();
|
||||
@Output() wikiLinkActivated = new EventEmitter<any>();
|
||||
@ -596,6 +589,30 @@ export class AppShellNimbusLayoutComponent {
|
||||
return [...list].sort((a, b) => (score(b) - score(a)));
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
queueMicrotask(() => this.inPageSearch.setRoot(this.pageRoot?.nativeElement || null));
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
onKeydown(e: KeyboardEvent) {
|
||||
const isFind = (e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'F');
|
||||
if (isFind) {
|
||||
e.preventDefault();
|
||||
this.openInPageSearch();
|
||||
} else if (e.key === 'Escape' && this.inPageSearch.openState()) {
|
||||
this.inPageSearch.close();
|
||||
} else if (e.key === 'Enter' && this.inPageSearch.openState()) {
|
||||
if (e.shiftKey) this.inPageSearch.prev(); else this.inPageSearch.next();
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
openInPageSearch(): void {
|
||||
this.inPageSearch.open();
|
||||
document.dispatchEvent(new CustomEvent('ov-search-focus'));
|
||||
this.inPageSearch.setRoot(this.pageRoot?.nativeElement || null);
|
||||
}
|
||||
|
||||
onQueryChange(query: string) {
|
||||
this.listQuery = query;
|
||||
this.autoSelectFirstNote();
|
||||
@ -637,6 +654,10 @@ export class AppShellNimbusLayoutComponent {
|
||||
}
|
||||
}
|
||||
|
||||
onAboutSelected(): void {
|
||||
this.showAboutPanel = true;
|
||||
}
|
||||
|
||||
onNoteCreated(noteId: string) {
|
||||
this.noteCreated.emit(noteId);
|
||||
}
|
||||
|
||||
@ -3,12 +3,14 @@ import type { Note } from '../../types';
|
||||
import { ToastService } from '../shared/toast/toast.service';
|
||||
import { VaultService } from './vault.service';
|
||||
import { UrlStateService } from './url-state.service';
|
||||
import { NoteInfoModalService } from './note-info-modal.service';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NoteContextMenuService {
|
||||
private readonly toast = inject(ToastService);
|
||||
private readonly vaultService = inject(VaultService);
|
||||
private readonly urlState = inject(UrlStateService);
|
||||
private readonly noteInfo = inject(NoteInfoModalService);
|
||||
|
||||
// État du menu
|
||||
readonly visible = signal(false);
|
||||
@ -176,31 +178,13 @@ export class NoteContextMenuService {
|
||||
}
|
||||
|
||||
showPageInfo(note: Note): void {
|
||||
// Calculer les statistiques
|
||||
const wordCount = note.content.split(/\s+/).length;
|
||||
const headingCount = (note.content.match(/^#+\s/gm) || []).length;
|
||||
const backlinksCount = note.backlinks?.length || 0;
|
||||
|
||||
const info = `
|
||||
📄 Informations de la page
|
||||
━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||
📁 Chemin: ${note.filePath}
|
||||
📝 Titre: ${note.title}
|
||||
📊 Mots: ${wordCount}
|
||||
🏷️ Titres: ${headingCount}
|
||||
🔗 Liens entrants: ${backlinksCount}
|
||||
📅 Créée: ${note.createdAt || 'Inconnue'}
|
||||
🔄 Modifiée: ${note.updatedAt || new Date(note.mtime).toLocaleDateString('fr-FR')}
|
||||
🏷️ Tags: ${note.tags?.join(', ') || 'Aucun'}
|
||||
${note.frontmatter?.color ? `🎨 Couleur: ${note.frontmatter.color}` : ''}
|
||||
${note.frontmatter?.readOnly ? '🔒 Lecture seule' : '✏️ Modifiable'}
|
||||
${note.frontmatter?.favoris ? '⭐ Favori' : ''}
|
||||
`.trim();
|
||||
|
||||
console.log(info);
|
||||
this.toast.info('Informations affichées dans la console');
|
||||
|
||||
this.emitEvent('noteInfoRequested', { path: note.filePath, info });
|
||||
try {
|
||||
this.noteInfo.open(note);
|
||||
this.emitEvent('noteInfoRequested', { path: note.filePath });
|
||||
} catch (error) {
|
||||
console.error('Open note info modal error:', error);
|
||||
this.toast.info('Impossible d\'ouvrir la fenêtre d\'information');
|
||||
}
|
||||
}
|
||||
|
||||
async toggleReadOnly(note: Note): Promise<void> {
|
||||
|
||||
18
src/app/services/note-info-modal.service.ts
Normal file
18
src/app/services/note-info-modal.service.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
import type { Note } from '../../types';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class NoteInfoModalService {
|
||||
readonly visible = signal(false);
|
||||
readonly note = signal<Note | null>(null);
|
||||
|
||||
open(n: Note): void {
|
||||
this.note.set(n);
|
||||
this.visible.set(true);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.visible.set(false);
|
||||
this.note.set(null);
|
||||
}
|
||||
}
|
||||
78
src/app/shared/search/in-page-search-overlay.component.ts
Normal file
78
src/app/shared/search/in-page-search-overlay.component.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import { Component, ChangeDetectionStrategy, effect, inject, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { InPageSearchService } from './in-page-search.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-in-page-search-overlay',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
template: `
|
||||
<section *ngIf="search.openState()" class="fixed bottom-3 left-1/2 -translate-x-1/2 z-[1000] w-[min(720px,calc(100vw-24px))]">
|
||||
<div class="rounded-2xl border border-border bg-card shadow-lg px-3 py-2 flex flex-col gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="11" cy="11" r="8"/>
|
||||
<path d="m21 21-4.35-4.35" />
|
||||
</svg>
|
||||
<input
|
||||
#q
|
||||
type="search"
|
||||
[value]="search.query()"
|
||||
(input)="onQuery(($any($event.target).value || '').toString())"
|
||||
placeholder="Rechercher dans la note…"
|
||||
class="flex-1 bg-transparent text-sm text-main placeholder:text-muted focus:outline-none"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg px-2 py-1 text-xs font-medium text-muted hover:bg-surface1 dark:hover:bg-card transition"
|
||||
(click)="close()"
|
||||
aria-label="Fermer la recherche"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex items-center justify-between text-xs text-muted">
|
||||
<span class="font-medium">{{ positionLabel() }}</span>
|
||||
<div class="flex items-center gap-1">
|
||||
<button type="button" class="inline-flex items-center gap-1 rounded-lg px-2 py-1 hover:bg-surface1 dark:hover:bg-card transition" (click)="prev()" aria-label="Occurrence précédente">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
|
||||
<span>Préc.</span>
|
||||
</button>
|
||||
<button type="button" class="inline-flex items-center gap-1 rounded-lg px-2 py-1 hover:bg-surface1 dark:hover:bg-card transition" (click)="next()" aria-label="Occurrence suivante">
|
||||
<span>Suiv.</span>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6" /></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
`
|
||||
})
|
||||
export class InPageSearchOverlayComponent {
|
||||
search = inject(InPageSearchService);
|
||||
|
||||
positionLabel = computed(() => {
|
||||
const total = this.search.count();
|
||||
if (!total) return '0 occurrence';
|
||||
const current = this.search.currentIndex() + 1;
|
||||
return `${current}/${total} occurrence${total > 1 ? 's' : ''}`;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.search.openState()) return;
|
||||
queueMicrotask(() => {
|
||||
const el = document.querySelector<HTMLInputElement>('app-in-page-search-overlay input[type="search"]');
|
||||
el?.focus();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
onQuery(q: string) {
|
||||
this.search.queryChange(q);
|
||||
}
|
||||
next() { this.search.next(); }
|
||||
prev() { this.search.prev(); }
|
||||
close() { this.search.close(); }
|
||||
}
|
||||
122
src/app/shared/search/in-page-search.service.ts
Normal file
122
src/app/shared/search/in-page-search.service.ts
Normal file
@ -0,0 +1,122 @@
|
||||
import { Injectable, signal } from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class InPageSearchService {
|
||||
private rootEl: HTMLElement | null = null;
|
||||
private marks: HTMLElement[] = [];
|
||||
private activeIndex = signal<number>(-1);
|
||||
count = signal<number>(0);
|
||||
query = signal<string>('');
|
||||
openState = signal<boolean>(false);
|
||||
|
||||
setRoot(el: HTMLElement | null) {
|
||||
this.rootEl = el;
|
||||
}
|
||||
|
||||
open() {
|
||||
this.openState.set(true);
|
||||
}
|
||||
close() {
|
||||
this.clear();
|
||||
this.openState.set(false);
|
||||
}
|
||||
|
||||
queryChange(q: string) {
|
||||
this.query.set(q);
|
||||
this.highlight(q);
|
||||
}
|
||||
|
||||
next() {
|
||||
if (!this.marks.length) return;
|
||||
const next = (this.activeIndex() + 1) % this.marks.length;
|
||||
this.setActive(next);
|
||||
}
|
||||
prev() {
|
||||
if (!this.marks.length) return;
|
||||
const prev = (this.activeIndex() - 1 + this.marks.length) % this.marks.length;
|
||||
this.setActive(prev);
|
||||
}
|
||||
|
||||
clear() {
|
||||
// remove active class first
|
||||
this.marks.forEach(m => m.classList.remove('ov-mark-active'));
|
||||
// unwrap marks
|
||||
for (const m of this.marks) {
|
||||
const parent = m.parentNode as Node | null;
|
||||
if (!parent) continue;
|
||||
const text = document.createTextNode(m.textContent || '');
|
||||
parent.replaceChild(text, m);
|
||||
(parent as any).normalize?.();
|
||||
}
|
||||
this.marks = [];
|
||||
this.count.set(0);
|
||||
this.activeIndex.set(-1);
|
||||
}
|
||||
|
||||
currentIndex(): number {
|
||||
return this.activeIndex();
|
||||
}
|
||||
|
||||
private highlight(term: string) {
|
||||
this.clear();
|
||||
if (!term || !this.rootEl) return;
|
||||
|
||||
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
const pattern = new RegExp(escaped, 'gi');
|
||||
|
||||
const walker = document.createTreeWalker(this.rootEl, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node: any) => {
|
||||
const parentEl = (node.parentElement as HTMLElement) || null;
|
||||
if (!parentEl) return NodeFilter.FILTER_REJECT;
|
||||
const tag = parentEl.tagName.toLowerCase();
|
||||
if (tag === 'script' || tag === 'style' || tag === 'mark') return NodeFilter.FILTER_REJECT;
|
||||
if (parentEl.closest('code, pre, button, a')) return NodeFilter.FILTER_REJECT;
|
||||
const val = node.nodeValue || '';
|
||||
if (!val.trim()) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
} as any);
|
||||
|
||||
const nodes: Text[] = [];
|
||||
while (walker.nextNode()) nodes.push(walker.currentNode as Text);
|
||||
|
||||
let count = 0;
|
||||
for (const textNode of nodes) {
|
||||
const text = textNode.nodeValue || '';
|
||||
pattern.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let last = 0;
|
||||
const fragments: (string | HTMLElement)[] = [];
|
||||
while ((m = pattern.exec(text)) && count < 2000) {
|
||||
if (m.index > last) fragments.push(text.slice(last, m.index));
|
||||
const mark = document.createElement('mark');
|
||||
mark.className = 'ov-mark';
|
||||
mark.textContent = m[0];
|
||||
fragments.push(mark);
|
||||
last = m.index + m[0].length;
|
||||
count++;
|
||||
}
|
||||
if (!fragments.length) continue;
|
||||
if (last < text.length) fragments.push(text.slice(last));
|
||||
const parent = textNode.parentNode as Node | null;
|
||||
if (!parent) continue;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const f of fragments) frag.appendChild(typeof f === 'string' ? document.createTextNode(f) : f);
|
||||
parent.replaceChild(frag, textNode);
|
||||
}
|
||||
|
||||
this.marks = Array.from(this.rootEl.querySelectorAll('mark.ov-mark')) as HTMLElement[];
|
||||
this.count.set(this.marks.length);
|
||||
if (this.marks.length) this.setActive(0);
|
||||
}
|
||||
|
||||
private setActive(idx: number) {
|
||||
if (!this.marks.length) return;
|
||||
const bounded = Math.max(0, Math.min(idx, this.marks.length - 1));
|
||||
this.marks.forEach(m => m.classList.remove('ov-mark-active'));
|
||||
const el = this.marks[bounded];
|
||||
el.classList.add('ov-mark-active');
|
||||
try { el.scrollIntoView({ block: 'center', behavior: 'smooth' }); } catch {}
|
||||
this.activeIndex.set(bounded);
|
||||
}
|
||||
}
|
||||
@ -20,6 +20,22 @@
|
||||
transition: transform 0.15s ease, box-shadow 0.2s ease, background 0.2s ease, border-color 0.2s ease;
|
||||
}
|
||||
|
||||
/* Disabled state for alphabet buttons */
|
||||
.btn-disabled,
|
||||
.btn-colored-xs:disabled {
|
||||
opacity: 0.55;
|
||||
filter: grayscale(0.4) saturate(0.7);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-colored-xs:disabled:hover,
|
||||
.btn-colored-xs:disabled:focus-visible {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn-colored-xs:hover,
|
||||
.btn-colored-xs:focus-visible {
|
||||
transform: translateY(-1px);
|
||||
@ -64,3 +80,80 @@
|
||||
color-mix(in oklab, var(--surface2, #0b1220) 60%, transparent) 100%);
|
||||
border-color: color-mix(in oklab, var(--border, #334155) 60%, transparent);
|
||||
}
|
||||
|
||||
/* Ensure the overlay panel is fully opaque and above editors */
|
||||
.tag-overlay-panel {
|
||||
background-color: var(--card, #0b1220);
|
||||
-webkit-backdrop-filter: none;
|
||||
backdrop-filter: none;
|
||||
position: relative;
|
||||
z-index: 1; /* within the overlay root (zIndex set inline) */
|
||||
}
|
||||
|
||||
:host-context(:not(.dark)) .tag-overlay-panel {
|
||||
background-color: var(--card, #ffffff);
|
||||
}
|
||||
|
||||
/* Tag badge styling (copied locally so overlay keeps colors/icons when moved under <body>) */
|
||||
.md-tag-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 2.1rem;
|
||||
padding: 0.25rem 0.55rem;
|
||||
border-radius: 9999px;
|
||||
border: none;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.03em;
|
||||
color: #0f172a;
|
||||
background: rgba(15, 118, 110, 0.12);
|
||||
cursor: default;
|
||||
transition: background 0.15s ease, transform 0.15s ease;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.md-tag-badge::before {
|
||||
content: '\1F516'; /* 🔖 */
|
||||
font-size: 0.9em;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
:host-context(.dark) .md-tag-badge {
|
||||
background: rgba(45, 212, 191, 0.22);
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
.md-tag-badge button {
|
||||
font-size: 0.85em;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.md-tag-color-0 { background: rgba(190, 242, 100, 0.35); color: #3f6212; }
|
||||
.md-tag-color-1 { background: rgba(129, 230, 217, 0.35); color: #0f766e; }
|
||||
.md-tag-color-2 { background: rgba(196, 181, 253, 0.35); color: #6b21a8; }
|
||||
.md-tag-color-3 { background: rgba(248, 196, 113, 0.35); color: #b45309; }
|
||||
.md-tag-color-4 { background: rgba(248, 113, 113, 0.35); color: #b91c1c; }
|
||||
.md-tag-color-5 { background: rgba(129, 140, 248, 0.35); color: #4338ca; }
|
||||
.md-tag-color-6 { background: rgba(233, 213, 255, 0.35); color: #7e22ce; }
|
||||
.md-tag-color-7 { background: rgba(209, 250, 229, 0.35); color: #047857; }
|
||||
.md-tag-color-8 { background: rgba(165, 180, 252, 0.35); color: #1d4ed8; }
|
||||
.md-tag-color-9 { background: rgba(253, 224, 71, 0.35); color: #92400e; }
|
||||
.md-tag-color-10 { background: rgba(244, 114, 182, 0.35); color: #be185d; }
|
||||
.md-tag-color-11 { background: rgba(148, 163, 184, 0.35); color: #1f2937; }
|
||||
|
||||
:host-context(.dark) .md-tag-color-0 { background: rgba(132, 204, 22, 0.35); color: #d9f99d; }
|
||||
:host-context(.dark) .md-tag-color-1 { background: rgba(45, 212, 191, 0.3); color: #d1fae5; }
|
||||
:host-context(.dark) .md-tag-color-2 { background: rgba(168, 85, 247, 0.28); color: #ede9fe; }
|
||||
:host-context(.dark) .md-tag-color-3 { background: rgba(245, 158, 11, 0.28); color: #fde68a; }
|
||||
:host-context(.dark) .md-tag-color-4 { background: rgba(248, 113, 113, 0.3); color: #fee2e2; }
|
||||
:host-context(.dark) .md-tag-color-5 { background: rgba(99, 102, 241, 0.3); color: #e0e7ff; }
|
||||
:host-context(.dark) .md-tag-color-6 { background: rgba(217, 70, 239, 0.28); color: #f5d0fe; }
|
||||
:host-context(.dark) .md-tag-color-7 { background: rgba(34, 197, 94, 0.3); color: #bbf7d0; }
|
||||
:host-context(.dark) .md-tag-color-8 { background: rgba(59, 130, 246, 0.28); color: #bfdbfe; }
|
||||
:host-context(.dark) .md-tag-color-9 { background: rgba(250, 204, 21, 0.28); color: #fef08a; }
|
||||
:host-context(.dark) .md-tag-color-10 { background: rgba(236, 72, 153, 0.3); color: #fbcfe8; }
|
||||
:host-context(.dark) .md-tag-color-11 { background: rgba(107, 114, 128, 0.3); color: #cbd5f5; }
|
||||
|
||||
@ -1,6 +1,12 @@
|
||||
<div class="fixed inset-0 z-50 flex items-end md:items-center justify-center">
|
||||
<div class="absolute inset-0 bg-black/40"></div>
|
||||
<div class="relative rounded-2xl shadow-xl border border-border dark:border-border bg-card dark:bg-main p-4 md:p-5 w-[min(880px,96vw)] h-[92vh] md:h-auto md:max-h-[85vh] overflow-auto pb-24 md:pb-5">
|
||||
<div class="fixed inset-0 flex items-end md:items-center justify-center" [style.zIndex]="2147483640" [style.pointerEvents]="'auto'" (click)="close.emit()">
|
||||
<div class="absolute inset-0 bg-black/40" [style.zIndex]="-1"></div>
|
||||
<div class="relative tag-overlay-panel rounded-2xl shadow-xl border border-border dark:border-border p-4 md:p-5 w-[min(880px,96vw)] h-[92vh] md:h-auto md:max-h-[85vh] overflow-auto pb-24 md:pb-5"
|
||||
[style.background]="'var(--card)'"
|
||||
[style.opacity]="1"
|
||||
[style.filter]="'none'"
|
||||
[style.backdropFilter]="'none'"
|
||||
[style.isolation]="'isolate'"
|
||||
(click)="$event.stopPropagation()">
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg class="h-5 w-5 text-muted" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h.01"/><path d="M3 7h5a2 2 0 0 1 1.414.586l7 7a2 2 0 0 1 0 2.828l-3.172 3.172a2 2 0 0 1-2.828 0l-7-7A2 2 0 0 1 3 12V7Z"/></svg>
|
||||
@ -18,15 +24,30 @@
|
||||
<div class="flex flex-wrap items-center gap-1 md:gap-1.5">
|
||||
<button type="button" class="btn-colored-xs" [class.btn-colored-active]="letter()==='all'" (click)="letter.set('all')">Tous</button>
|
||||
@for (L of letters; track L) {
|
||||
<button type="button" class="btn-colored-xs" [class.btn-colored-active]="letter()===L" (click)="letter.set(L)">{{ L }}</button>
|
||||
<button type="button"
|
||||
class="btn-colored-xs"
|
||||
[class.btn-colored-active]="letter()===L"
|
||||
[class.btn-disabled]="!isLetterEnabled(L)"
|
||||
[disabled]="!isLetterEnabled(L)"
|
||||
(click)="letter.set(L)">{{ L }}</button>
|
||||
}
|
||||
<button type="button" class="btn-colored-xs" [class.btn-colored-active]="letter()==='#'" (click)="letter.set('#')">#</button>
|
||||
<button type="button" class="btn-colored-xs" [class.btn-colored-active]="letter()==='other'" (click)="letter.set('other')">Autre</button>
|
||||
<button type="button"
|
||||
class="btn-colored-xs"
|
||||
[class.btn-colored-active]="letter()==='#'"
|
||||
[class.btn-disabled]="!isLetterEnabled('#')"
|
||||
[disabled]="!isLetterEnabled('#')"
|
||||
(click)="letter.set('#')">#</button>
|
||||
<button type="button"
|
||||
class="btn-colored-xs"
|
||||
[class.btn-colored-active]="letter()==='other'"
|
||||
[class.btn-disabled]="!isLetterEnabled('other')"
|
||||
[disabled]="!isLetterEnabled('other')"
|
||||
(click)="letter.set('other')">Autre</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Selected tags + input -->
|
||||
<div class="flex flex-wrap items-center gap-2 rounded-xl border border-border dark:border-border p-3 min-h-[48px]">
|
||||
<div class="flex flex-wrap items-center gap-2 rounded-xl border border-border dark:border-border p-3 min-h-[48px] bg-card">
|
||||
@for (t of working(); track t) {
|
||||
<span class="md-tag-badge" [ngClass]="tagColorClass(t)" [attr.data-tag]="t">
|
||||
{{ t }}
|
||||
@ -41,11 +62,11 @@
|
||||
(input)="inputValue.set($any($event.target).value)"
|
||||
(keydown)="onKeydown($event)"
|
||||
placeholder="Ajouter un tag..."
|
||||
class="min-w-[200px] flex-1 w-full md:w-auto rounded-full border border-border bg-bg-muted/70 py-2.5 px-4 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring transition-all dark:border-border dark:bg-card/80 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:ring-ring" />
|
||||
class="min-w-[200px] flex-1 w-full md:w-auto rounded-full border border-border bg-bg-muted py-2.5 px-4 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring transition-all dark:border-border dark:bg-card dark:text-gray-100 dark:placeholder-gray-400 dark:focus:ring-ring" />
|
||||
</div>
|
||||
|
||||
<!-- Suggestions -->
|
||||
<div class="rounded-xl border border-border dark:border-border p-3">
|
||||
<div class="rounded-xl border border-border dark:border-border p-3 bg-card">
|
||||
@if (suggestions().length === 0) {
|
||||
<div class="px-1 py-0.5 text-sm text-muted">Aucune suggestion</div>
|
||||
} @else {
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, inject, signal, computed } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, Input, Output, EventEmitter, inject, signal, computed, ElementRef, OnInit, OnDestroy, Renderer2 } from '@angular/core';
|
||||
import { CommonModule, DOCUMENT } from '@angular/common';
|
||||
import { uniqueTags, normalizeTag } from '../tag-utils';
|
||||
import { VaultService } from '../../../../services/vault.service';
|
||||
import { ToastService } from '../../toast/toast.service';
|
||||
@ -12,9 +12,15 @@ import { ToastService } from '../../toast/toast.service';
|
||||
styleUrls: ['./tag-editor-overlay.component.css'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class TagEditorOverlayComponent {
|
||||
export class TagEditorOverlayComponent implements OnInit, OnDestroy {
|
||||
private vault = inject(VaultService);
|
||||
private toast = inject(ToastService);
|
||||
private elRef = inject(ElementRef<HTMLElement>);
|
||||
private renderer = inject(Renderer2);
|
||||
private doc = inject(DOCUMENT);
|
||||
|
||||
private originalParent: Node | null = null;
|
||||
private originalNextSibling: Node | null = null;
|
||||
|
||||
@Input() value: string[] = [];
|
||||
@Input() allTags: string[] = [];
|
||||
@ -35,8 +41,42 @@ export class TagEditorOverlayComponent {
|
||||
letter = signal<string>('all');
|
||||
readonly letters = Array.from('ABCDEFGHIJKLMNOPQRSTUVWXYZ');
|
||||
|
||||
// Compute which letters actually have at least one tag
|
||||
private usedLetters = computed(() => {
|
||||
const source = this.allKnownTags();
|
||||
const set = new Set<string>();
|
||||
for (const raw of source) {
|
||||
const t = `${raw || ''}`;
|
||||
if (!t) continue;
|
||||
const first = (t[0] || '').toUpperCase();
|
||||
if (/^[A-Z]$/.test(first)) {
|
||||
set.add(first);
|
||||
} else if (/^[0-9]$/.test(first)) {
|
||||
set.add('#');
|
||||
} else {
|
||||
set.add('other');
|
||||
}
|
||||
}
|
||||
return set;
|
||||
});
|
||||
|
||||
isLetterEnabled = (L: string) => {
|
||||
if (L === 'all') return true;
|
||||
return this.usedLetters().has(L.toUpperCase());
|
||||
};
|
||||
|
||||
ngOnInit() {
|
||||
this.working.set(uniqueTags(this.value || []));
|
||||
// Move host under <body> to avoid any parent stacking/overflow clipping
|
||||
const host = this.elRef.nativeElement;
|
||||
if (host && host.parentNode && this.doc?.body) {
|
||||
this.originalParent = host.parentNode;
|
||||
this.originalNextSibling = host.nextSibling;
|
||||
this.doc.body.appendChild(host);
|
||||
// Ensure host itself doesn't introduce stacking issues
|
||||
this.renderer.setStyle(host, 'position', 'relative');
|
||||
this.renderer.setStyle(host, 'z-index', '2147483638');
|
||||
}
|
||||
}
|
||||
|
||||
tagColorClass(tag: string): string {
|
||||
@ -140,4 +180,19 @@ export class TagEditorOverlayComponent {
|
||||
this.saving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
// Remove host to prevent any ghost overlay left in DOM
|
||||
const host = this.elRef.nativeElement;
|
||||
try {
|
||||
if (host && host.parentNode) {
|
||||
host.parentNode.removeChild(host);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
} finally {
|
||||
this.originalParent = null;
|
||||
this.originalNextSibling = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -9,6 +9,31 @@
|
||||
@import './styles/codemirror.css';
|
||||
|
||||
/* Local-only fallbacks: use installed fonts if available */
|
||||
|
||||
/* In-page search highlight */
|
||||
.ov-mark {
|
||||
background-color: rgba(253, 224, 71, 0.4); /* yellow-300/40 */
|
||||
outline: 1px solid rgba(253, 224, 71, 0.4);
|
||||
border-radius: 0.25rem;
|
||||
padding: 0 0.125rem;
|
||||
}
|
||||
|
||||
.dark .ov-mark {
|
||||
background-color: rgba(253, 224, 71, 0.3);
|
||||
outline-color: rgba(253, 224, 71, 0.4);
|
||||
}
|
||||
|
||||
.ov-mark-active {
|
||||
outline-width: 2px;
|
||||
outline-color: rgba(234, 179, 8, 0.9); /* yellow-500 */
|
||||
}
|
||||
|
||||
/* Sidebar width variables (default values) */
|
||||
:root {
|
||||
--sidebar-open: 280px;
|
||||
--sidebar-closed: 64px;
|
||||
--sidebar-width: var(--sidebar-open);
|
||||
}
|
||||
@font-face {
|
||||
font-family: 'gg sans';
|
||||
font-style: normal;
|
||||
|
||||
17
vault/.trash/Nouvelle note 14_2025-10-27T02-25-53-936Z.md
Normal file
17
vault/.trash/Nouvelle note 14_2025-10-27T02-25-53-936Z.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
titre: Nouvelle note 14
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-27T01:39:31.961Z
|
||||
modification_date: 2025-10-26T21:39:32-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
---
|
||||
83
vault/Allo-3/page test.md
Normal file
83
vault/Allo-3/page test.md
Normal file
@ -0,0 +1,83 @@
|
||||
---
|
||||
titre: "page test"
|
||||
auteur: "Bruno Charest"
|
||||
creation_date: "2025-10-26T13:57:39-04:00"
|
||||
modification_date: "2025-10-26T13:57:40-04:00"
|
||||
tags: [""]
|
||||
aliases: [""]
|
||||
status: "en-cours"
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
color: "#22C55E"
|
||||
---
|
||||
# Page de Test Markdown
|
||||
|
||||
## Section 1
|
||||
|
||||
### Sous-section 1.1
|
||||
|
||||
## Section 2
|
||||
|
||||
### Sous-section 2.1
|
||||
|
||||
### Sous-section 2.2
|
||||
|
||||
### Sous-section 2.3
|
||||
|
||||
# tableau:
|
||||
|
||||
| Colonne 1 | Colonne 2 | Colonne 3 |
|
||||
|---------|---------|---------|
|
||||
| Cellule 1 | Cellule 2 | Cellule 3 |
|
||||
| Cellule 4 | Cellule 5 | Cellule 6 |
|
||||
|
||||
# Liens
|
||||
|
||||
[lien](https://google.com)
|
||||
|
||||
# Liste
|
||||
|
||||
- item 1
|
||||
- item 2
|
||||
- item 3
|
||||
|
||||
# Liste à puces
|
||||
|
||||
- item 1
|
||||
- item 2
|
||||
- item 3
|
||||
|
||||
# Liste à numérotation
|
||||
|
||||
1. item 1
|
||||
2. item 2
|
||||
3. item 3
|
||||
|
||||
# Bloc de code
|
||||
|
||||
```javascript
|
||||
console.log('hello world');
|
||||
```
|
||||
|
||||
# Bloc de citation
|
||||
|
||||
> citation
|
||||
|
||||
# Bloc de citation avec plusieurs lignes
|
||||
|
||||
> citation
|
||||
> avec plusieurs lignes
|
||||
|
||||
# Bloc de citation avec plusieurs lignes et bloc de code
|
||||
|
||||
> citation
|
||||
> avec plusieurs lignes
|
||||
|
||||
```javascript
|
||||
console.log('hello world');
|
||||
```
|
||||
@ -17,3 +17,69 @@ archive: false
|
||||
draft: false
|
||||
private: false
|
||||
---
|
||||
# Page de Test Markdown
|
||||
|
||||
## Section 1
|
||||
|
||||
### Sous-section 1.1
|
||||
|
||||
## Section 2
|
||||
|
||||
### Sous-section 2.1
|
||||
|
||||
### Sous-section 2.2
|
||||
|
||||
### Sous-section 2.3
|
||||
|
||||
# tableau:
|
||||
|
||||
| Colonne 1 | Colonne 2 | Colonne 3 |
|
||||
|---------|---------|---------|
|
||||
| Cellule 1 | Cellule 2 | Cellule 3 |
|
||||
| Cellule 4 | Cellule 5 | Cellule 6 |
|
||||
|
||||
# Liens
|
||||
|
||||
[lien](https://google.com)
|
||||
|
||||
# Liste
|
||||
|
||||
- item 1
|
||||
- item 2
|
||||
- item 3
|
||||
|
||||
# Liste à puces
|
||||
|
||||
- item 1
|
||||
- item 2
|
||||
- item 3
|
||||
|
||||
# Liste à numérotation
|
||||
|
||||
1. item 1
|
||||
2. item 2
|
||||
3. item 3
|
||||
|
||||
# Bloc de code
|
||||
|
||||
```javascript
|
||||
console.log('hello world');
|
||||
```
|
||||
|
||||
# Bloc de citation
|
||||
|
||||
> citation
|
||||
|
||||
# Bloc de citation avec plusieurs lignes
|
||||
|
||||
> citation
|
||||
> avec plusieurs lignes
|
||||
|
||||
# Bloc de citation avec plusieurs lignes et bloc de code
|
||||
|
||||
> citation
|
||||
> avec plusieurs lignes
|
||||
|
||||
```javascript
|
||||
console.log('hello world');
|
||||
```
|
||||
@ -1,14 +1,9 @@
|
||||
---
|
||||
titre: Nouvelle note 3
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-24T15:44:07.120Z
|
||||
modification_date: 2025-10-25T19:53:45-04:00
|
||||
catégorie: ""
|
||||
tags:
|
||||
- ""
|
||||
aliases:
|
||||
- ""
|
||||
status: en-cours
|
||||
titre: "Nouvelle note 3"
|
||||
auteur: "Bruno Charest"
|
||||
creation_date: "2025-10-24T15:44:07.120Z"
|
||||
modification_date: "2025-10-25T19:53:45-04:00"
|
||||
status: "en-cours"
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
---
|
||||
titre: Nouvelle note 1
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-24T03:30:58.977Z
|
||||
modification_date: 2025-10-23T23:30:59-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
aliases: []
|
||||
status: en-cours
|
||||
titre: "Nouvelle note 1"
|
||||
auteur: "Bruno Charest"
|
||||
creation_date: "2025-10-24T03:30:58.977Z"
|
||||
modification_date: "2025-10-23T23:30:59-04:00"
|
||||
aliases: [""]
|
||||
status: "en-cours"
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
|
||||
@ -12,6 +12,7 @@ archive: true
|
||||
draft: true
|
||||
private: true
|
||||
toto: "tata"
|
||||
color: "#EF4444"
|
||||
---
|
||||
Allo ceci est un tests
|
||||
toto
|
||||
|
||||
@ -1,14 +1,9 @@
|
||||
---
|
||||
titre: Nouveau-markdown
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-19T21:42:53-04:00
|
||||
modification_date: 2025-10-19T21:43:06-04:00
|
||||
catégorie: markdown
|
||||
tags:
|
||||
- allo
|
||||
aliases:
|
||||
- nouveau
|
||||
status: en-cours
|
||||
titre: "Nouveau-markdown"
|
||||
auteur: "Bruno Charest"
|
||||
creation_date: "2025-10-19T21:42:53-04:00"
|
||||
modification_date: "2025-10-19T21:43:06-04:00"
|
||||
status: "en-cours"
|
||||
publish: true
|
||||
favoris: true
|
||||
template: true
|
||||
@ -16,8 +11,9 @@ task: true
|
||||
archive: true
|
||||
draft: true
|
||||
private: true
|
||||
toto: tata
|
||||
toto: "tata"
|
||||
readOnly: false
|
||||
color: "#A855F7"
|
||||
---
|
||||
Allo ceci est un tests
|
||||
toto
|
||||
|
||||
@ -3,14 +3,16 @@ titre: "Nouvelle note 10"
|
||||
auteur: "Bruno Charest"
|
||||
creation_date: "2025-10-26T14:55:55.330Z"
|
||||
modification_date: "2025-10-26T10:55:55-04:00"
|
||||
tags: [""]
|
||||
aliases: [""]
|
||||
status: "en-cours"
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
publish: true
|
||||
favoris: true
|
||||
template: true
|
||||
task: true
|
||||
archive: true
|
||||
draft: true
|
||||
private: true
|
||||
color: "#EF4444"
|
||||
---
|
||||
# TEST
|
||||
|
||||
|
||||
@ -1,12 +1,10 @@
|
||||
---
|
||||
titre: Nouvelle note 13
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-26T14:56:06.375Z
|
||||
modification_date: 2025-10-26T10:56:06-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
aliases: []
|
||||
status: en-cours
|
||||
titre: "Nouvelle note 13"
|
||||
auteur: "Bruno Charest"
|
||||
creation_date: "2025-10-26T14:56:06.375Z"
|
||||
modification_date: "2025-10-26T10:56:06-04:00"
|
||||
aliases: [""]
|
||||
status: "en-cours"
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
@ -14,4 +12,10 @@ task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
color: "#F59E0B"
|
||||
tags:
|
||||
- configuration
|
||||
- tag2
|
||||
- bruno
|
||||
- accueil
|
||||
---
|
||||
|
||||
17
vault/folder-4/Nouvelle note 14.md
Normal file
17
vault/folder-4/Nouvelle note 14.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
titre: Nouvelle note 14
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-27T02:36:45.394Z
|
||||
modification_date: 2025-10-26T22:36:45-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
favoris: true
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
---
|
||||
@ -1,12 +1,11 @@
|
||||
---
|
||||
titre: Nouvelle note 8
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-26T14:55:08.540Z
|
||||
modification_date: 2025-10-26T10:55:08-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
aliases: []
|
||||
status: en-cours
|
||||
titre: "Nouvelle note 8"
|
||||
auteur: "Bruno Charest"
|
||||
creation_date: "2025-10-26T14:55:08.540Z"
|
||||
modification_date: "2025-10-26T10:55:08-04:00"
|
||||
tags: [""]
|
||||
aliases: [""]
|
||||
status: "en-cours"
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
@ -14,4 +13,5 @@ task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
color: "#F59E0B"
|
||||
---
|
||||
|
||||
@ -1,17 +1,20 @@
|
||||
---
|
||||
titre: test-add-properties
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-23T13:11:50-04:00
|
||||
modification_date: 2025-10-23T13:11:50-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
aliases: []
|
||||
status: en-cours
|
||||
titre: "test-add-properties"
|
||||
auteur: "Bruno Charest"
|
||||
creation_date: "2025-10-23T13:11:50-04:00"
|
||||
modification_date: "2025-10-23T13:11:50-04:00"
|
||||
aliases: [""]
|
||||
status: "en-cours"
|
||||
publish: false
|
||||
favoris: false
|
||||
favoris: true
|
||||
template: false
|
||||
task: false
|
||||
task: true
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
private: true
|
||||
color: "#22C55E"
|
||||
tags:
|
||||
- tag1
|
||||
- accueil
|
||||
- tag3
|
||||
---
|
||||
|
||||
17
vault/nouveauDossierRacine/Nouvelle note 10.md
Normal file
17
vault/nouveauDossierRacine/Nouvelle note 10.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
titre: Nouvelle note 10
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-27T01:39:29.029Z
|
||||
modification_date: 2025-10-26T21:39:29-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
---
|
||||
@ -1,8 +1,8 @@
|
||||
---
|
||||
titre: "Nouvelle note 10"
|
||||
auteur: "Bruno Charest"
|
||||
creation_date: "2025-10-26T14:55:55.330Z"
|
||||
modification_date: "2025-10-26T14:55:55.330Z"
|
||||
creation_date: "2025-10-27T01:39:29.029Z"
|
||||
modification_date: "2025-10-27T01:39:29.029Z"
|
||||
status: "en-cours"
|
||||
publish: false
|
||||
favoris: false
|
||||
17
vault/nouveauDossierRacine/Nouvelle note 11.md
Normal file
17
vault/nouveauDossierRacine/Nouvelle note 11.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
titre: Nouvelle note 11
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-27T01:39:30.678Z
|
||||
modification_date: 2025-10-26T21:39:31-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
---
|
||||
@ -1,8 +1,8 @@
|
||||
---
|
||||
titre: "Nouvelle note 13"
|
||||
titre: "Nouvelle note 11"
|
||||
auteur: "Bruno Charest"
|
||||
creation_date: "2025-10-26T14:56:06.375Z"
|
||||
modification_date: "2025-10-26T14:56:06.375Z"
|
||||
creation_date: "2025-10-27T01:39:30.678Z"
|
||||
modification_date: "2025-10-27T01:39:30.678Z"
|
||||
status: "en-cours"
|
||||
publish: false
|
||||
favoris: false
|
||||
@ -1,10 +1,8 @@
|
||||
---
|
||||
titre: "Nouvelle note 3"
|
||||
titre: "Nouvelle note 14"
|
||||
auteur: "Bruno Charest"
|
||||
creation_date: "2025-10-24T15:44:07.120Z"
|
||||
modification_date: "2025-10-24T11:44:07-04:00"
|
||||
tags: [""]
|
||||
aliases: [""]
|
||||
creation_date: "2025-10-27T01:39:31.961Z"
|
||||
modification_date: "2025-10-27T01:39:31.961Z"
|
||||
status: "en-cours"
|
||||
publish: false
|
||||
favoris: false
|
||||
@ -14,3 +12,4 @@ archive: false
|
||||
draft: false
|
||||
private: false
|
||||
---
|
||||
|
||||
17
vault/nouveauDossierRacine/Nouvelle note 15.md
Normal file
17
vault/nouveauDossierRacine/Nouvelle note 15.md
Normal file
@ -0,0 +1,17 @@
|
||||
---
|
||||
titre: Nouvelle note 15
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-27T01:39:33.465Z
|
||||
modification_date: 2025-10-26T21:39:33-04:00
|
||||
catégorie: ""
|
||||
tags: []
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
---
|
||||
15
vault/nouveauDossierRacine/Nouvelle note 15.md.bak
Normal file
15
vault/nouveauDossierRacine/Nouvelle note 15.md.bak
Normal file
@ -0,0 +1,15 @@
|
||||
---
|
||||
titre: "Nouvelle note 15"
|
||||
auteur: "Bruno Charest"
|
||||
creation_date: "2025-10-27T01:39:33.465Z"
|
||||
modification_date: "2025-10-27T01:39:33.465Z"
|
||||
status: "en-cours"
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
---
|
||||
|
||||
@ -1,21 +1,6 @@
|
||||
---
|
||||
titre: test-drawing.excalidraw
|
||||
auteur: Bruno Charest
|
||||
creation_date: 2025-10-10T09:55:20-04:00
|
||||
modification_date: 2025-10-19T12:09:47-04:00
|
||||
catégorie: ""
|
||||
tags:
|
||||
- excalidraw
|
||||
aliases: []
|
||||
status: en-cours
|
||||
publish: false
|
||||
favoris: false
|
||||
template: false
|
||||
task: false
|
||||
archive: false
|
||||
draft: false
|
||||
private: false
|
||||
excalidraw-plugin: parsed
|
||||
tags: [excalidraw]
|
||||
---
|
||||
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
|
||||
You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'
|
||||
@ -26,74 +11,6 @@ You can decompress Drawing data with the command palette: 'Decompress current Ex
|
||||
%%
|
||||
## Drawing
|
||||
```compressed-json
|
||||
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
|
||||
|
||||
unsu
|
||||
N4IgpgNmC2YHYBcDOIBcBtUBLAJmkA1gCIQAsA7AHIBuWAzALQCuAjnOQOoDyAngKIALACp0QAGhAIeABzD4ATmADGCAIZwA5lHEgAHmjoBWAEwSeaAIwWAHBIDuuBAMvlDAOkMSBYLBoEJLUndPEHUtOVQABgkkBHkAewIwAGF4iHj5fABiCzBc3J0AI1UlAg0EpjgcVPTM1El5dSRpVUVEHQAzLAgIAGUpbXqkNNwdWISkjkdnVFMQccSwfp5B+ZG8CQq/ODAkFFQLCXiWpSwpS0jokHL4pmkASRx99ABdCQ7G2Ee0OCYezduVR2ezQoCksgMAF8YmAwHgDgBOUg2IKGABsCIk1DA8iQWHicEsthA2Nx+LglAJSgiNjRpGMCMMQXIEiwSCIkDACDhaA6qggSDAEkKgJwfCgsEQ+1+/xAdxwqm58Is5DRFkMdDoDOMhl1EggWDgBB+fwg+vipR5qD5ArAkLeoWk0n6ioioCQAnidg4kCU8VgvSUingaDiTCFkm8sHwCvkxokfp6qhFjQQGX2wGhICUTHkbQQyQErQQQhkERAxUyCdz+fu3OgACESmUKlUahl8HEmi18zoc3n4Ag6zA+FUAIJ5r3eVTw0KTux9muD4fQABi3T6A3LwwNG2zS8QK9XBIQq9U0G65gO1YHh/rx8QvSwAC8IsYrv3a/WuCczleLJcN5fjAABKtzbLs+yHPut5DvWLryAgE4JHY07wjKZowcB0D9BMKRpB29Q5HkJGLrBK5gZUODAvsICtnun7LvBcSLMsqw7qMQFMTAuGLFMOBOGgcyMXeMBCGAuhIQaGiEvUUAdAEN7DPIDZMAgaayXK0g6CUCBYNifDQIUcIKoUgwYRIOCNBoGiGho4owIOJqynCZx2Q5koBKgFngDgbmaAA4hUDzoaaEiuXpmgADKGmArQeU53lhaEKj6WAQjxGkoKSGW+CClAqUEousT+qWEJJbK6SWvCNqCvqqixGOqXYhlWUVRAWayHAACy8Q4BEtURl1HLcioVqDeFk7yN1kGqBoEQ+RJ0gZAgTalDcVGhvI4bhboy2IYG/Jvrt+0IIZxk4IG8ADfydXgHtK1TE4RCtAQvX9byt0Rl0UAABLqDg5nJeUuBPq+zmYWyDaGn5mijsmUDwmGEb9XyfwIE+/WVkQFpJDgAAKigdDi8DUp9tqskgUXxDOdnk3dbIgbsL509aX2U2BaiRRo9MRhADUIPj8SGty8g43YcBPTMIDQLcgo6NA6NYAl7TtRIcDnuWACqiBnIjDDvjqDAAQbaIG8YpDWH2J4SQgM2/BDRxdfbTCOyAxzwEL0h3G7HtwJjYCVr7w1YPy8Q82rIAtLEYBEKH6QR+6noS7zlmKqoEOdYotBy70nJjWKErLk8oJZooeLPu5Reqz5SBBmkiNcGpeIfWzFPzPXPQABpoFcdcJD0ACavcwgVSoq0OJeoKAxBkFQtCMKw7DcPwwiiKgyNZvlyhKkFtwhRmW/5+P1fIBOYANj4mhENZ801ez8zH+SE9ux6twQNUJTePcMkZGAABamVoCp3mMnF0yAQGxGLA2UUL9I5ICYDZXYSpoZVDss8B0HxNZMyqDiVmoB4AIytMjBMBptIb22hGDW0YKE7XdmpA0OwtrhizFg2AGVfq+AENJfwbsIp2VXJ8BayVOSeSQBwrhPCvI+TTALN2tAwB2DWi2UU7Y6ggCyB0awHQER8h0K+HqfUwDwzMuNB+z4gHZWoPycMlgswKLsO9YxGtTH33bl1WGGh7jQDmsY0+3x4HJ1+mWeQjCCBex9m3O628C4xR2PFU+r8NbSDibsNArwjjyF8IaXoySuAdA6IKaRyV4iFAAFY7yQLk1Q0gnEmMRiA+IBSilRTAApAwjIjjNK5BlchKpiQOAEjMBE1gQjeC4V5JkaJWHdDSdPSEkIgA=
|
||||
```
|
||||
%%
|
||||
%%
|
||||
@ -1,84 +0,0 @@
|
||||
---
|
||||
excalidraw-plugin: parsed
|
||||
tags: [excalidraw]
|
||||
---
|
||||
==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
|
||||
You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'
|
||||
|
||||
# Excalidraw Data
|
||||
|
||||
## Text Elements
|
||||
%%
|
||||
## Drawing
|
||||
```compressed-json
|
||||
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
|
||||
|
||||
unsu
|
||||
```
|
||||
%%
|
||||
Loading…
x
Reference in New Issue
Block a user