feat: add fullscreen mode and toolbar to note viewer
This commit is contained in:
parent
c2315735ff
commit
0555eb9ede
@ -45,7 +45,7 @@ export class AppComponent implements OnDestroy {
|
|||||||
// --- State Signals ---
|
// --- State Signals ---
|
||||||
isDarkMode = signal<boolean>(true);
|
isDarkMode = signal<boolean>(true);
|
||||||
isSidebarOpen = signal<boolean>(true);
|
isSidebarOpen = signal<boolean>(true);
|
||||||
isOutlineOpen = signal<boolean>(true);
|
isOutlineOpen = signal<boolean>(false);
|
||||||
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar'>('files');
|
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar'>('files');
|
||||||
selectedNoteId = signal<string>('');
|
selectedNoteId = signal<string>('');
|
||||||
sidebarSearchTerm = signal<string>('');
|
sidebarSearchTerm = signal<string>('');
|
||||||
|
|||||||
@ -86,7 +86,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
// --- State Signals ---
|
// --- State Signals ---
|
||||||
isSidebarOpen = signal<boolean>(true);
|
isSidebarOpen = signal<boolean>(true);
|
||||||
isOutlineOpen = signal<boolean>(true);
|
isOutlineOpen = signal<boolean>(false);
|
||||||
outlineTab = signal<'outline' | 'settings'>('outline');
|
outlineTab = signal<'outline' | 'settings'>('outline');
|
||||||
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings' | 'markdown-playground'>('files');
|
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings' | 'markdown-playground'>('files');
|
||||||
currentDrawingPath = signal<string | null>(null);
|
currentDrawingPath = signal<string | null>(null);
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
import { Component, EventEmitter, HostListener, Input, Output, inject } from '@angular/core';
|
||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { UiModeService } from '../../shared/services/ui-mode.service';
|
import { UiModeService } from '../../shared/services/ui-mode.service';
|
||||||
import { ResponsiveService } from '../../shared/services/responsive.service';
|
import { ResponsiveService } from '../../shared/services/responsive.service';
|
||||||
@ -22,9 +22,9 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent],
|
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective, MarkdownPlaygroundComponent],
|
||||||
template: `
|
template: `
|
||||||
<div class="h-screen flex flex-col bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
<div class="relative h-screen flex flex-col bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||||
<!-- Header (desktop/tablet), compact on mobile) -->
|
<!-- Header (desktop/tablet), compact on mobile) -->
|
||||||
<header class="flex items-center justify-between px-4 border-b border-gray-200 dark:border-gray-800 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm" [class.h-14]="responsive.isDesktop() || responsive.isTablet()" [class.h-12]="responsive.isMobile()">
|
<header *ngIf="!noteFullScreen" class="flex items-center justify-between px-4 border-b border-gray-200 dark:border-gray-800 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm" [class.h-14]="responsive.isDesktop() || responsive.isTablet()" [class.h-12]="responsive.isMobile()">
|
||||||
<div class="flex items-center gap-2 min-w-0">
|
<div class="flex items-center gap-2 min-w-0">
|
||||||
<button *ngIf="responsive.isMobile()" (click)="mobileNav.toggleSidebar()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">☰</button>
|
<button *ngIf="responsive.isMobile()" (click)="mobileNav.toggleSidebar()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">☰</button>
|
||||||
<span class="inline-flex items-center rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 truncate">{{ vaultName }}</span>
|
<span class="inline-flex items-center rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 truncate">{{ vaultName }}</span>
|
||||||
@ -43,8 +43,28 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
<!-- Fullscreen overlay for note -->
|
||||||
|
<div *ngIf="noteFullScreen && selectedNote && activeView !== 'markdown-playground'" class="absolute inset-0 z-50 flex flex-col bg-white dark:bg-gray-900">
|
||||||
|
<div class="note-content-area flex-1 overflow-y-auto px-4 py-4 lg:px-12" appScrollableOverlay>
|
||||||
|
<app-note-viewer
|
||||||
|
[note]="selectedNote || null"
|
||||||
|
[noteHtmlContent]="renderedNoteContent"
|
||||||
|
[allNotes]="vault.allNotes()"
|
||||||
|
(noteLinkClicked)="noteSelected.emit($event)"
|
||||||
|
(tagClicked)="tagClicked.emit($event)"
|
||||||
|
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
|
||||||
|
[fullScreenActive]="noteFullScreen"
|
||||||
|
(fullScreenRequested)="toggleNoteFullScreen()"
|
||||||
|
(legacyRequested)="ui.toggleUIMode()"
|
||||||
|
(showToc)="toggleOutlineRequest.emit()"
|
||||||
|
(directoryClicked)="onFolderSelected($event)"
|
||||||
|
[tocOpen]="isOutlineOpen"
|
||||||
|
></app-note-viewer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Desktop 3-column layout -->
|
<!-- Desktop 3-column layout -->
|
||||||
<div *ngIf="responsive.isDesktop()" class="flex-1 flex overflow-hidden relative">
|
<div *ngIf="responsive.isDesktop() && !noteFullScreen" class="flex-1 flex overflow-hidden relative">
|
||||||
<!-- Left: full sidebar or collapsed rail -->
|
<!-- Left: full sidebar or collapsed rail -->
|
||||||
<ng-container *ngIf="isSidebarOpen; else collapsedRail">
|
<ng-container *ngIf="isSidebarOpen; else collapsedRail">
|
||||||
<aside class="flex flex-col border-r border-gray-200 dark:border-gray-800 min-h-0" [style.width.px]="leftSidebarWidth">
|
<aside class="flex flex-col border-r border-gray-200 dark:border-gray-800 min-h-0" [style.width.px]="leftSidebarWidth">
|
||||||
@ -127,9 +147,15 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
|||||||
(noteLinkClicked)="noteSelected.emit($event)"
|
(noteLinkClicked)="noteSelected.emit($event)"
|
||||||
(tagClicked)="tagClicked.emit($event)"
|
(tagClicked)="tagClicked.emit($event)"
|
||||||
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
|
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
|
||||||
|
[fullScreenActive]="noteFullScreen"
|
||||||
|
(fullScreenRequested)="toggleNoteFullScreen()"
|
||||||
|
(legacyRequested)="ui.toggleUIMode()"
|
||||||
|
(showToc)="toggleOutlineRequest.emit()"
|
||||||
|
(directoryClicked)="onFolderSelected($event)"
|
||||||
|
[tocOpen]="isOutlineOpen"
|
||||||
></app-note-viewer>
|
></app-note-viewer>
|
||||||
</div>
|
</div>
|
||||||
<aside class="hidden xl:block border-l border-gray-200 dark:border-gray-800 overflow-y-auto" appScrollableOverlay [style.width.px]="isOutlineOpen ? rightSidebarWidth : 0" [class.opacity-0]="!isOutlineOpen" [class.pointer-events-none]="!isOutlineOpen">
|
<aside class="hidden xl:block border-l border-gray-200 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">
|
<div class="p-3">
|
||||||
<h2 class="text-sm font-semibold mb-2">Sommaire</h2>
|
<h2 class="text-sm font-semibold mb-2">Sommaire</h2>
|
||||||
<ul class="space-y-1 text-sm text-gray-600 dark:text-gray-300">
|
<ul class="space-y-1 text-sm text-gray-600 dark:text-gray-300">
|
||||||
@ -143,7 +169,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tablet: simple tabbed areas -->
|
<!-- Tablet: simple tabbed areas -->
|
||||||
<div *ngIf="responsive.isTablet()" class="flex-1 flex flex-col overflow-hidden">
|
<div *ngIf="responsive.isTablet() && !noteFullScreen" class="flex-1 flex flex-col overflow-hidden">
|
||||||
<div class="h-12 border-b border-gray-200 dark:border-gray-800 flex items-center">
|
<div class="h-12 border-b border-gray-200 dark:border-gray-800 flex items-center">
|
||||||
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'sidebar'" (click)="mobileNav.setActiveTab('sidebar')">Sidebar</button>
|
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'sidebar'" (click)="mobileNav.setActiveTab('sidebar')">Sidebar</button>
|
||||||
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'list'" (click)="mobileNav.setActiveTab('list')">Liste</button>
|
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'list'" (click)="mobileNav.setActiveTab('list')">Liste</button>
|
||||||
@ -157,13 +183,13 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
|||||||
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [query]="listQuery" (queryChange)="listQuery=$event" (openNote)="onOpenNote($event)"></app-notes-list>
|
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [query]="listQuery" (queryChange)="listQuery=$event" (openNote)="onOpenNote($event)"></app-notes-list>
|
||||||
</div>
|
</div>
|
||||||
<div [hidden]="mobileNav.activeTab() !== 'page'" class="h-full overflow-y-auto px-3 py-4" appScrollableOverlay>
|
<div [hidden]="mobileNav.activeTab() !== 'page'" class="h-full overflow-y-auto px-3 py-4" appScrollableOverlay>
|
||||||
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)"></app-note-viewer>
|
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" [fullScreenActive]="noteFullScreen" (fullScreenRequested)="toggleNoteFullScreen()" (legacyRequested)="ui.toggleUIMode()" (showToc)="mobileNav.toggleToc()" (directoryClicked)="onFolderSelected($event)" [tocOpen]="mobileNav.tocOpen()"></app-note-viewer>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile: bottom nav + drawer + swipe -->
|
<!-- Mobile: bottom nav + drawer + swipe -->
|
||||||
<div *ngIf="responsive.isMobile()" class="flex-1 relative overflow-hidden" appSwipeNav (swipeLeft)="nextTab()" (swipeRight)="prevTab()">
|
<div *ngIf="responsive.isMobile() && !noteFullScreen" class="flex-1 relative overflow-hidden" appSwipeNav (swipeLeft)="nextTab()" (swipeRight)="prevTab()">
|
||||||
<app-sidebar-drawer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" (noteSelected)="onNoteSelectedMobile($event)" (folderSelected)="onFolderSelectedFromDrawer($event)"></app-sidebar-drawer>
|
<app-sidebar-drawer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" (noteSelected)="onNoteSelectedMobile($event)" (folderSelected)="onFolderSelectedFromDrawer($event)"></app-sidebar-drawer>
|
||||||
|
|
||||||
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full flex flex-col overflow-hidden">
|
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full flex flex-col overflow-hidden">
|
||||||
@ -175,7 +201,7 @@ import { MarkdownPlaygroundComponent } from '../../features/tests/markdown-playg
|
|||||||
<h2 class="text-base font-semibold truncate">{{ selectedNote?.title || 'Aucune page' }}</h2>
|
<h2 class="text-base font-semibold truncate">{{ selectedNote?.title || 'Aucune page' }}</h2>
|
||||||
<button *ngIf="tableOfContents.length > 0" (click)="mobileNav.toggleToc()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">📋</button>
|
<button *ngIf="tableOfContents.length > 0" (click)="mobileNav.toggleToc()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">📋</button>
|
||||||
</div>
|
</div>
|
||||||
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)"></app-note-viewer>
|
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)" (fullScreenRequested)="toggleNoteFullScreen()"></app-note-viewer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<app-toc-overlay *ngIf="mobileNav.tocOpen()" [headings]="tableOfContents" (go)="navigateHeading.emit($event); mobileNav.toggleToc()" (close)="mobileNav.toggleToc()"></app-toc-overlay>
|
<app-toc-overlay *ngIf="mobileNav.tocOpen()" [headings]="tableOfContents" (go)="navigateHeading.emit($event); mobileNav.toggleToc()" (close)="mobileNav.toggleToc()"></app-toc-overlay>
|
||||||
@ -191,6 +217,8 @@ export class AppShellNimbusLayoutComponent {
|
|||||||
responsive = inject(ResponsiveService);
|
responsive = inject(ResponsiveService);
|
||||||
mobileNav = inject(MobileNavService);
|
mobileNav = inject(MobileNavService);
|
||||||
|
|
||||||
|
noteFullScreen = false;
|
||||||
|
|
||||||
@Input() vaultName = '';
|
@Input() vaultName = '';
|
||||||
@Input() effectiveFileTree: VaultNode[] = [];
|
@Input() effectiveFileTree: VaultNode[] = [];
|
||||||
@Input() selectedNoteId: string | null = '';
|
@Input() selectedNoteId: string | null = '';
|
||||||
@ -198,7 +226,7 @@ export class AppShellNimbusLayoutComponent {
|
|||||||
@Input() renderedNoteContent = '';
|
@Input() renderedNoteContent = '';
|
||||||
@Input() tableOfContents: Array<{ level: number; text: string; id: string }> = [];
|
@Input() tableOfContents: Array<{ level: number; text: string; id: string }> = [];
|
||||||
@Input() isSidebarOpen = true;
|
@Input() isSidebarOpen = true;
|
||||||
@Input() isOutlineOpen = true;
|
@Input() isOutlineOpen = false;
|
||||||
@Input() leftSidebarWidth = 288;
|
@Input() leftSidebarWidth = 288;
|
||||||
@Input() rightSidebarWidth = 288;
|
@Input() rightSidebarWidth = 288;
|
||||||
@Input() searchTerm = '';
|
@Input() searchTerm = '';
|
||||||
@ -225,6 +253,18 @@ export class AppShellNimbusLayoutComponent {
|
|||||||
private flyoutCloseTimer: any = null;
|
private flyoutCloseTimer: any = null;
|
||||||
tagFilter: string | null = null;
|
tagFilter: string | null = null;
|
||||||
|
|
||||||
|
toggleNoteFullScreen(): void {
|
||||||
|
this.noteFullScreen = !this.noteFullScreen;
|
||||||
|
document.body.classList.toggle('note-fullscreen-active', this.noteFullScreen);
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
handleEscape(): void {
|
||||||
|
if (!this.noteFullScreen) return;
|
||||||
|
this.noteFullScreen = false;
|
||||||
|
document.body.classList.remove('note-fullscreen-active');
|
||||||
|
}
|
||||||
|
|
||||||
nextTab() {
|
nextTab() {
|
||||||
const order: Array<'sidebar' | 'list' | 'page' | 'toc'> = ['sidebar', 'list', 'page', 'toc'];
|
const order: Array<'sidebar' | 'list' | 'page' | 'toc'> = ['sidebar', 'list', 'page', 'toc'];
|
||||||
const idx = order.indexOf(this.mobileNav.activeTab());
|
const idx = order.indexOf(this.mobileNav.activeTab());
|
||||||
|
|||||||
28
src/app/shared/services/clipboard.service.ts
Normal file
28
src/app/shared/services/clipboard.service.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@angular/core';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ClipboardService {
|
||||||
|
async write(text: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
if (typeof navigator !== 'undefined' && navigator.clipboard?.writeText) {
|
||||||
|
await navigator.clipboard.writeText(text ?? '');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.value = text ?? '';
|
||||||
|
ta.style.position = 'fixed';
|
||||||
|
ta.style.opacity = '0';
|
||||||
|
ta.style.pointerEvents = 'none';
|
||||||
|
document.body.appendChild(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.select();
|
||||||
|
const ok = document.execCommand('copy');
|
||||||
|
document.body.removeChild(ta);
|
||||||
|
return ok;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
11
src/app/shared/utils/trilean.ts
Normal file
11
src/app/shared/utils/trilean.ts
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
export type TriBool = 'absent' | 'true' | 'false';
|
||||||
|
|
||||||
|
export interface HasFrontmatterLike {
|
||||||
|
frontmatter?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function trilean(meta: Record<string, unknown> | undefined | null, key: string): TriBool {
|
||||||
|
const m = meta ?? {};
|
||||||
|
if (!(key in m)) return 'absent';
|
||||||
|
return Boolean((m as any)[key]) ? 'true' : 'false';
|
||||||
|
}
|
||||||
@ -15,6 +15,7 @@ import { CommonModule } from '@angular/common';
|
|||||||
import { Note } from '../../../types';
|
import { Note } from '../../../types';
|
||||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||||
import { NotePreviewService, PreviewData } from '../../../services/note-preview.service';
|
import { NotePreviewService, PreviewData } from '../../../services/note-preview.service';
|
||||||
|
import { ClipboardService } from '../../../app/shared/services/clipboard.service';
|
||||||
import { Subscription } from 'rxjs';
|
import { Subscription } from 'rxjs';
|
||||||
import mermaid from 'mermaid';
|
import mermaid from 'mermaid';
|
||||||
|
|
||||||
@ -64,25 +65,80 @@ interface MetadataEntry {
|
|||||||
imports: [CommonModule],
|
imports: [CommonModule],
|
||||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
template: `
|
template: `
|
||||||
<div class="p-8 prose prose-lg dark:prose-invert max-w-none prose-p:leading-[1] prose-li:leading-[1] prose-blockquote:leading-[1]">
|
<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="!mb-6 pb-2 border-b border-border">
|
<div class="sr-only" role="status" aria-live="polite">{{ copyStatus() }}</div>
|
||||||
<h1 class="!text-4xl !font-bold !mb-3">{{ note().title }}</h1>
|
<!-- Compact Top Bar -->
|
||||||
|
<div class="flex items-center justify-between gap-2 px-2 py-1 mb-2 text-text-muted text-xs">
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
|
||||||
|
<!-- Directory clickable -->
|
||||||
|
<button type="button" class="note-toolbar-icon" (click)="directoryClicked.emit(getDirectoryFromPath(note().filePath))" title="Open directory">🗂️ {{ note().filePath }}</button>
|
||||||
|
<button type="button" class="note-toolbar-icon" (click)="copyPath()" aria-label="Copier le chemin" title="Copier le chemin">
|
||||||
|
<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"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16V6a2 2 0 0 1 2-2h10"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
@if (note().tags.length > 0) {
|
<!-- Tags (discreet) -->
|
||||||
<div class="md-tag-group not-prose">
|
@if (note().tags.length > 0) {
|
||||||
@for (tag of note().tags; track tag) {
|
<div class="hidden md:flex items-center gap-1 not-prose">
|
||||||
<button
|
@for (tag of note().tags; track tag) {
|
||||||
type="button"
|
<button type="button" class="md-tag-badge" [ngClass]="tagColorClass(tag)" data-origin="header" [attr.data-tag]="tag" (click)="tagClicked.emit(tag)">
|
||||||
class="md-tag-badge"
|
{{ tag }}
|
||||||
[ngClass]="tagColorClass(tag)"
|
</button>
|
||||||
data-origin="header"
|
}
|
||||||
[attr.data-tag]="tag"
|
</div>
|
||||||
(click)="tagClicked.emit(tag)">
|
}
|
||||||
{{ tag }}
|
<button type="button" class="chip" (click)="addTagRequested.emit()" title="Add tag">+ tag</button>
|
||||||
</button>
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="note-toolbar-icon"
|
||||||
|
(click)="fullScreenRequested.emit()"
|
||||||
|
[attr.title]="fullScreenActive() ? 'Exit full screen' : 'Open in full screen'"
|
||||||
|
[attr.aria-label]="fullScreenActive() ? 'Exit full screen' : 'Open in full screen'"
|
||||||
|
>
|
||||||
|
{{ fullScreenActive() ? '⤢' : '⤢' }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="note-toolbar-icon"
|
||||||
|
(click)="showToc.emit()"
|
||||||
|
[attr.title]="tocOpen() ? 'Cacher sommaire' : 'Afficher sommaire'"
|
||||||
|
[attr.aria-label]="tocOpen() ? 'Cacher sommaire' : 'Afficher sommaire'"
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" [ngClass]="tocOpen() ? 'text-accent' : 'text-text-muted'"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="M13 8h5"/><path d="M13 12h5"/><path d="M13 16h5"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="note-toolbar-icon"
|
||||||
|
(click)="copyMarkdown()"
|
||||||
|
aria-label="Copier tout le markdown"
|
||||||
|
title="Copier tout le markdown">
|
||||||
|
<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"><path d="M14 2H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h8"/><path d="M20 7v11a2 2 0 0 1-2 2H9"/><path d="M8 7h8"/><path d="M8 11h8"/></svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div class="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="note-toolbar-icon"
|
||||||
|
(click)="toggleMenu()"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
[attr.aria-expanded]="menuOpen()"
|
||||||
|
title="More options"
|
||||||
|
aria-label="More options"
|
||||||
|
>
|
||||||
|
⋯
|
||||||
|
</button>
|
||||||
|
@if (menuOpen()) {
|
||||||
|
<div class="absolute right-0 mt-2 w-56 rounded-md border border-border bg-card shadow-subtle not-prose z-10">
|
||||||
|
<button type="button" class="block w-full text-left px-3 py-2 hover:bg-muted" (click)="legacyRequested.emit(); closeMenu()">🔧 Legacy</button>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (frontmatterTags().length > 0) {
|
@if (frontmatterTags().length > 0) {
|
||||||
@ -153,12 +209,70 @@ interface MetadataEntry {
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM5.5 21a6.5 6.5 0 0113 0"/>
|
d="M12 8c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM5.5 21a6.5 6.5 0 0113 0"/>
|
||||||
</svg>
|
</svg>
|
||||||
{{ note().author ?? 'Auteur inconnu' }}
|
{{ getAuthorFromFrontmatter() ?? note().author ?? 'Auteur inconnu' }}
|
||||||
</span>
|
</span>
|
||||||
|
@if (hasState('publish')) {
|
||||||
|
<span class="inline-flex items-center gap-1 transition-colors" [ngClass]="state('publish') ? 'text-green-500' : 'text-gray-400'" title="{{ state('publish') ? 'Publié sur le web' : 'Non publié' }}" role="img" aria-label="{{ state('publish') ? 'Publié sur le web' : 'Non publié' }}">
|
||||||
|
<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">
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M2 12h20" />
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (hasState('favoris')) {
|
||||||
|
<span class="inline-flex items-center gap-1 transition-colors" [ngClass]="state('favoris') ? 'text-rose-500' : 'text-gray-400'" title="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}" role="img" aria-label="{{ state('favoris') ? 'Ajouté aux favoris' : 'Non favori' }}">
|
||||||
|
@if (state('favoris')) {
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor" stroke="none">
|
||||||
|
<path d="M19 14.5c1.5-1.5 2-4 0-6s-5-2-7 1c-2-3-5-3.5-7-1s-1.5 4.5 0 6l7 6z" />
|
||||||
|
</svg>
|
||||||
|
} @else {
|
||||||
|
<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">
|
||||||
|
<path d="M19 14.5c1.5-1.5 2-4 0-6s-5-2-7 1c-2-3-5-3.5-7-1s-1.5 4.5 0 6l7 6z" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (hasState('archive')) {
|
||||||
|
<span class="inline-flex items-center gap-1 transition-colors" [ngClass]="state('archive') ? 'text-amber-600' : 'text-gray-400'" title="{{ state('archive') ? 'Document archivé' : 'Document non archivé' }}" role="img" aria-label="{{ state('archive') ? 'Document archivé' : 'Document non archivé' }}">
|
||||||
|
{{ state('archive') ? '🗃️' : '📋' }}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (hasState('draft')) {
|
||||||
|
<span class="inline-flex items-center gap-1 transition-colors" [ngClass]="state('draft') ? 'text-yellow-500' : 'text-gray-400'" title="{{ state('draft') ? 'Brouillon actif' : 'Pas un brouillon' }}" role="img" aria-label="{{ state('draft') ? 'Brouillon actif' : 'Pas un brouillon' }}">
|
||||||
|
@if (state('draft')) {
|
||||||
|
<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">
|
||||||
|
<path d="M3 3h18v4H3z" />
|
||||||
|
<path d="M6 7v14h12V7" />
|
||||||
|
<path d="M9 12h6" />
|
||||||
|
</svg>
|
||||||
|
} @else {
|
||||||
|
<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">
|
||||||
|
<rect x="3" y="4" width="18" height="16" rx="2" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
@if (hasState('private')) {
|
||||||
|
<span class="inline-flex items-center gap-1 transition-colors" [ngClass]="state('private') ? 'text-purple-500' : 'text-gray-400'" title="{{ state('private') ? 'Privé' : 'Public' }}" role="img" aria-label="{{ state('private') ? 'Privé' : 'Public' }}">
|
||||||
|
@if (state('private')) {
|
||||||
|
<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">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||||
|
</svg>
|
||||||
|
} @else {
|
||||||
|
<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">
|
||||||
|
<rect x="3" y="11" width="18" height="11" rx="2" />
|
||||||
|
<path d="M7 11V7a5 5 0 0 1 9 0v1" />
|
||||||
|
<path d="M17 21V11" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div [innerHTML]="sanitizedHtmlContent()"></div>
|
<div [innerHTML]="sanitizedHtmlContent()"></div>
|
||||||
|
|
||||||
@if (note().backlinks.length > 0) {
|
@if (note().backlinks.length > 0) {
|
||||||
<div class="mt-12 pt-6 border-t border-border not-prose">
|
<div class="mt-12 pt-6 border-t border-border not-prose">
|
||||||
<h2 class="text-2xl font-bold mb-4">Backlinks</h2>
|
<h2 class="text-2xl font-bold mb-4">Backlinks</h2>
|
||||||
@ -185,10 +299,18 @@ export class NoteViewerComponent implements OnDestroy {
|
|||||||
noteLinkClicked = output<string>();
|
noteLinkClicked = output<string>();
|
||||||
wikiLinkActivated = output<WikiLinkActivation>();
|
wikiLinkActivated = output<WikiLinkActivation>();
|
||||||
tagClicked = output<string>();
|
tagClicked = output<string>();
|
||||||
|
showToc = output<void>();
|
||||||
|
directoryClicked = output<string>();
|
||||||
|
addTagRequested = output<void>();
|
||||||
|
fullScreenRequested = output<void>();
|
||||||
|
legacyRequested = output<void>();
|
||||||
|
fullScreenActive = input<boolean>(false);
|
||||||
|
tocOpen = input<boolean>(false);
|
||||||
|
|
||||||
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
private readonly elementRef = inject(ElementRef<HTMLElement>);
|
||||||
private readonly sanitizer = inject(DomSanitizer);
|
private readonly sanitizer = inject(DomSanitizer);
|
||||||
private readonly previewService = inject(NotePreviewService);
|
private readonly previewService = inject(NotePreviewService);
|
||||||
|
private readonly clipboard = inject(ClipboardService);
|
||||||
private readonly tagPaletteSize = 12;
|
private readonly tagPaletteSize = 12;
|
||||||
private readonly tagColorCache = new Map<string, number>();
|
private readonly tagColorCache = new Map<string, number>();
|
||||||
private readonly copyFeedbackTimers = new Map<HTMLElement, number>();
|
private readonly copyFeedbackTimers = new Map<HTMLElement, number>();
|
||||||
@ -206,7 +328,9 @@ export class NoteViewerComponent implements OnDestroy {
|
|||||||
private previewOpenSub: Subscription | null = null;
|
private previewOpenSub: Subscription | null = null;
|
||||||
|
|
||||||
readonly metadataExpanded = signal(false);
|
readonly metadataExpanded = signal(false);
|
||||||
|
readonly menuOpen = signal(false);
|
||||||
readonly maxMetadataPreviewItems = 3;
|
readonly maxMetadataPreviewItems = 3;
|
||||||
|
readonly copyStatus = signal('');
|
||||||
|
|
||||||
readonly sanitizedHtmlContent = computed<SafeHtml>(() =>
|
readonly sanitizedHtmlContent = computed<SafeHtml>(() =>
|
||||||
this.sanitizer.bypassSecurityTrustHtml(this.noteHtmlContent())
|
this.sanitizer.bypassSecurityTrustHtml(this.noteHtmlContent())
|
||||||
@ -228,13 +352,6 @@ export class NoteViewerComponent implements OnDestroy {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (typeof tags === 'string') {
|
|
||||||
return tags
|
|
||||||
.split(',')
|
|
||||||
.map(tag => tag.trim())
|
|
||||||
.filter(Boolean)
|
|
||||||
.filter(tag => !headerTags.has(tag.toLowerCase()));
|
|
||||||
}
|
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -329,6 +446,21 @@ export class NoteViewerComponent implements OnDestroy {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleMenu(): void {
|
||||||
|
this.menuOpen.update(v => !v);
|
||||||
|
}
|
||||||
|
|
||||||
|
closeMenu(): void {
|
||||||
|
this.menuOpen.set(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
getAuthorFromFrontmatter(): string | null {
|
||||||
|
const frontmatter = this.note().frontmatter ?? {};
|
||||||
|
const authorValue = frontmatter['author'] ?? frontmatter['auteur'];
|
||||||
|
if (!authorValue) return null;
|
||||||
|
return this.coerceToString(authorValue).trim() || null;
|
||||||
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
this.mermaidObserver?.disconnect();
|
this.mermaidObserver?.disconnect();
|
||||||
this.mermaidObserver = null;
|
this.mermaidObserver = null;
|
||||||
@ -962,4 +1094,46 @@ export class NoteViewerComponent implements OnDestroy {
|
|||||||
|
|
||||||
this.copyFeedbackTimers.set(block, timeout);
|
this.copyFeedbackTimers.set(block, timeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hasState(key: 'publish' | 'favoris' | 'archive' | 'draft' | 'private'): boolean {
|
||||||
|
try {
|
||||||
|
const fm = (this.note()?.frontmatter ?? {}) as any;
|
||||||
|
const raw = (fm as any)[key];
|
||||||
|
return raw !== undefined && raw !== null;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state(key: 'publish' | 'favoris' | 'archive' | 'draft' | 'private'): boolean {
|
||||||
|
try {
|
||||||
|
const fm = (this.note()?.frontmatter ?? {}) as any;
|
||||||
|
const raw = (fm as any)[key];
|
||||||
|
return this.toBoolean(raw);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getDirectoryFromPath(path: string | undefined | null): string {
|
||||||
|
const p = (path ?? '').toString();
|
||||||
|
if (!p) return '';
|
||||||
|
const idx = p.lastIndexOf('/');
|
||||||
|
if (idx <= 0) return p.replace(/\\/g, '/');
|
||||||
|
return p.slice(0, idx).replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyPath(): Promise<void> {
|
||||||
|
const path = this.note()?.originalPath || this.note()?.filePath || '';
|
||||||
|
const ok = await this.clipboard.write(path);
|
||||||
|
this.copyStatus.set(ok ? 'Copié !' : 'Échec de la copie');
|
||||||
|
setTimeout(() => this.copyStatus.set(''), 1500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyMarkdown(): Promise<void> {
|
||||||
|
const raw = this.note()?.rawContent || '';
|
||||||
|
const ok = await this.clipboard.write(raw);
|
||||||
|
this.copyStatus.set(ok ? 'Copié !' : 'Échec de la copie');
|
||||||
|
setTimeout(() => this.copyStatus.set(''), 1500);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -139,6 +139,28 @@
|
|||||||
color: var(--text-main);
|
color: var(--text-main);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.note-toolbar-icon {
|
||||||
|
margin-left: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 4px 4px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-toolbar-icon:hover {
|
||||||
|
color: var(--text-main);
|
||||||
|
background-color: color-mix(in srgb, var(--bg-muted) 70%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-toolbar-icon:focus-visible {
|
||||||
|
outline: 1px solid var(--accent);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
.badge {
|
.badge {
|
||||||
@apply inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold uppercase tracking-wide;
|
@apply inline-flex items-center rounded-full px-2.5 py-1 text-xs font-semibold uppercase tracking-wide;
|
||||||
background-color: color-mix(in srgb, var(--bg-muted) 90%, transparent);
|
background-color: color-mix(in srgb, var(--bg-muted) 90%, transparent);
|
||||||
|
|||||||
18
src/types.ts
18
src/types.ts
@ -1,12 +1,28 @@
|
|||||||
export type { FileMetadata } from './types/file-metadata.model';
|
export type { FileMetadata } from './types/file-metadata.model';
|
||||||
|
|
||||||
|
export interface NoteFrontmatter {
|
||||||
|
auteur?: string;
|
||||||
|
catégorie?: string;
|
||||||
|
creation_date?: string;
|
||||||
|
modification_date?: string;
|
||||||
|
tags?: string[];
|
||||||
|
aliases?: string[];
|
||||||
|
status?: string;
|
||||||
|
publish?: boolean;
|
||||||
|
favoris?: boolean;
|
||||||
|
archive?: boolean;
|
||||||
|
draft?: boolean;
|
||||||
|
private?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
id: string;
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
content: string;
|
content: string;
|
||||||
rawContent: string;
|
rawContent: string;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
frontmatter: { [key: string]: any };
|
frontmatter: NoteFrontmatter;
|
||||||
backlinks: string[];
|
backlinks: string[];
|
||||||
mtime: number; // last modified time
|
mtime: number; // last modified time
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user