feat: add fullscreen mode and toolbar to note viewer
This commit is contained in:
@@ -15,6 +15,7 @@ import { CommonModule } from '@angular/common';
|
||||
import { Note } from '../../../types';
|
||||
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
|
||||
import { NotePreviewService, PreviewData } from '../../../services/note-preview.service';
|
||||
import { ClipboardService } from '../../../app/shared/services/clipboard.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import mermaid from 'mermaid';
|
||||
|
||||
@@ -64,25 +65,80 @@ interface MetadataEntry {
|
||||
imports: [CommonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
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="!mb-6 pb-2 border-b border-border">
|
||||
<h1 class="!text-4xl !font-bold !mb-3">{{ note().title }}</h1>
|
||||
<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="sr-only" role="status" aria-live="polite">{{ copyStatus() }}</div>
|
||||
<!-- 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) {
|
||||
<div class="md-tag-group not-prose">
|
||||
@for (tag of note().tags; track tag) {
|
||||
<button
|
||||
type="button"
|
||||
class="md-tag-badge"
|
||||
[ngClass]="tagColorClass(tag)"
|
||||
data-origin="header"
|
||||
[attr.data-tag]="tag"
|
||||
(click)="tagClicked.emit(tag)">
|
||||
{{ tag }}
|
||||
</button>
|
||||
<!-- Tags (discreet) -->
|
||||
@if (note().tags.length > 0) {
|
||||
<div class="hidden md:flex items-center gap-1 not-prose">
|
||||
@for (tag of note().tags; track tag) {
|
||||
<button type="button" class="md-tag-badge" [ngClass]="tagColorClass(tag)" data-origin="header" [attr.data-tag]="tag" (click)="tagClicked.emit(tag)">
|
||||
{{ tag }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<button type="button" class="chip" (click)="addTagRequested.emit()" title="Add tag">+ tag</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>
|
||||
|
||||
@if (frontmatterTags().length > 0) {
|
||||
@@ -153,12 +209,70 @@ interface MetadataEntry {
|
||||
<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"/>
|
||||
</svg>
|
||||
{{ note().author ?? 'Auteur inconnu' }}
|
||||
{{ getAuthorFromFrontmatter() ?? note().author ?? 'Auteur inconnu' }}
|
||||
</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 [innerHTML]="sanitizedHtmlContent()"></div>
|
||||
|
||||
|
||||
@if (note().backlinks.length > 0) {
|
||||
<div class="mt-12 pt-6 border-t border-border not-prose">
|
||||
<h2 class="text-2xl font-bold mb-4">Backlinks</h2>
|
||||
@@ -185,10 +299,18 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
noteLinkClicked = output<string>();
|
||||
wikiLinkActivated = output<WikiLinkActivation>();
|
||||
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 sanitizer = inject(DomSanitizer);
|
||||
private readonly previewService = inject(NotePreviewService);
|
||||
private readonly clipboard = inject(ClipboardService);
|
||||
private readonly tagPaletteSize = 12;
|
||||
private readonly tagColorCache = new Map<string, number>();
|
||||
private readonly copyFeedbackTimers = new Map<HTMLElement, number>();
|
||||
@@ -206,7 +328,9 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
private previewOpenSub: Subscription | null = null;
|
||||
|
||||
readonly metadataExpanded = signal(false);
|
||||
readonly menuOpen = signal(false);
|
||||
readonly maxMetadataPreviewItems = 3;
|
||||
readonly copyStatus = signal('');
|
||||
|
||||
readonly sanitizedHtmlContent = computed<SafeHtml>(() =>
|
||||
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 [];
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
this.mermaidObserver?.disconnect();
|
||||
this.mermaidObserver = null;
|
||||
@@ -962,4 +1094,46 @@ export class NoteViewerComponent implements OnDestroy {
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user