chore: update TypeScript build info cache
This commit is contained in:
@@ -1,5 +1,14 @@
|
||||
<!-- ObsiViewer - Application optimisée pour mobile et desktop -->
|
||||
<main class="relative flex min-h-screen flex-col bg-obs-l-bg-main text-obs-l-text-main dark:bg-obs-d-bg-main dark:text-obs-d-text-main lg:flex-row lg:h-screen lg:overflow-hidden">
|
||||
@if (isRawViewOpen()) {
|
||||
<app-raw-view-overlay
|
||||
[content]="rawNoteContent()"
|
||||
[filename]="rawNoteFilename()"
|
||||
[wrap]="isRawViewWrapped()"
|
||||
(close)="closeRawView()"
|
||||
(toggleWrap)="toggleRawWrap()"
|
||||
></app-raw-view-overlay>
|
||||
}
|
||||
<!-- Navigation latérale desktop -->
|
||||
<nav class="hidden w-14 flex-col items-center gap-4 border-r border-obs-l-border bg-obs-l-bg-main py-4 dark:border-obs-d-border dark:bg-obs-d-bg-main lg:flex">
|
||||
<button
|
||||
@@ -318,6 +327,38 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary disabled:opacity-40 disabled:pointer-events-none"
|
||||
(click)="toggleRawView()"
|
||||
[disabled]="!selectedNote()"
|
||||
aria-label="Afficher le markdown brut"
|
||||
aria-keyshortcuts="Alt+R"
|
||||
title="Vue brute (Alt+R)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||
<path d="M8.5 8 5 12l3.5 4" />
|
||||
<path d="M15.5 8 19 12l-3.5 4" />
|
||||
<path d="M12.5 6 11 18" />
|
||||
</svg>
|
||||
<span class="sr-only">Alt+R</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary disabled:opacity-40 disabled:pointer-events-none"
|
||||
(click)="downloadCurrentNote()"
|
||||
[disabled]="!selectedNote()"
|
||||
aria-label="Télécharger le fichier markdown"
|
||||
aria-keyshortcuts="Alt+D"
|
||||
title="Télécharger (Alt+D)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||
<path d="M12 3v12" />
|
||||
<path d="M7 11l5 5 5-5" />
|
||||
<path d="M5 19h14" />
|
||||
</svg>
|
||||
<span class="sr-only">Alt+D</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleTheme()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
@@ -345,6 +386,38 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between lg:gap-6">
|
||||
<div class="flex items-center gap-2 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary disabled:opacity-40 disabled:pointer-events-none"
|
||||
(click)="toggleRawView()"
|
||||
[disabled]="!selectedNote()"
|
||||
aria-label="Afficher le markdown brut"
|
||||
aria-keyshortcuts="Alt+R"
|
||||
title="Vue brute (Alt+R)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||
<path d="M8.5 8 5 12l3.5 4" />
|
||||
<path d="M15.5 8 19 12l-3.5 4" />
|
||||
<path d="M12.5 6 11 18" />
|
||||
</svg>
|
||||
<span class="sr-only">Raccourci Alt+R</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary disabled:opacity-40 disabled:pointer-events-none"
|
||||
(click)="downloadCurrentNote()"
|
||||
[disabled]="!selectedNote()"
|
||||
aria-label="Télécharger le fichier markdown"
|
||||
aria-keyshortcuts="Alt+D"
|
||||
title="Télécharger (Alt+D)"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||
<path d="M12 3v12" />
|
||||
<path d="M7 11l5 5 5-5" />
|
||||
<path d="M5 19h14" />
|
||||
</svg>
|
||||
<span class="sr-only">Raccourci Alt+D</span>
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleTheme()"
|
||||
class="rounded-lg p-2 text-obs-l-text-muted transition hover:bg-obs-l-bg-secondary dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-secondary"
|
||||
|
||||
+119
-2
@@ -1,9 +1,11 @@
|
||||
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, ElementRef, OnDestroy } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, HostListener, inject, signal, computed, effect, ElementRef, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
// Services
|
||||
import { VaultService } from './services/vault.service';
|
||||
import { MarkdownService } from './services/markdown.service';
|
||||
import { MarkdownViewerService } from './services/markdown-viewer.service';
|
||||
import { DownloadService } from './core/services/download.service';
|
||||
|
||||
// Components
|
||||
import { FileExplorerComponent } from './components/file-explorer/file-explorer.component';
|
||||
@@ -11,6 +13,7 @@ import { NoteViewerComponent, WikiLinkActivation } from './components/note-viewe
|
||||
import { GraphViewComponent } from './components/graph-view/graph-view.component';
|
||||
import { TagsViewComponent } from './components/tags-view/tags-view.component';
|
||||
import { MarkdownCalendarComponent } from './components/markdown-calendar/markdown-calendar.component';
|
||||
import { RawViewOverlayComponent } from './shared/overlays/raw-view-overlay.component';
|
||||
|
||||
// Types
|
||||
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
|
||||
@@ -31,6 +34,7 @@ interface TocEntry {
|
||||
GraphViewComponent,
|
||||
TagsViewComponent,
|
||||
MarkdownCalendarComponent,
|
||||
RawViewOverlayComponent,
|
||||
],
|
||||
templateUrl: './app.component.simple.html',
|
||||
styleUrls: ['./app.component.css'],
|
||||
@@ -39,6 +43,8 @@ interface TocEntry {
|
||||
export class AppComponent implements OnDestroy {
|
||||
private vaultService = inject(VaultService);
|
||||
private markdownService = inject(MarkdownService);
|
||||
private markdownViewerService = inject(MarkdownViewerService);
|
||||
private downloadService = inject(DownloadService);
|
||||
private elementRef = inject(ElementRef);
|
||||
|
||||
// --- State Signals ---
|
||||
@@ -51,10 +57,13 @@ export class AppComponent implements OnDestroy {
|
||||
tableOfContents = signal<TocEntry[]>([]);
|
||||
leftSidebarWidth = signal<number>(288);
|
||||
rightSidebarWidth = signal<number>(288);
|
||||
isRawViewOpen = signal<boolean>(false);
|
||||
isRawViewWrapped = signal<boolean>(true);
|
||||
readonly LEFT_MIN_WIDTH = 220;
|
||||
readonly LEFT_MAX_WIDTH = 520;
|
||||
readonly RIGHT_MIN_WIDTH = 220;
|
||||
readonly RIGHT_MAX_WIDTH = 520;
|
||||
private rawViewTriggerElement: HTMLElement | null = null;
|
||||
|
||||
private viewportWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||
private resizeHandler = () => {
|
||||
@@ -98,6 +107,23 @@ export class AppComponent implements OnDestroy {
|
||||
return this.markdownService.render(note.content, allNotes, note);
|
||||
});
|
||||
|
||||
rawNoteContent = computed<string>(() => {
|
||||
const note = this.selectedNote();
|
||||
if (!note) {
|
||||
return '';
|
||||
}
|
||||
return note.rawContent ?? note.content ?? '';
|
||||
});
|
||||
|
||||
rawNoteFilename = computed<string>(() => {
|
||||
const note = this.selectedNote();
|
||||
if (!note) {
|
||||
return this.buildFallbackFilename();
|
||||
}
|
||||
const name = note.fileName?.trim();
|
||||
return name && name.length > 0 ? name : this.buildFallbackFilename();
|
||||
});
|
||||
|
||||
selectedNoteBreadcrumb = computed<string[]>(() => {
|
||||
const note = this.selectedNote();
|
||||
if (!note) {
|
||||
@@ -195,14 +221,22 @@ export class AppComponent implements OnDestroy {
|
||||
|
||||
// Effect to generate Table of Contents when the note changes
|
||||
effect(() => {
|
||||
const note = this.selectedNote();
|
||||
this.markdownViewerService.setCurrentNote(note ?? null);
|
||||
const html = this.renderedNoteContent();
|
||||
if (html && this.selectedNote()) {
|
||||
if (html && note) {
|
||||
this.generateToc(html);
|
||||
} else {
|
||||
this.tableOfContents.set([]);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!this.selectedNote()) {
|
||||
this.isRawViewOpen.set(false);
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
const pending = this.pendingWikiNavigation();
|
||||
const activeNoteId = this.selectedNoteId();
|
||||
@@ -408,6 +442,7 @@ export class AppComponent implements OnDestroy {
|
||||
|
||||
this.vaultService.ensureFolderOpen(note.originalPath);
|
||||
this.selectedNoteId.set(note.id);
|
||||
this.markdownViewerService.setCurrentNote(note);
|
||||
|
||||
if (!this.isDesktopView() && this.activeView() === 'search') {
|
||||
this.isSidebarOpen.set(false);
|
||||
@@ -421,6 +456,88 @@ export class AppComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
toggleRawView(): void {
|
||||
if (!this.selectedNote()) {
|
||||
return;
|
||||
}
|
||||
if (this.isRawViewOpen()) {
|
||||
this.closeRawView();
|
||||
} else {
|
||||
this.openRawView();
|
||||
}
|
||||
}
|
||||
|
||||
openRawView(): void {
|
||||
if (!this.selectedNote()) {
|
||||
return;
|
||||
}
|
||||
if (typeof document !== 'undefined') {
|
||||
this.rawViewTriggerElement = document.activeElement as HTMLElement | null;
|
||||
}
|
||||
this.isRawViewOpen.set(true);
|
||||
}
|
||||
|
||||
closeRawView(): void {
|
||||
this.isRawViewOpen.set(false);
|
||||
const target = this.rawViewTriggerElement;
|
||||
this.rawViewTriggerElement = null;
|
||||
if (target && typeof target.focus === 'function') {
|
||||
queueMicrotask(() => target.focus());
|
||||
}
|
||||
}
|
||||
|
||||
toggleRawWrap(): void {
|
||||
this.isRawViewWrapped.update(value => !value);
|
||||
}
|
||||
|
||||
downloadCurrentNote(): void {
|
||||
const note = this.selectedNote();
|
||||
if (!note) {
|
||||
return;
|
||||
}
|
||||
const filename = this.rawNoteFilename();
|
||||
const content = this.rawNoteContent();
|
||||
if (!content) {
|
||||
console.warn('[ObsiViewer] Aucun contenu à télécharger.');
|
||||
return;
|
||||
}
|
||||
this.downloadService.downloadText(content, filename);
|
||||
}
|
||||
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
handleGlobalKeydown(event: KeyboardEvent): void {
|
||||
if (!event.altKey || event.repeat) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = event.key.toLowerCase();
|
||||
if (key === 'r') {
|
||||
if (!this.selectedNote()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.toggleRawView();
|
||||
}
|
||||
if (key === 'd') {
|
||||
if (!this.selectedNote()) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
this.downloadCurrentNote();
|
||||
}
|
||||
}
|
||||
|
||||
private buildFallbackFilename(): string {
|
||||
const now = new Date();
|
||||
const pad = (value: number) => value.toString().padStart(2, '0');
|
||||
const year = now.getFullYear();
|
||||
const month = pad(now.getMonth() + 1);
|
||||
const day = pad(now.getDate());
|
||||
const hours = pad(now.getHours());
|
||||
const minutes = pad(now.getMinutes());
|
||||
return `note-${year}${month}${day}-${hours}${minutes}.md`;
|
||||
}
|
||||
|
||||
handleWikiLink(link: WikiLinkActivation): void {
|
||||
const target = link.target?.trim();
|
||||
if (!target) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert/strict';
|
||||
import { DownloadService } from './download.service.js';
|
||||
|
||||
const makeFakeDocument = () => {
|
||||
const appendCalls: unknown[] = [];
|
||||
const removeCalls: unknown[] = [];
|
||||
const anchor = {
|
||||
href: '',
|
||||
download: '',
|
||||
rel: '',
|
||||
style: {} as Record<string, unknown>,
|
||||
clickCount: 0,
|
||||
click() {
|
||||
this.clickCount += 1;
|
||||
},
|
||||
};
|
||||
|
||||
const documentMock = {
|
||||
createElement(tag: string) {
|
||||
if (tag !== 'a') {
|
||||
throw new Error(`Unexpected element request: ${tag}`);
|
||||
}
|
||||
return anchor;
|
||||
},
|
||||
body: {
|
||||
appendChild(node: unknown) {
|
||||
appendCalls.push(node);
|
||||
},
|
||||
removeChild(node: unknown) {
|
||||
removeCalls.push(node);
|
||||
},
|
||||
},
|
||||
} as unknown as Document;
|
||||
|
||||
return { documentMock, anchor, appendCalls, removeCalls };
|
||||
};
|
||||
|
||||
test('DownloadService downloads normalized UTF-8 content', async () => {
|
||||
const service = new DownloadService();
|
||||
const originalDocument = globalThis.document;
|
||||
const NativeURL = globalThis.URL;
|
||||
|
||||
const { documentMock, anchor, appendCalls, removeCalls } = makeFakeDocument();
|
||||
let lastBlob: Blob | null = null;
|
||||
const revoked: string[] = [];
|
||||
|
||||
const urlMock = Object.assign(
|
||||
function MockURL(url: string | URL, base?: string | URL) {
|
||||
return new NativeURL(url as string, base as string);
|
||||
},
|
||||
{
|
||||
prototype: NativeURL.prototype,
|
||||
canParse: () => true,
|
||||
parse: (url: string | URL, base?: string | URL) => new NativeURL(url as string, base as string),
|
||||
createObjectURL(blob: Blob) {
|
||||
lastBlob = blob;
|
||||
return 'blob:test-url';
|
||||
},
|
||||
revokeObjectURL(url: string) {
|
||||
revoked.push(url);
|
||||
},
|
||||
}
|
||||
) as unknown as typeof URL;
|
||||
|
||||
globalThis.document = documentMock;
|
||||
globalThis.URL = urlMock;
|
||||
|
||||
try {
|
||||
service.downloadText('line1\r\nline2', 'note.md');
|
||||
|
||||
assert.equal(anchor.download, 'note.md');
|
||||
assert.equal(anchor.rel, 'noopener');
|
||||
assert.equal(anchor.clickCount, 1);
|
||||
assert.equal(appendCalls.length, 1);
|
||||
assert.equal(removeCalls.length, 1);
|
||||
assert.equal(revoked.length, 1);
|
||||
assert.ok(lastBlob, 'Blob should be created');
|
||||
const blobContent = await lastBlob!.text();
|
||||
assert.equal(blobContent, 'line1\nline2');
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.URL = NativeURL;
|
||||
}
|
||||
});
|
||||
|
||||
test('DownloadService warns when content is empty', () => {
|
||||
const service = new DownloadService();
|
||||
const originalDocument = globalThis.document;
|
||||
const NativeURL = globalThis.URL;
|
||||
const originalWarn = console.warn;
|
||||
|
||||
const { documentMock } = makeFakeDocument();
|
||||
const urlMock = Object.assign(
|
||||
function MockURL(url: string | URL, base?: string | URL) {
|
||||
return new NativeURL(url as string, base as string);
|
||||
},
|
||||
{
|
||||
prototype: NativeURL.prototype,
|
||||
canParse: () => true,
|
||||
parse: (url: string | URL, base?: string | URL) => new NativeURL(url as string, base as string),
|
||||
createObjectURL() {
|
||||
return 'blob:noop';
|
||||
},
|
||||
revokeObjectURL() {
|
||||
// noop
|
||||
},
|
||||
}
|
||||
) as unknown as typeof URL;
|
||||
|
||||
globalThis.document = documentMock;
|
||||
globalThis.URL = urlMock;
|
||||
|
||||
let warned = false;
|
||||
console.warn = () => {
|
||||
warned = true;
|
||||
};
|
||||
|
||||
try {
|
||||
service.downloadText('', 'empty.md');
|
||||
assert.equal(warned, true);
|
||||
} finally {
|
||||
console.warn = originalWarn;
|
||||
globalThis.document = originalDocument;
|
||||
globalThis.URL = NativeURL;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class DownloadService {
|
||||
downloadText(content: string, filename: string, mime = 'text/markdown;charset=utf-8'): void {
|
||||
if (!content) {
|
||||
console.warn('[DownloadService] Empty content provided for download.');
|
||||
}
|
||||
|
||||
const normalized = content.replace(/\r\n/g, '\n');
|
||||
const blob = new Blob([normalized], { type: mime });
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
try {
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
link.rel = 'noopener';
|
||||
link.style.display = 'none';
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
} finally {
|
||||
setTimeout(() => URL.revokeObjectURL(url), 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert/strict';
|
||||
import { firstValueFrom, skip, take } from 'rxjs';
|
||||
import { MarkdownViewerService } from './markdown-viewer.service.js';
|
||||
import type { Note } from '../../types.js';
|
||||
|
||||
test('MarkdownViewerService exposes current note streams', async () => {
|
||||
const service = new MarkdownViewerService();
|
||||
|
||||
const note: Note = {
|
||||
id: 'note-1',
|
||||
title: 'Test note',
|
||||
content: 'rendered content',
|
||||
rawContent: 'raw markdown',
|
||||
tags: [],
|
||||
frontmatter: {},
|
||||
backlinks: [],
|
||||
mtime: Date.now(),
|
||||
fileName: 'note-1.md',
|
||||
filePath: '/note-1.md',
|
||||
originalPath: 'note-1',
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
};
|
||||
|
||||
service.setCurrentNote(note);
|
||||
|
||||
const observedNote = await firstValueFrom(service.getCurrentNote$().pipe(skip(1), take(1)));
|
||||
const observedRaw = await firstValueFrom(service.getCurrentRaw$().pipe(skip(1), take(1)));
|
||||
const observedFilename = await firstValueFrom(service.getCurrentFilename$().pipe(skip(1), take(1)));
|
||||
|
||||
assert.equal(observedNote?.id, note.id);
|
||||
assert.equal(observedRaw, note.rawContent);
|
||||
assert.equal(observedFilename, note.fileName);
|
||||
});
|
||||
|
||||
test('MarkdownViewerService falls back to content when rawContent missing', async () => {
|
||||
const service = new MarkdownViewerService();
|
||||
|
||||
const note: Note = {
|
||||
id: 'note-2',
|
||||
title: 'Another note',
|
||||
content: 'fallback content',
|
||||
rawContent: undefined as unknown as string,
|
||||
tags: [],
|
||||
frontmatter: {},
|
||||
backlinks: [],
|
||||
mtime: Date.now(),
|
||||
fileName: 'note-2.md',
|
||||
filePath: '/note-2.md',
|
||||
originalPath: 'note-2',
|
||||
createdAt: undefined,
|
||||
updatedAt: undefined,
|
||||
};
|
||||
|
||||
service.setCurrentNote(note);
|
||||
|
||||
const observedRaw = await firstValueFrom(service.getCurrentRaw$().pipe(skip(1), take(1)));
|
||||
assert.equal(observedRaw, note.content);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { BehaviorSubject, Observable } from 'rxjs';
|
||||
import { Note } from '../../types';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class MarkdownViewerService {
|
||||
private readonly currentNoteSubject = new BehaviorSubject<Note | null>(null);
|
||||
private readonly currentRawSubject = new BehaviorSubject<string>('');
|
||||
private readonly currentFilenameSubject = new BehaviorSubject<string | null>(null);
|
||||
|
||||
getCurrentNote$(): Observable<Note | null> {
|
||||
return this.currentNoteSubject.asObservable();
|
||||
}
|
||||
|
||||
getCurrentRaw$(): Observable<string> {
|
||||
return this.currentRawSubject.asObservable();
|
||||
}
|
||||
|
||||
getCurrentFilename$(): Observable<string | null> {
|
||||
return this.currentFilenameSubject.asObservable();
|
||||
}
|
||||
|
||||
setCurrentNote(note: Note | null): void {
|
||||
if (!note) {
|
||||
this.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentNoteSubject.next(note);
|
||||
const raw = typeof note.rawContent === 'string' ? note.rawContent : note.content;
|
||||
this.currentRawSubject.next(raw ?? '');
|
||||
this.currentFilenameSubject.next(note.fileName ?? null);
|
||||
}
|
||||
|
||||
clear(): void {
|
||||
this.currentNoteSubject.next(null);
|
||||
this.currentRawSubject.next('');
|
||||
this.currentFilenameSubject.next(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { MarkdownViewerService } from '../core/services/markdown-viewer.service';
|
||||
@@ -237,7 +237,8 @@ export class VaultService implements OnDestroy {
|
||||
let detectedHomeVaultName: string | null = null;
|
||||
|
||||
for (const apiNote of notes) {
|
||||
const { frontmatter, body } = this.parseFrontmatter(apiNote.content);
|
||||
const normalizedRaw = apiNote.content.replace(/\r\n/g, '\n');
|
||||
const { frontmatter, body } = this.parseFrontmatter(normalizedRaw);
|
||||
const derivedTitle = this.extractTitle(body, apiNote.id);
|
||||
const noteTitle = frontmatter.title || apiNote.title || derivedTitle;
|
||||
|
||||
@@ -265,6 +266,7 @@ export class VaultService implements OnDestroy {
|
||||
id: apiNote.id,
|
||||
title: noteTitle,
|
||||
content: body,
|
||||
rawContent: normalizedRaw,
|
||||
tags: Array.from(tagSet),
|
||||
frontmatter,
|
||||
backlinks: [],
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<div class="raw-overlay__backdrop" (click)="onBackdropClick()">
|
||||
<div
|
||||
class="raw-overlay__panel"
|
||||
cdkTrapFocus
|
||||
[cdkTrapFocusAutoCapture]="true"
|
||||
(click)="onPanelClick($event)"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
[attr.aria-labelledby]="headingId"
|
||||
>
|
||||
<header class="raw-overlay__header">
|
||||
<div class="raw-overlay__title-group">
|
||||
<h2 class="raw-overlay__title" [id]="headingId">Vue brute</h2>
|
||||
<p class="raw-overlay__subtitle" *ngIf="filename">{{ filename }}</p>
|
||||
</div>
|
||||
<div class="raw-overlay__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="raw-overlay__action"
|
||||
(click)="copyAll()"
|
||||
aria-label="Copier tout"
|
||||
>
|
||||
Copier tout
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="raw-overlay__action"
|
||||
(click)="onToggleWrap()"
|
||||
[attr.aria-pressed]="wrap"
|
||||
[attr.aria-label]="wrapButtonAriaLabel"
|
||||
>
|
||||
@if (wrap) {
|
||||
<span>Retour auto désactivé</span>
|
||||
} @else {
|
||||
<span>Retour auto activé</span>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="raw-overlay__action raw-overlay__action--primary"
|
||||
(click)="close.emit()"
|
||||
aria-label="Fermer la vue brute"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="raw-overlay__feedback" *ngIf="feedback() as message" role="status" aria-live="polite">
|
||||
{{ message }}
|
||||
</div>
|
||||
|
||||
<section
|
||||
class="raw-overlay__content"
|
||||
[class.raw-overlay__content--pre-wrap]="wrap"
|
||||
tabindex="0"
|
||||
aria-label="Zone de contenu markdown brut"
|
||||
>
|
||||
<pre>{{ content }}</pre>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,187 @@
|
||||
@use 'sass:color';
|
||||
|
||||
:host {
|
||||
--raw-overlay-bg: #f7fdfb;
|
||||
--raw-overlay-border: rgba(14, 116, 144, 0.18);
|
||||
--raw-overlay-text: #0f172a;
|
||||
--raw-overlay-accent: rgba(14, 116, 144, 0.14);
|
||||
--raw-overlay-accent-strong: rgba(14, 116, 144, 0.22);
|
||||
--raw-overlay-action-text: #0f766e;
|
||||
--raw-overlay-action-border: rgba(13, 148, 136, 0.45);
|
||||
--raw-overlay-action-bg: rgba(13, 148, 136, 0.12);
|
||||
--raw-overlay-action-bg-hover: rgba(13, 148, 136, 0.2);
|
||||
--raw-overlay-action-border-hover: rgba(13, 148, 136, 0.6);
|
||||
--raw-overlay-primary-bg: rgba(21, 128, 61, 0.18);
|
||||
--raw-overlay-primary-border: rgba(21, 128, 61, 0.55);
|
||||
--raw-overlay-primary-bg-hover: rgba(21, 128, 61, 0.28);
|
||||
--raw-overlay-primary-text: #166534;
|
||||
--raw-overlay-primary-border-hover: rgba(21, 128, 61, 0.7);
|
||||
}
|
||||
|
||||
:host-context(.dark) {
|
||||
--raw-overlay-bg: rgba(32, 33, 35, 0.96); /* #202123 */
|
||||
--raw-overlay-border: rgba(99, 102, 106, 0.35);
|
||||
--raw-overlay-text: #e5e7eb;
|
||||
--raw-overlay-accent: rgba(45, 46, 48, 0.88); /* #2D2E30 */
|
||||
--raw-overlay-accent-strong: rgba(61, 62, 65, 0.92);
|
||||
--raw-overlay-action-text: #34d399;
|
||||
--raw-overlay-action-border: rgba(52, 211, 153, 0.35);
|
||||
--raw-overlay-action-bg: rgba(12, 84, 68, 0.32);
|
||||
--raw-overlay-action-bg-hover: rgba(16, 185, 129, 0.36);
|
||||
--raw-overlay-action-border-hover: rgba(52, 211, 153, 0.55);
|
||||
--raw-overlay-primary-bg: rgba(22, 163, 74, 0.32);
|
||||
--raw-overlay-primary-border: rgba(22, 163, 74, 0.55);
|
||||
--raw-overlay-primary-bg-hover: rgba(22, 163, 74, 0.42);
|
||||
--raw-overlay-primary-text: #bbf7d0;
|
||||
--raw-overlay-primary-border-hover: rgba(22, 163, 74, 0.75);
|
||||
}
|
||||
|
||||
.raw-overlay__backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 60;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
padding: clamp(1rem, 2vw, 2rem);
|
||||
background-color: rgba(15, 23, 42, 0.55);
|
||||
backdrop-filter: blur(6px);
|
||||
}
|
||||
|
||||
.raw-overlay__panel {
|
||||
width: min(960px, calc(100vw - clamp(2rem, 4vw, 4rem)));
|
||||
max-height: calc(100vh - clamp(2rem, 4vw, 4rem));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border-radius: 1.25rem;
|
||||
border: 1px solid var(--raw-overlay-border);
|
||||
background: var(--raw-overlay-bg);
|
||||
color: var(--raw-overlay-text);
|
||||
box-shadow: 0 28px 52px -16px rgba(15, 23, 42, 0.55);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.raw-overlay__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
padding: 1.5rem clamp(1.25rem, 2vw, 2rem);
|
||||
border-bottom: 1px solid rgba(148, 163, 184, 0.12);
|
||||
}
|
||||
|
||||
.raw-overlay__title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.raw-overlay__title {
|
||||
margin: 0;
|
||||
font-size: clamp(1.25rem, 2vw, 1.5rem);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.raw-overlay__subtitle {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.raw-overlay__actions {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.raw-overlay__action {
|
||||
appearance: none;
|
||||
border: 1px solid var(--raw-overlay-action-border);
|
||||
border-radius: 9999px;
|
||||
padding: 0.45rem 1.1rem;
|
||||
background: var(--raw-overlay-action-bg);
|
||||
color: var(--raw-overlay-action-text);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.raw-overlay__action:hover,
|
||||
.raw-overlay__action:focus-visible {
|
||||
border-color: var(--raw-overlay-action-border-hover);
|
||||
background: var(--raw-overlay-action-bg-hover);
|
||||
color: var(--raw-overlay-action-text);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.raw-overlay__action--primary {
|
||||
background: var(--raw-overlay-primary-bg);
|
||||
border-color: var(--raw-overlay-primary-border);
|
||||
color: var(--raw-overlay-primary-text);
|
||||
}
|
||||
|
||||
.raw-overlay__action--primary:hover,
|
||||
.raw-overlay__action--primary:focus-visible {
|
||||
background: var(--raw-overlay-primary-bg-hover);
|
||||
border-color: var(--raw-overlay-primary-border-hover);
|
||||
}
|
||||
|
||||
.raw-overlay__feedback {
|
||||
margin: 0.75rem clamp(1.25rem, 2vw, 2rem) 0;
|
||||
padding: 0.6rem 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(21, 128, 61, 0.18);
|
||||
color: rgba(22, 163, 74, 0.9);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
:host-context(.dark) .raw-overlay__feedback {
|
||||
background: rgba(34, 197, 94, 0.25);
|
||||
color: rgba(190, 242, 100, 0.95);
|
||||
}
|
||||
|
||||
.raw-overlay__content {
|
||||
flex: 1;
|
||||
margin: clamp(1rem, 2vw, 1.5rem) clamp(1.25rem, 2vw, 2rem) clamp(1.5rem, 3vw, 2rem);
|
||||
padding: clamp(1rem, 2vw, 1.5rem);
|
||||
border-radius: 1rem;
|
||||
background: var(--raw-overlay-accent);
|
||||
border: 1px solid var(--raw-overlay-border);
|
||||
overflow: auto;
|
||||
font-family: ui-monospace, SFMono-Regular, SFMono, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1.45;
|
||||
white-space: pre;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.raw-overlay__content pre {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.raw-overlay__content--pre-wrap {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
:host-context(.dark) .raw-overlay__content {
|
||||
background: var(--raw-overlay-accent);
|
||||
border-color: var(--raw-overlay-accent-strong);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.raw-overlay__panel {
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.raw-overlay__actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.raw-overlay__content {
|
||||
max-height: calc(100vh - 14rem);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { test } from 'node:test';
|
||||
import { strict as assert } from 'node:assert/strict';
|
||||
import { RawViewOverlayComponent } from './raw-view-overlay.component.js';
|
||||
|
||||
test('wrap button aria label reflects current state', () => {
|
||||
const component = new RawViewOverlayComponent();
|
||||
component.wrap = true;
|
||||
assert.equal(component.wrapButtonAriaLabel, 'Désactiver le retour automatique à la ligne');
|
||||
|
||||
component.wrap = false;
|
||||
assert.equal(component.wrapButtonAriaLabel, 'Activer le retour automatique à la ligne');
|
||||
});
|
||||
|
||||
test('toggle wrap emits through output', () => {
|
||||
const component = new RawViewOverlayComponent();
|
||||
let emitted = false;
|
||||
component.toggleWrap.subscribe(() => {
|
||||
emitted = true;
|
||||
});
|
||||
|
||||
component.onToggleWrap();
|
||||
assert.equal(emitted, true);
|
||||
});
|
||||
|
||||
test('close event emits on backdrop click and Esc key', () => {
|
||||
const component = new RawViewOverlayComponent();
|
||||
let closeCount = 0;
|
||||
component.close.subscribe(() => {
|
||||
closeCount += 1;
|
||||
});
|
||||
|
||||
component.onBackdropClick();
|
||||
component.handleKeydown(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
assert.equal(closeCount, 2);
|
||||
});
|
||||
|
||||
test('copyAll uses clipboard when available', async () => {
|
||||
const component = new RawViewOverlayComponent();
|
||||
component.content = 'Example content';
|
||||
|
||||
const originalNavigator = globalThis.navigator;
|
||||
let captured = '';
|
||||
const mockNavigator = Object.assign(
|
||||
Object.create(originalNavigator ? Object.getPrototypeOf(originalNavigator) : {}),
|
||||
originalNavigator ?? {},
|
||||
{
|
||||
clipboard: {
|
||||
writeText: async (text: string) => {
|
||||
captured = text;
|
||||
},
|
||||
},
|
||||
},
|
||||
) as Navigator;
|
||||
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: mockNavigator,
|
||||
});
|
||||
|
||||
try {
|
||||
await component.copyAll();
|
||||
assert.equal(captured, 'Example content');
|
||||
assert.equal(component.feedback(), component.copySuccessMessage);
|
||||
} finally {
|
||||
if (originalNavigator) {
|
||||
Object.defineProperty(globalThis, 'navigator', {
|
||||
configurable: true,
|
||||
value: originalNavigator,
|
||||
});
|
||||
} else {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
delete (globalThis as any).navigator;
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { CdkTrapFocus } from '@angular/cdk/a11y';
|
||||
import { ChangeDetectionStrategy, Component, EventEmitter, HostListener, Input, OnDestroy, Output, signal } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-raw-view-overlay',
|
||||
standalone: true,
|
||||
imports: [CommonModule, CdkTrapFocus],
|
||||
templateUrl: './raw-view-overlay.component.html',
|
||||
styleUrls: ['./raw-view-overlay.component.scss'],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class RawViewOverlayComponent implements OnDestroy {
|
||||
@Input({ required: true }) content = '';
|
||||
@Input() filename: string | null = null;
|
||||
@Input() wrap = true;
|
||||
|
||||
@Output() close = new EventEmitter<void>();
|
||||
@Output() toggleWrap = new EventEmitter<void>();
|
||||
|
||||
readonly feedback = signal<string | null>(null);
|
||||
|
||||
private feedbackTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
readonly headingId = 'raw-view-overlay-title';
|
||||
readonly copySuccessMessage = 'Copié dans le presse-papiers';
|
||||
readonly copyErrorMessage = 'Copie impossible';
|
||||
private readonly wrapDisableLabel = 'Désactiver le retour automatique à la ligne';
|
||||
private readonly wrapEnableLabel = 'Activer le retour automatique à la ligne';
|
||||
|
||||
get wrapButtonAriaLabel(): string {
|
||||
return this.wrap ? this.wrapDisableLabel : this.wrapEnableLabel;
|
||||
}
|
||||
|
||||
@HostListener('document:keydown', ['$event'])
|
||||
handleKeydown(event: KeyboardEvent): void {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
this.close.emit();
|
||||
}
|
||||
}
|
||||
|
||||
onBackdropClick(): void {
|
||||
this.close.emit();
|
||||
}
|
||||
|
||||
onPanelClick(event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
onToggleWrap(): void {
|
||||
this.toggleWrap.emit();
|
||||
}
|
||||
|
||||
async copyAll(): Promise<void> {
|
||||
try {
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
await navigator.clipboard.writeText(this.content);
|
||||
} else {
|
||||
this.fallbackCopy(this.content);
|
||||
}
|
||||
this.showFeedback(this.copySuccessMessage);
|
||||
} catch (error) {
|
||||
console.error('[RawViewOverlay] Unable to copy content', error);
|
||||
this.showFeedback(this.copyErrorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
if (this.feedbackTimer) {
|
||||
clearTimeout(this.feedbackTimer);
|
||||
this.feedbackTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private fallbackCopy(text: string): void {
|
||||
const textarea = document.createElement('textarea');
|
||||
textarea.value = text;
|
||||
textarea.style.position = 'fixed';
|
||||
textarea.style.opacity = '0';
|
||||
textarea.style.pointerEvents = 'none';
|
||||
document.body.appendChild(textarea);
|
||||
textarea.focus();
|
||||
textarea.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
} finally {
|
||||
document.body.removeChild(textarea);
|
||||
}
|
||||
}
|
||||
|
||||
private showFeedback(message: string): void {
|
||||
this.feedback.set(message);
|
||||
if (this.feedbackTimer) {
|
||||
clearTimeout(this.feedbackTimer);
|
||||
}
|
||||
this.feedbackTimer = setTimeout(() => {
|
||||
this.feedback.set(null);
|
||||
this.feedbackTimer = null;
|
||||
}, 2400);
|
||||
}
|
||||
}
|
||||
@@ -163,6 +163,18 @@
|
||||
-webkit-overflow-scrolling: touch;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0, 0, 0, 0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
&:root {
|
||||
--external-link: #6366f1;
|
||||
--external-link-hover: #4338ca;
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface Note {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
rawContent: string;
|
||||
tags: string[];
|
||||
frontmatter: { [key: string]: any };
|
||||
backlinks: string[];
|
||||
|
||||
Reference in New Issue
Block a user