From 11a58426d0278fc102d45cb92dc917def163cacf Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Mon, 27 Oct 2025 10:11:20 -0400 Subject: [PATCH] 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 --- .../features/about/about-panel.component.ts | 7 + .../editor/markdown-editor.component.ts | 256 +++----------- src/app/features/list/notes-list.component.ts | 140 +++++++- .../note-info/note-info-modal.component.ts | 326 ++++++++++++++++++ .../move-note-to-folder.component.css | 196 +++++++++++ .../move-note-to-folder.component.html | 48 +-- .../move-note-to-folder.component.ts | 15 +- .../note-header/note-header.component.scss | 22 ++ .../app-shell-nimbus.component.ts | 77 +++-- src/app/services/note-context-menu.service.ts | 34 +- src/app/services/note-info-modal.service.ts | 18 + .../in-page-search-overlay.component.ts | 78 +++++ .../shared/search/in-page-search.service.ts | 122 +++++++ .../tag-editor-overlay.component.css | 93 +++++ .../tag-editor-overlay.component.html | 39 ++- .../tag-editor-overlay.component.ts | 61 +++- .../context-menu/context-menu.component.ts | 85 ++++- .../file-explorer/file-explorer.component.ts | 10 +- .../note-context-menu.component.ts | 79 ++++- .../note-viewer/note-viewer.component.ts | 70 +++- src/styles.css | 25 ++ ...velle note 11_2025-10-26T17-42-43-378Z.md} | 0 ...uvelle note 14_2025-10-27T02-25-53-936Z.md | 17 + vault/Allo-3/page test.md | 83 +++++ .../page test.md.bak} | 0 vault/Allo-3/tata.md | 66 ++++ vault/Allo-3/test/titi.md | 15 +- vault/Allo-3/toto.md | 14 +- vault/Nouveau-markdown.md | 1 + vault/Test 1 Markdown copy.md | 18 +- vault/folder-4/Nouvelle note 10.md | 18 +- vault/folder-4/Nouvelle note 13.md | 20 +- vault/folder-4/Nouvelle note 14.md | 17 + vault/folder-4/Nouvelle note 8.md | 16 +- vault/folder-5/test-add-properties.md | 25 +- .../nouveauDossierRacine/Nouvelle note 10.md | 17 + .../Nouvelle note 10.md.bak | 4 +- .../nouveauDossierRacine/Nouvelle note 11.md | 17 + .../Nouvelle note 11.md.bak} | 6 +- .../Nouvelle note 14.md.bak} | 9 +- .../nouveauDossierRacine/Nouvelle note 15.md | 17 + .../Nouvelle note 15.md.bak | 15 + vault/test-drawing.excalidraw.md | 89 +---- vault/test-drawing.excalidraw.md.bak | 84 ----- 44 files changed, 1781 insertions(+), 588 deletions(-) create mode 100644 src/app/features/note-info/note-info-modal.component.ts create mode 100644 src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.css create mode 100644 src/app/services/note-info-modal.service.ts create mode 100644 src/app/shared/search/in-page-search-overlay.component.ts create mode 100644 src/app/shared/search/in-page-search.service.ts rename vault/{folder-4/Nouvelle note 11.md => .trash/Nouvelle note 11_2025-10-26T17-42-43-378Z.md} (100%) create mode 100644 vault/.trash/Nouvelle note 14_2025-10-27T02-25-53-936Z.md create mode 100644 vault/Allo-3/page test.md rename vault/{folder-5/test-add-properties.md.bak => Allo-3/page test.md.bak} (100%) create mode 100644 vault/folder-4/Nouvelle note 14.md create mode 100644 vault/nouveauDossierRacine/Nouvelle note 10.md rename vault/{folder-4 => nouveauDossierRacine}/Nouvelle note 10.md.bak (67%) create mode 100644 vault/nouveauDossierRacine/Nouvelle note 11.md rename vault/{folder-4/Nouvelle note 13.md.bak => nouveauDossierRacine/Nouvelle note 11.md.bak} (57%) rename vault/{Allo-3/test/titi.md.bak => nouveauDossierRacine/Nouvelle note 14.md.bak} (52%) create mode 100644 vault/nouveauDossierRacine/Nouvelle note 15.md create mode 100644 vault/nouveauDossierRacine/Nouvelle note 15.md.bak delete mode 100644 vault/test-drawing.excalidraw.md.bak diff --git a/src/app/features/about/about-panel.component.ts b/src/app/features/about/about-panel.component.ts index cb6afd0..b76ad05 100644 --- a/src/app/features/about/about-panel.component.ts +++ b/src/app/features/about/about-panel.component.ts @@ -78,6 +78,10 @@ import { trigger, transition, style, animate } from '@angular/animations';

Créé par Bruno Charest

+
+
Version: {{ version }}
+
Date de compilation: {{ buildDate }}
+
@@ -104,6 +108,7 @@ import { trigger, transition, style, animate } from '@angular/animations'; Voir sur GitHub +
© {{ currentYear }} ObsiViewer. Tous droits réservés.
@@ -119,6 +124,8 @@ export class AboutPanelComponent { @Output() close = new EventEmitter(); readonly version = '1.0.0'; + readonly buildDate = new Date().toLocaleString(); + readonly currentYear = new Date().getFullYear(); @HostListener('document:keydown.escape') onEscapeKey(): void { diff --git a/src/app/features/editor/markdown-editor.component.ts b/src/app/features/editor/markdown-editor.component.ts index 3a669d8..4453cef 100644 --- a/src/app/features/editor/markdown-editor.component.ts +++ b/src/app/features/editor/markdown-editor.component.ts @@ -43,84 +43,25 @@ import { EditorHighlightService } from '../../shared/editor/editor-highlight.ser imports: [CommonModule], template: `
- -
-
- Editing - {{ fileName() }} + +
+

{{ fileName() }}

+
+
+ + + +
+ + +
- -
- - - - - - - - - - - - - - -
-
+
@@ -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(''); isDirty = signal(false); isSaving = signal(false); + isAutoSaving = signal(false); wordWrap = signal(false); cursorLine = signal(1); cursorCol = signal(1); @@ -362,6 +208,12 @@ export class MarkdownEditorComponent implements OnInit, OnDestroy { isDarkTheme = signal(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); } diff --git a/src/app/features/list/notes-list.component.ts b/src/app/features/list/notes-list.component.ts index a3823d9..d7c4dd1 100644 --- a/src/app/features/list/notes-list.component.ts +++ b/src/app/features/list/notes-list.component.ts @@ -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';
  • + + +
    + + +
    + -
    +
    +
    {{ n.title }}
    -
    -
    {{ n.title }}
    -
    {{ n.filePath }}
    +
    + +
    +
    {{ n.title }}
    +
    {{ n.filePath }}
    +
    -
    -
    {{ n.title }}
    -
    {{ n.filePath }}
    -
    - Status: {{ n.frontmatter.status }} - {{ formatDate(n.mtime) }} +
    + +
    +
    {{ n.title }}
    +
    {{ n.filePath }}
    +
    + Status: {{ n.frontmatter.status }} + {{ formatDate(n.mtime) }} +
  • @@ -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; private urlState = inject(UrlStateService); private pendingSelectId = signal(null); + private editorState = inject(EditorStateService); + private vault = inject(VaultService); // Delete warning modal state deleteWarningOpen = signal(false); @@ -743,4 +840,25 @@ export class NotesListComponent { backgroundImage: `linear-gradient(to left, ${gradientColor} 0%, transparent 65%)` } as Record; } + + // 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); + } + } } diff --git a/src/app/features/note-info/note-info-modal.component.ts b/src/app/features/note-info/note-info-modal.component.ts new file mode 100644 index 0000000..457257e --- /dev/null +++ b/src/app/features/note-info/note-info-modal.component.ts @@ -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: ` + +
    + +
    + +
    + +
    + + + + +
    + +
    +
    + 🧾 +
    +
    +

    {{ note?.title }}

    +
    {{ note?.filePath }}
    +
    +
    + +
    + +
    +

    🧾 Informations de base

    +
    +
    📄 Nom du fichier: {{ base.fileName }}
    +
    📁 Dossier parent: {{ base.parent }}
    +
    🧩 Type: {{ base.type }}
    +
    💾 Taille: {{ tech.sizeExact }}
    +
    🔡 Encodage: UTF-8
    +
    🧠 MIME: {{ tech.mime }}
    +
    🧭 Emplacement serveur: /vault/{{ note?.filePath }}
    +
    +
    + + +
    +

    🧠 Métadonnées

    +
    +
    👤 Auteur: {{ fm.auteur || note?.author || '—' }}
    +
    🕒 Création: {{ note?.createdAt || '—' }}
    +
    🕒 Modification: {{ note?.updatedAt || (note?.mtime ? (note?.mtime | date:'medium') : '—') }}
    +
    📜 Tags: {{ (fm.tags || note?.tags || []).join(', ') || '—' }}
    +
    🗂️ Catégorie: {{ fm['catégorie'] || fm.categorie || '—' }}
    +
    🪪 Alias: {{ (fm.aliases || []).join(', ') || '—' }}
    +
    🔖 Statut: {{ fm.status || '—' }}
    +
    ⭐ Favoris: {{ fm.favoris ? 'true' : 'false' }}
    +
    🔒 Lecture seule: {{ fm.readOnly ? 'true' : 'false' }}
    +
    🔐 Hash (SHA-1): {{ hash }}
    +
    +
    + + +
    +

    📊 Statistiques de contenu

    +
    +
    🔤 Caractères: {{ stats.chars }}
    +
    📝 Mots: {{ stats.words }}
    +
    ✉️ Phrases: {{ stats.sentences }}
    +
    📑 Paragraphes: {{ stats.paragraphs }}
    +
    📏 Longueur moyenne: {{ stats.avgSentenceLen }}
    +
    #️⃣ Titres H1–H6: {{ stats.headings }}
    +
    ✅ Listes: {{ stats.lists }}
    +
    🧩 Code: {{ stats.codeBlocks }}
    +
    🖼️ Images: {{ stats.images }}
    +
    📊 Tableaux: {{ stats.tables }}
    +
    💬 Citations: {{ stats.quotes }}
    +
    🧠 Lisibilité (Flesch approx): {{ stats.readability }}
    +
    🌍 Langue (naive): {{ stats.lang }}
    +
    📌 Qualité contenu: {{ stats.sizeClass }}
    +
    +
    + + +
    +

    🔗 Liens

    +
    +
    + Internes ({{ links.internalCount }}): + + + +
    +
    +
    Externes ({{ links.externalCount }}):
    + +
    +
    Cassés (internes): {{ links.brokenInternal }}
    +
    Domaines: {{ links.uniqueExternalDomains.join(', ') }}
    +
    +
    +
    +
    + + +
    +
    + + + +
    +
    + +
    +
    +
    +
    + `, +}) +export class NoteInfoModalComponent { + @Input() note: Note | null = null; + @Output() close = new EventEmitter(); + + 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 '—'; + } +} diff --git a/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.css b/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.css new file mode 100644 index 0000000..c3c75ec --- /dev/null +++ b/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.css @@ -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; + } +} diff --git a/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.html b/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.html index f02b34b..ab57ec1 100644 --- a/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.html +++ b/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.html @@ -2,7 +2,7 @@ @if (showMenu()) { -
    -
    -
    Move note to folder
    - +
    +
    Move note to folder
    +
    -
    -
    +
    + @if (isSearching() && searchResults().length === 0) { -
    No matching folders
    +
    No matching folders
    } @if (!isSearching()) { -
    All my folders
    -
    - +
    All my folders
    +
    + @for (crumb of breadcrumb(); track crumb.path; let i = $index) { - - + + }
    -
    +
    @if (loading()) { -
    Loading folders…
    +
    Loading folders…
    } @else { @for (folder of currentLevelFolders(); track folder.path) { } @@ -80,11 +80,11 @@ } @if (isSearching()) { -
    +
    @for (result of searchResults(); track result.path) {
    } -
    - - +
    diff --git a/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.ts b/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.ts index cc7de70..80fe33f 100644 --- a/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.ts +++ b/src/app/features/note/components/move-note-to-folder/move-note-to-folder.component.ts @@ -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 { diff --git a/src/app/features/note/components/note-header/note-header.component.scss b/src/app/features/note/components/note-header/note-header.component.scss index 9d0f52f..40e3e67 100644 --- a/src/app/features/note/components/note-header/note-header.component.scss +++ b/src/app/features/note/components/note-header/note-header.component.scss @@ -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 { diff --git a/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts b/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts index 368a6b8..9b7e49f 100644 --- a/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts +++ b/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts @@ -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: ` -
    +
    @@ -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" > +
    @@ -148,26 +154,6 @@ import { UrlStateService } from '../../services/url-state.service';
    - -
    -
    -
    Tests
    -
    - - -
    -
    -
    @@ -197,7 +183,7 @@ import { UrlStateService } from '../../services/url-state.service';
    -
    +
    @@ -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" > +