chore: update TypeScript build info cache

This commit is contained in:
2025-09-28 23:04:52 -04:00
parent a8b60f87f6
commit 1c7a49fd0e
40 changed files with 4337 additions and 126 deletions
+73
View File
@@ -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
View File
@@ -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) {
+127
View File
@@ -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;
}
});
+29
View File
@@ -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);
}
}
+1
View File
@@ -0,0 +1 @@
export { MarkdownViewerService } from '../core/services/markdown-viewer.service';
+3 -1
View File
@@ -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);
}
}
+12
View File
@@ -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;
+1
View File
@@ -4,6 +4,7 @@ export interface Note {
id: string;
title: string;
content: string;
rawContent: string;
tags: string[];
frontmatter: { [key: string]: any };
backlinks: string[];