chore: update TypeScript build info cache for Angular 20.3.3

This commit is contained in:
2025-09-29 11:18:25 -04:00
parent 0e604c06d0
commit 0b72a6b810
20 changed files with 763 additions and 523 deletions
@@ -1,14 +1,14 @@
<ul class="p-2 space-y-1">
<ul class="space-y-1 p-2">
@for (node of nodes(); track node.name) {
<li>
@if (node.type === 'folder') {
@let folder = node;
<div>
<button (click)="toggleFolder(folder)" class="w-full flex items-center text-left p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
<button (click)="toggleFolder(folder)" class="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm font-semibold text-text-main transition hover:bg-bg-muted">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
<span class="font-semibold text-sm">{{ folder.name }}</span>
<span class="truncate">{{ folder.name }}</span>
</button>
@if (folder.isOpen) {
<div class="pl-4">
@@ -19,11 +19,10 @@
} @else {
@let file = node;
<button (click)="onFileSelected(file.id)"
class="w-full text-left p-1.5 rounded text-sm transition-colors"
[class]="file.id === selectedNoteId()
? 'bg-obs-l-accent text-white dark:bg-obs-d-accent'
: 'hover:bg-gray-200 dark:hover:bg-gray-600'">
<span class="pl-6">{{ file.name }}</span>
class="flex w-full items-center gap-3 rounded-lg px-3 py-1.5 text-left text-sm transition"
[ngClass]="file.id === selectedNoteId() ? 'bg-brand text-white shadow-subtle' : 'text-text-main hover:bg-bg-muted'">
<span class="inline-flex h-8 w-8 items-center justify-center rounded-lg border border-border bg-card text-xs font-semibold uppercase text-text-muted">{{ file.name | slice:0:2 }}</span>
<span class="truncate">{{ file.name }}</span>
</button>
}
</li>
@@ -12,18 +12,18 @@ interface SimulatedNode extends GraphNode {
@Component({
selector: 'app-graph-view',
template: `
<div class="w-full h-full relative">
<div class="relative h-full w-full">
<svg #graphSvg class="w-full h-full">
<g class="links">
@for (edge of graphData().edges; track edge.source + '-' + edge.target) {
<path [attr.d]="getEdgePath(edge)" class="stroke-obs-l-border dark:stroke-obs-d-border" fill="none"></path>
<path [attr.d]="getEdgePath(edge)" class="stroke-border/80" fill="none"></path>
}
</g>
<g class="nodes">
@for (node of simulatedNodes(); track node.id) {
<g [attr.transform]="'translate(' + node.x + ',' + node.y + ')'" (click)="selectNode(node.id)" class="cursor-pointer group">
<circle r="5" class="fill-current text-obs-l-accent dark:text-obs-d-accent group-hover:r-7 transition-all"></circle>
<text y="-12" text-anchor="middle" class="text-xs fill-current text-obs-l-text-main dark:text-obs-d-text-main pointer-events-none opacity-0 group-hover:opacity-100 transition-opacity">{{ node.label }}</text>
<circle r="5" class="fill-accent transition-all group-hover:r-7"></circle>
<text y="-12" text-anchor="middle" class="pointer-events-none text-xs fill-text-main opacity-0 transition-opacity group-hover:opacity-100">{{ node.label }}</text>
</g>
}
</g>
@@ -1,12 +1,12 @@
<div class="flex flex-col gap-4">
<header class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<p class="text-xs uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">Calendrier</p>
<h2 class="text-lg font-semibold text-obs-l-text-main dark:text-obs-d-text-main">
<p class="text-xs uppercase tracking-wide text-text-muted">Calendrier</p>
<h2 class="text-lg font-semibold text-text-main">
{{ viewDate() | calendarDate: 'monthViewTitle' : 'fr' }}
</h2>
@if (selectionLabel(); as label) {
<span class="mt-1 inline-flex items-center rounded-full bg-obs-l-bg-secondary/70 px-2 py-0.5 text-xs font-medium text-obs-l-text-muted dark:bg-obs-d-bg-secondary/60 dark:text-obs-d-text-muted">
<span class="mt-1 inline-flex items-center rounded-full border border-border bg-bg-muted px-2 py-0.5 text-xs font-medium text-text-muted">
{{ label }}
</span>
}
@@ -16,15 +16,16 @@
type="button"
(click)="shiftMonth(-1)"
aria-label="Mois précédent"
class="h-9 w-9 rounded-full border border-obs-l-border bg-obs-l-bg-main text-lg leading-none text-obs-l-text-main transition hover:bg-obs-l-bg-secondary dark:border-obs-d-border dark:bg-obs-d-bg-main dark:text-obs-d-text-main dark:hover:bg-obs-d-bg-secondary"
>
class="btn btn-sm btn-icon"
[ngClass]="'bg-card text-text-main'">
>
‹
</button>
<button
type="button"
(click)="goToToday()"
aria-label="Revenir à aujourd'hui"
class="inline-flex items-center gap-1 rounded-full border border-obs-l-border bg-obs-l-bg-main px-3 py-1.5 text-sm font-medium text-obs-l-text-main transition hover:bg-obs-l-bg-secondary dark:border-obs-d-border dark:bg-obs-d-bg-main dark:text-obs-d-text-main dark:hover:bg-obs-d-bg-secondary"
class="btn btn-sm btn-secondary"
>
Aujourd'hui
</button>
@@ -32,14 +33,14 @@
type="button"
(click)="shiftMonth(1)"
aria-label="Mois suivant"
class="h-9 w-9 rounded-full border border-obs-l-border bg-obs-l-bg-main text-lg leading-none text-obs-l-text-main transition hover:bg-obs-l-bg-secondary dark:border-obs-d-border dark:bg-obs-d-bg-main dark:text-obs-d-text-main dark:hover:bg-obs-d-bg-secondary"
class="btn btn-sm btn-icon bg-card text-text-main"
>
›
</button>
</div>
</header>
<div class="rounded-2xl border border-obs-l-border bg-obs-l-bg-main/80 shadow-panel backdrop-blur-xs dark:border-obs-d-border dark:bg-obs-d-bg-secondary/80">
<div class="rounded-2xl border border-border bg-card shadow-surface backdrop-blur-xs">
<mwl-calendar-month-view
class="calendar-compact"
[viewDate]="viewDate()"
@@ -51,12 +52,12 @@
></mwl-calendar-month-view>
</div>
<div class="flex flex-wrap items-center justify-center gap-3 text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
<span class="inline-flex items-center gap-2 rounded-full bg-obs-l-bg-secondary/60 px-3 py-1 dark:bg-obs-d-bg-secondary/60">
<div class="flex flex-wrap items-center justify-center gap-3 text-xs text-text-muted">
<span class="inline-flex items-center gap-2 rounded-full border border-border bg-bg-muted px-3 py-1">
<span class="h-2 w-2 rounded-full bg-gradient-to-r from-green-500 to-emerald-400"></span>
Création
</span>
<span class="inline-flex items-center gap-2 rounded-full bg-obs-l-bg-secondary/60 px-3 py-1 dark:bg-obs-d-bg-secondary/60">
<span class="inline-flex items-center gap-2 rounded-full border border-border bg-bg-muted px-3 py-1">
<span class="h-2 w-2 rounded-full bg-gradient-to-r from-indigo-400 to-indigo-600"></span>
Modification
</span>
@@ -1,42 +1,42 @@
@if(note(); as note) {
<div class="max-w-7xl mx-auto flex">
<div class="flex-grow min-w-0 max-w-4xl p-8 lg:p-12">
<div class="mx-auto flex max-w-7xl">
<div class="flex-grow min-w-0 max-w-4xl px-6 py-8 lg:px-12 lg:py-12">
<header class="mb-8">
<h1 class="text-4xl font-bold text-obs-l-text-main dark:text-gray-100 mb-2">{{ note.title }}</h1>
<div class="text-sm text-obs-l-text-muted dark:text-obs-d-text-muted">
<span>Updated: {{ note.updatedAt | date:'medium' }}</span>
<h1 class="text-4xl font-bold text-text-main mb-2">{{ note.title }}</h1>
<div class="text-sm text-text-muted">
<span>Mise à jour&nbsp;: {{ note.updatedAt | date:'medium' }}</span>
</div>
<div class="mt-4 flex flex-wrap gap-2">
@for(tag of note.tags; track tag) {
<span class="bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300 text-xs font-medium px-2.5 py-1 rounded-full">{{ tag }}</span>
<span class="chip text-sm">{{ tag }}</span>
}
</div>
</header>
@if (getFrontmatterKeys(note.frontmatter).length > 0) {
<div class="mb-8 p-4 border border-obs-l-border dark:border-obs-d-border rounded-lg bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary">
<h3 class="font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted mb-2 text-sm">Metadata</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-2 text-sm">
<div class="mb-8 rounded-xl border border-border bg-card p-4 shadow-subtle">
<h3 class="mb-2 text-sm font-semibold uppercase tracking-wide text-text-muted">Métadonnées</h3>
<div class="grid grid-cols-2 gap-3 text-sm md:grid-cols-4">
@for (key of getFrontmatterKeys(note.frontmatter); track key) {
<div class="flex flex-col">
<span class="text-obs-l-text-muted dark:text-obs-d-text-muted capitalize">{{ key }}</span>
<span class="text-obs-l-text-main dark:text-obs-d-text-main font-medium">{{ note.frontmatter[key] }}</span>
<div class="flex flex-col gap-1">
<span class="text-xs uppercase tracking-wide text-text-muted">{{ key }}</span>
<span class="font-medium text-text-main">{{ note.frontmatter[key] }}</span>
</div>
}
</div>
</div>
}
<article class="prose dark:prose-invert prose-lg max-w-none prose-p:text-obs-l-text-main dark:prose-p:text-obs-d-text-main prose-headings:text-obs-l-text-main dark:prose-headings:text-white" [innerHTML]="noteHtmlContent()">
<article class="prose prose-lg prose-headings:text-text-main prose-p:text-text-main max-w-none dark:prose-invert" [innerHTML]="noteHtmlContent()">
</article>
@if (note.backlinks.length > 0) {
<footer class="mt-12 border-t border-obs-l-border dark:border-obs-d-border pt-6">
<h2 class="text-xl font-semibold mb-4 text-obs-l-text-main dark:text-gray-300">Backlinks</h2>
<footer class="mt-12 border-t border-border pt-6">
<h2 class="mb-4 text-xl font-semibold text-text-main">Backlinks</h2>
<ul class="space-y-2">
@for (backlinkId of note.backlinks; track backlinkId) {
<li>
<button (click)="noteLinkClicked.emit(backlinkId)" class="text-obs-l-accent dark:text-obs-d-accent hover:underline">
<button (click)="noteLinkClicked.emit(backlinkId)" class="text-sm font-medium text-accent hover:underline">
{{ formatBacklinkId(backlinkId) }}
</button>
</li>
@@ -47,16 +47,16 @@
</div>
@if (tableOfContents().length > 0) {
<aside class="w-64 flex-shrink-0 hidden lg:block">
<div class="sticky top-0 h-screen overflow-y-auto pt-8 lg:pt-12">
<nav class="pb-8">
<h3 class="text-sm font-semibold uppercase tracking-wider text-obs-l-text-muted dark:text-obs-d-text-muted mb-4">On this page</h3>
<aside class="hidden w-64 flex-shrink-0 lg:block">
<div class="sticky top-0 h-screen overflow-y-auto px-4 pt-8 lg:pt-12">
<nav class="rounded-xl border border-border bg-card p-4 shadow-subtle">
<h3 class="mb-4 text-sm font-semibold uppercase tracking-wide text-text-muted">Dans cette page</h3>
<ul class="space-y-1">
@for(item of tableOfContents(); track item.id) {
<li>
<button (click)="scrollToHeading(item.id)"
[style.padding-left.rem]="(item.level - 1) * 1"
class="block w-full text-left py-1 text-sm rounded text-obs-l-text-muted dark:text-obs-d-text-muted hover:text-obs-l-text-main dark:hover:text-obs-d-text-main transition-colors focus:outline-none focus:text-obs-l-text-main dark:focus:text-obs-d-text-main">
class="block w-full rounded-lg py-1 text-left text-sm text-text-muted transition-colors hover:text-text-main focus:outline-none focus:text-text-main">
{{ item.text }}
</button>
</li>
@@ -1,8 +1,20 @@
import { Component, ChangeDetectionStrategy, ElementRef, OnDestroy, afterNextRender, computed, effect, inject, input, output, signal } from '@angular/core';
import {
Component,
ChangeDetectionStrategy,
ElementRef,
OnDestroy,
afterNextRender,
computed,
effect,
inject,
input,
output,
signal,
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { Note } from '../../types';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import mermaid, { MermaidConfig, RenderResult } from 'mermaid';
import mermaid from 'mermaid';
type MermaidLib = typeof mermaid;
@@ -10,14 +22,20 @@ type MathJaxInstance = {
tex2chtml(math: string, options: { display: boolean }): HTMLElement;
startup: {
promise: Promise<void>;
document: {
clear(): void;
updateDocument(): void;
};
document: { clear(): void; updateDocument(): void };
};
};
type MetadataEntryType = 'text' | 'date' | 'email' | 'url' | 'number' | 'boolean' | 'image' | 'list' | 'object';
type MetadataEntryType =
| 'text'
| 'date'
| 'email'
| 'url'
| 'number'
| 'boolean'
| 'image'
| 'list'
| 'object';
export interface WikiLinkActivation {
target: string;
@@ -40,12 +58,14 @@ interface MetadataEntry {
@Component({
selector: 'app-note-viewer',
standalone: true,
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-obs-l-border dark:border-obs-d-border">
<div class="!mb-6 pb-2 border-b border-border">
<h1 class="!text-4xl !font-bold !mb-3">{{ note().title }}</h1>
@if (note().tags.length > 0) {
<div class="md-tag-group not-prose">
@for (tag of note().tags; track tag) {
@@ -86,35 +106,31 @@ interface MetadataEntry {
[class.is-expanded]="metadataExpanded()"
[attr.id]="metadataListId()"
aria-live="polite">
@for (entry of metadataVisibleEntries(); track entry.key) {
<div class="metadata-panel__item" [attr.data-type]="entry.type">
<div class="metadata-panel__icon" aria-hidden="true">{{ entry.icon }}</div>
<div class="metadata-panel__details">
<div class="metadata-panel__label">{{ entry.label }}</div>
@if (entry.type === 'image' && entry.imageUrl) {
<img class="metadata-panel__image" [src]="entry.imageUrl" [alt]="entry.label" loading="lazy">
} @else if ((entry.type === 'url' || entry.type === 'email') && entry.linkHref) {
<a class="metadata-panel__link" [href]="entry.linkHref" target="_blank" rel="noopener noreferrer">{{ entry.displayValue }}</a>
} @else if (entry.type === 'boolean') {
<div class="metadata-panel__value metadata-panel__value--boolean" [class.is-true]="entry.booleanValue">
<span class="metadata-panel__boolean-indicator" [class.is-checked]="entry.booleanValue"></span>
<span class="metadata-panel__boolean" [attr.data-value]="entry.booleanValue ? 'true' : 'false'">
{{ entry.displayValue }}
</div>
</span>
} @else {
<div class="metadata-panel__value">{{ entry.displayValue }}</div>
<span class="metadata-panel__text">{{ entry.displayValue }}</span>
}
<div class="metadata-panel__label">{{ entry.label }}</div>
</div>
</div>
}
</div>
@if (metadataEntries().length > maxMetadataPreviewItems) {
<div class="metadata-panel__actions">
<button
type="button"
class="metadata-panel__toggle"
(click)="toggleMetadataPanel()"
[attr.aria-controls]="metadataListId()"
[attr.aria-expanded]="metadataExpanded()">
<div class="mt-3">
<button type="button" class="btn btn-secondary" (click)="toggleMetadataPanel()">
{{ metadataExpanded() ? translate('metadata.collapse') : translate('metadata.showAll') }}
</button>
</div>
@@ -122,18 +138,34 @@ interface MetadataEntry {
</aside>
}
<div class="not-prose flex items-center gap-3 text-sm text-text-muted my-4">
<span class="inline-flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2h-1.5a1.5 1.5 0 01-3 0h-5a1.5 1.5 0 01-3 0H5a2 2 0 00-2 2v12a2 2 0 002 2z"/>
</svg>
{{ note().updatedAt | date:'medium' }}
</span>
<span class="inline-flex items-center gap-1">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<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' }}
</span>
</div>
<div [innerHTML]="sanitizedHtmlContent()"></div>
@if (note().backlinks.length > 0) {
<div class="mt-12 pt-6 border-t border-obs-l-border dark:border-obs-d-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>
<ul>
@for (backlinkId of note().backlinks; track backlinkId) {
<li class="mb-2">
<a
(click)="noteLinkClicked.emit(backlinkId)"
class="cursor-pointer text-obs-l-accent dark:text-obs-d-accent hover:underline"
>
class="cursor-pointer text-accent hover:underline">
{{ formatBacklinkId(backlinkId) }}
</a>
</li>
@@ -166,24 +198,29 @@ export class NoteViewerComponent implements OnDestroy {
private readonly metadataKeysToExclude = new Set(['tags', 'tag', 'keywords']);
private attachmentErrorCleanup: (() => void) | null = null;
private attachmentHandlersScheduled = false;
readonly metadataExpanded = signal(false);
readonly maxMetadataPreviewItems = 3;
sanitizedHtmlContent = computed<SafeHtml>(() =>
readonly sanitizedHtmlContent = computed<SafeHtml>(() =>
this.sanitizer.bypassSecurityTrustHtml(this.noteHtmlContent())
);
frontmatterTags = computed<string[]>(() => {
const tags = this.note().frontmatter?.tags;
const headerTags = new Set((this.note().tags ?? []).map(tag => `${tag}`.trim().toLowerCase()).filter(Boolean));
if (!tags) {
return [];
}
const headerTags = new Set(
(this.note().tags ?? []).map(tag => `${tag}`.trim().toLowerCase()).filter(Boolean)
);
if (!tags) return [];
if (Array.isArray(tags)) {
return Array.from(new Set(tags
.map(tag => `${tag}`.trim())
.filter(Boolean)
.filter(tag => !headerTags.has(tag.toLowerCase()))));
return Array.from(
new Set(
tags
.map(tag => `${tag}`.trim())
.filter(Boolean)
.filter(tag => !headerTags.has(tag.toLowerCase()))
)
);
}
if (typeof tags === 'string') {
return tags
@@ -200,23 +237,17 @@ export class NoteViewerComponent implements OnDestroy {
const keys = this.getFrontmatterKeys(frontmatter);
const entries: MetadataEntry[] = [];
for (const key of keys) {
if (this.shouldSkipMetadataKey(key)) {
continue;
}
if (this.shouldSkipMetadataKey(key)) continue;
const rawValue = frontmatter[key];
const entry = this.buildMetadataEntry(key, rawValue);
if (entry) {
entries.push(entry);
}
if (entry) entries.push(entry);
}
return entries;
});
metadataVisibleEntries = computed<MetadataEntry[]>(() => {
const entries = this.metadataEntries();
if (entries.length <= this.maxMetadataPreviewItems) {
return entries;
}
if (entries.length <= this.maxMetadataPreviewItems) return entries;
return this.metadataExpanded() ? entries : entries.slice(0, this.maxMetadataPreviewItems);
});
@@ -237,9 +268,7 @@ export class NoteViewerComponent implements OnDestroy {
const hostElement = this.elementRef.nativeElement as HTMLElement;
hostElement.addEventListener('click', (event: MouseEvent) => {
const target = event.target as HTMLElement | null;
if (!target) {
return;
}
if (!target) return;
const languageBadge = target.closest('button.code-block__language-badge') as HTMLButtonElement | null;
if (languageBadge) {
@@ -252,9 +281,7 @@ export class NoteViewerComponent implements OnDestroy {
if (inlineTagButton) {
event.preventDefault();
const tagValue = inlineTagButton.dataset.tag;
if (tagValue) {
this.tagClicked.emit(tagValue);
}
if (tagValue) this.tagClicked.emit(tagValue);
return;
}
@@ -263,11 +290,8 @@ export class NoteViewerComponent implements OnDestroy {
event.preventDefault();
const noteId = anchor.getAttribute('data-note-id');
const noteTitle = anchor.getAttribute('data-note-title');
if (noteId) {
this.noteLinkClicked.emit(noteId);
} else if (noteTitle) {
this.noteLinkClicked.emit(noteTitle.toLowerCase().replace(/\s+/g, '-'));
}
if (noteId) this.noteLinkClicked.emit(noteId);
else if (noteTitle) this.noteLinkClicked.emit(noteTitle.toLowerCase().replace(/\s+/g, '-'));
return;
}
@@ -283,7 +307,7 @@ export class NoteViewerComponent implements OnDestroy {
heading: headingSlug || undefined,
headingText: headingText || undefined,
block: blockRef || undefined,
alias: wikiAnchor.textContent?.trim() ?? targetValue
alias: wikiAnchor.textContent?.trim() ?? targetValue,
});
return;
}
@@ -307,9 +331,7 @@ export class NoteViewerComponent implements OnDestroy {
}
private scheduleMathRender(): void {
if (this.mathRenderScheduled) {
return;
}
if (this.mathRenderScheduled) return;
this.mathRenderScheduled = true;
queueMicrotask(() => {
this.mathRenderScheduled = false;
@@ -321,50 +343,30 @@ export class NoteViewerComponent implements OnDestroy {
return Object.keys(frontmatter).filter(key => key !== 'tags' && key !== 'aliases' && key !== 'mtime');
}
formatFrontmatterValue(value: any): string {
if (Array.isArray(value)) {
return value.join(', ');
}
if (typeof value === 'object' && value !== null) {
return JSON.stringify(value);
}
return `${value}`;
}
formatBacklinkId(id: string): string {
return id.replace(/-/g, ' ').replace(/\b\w/g, char => char.toUpperCase());
}
tagColorClass(tag: string): string {
if (!tag) {
return '';
}
if (!tag) return '';
const normalized = `${tag}`.toLowerCase();
if (this.tagColorCache.has(normalized)) {
return `md-tag-color-${this.tagColorCache.get(normalized)}`;
}
if (this.tagColorCache.has(normalized)) return `md-tag-color-${this.tagColorCache.get(normalized)}`;
let hash = 0;
for (let i = 0; i < normalized.length; i++) {
hash = (hash * 31 + normalized.charCodeAt(i)) >>> 0;
}
for (let i = 0; i < normalized.length; i++) hash = (hash * 31 + normalized.charCodeAt(i)) >>> 0;
const colorIndex = hash % this.tagPaletteSize;
this.tagColorCache.set(normalized, colorIndex);
return `md-tag-color-${colorIndex}`;
}
private setupMermaidObservation(): void {
if (typeof MutationObserver === 'undefined') {
return;
}
if (typeof MutationObserver === 'undefined') return;
this.mermaidObserver?.disconnect();
this.mermaidObserver = new MutationObserver(() => this.scheduleMermaidRender());
this.mermaidObserver.observe(this.elementRef.nativeElement as HTMLElement, { childList: true, subtree: true });
}
private scheduleMermaidRender(): void {
if (this.mermaidRenderScheduled) {
return;
}
if (this.mermaidRenderScheduled) return;
this.mermaidRenderScheduled = true;
queueMicrotask(() => {
this.mermaidRenderScheduled = false;
@@ -373,10 +375,7 @@ export class NoteViewerComponent implements OnDestroy {
}
private scheduleAttachmentHandlers(): void {
if (this.attachmentHandlersScheduled) {
return;
}
if (this.attachmentHandlersScheduled) return;
this.attachmentHandlersScheduled = true;
queueMicrotask(() => {
this.attachmentHandlersScheduled = false;
@@ -390,10 +389,7 @@ export class NoteViewerComponent implements OnDestroy {
const host = this.elementRef.nativeElement as HTMLElement;
const images = Array.from(host.querySelectorAll<HTMLImageElement>('img.md-attachment-image'));
if (!images.length) {
return;
}
if (!images.length) return;
const cleanupCallbacks: Array<() => void> = [];
const noteId = this.note()?.id ?? 'unknown-note';
@@ -401,9 +397,10 @@ export class NoteViewerComponent implements OnDestroy {
for (const image of images) {
const handleError = () => {
image.removeEventListener('error', handleError);
const attachmentName = image.dataset.attachmentName?.trim() || image.alt || 'attachment';
const fallbackMarkup = image.dataset.errorMessage || `<div class=\"missing-attachment text-center text-sm text-red-500 dark:text-red-400\">‼️Attachement ${attachmentName} introuvable</div>`;
const fallbackMarkup =
image.dataset.errorMessage ||
`<div class="missing-attachment text-center text-sm text-red-500 dark:text-red-400">‼️Attachement ${attachmentName} introuvable</div>`;
console.warn('[ObsiViewer] Attachment missing', {
noteId,
@@ -436,24 +433,14 @@ export class NoteViewerComponent implements OnDestroy {
}
private ensureMermaid(): Promise<MermaidLib> {
if (this.mermaidLib) {
return Promise.resolve(this.mermaidLib);
}
if (this.mermaidLoader) {
return this.mermaidLoader;
}
if (typeof window === 'undefined') {
return Promise.reject(new Error('Mermaid is only available in the browser environment.'));
}
if (this.mermaidLib) return Promise.resolve(this.mermaidLib);
if (this.mermaidLoader) return this.mermaidLoader;
if (typeof window === 'undefined') return Promise.reject(new Error('Mermaid is only available in the browser environment.'));
this.mermaidLoader = import('mermaid')
.then(module => {
const mermaidLib = (module.default ?? module) as MermaidLib;
const prefersDark = document.documentElement.classList.contains('dark');
mermaidLib.initialize({
startOnLoad: false,
securityLevel: 'loose',
theme: prefersDark ? 'dark' : 'default',
});
mermaidLib.initialize({ startOnLoad: false, securityLevel: 'loose', theme: prefersDark ? 'dark' : 'default' });
this.mermaidLib = mermaidLib;
return mermaidLib;
})
@@ -465,28 +452,21 @@ export class NoteViewerComponent implements OnDestroy {
}
private renderMermaidDiagrams(): void {
if (typeof window === 'undefined') {
return;
}
if (typeof window === 'undefined') return;
const hostElement = this.elementRef.nativeElement as HTMLElement;
const diagrams = hostElement.querySelectorAll<HTMLElement>('.mermaid-diagram[data-mermaid-code]');
if (!diagrams.length) {
return;
}
if (!diagrams.length) return;
this.ensureMermaid()
.then(mermaidLib => {
diagrams.forEach((element, index) => {
if (element.dataset.mermaidRendered === 'true') {
return;
}
if (element.dataset.mermaidRendered === 'true') return;
const encoded = element.dataset.mermaidCode ?? element.getAttribute('data-mermaid-code');
if (!encoded) {
return;
}
if (!encoded) return;
const definition = this.decodeMermaidSource(encoded);
const diagramId = `mermaid-${Date.now()}-${index}-${Math.random().toString(36).slice(2)}`;
mermaidLib.render(diagramId, definition)
mermaidLib
.render(diagramId, definition)
.then(({ svg, bindFunctions }) => {
element.innerHTML = svg;
element.setAttribute('data-mermaid-rendered', 'true');
@@ -498,30 +478,22 @@ export class NoteViewerComponent implements OnDestroy {
});
});
})
.catch(error => {
console.error('Mermaid initialization error:', error);
});
.catch(error => console.error('Mermaid initialization error:', error));
}
private async renderMathExpressions(): Promise<void> {
if (typeof window === 'undefined') {
return;
}
if (typeof window === 'undefined') return;
const hostElement = this.elementRef.nativeElement as HTMLElement;
const mathElements = Array.from(hostElement.querySelectorAll<HTMLElement>('.md-math-inline, .md-math-block'));
const pending = mathElements.filter(element => element.dataset.math && element.dataset.mathProcessed !== 'true');
if (!pending.length) {
return;
}
const pending = mathElements.filter(el => el.dataset.math && el.dataset.mathProcessed !== 'true');
if (!pending.length) return;
try {
const mathJax = await this.ensureMathJax();
for (const element of pending) {
const expression = element.dataset.math ?? '';
if (!expression.trim()) {
continue;
}
if (!expression.trim()) continue;
const display = element.classList.contains('md-math-block');
try {
const rendered = mathJax.tex2chtml(expression, { display });
@@ -549,16 +521,11 @@ export class NoteViewerComponent implements OnDestroy {
}
private escapeHtmlForMermaid(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
private ensureMathJax(): Promise<MathJaxInstance> {
if (typeof window === 'undefined') {
return Promise.reject(new Error('MathJax requires a browser environment.'));
}
if (typeof window === 'undefined') return Promise.reject(new Error('MathJax requires a browser environment.'));
const globalWithMathJax = window as unknown as {
MathJax?: MathJaxInstance & { startup: { promise: Promise<void> } };
@@ -566,19 +533,21 @@ export class NoteViewerComponent implements OnDestroy {
};
if (globalWithMathJax.MathJax) {
return globalWithMathJax.MathJax.startup.promise.then(() => globalWithMathJax.MathJax as MathJaxInstance);
return globalWithMathJax.MathJax.startup.promise.then(
() => globalWithMathJax.MathJax as MathJaxInstance
);
}
if (this.mathJaxLoader) {
return this.mathJaxLoader;
}
if (this.mathJaxLoader) return this.mathJaxLoader;
this.mathJaxLoader = new Promise<MathJaxInstance>((resolve, reject) => {
if (globalWithMathJax._mathJaxLoading) {
const interval = window.setInterval(() => {
if (globalWithMathJax.MathJax) {
window.clearInterval(interval);
globalWithMathJax.MathJax.startup.promise.then(() => resolve(globalWithMathJax.MathJax as MathJaxInstance));
globalWithMathJax.MathJax.startup.promise.then(
() => resolve(globalWithMathJax.MathJax as MathJaxInstance)
);
}
}, 30);
return;
@@ -586,24 +555,22 @@ export class NoteViewerComponent implements OnDestroy {
globalWithMathJax._mathJaxLoading = true;
(window as any).MathJax = {
startup: {
typeset: false
},
startup: { typeset: false },
tex: {
inlineMath: [['$', '$'], ['\\(', '\\)']],
displayMath: [['$$', '$$'], ['\\[', '\\]']],
processEscapes: true
processEscapes: true,
},
options: {
skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre']
}
options: { skipHtmlTags: ['script', 'noscript', 'style', 'textarea', 'pre'] },
};
const existing = document.getElementById('mathjax-loader') as HTMLScriptElement | null;
if (existing) {
existing.addEventListener('load', () => {
if (globalWithMathJax.MathJax) {
globalWithMathJax.MathJax.startup.promise.then(() => resolve(globalWithMathJax.MathJax as MathJaxInstance));
globalWithMathJax.MathJax.startup.promise.then(
() => resolve(globalWithMathJax.MathJax as MathJaxInstance)
);
}
}, { once: true });
existing.addEventListener('error', () => reject(new Error('Failed to load MathJax script.')), { once: true });
@@ -633,38 +600,24 @@ export class NoteViewerComponent implements OnDestroy {
if (typeof window !== 'undefined') {
const currentScroll = window.scrollY;
this.metadataExpanded.update(expanded => !expanded);
queueMicrotask(() => {
window.scrollTo({ top: currentScroll, behavior: 'auto' });
});
queueMicrotask(() => window.scrollTo({ top: currentScroll, behavior: 'auto' }));
return;
}
this.metadataExpanded.update(expanded => !expanded);
}
private buildMetadataEntry(key: string, rawValue: unknown): MetadataEntry | null {
if (rawValue === undefined || rawValue === null) {
return null;
}
if (typeof rawValue === 'string' && rawValue.trim().length === 0) {
return null;
}
if (rawValue === undefined || rawValue === null) return null;
if (typeof rawValue === 'string' && rawValue.trim().length === 0) return null;
const normalizedKey = key.toLowerCase();
const label = this.formatMetadataLabel(key);
const baseString = this.coerceToString(rawValue);
const entry: MetadataEntry = {
key,
label,
icon: '📝',
type: 'text',
displayValue: baseString,
};
const entry: MetadataEntry = { key, label, icon: '📝', type: 'text', displayValue: baseString };
if (Array.isArray(rawValue)) {
const parts = rawValue.map(value => this.coerceToString(value)).filter(Boolean);
if (!parts.length) {
return null;
}
const parts = rawValue.map(v => this.coerceToString(v)).filter(Boolean);
if (!parts.length) return null;
entry.type = 'list';
entry.icon = '🧾';
entry.displayValue = parts.join(', ');
@@ -672,9 +625,7 @@ export class NoteViewerComponent implements OnDestroy {
}
if (rawValue instanceof Date) {
if (Number.isNaN(rawValue.getTime())) {
return null;
}
if (Number.isNaN(rawValue.getTime())) return null;
entry.type = 'date';
entry.icon = '📅';
entry.displayValue = this.dateFormatter.format(rawValue);
@@ -758,22 +709,14 @@ export class NoteViewerComponent implements OnDestroy {
.replace(/\s+/g, ' ')
.trim()
.replace(/^(\w)/, (_, first) => first.toUpperCase())
.replace(/\b(\w)/g, match => match.toUpperCase());
.replace(/\b(\w)/g, m => m.toUpperCase());
}
private coerceToString(value: unknown): string {
if (value === null || value === undefined) {
return '';
}
if (typeof value === 'string') {
return value.trim();
}
if (typeof value === 'number' || typeof value === 'boolean') {
return `${value}`;
}
if (value instanceof Date) {
return this.dateFormatter.format(value);
}
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value.trim();
if (typeof value === 'number' || typeof value === 'boolean') return `${value}`;
if (value instanceof Date) return this.dateFormatter.format(value);
try {
return JSON.stringify(value);
} catch {
@@ -782,22 +725,14 @@ export class NoteViewerComponent implements OnDestroy {
}
private looksLikeEmail(value: string): boolean {
if (!value) {
return false;
}
if (!value) return false;
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
}
private looksLikeUrl(value: string): boolean {
if (!value) {
return false;
}
if (/^https?:\/\//i.test(value)) {
return true;
}
if (value.startsWith('www.')) {
return true;
}
if (!value) return false;
if (/^https?:\/\//i.test(value)) return true;
if (value.startsWith('www.')) return true;
try {
new URL(value);
return true;
@@ -807,9 +742,7 @@ export class NoteViewerComponent implements OnDestroy {
}
private ensureUrlProtocol(value: string): string | null {
if (!value) {
return null;
}
if (!value) return null;
try {
const url = value.startsWith('http') ? new URL(value) : new URL(`https://${value}`);
return url.toString();
@@ -819,37 +752,23 @@ export class NoteViewerComponent implements OnDestroy {
}
private looksLikeImageUrl(value: string): boolean {
if (!value) {
return false;
}
if (!value) return false;
return /(\.(png|jpe?g|gif|svg|webp|avif|heic|heif)$)/i.test(value.split('?')[0]);
}
private tryParseDate(value: unknown): Date | null {
if (value instanceof Date) {
return Number.isNaN(value.getTime()) ? null : value;
}
if (typeof value !== 'string') {
return null;
}
if (value instanceof Date) return Number.isNaN(value.getTime()) ? null : value;
if (typeof value !== 'string') return null;
const trimmed = value.trim();
if (!trimmed) {
return null;
}
if (!trimmed) return null;
const parsed = Date.parse(trimmed);
if (Number.isNaN(parsed)) {
return null;
}
if (Number.isNaN(parsed)) return null;
return new Date(parsed);
}
private isBooleanLike(value: unknown): boolean {
if (typeof value === 'boolean') {
return true;
}
if (typeof value === 'number') {
return value === 0 || value === 1;
}
if (typeof value === 'boolean') return true;
if (typeof value === 'number') return value === 0 || value === 1;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return ['true', 'false', 'oui', 'non', 'yes', 'no', '1', '0'].includes(normalized);
@@ -871,20 +790,12 @@ export class NoteViewerComponent implements OnDestroy {
}
private slugify(value: string): string {
return value
.toLowerCase()
.trim()
.replace(/[^a-z0-9\s-]/g, '')
.replace(/\s+/g, '-');
return value.toLowerCase().trim().replace(/[^a-z0-9\s-]/g, '').replace(/\s+/g, '-');
}
private toBoolean(value: unknown): boolean {
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
return value !== 0;
}
if (typeof value === 'boolean') return value;
if (typeof value === 'number') return value !== 0;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
return normalized === 'true' || normalized === 'oui' || normalized === 'yes' || normalized === '1';
@@ -894,18 +805,14 @@ export class NoteViewerComponent implements OnDestroy {
private handleCodeCopy(button: HTMLButtonElement): void {
const codeId = button.dataset.codeId;
if (!codeId) {
return;
}
if (!codeId) return;
const block = (this.elementRef.nativeElement as HTMLElement).querySelector<HTMLElement>(
`.code-block[data-code-id="${codeId}"]`
);
const codeElement = block?.querySelector<HTMLElement>('code[data-raw-code]');
const rawEncoded = codeElement?.getAttribute('data-raw-code');
if (!block || !codeElement || rawEncoded == null) {
return;
}
if (!block || !codeElement || rawEncoded == null) return;
let decoded = '';
try {
@@ -914,9 +821,10 @@ export class NoteViewerComponent implements OnDestroy {
decoded = rawEncoded;
}
const copyPromise = typeof navigator !== 'undefined' && navigator.clipboard?.writeText
? navigator.clipboard.writeText(decoded)
: this.fallbackCopy(decoded);
const copyPromise =
typeof navigator !== 'undefined' && navigator.clipboard?.writeText
? navigator.clipboard.writeText(decoded)
: this.fallbackCopy(decoded);
copyPromise
.then(() => this.showCopyFeedback(block, 'Copié !'))
@@ -936,11 +844,8 @@ export class NoteViewerComponent implements OnDestroy {
textarea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textarea);
if (successful) {
resolve();
} else {
reject(new Error('Copy command failed'));
}
if (successful) resolve();
else reject(new Error('Copy command failed'));
} catch (error) {
reject(error as Error);
}
@@ -949,18 +854,14 @@ export class NoteViewerComponent implements OnDestroy {
private showCopyFeedback(block: HTMLElement, message: string): void {
const feedback = block.querySelector<HTMLElement>('.code-block__copy-feedback');
if (!feedback) {
return;
}
if (!feedback) return;
feedback.textContent = message;
feedback.hidden = false;
block.classList.add('copied');
const existingTimer = this.copyFeedbackTimers.get(block);
if (existingTimer) {
window.clearTimeout(existingTimer);
}
if (existingTimer) window.clearTimeout(existingTimer);
const timeout = window.setTimeout(() => {
feedback.hidden = true;
@@ -1,17 +0,0 @@
<div class="p-4">
<h3 class="text-sm font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted mb-3">All Tags</h3>
@if (tags().length > 0) {
<ul class="space-y-1">
@for (tag of tags(); track tag.name) {
<li>
<button (click)="tagSelected.emit(tag.name)" class="w-full flex justify-between items-center text-left p-1.5 rounded hover:bg-gray-200 dark:hover:bg-gray-600 text-sm">
<span>{{ tag.name }}</span>
<span class="text-xs bg-gray-200 dark:bg-gray-700 rounded-full px-2 py-0.5">{{ tag.count }}</span>
</button>
</li>
}
</ul>
} @else {
<p class="text-sm text-obs-l-text-muted dark:text-obs-d-text-muted italic">No tags found in vault.</p>
}
</div>
+20 -23
View File
@@ -21,8 +21,8 @@ interface TagSection {
styles: [
`
:host {
--tv-scroll-thumb-light: rgba(22, 28, 35, 0.35);
--tv-scroll-thumb-dark: rgba(156, 163, 175, 0.35);
--tv-scroll-thumb-light: color-mix(in srgb, var(--text-main) 35%, transparent);
--tv-scroll-thumb-dark: color-mix(in srgb, var(--text-muted) 55%, transparent);
--tv-scroll-track: transparent;
}
@@ -57,11 +57,11 @@ interface TagSection {
`,
],
template: `
<div class="flex h-full flex-col gap-4 p-3">
<div class="rounded-lg border border-obs-l-border/60 bg-obs-l-bg-secondary/70 px-3 py-2 shadow-sm dark:border-obs-d-border/60 dark:bg-obs-d-bg-secondary/70">
<div class="flex h-full flex-col gap-4 p-3 bg-card">
<div class="rounded-xl border border-border bg-card px-3 py-2 shadow-subtle">
<label class="sr-only" for="tag-search">Rechercher des tags</label>
<div class="flex items-center gap-2">
<svg class="h-4 w-4 text-obs-l-text-muted dark:text-obs-d-text-muted" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<div class="flex items-center gap-2 text-text-muted">
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-5.2-5.2m0 0A7 7 0 105.8 5.8a7 7 0 0010 10z" />
</svg>
<input
@@ -70,13 +70,13 @@ interface TagSection {
[value]="searchTerm()"
(input)="onSearchChange($event.target?.value ?? '')"
placeholder="Rechercher un tag..."
class="w-full bg-transparent text-sm text-obs-l-text-main outline-none placeholder:text-obs-l-text-muted dark:text-obs-d-text-main dark:placeholder:text-obs-d-text-muted"
class="w-full bg-transparent text-sm text-text-main placeholder:text-text-muted focus:outline-none"
/>
@if (searchTerm(); as value) {
@if (value.length > 0) {
<button
type="button"
class="text-xs text-obs-l-text-muted transition hover:text-obs-l-text-main dark:text-obs-d-text-muted dark:hover:text-obs-d-text-main"
class="btn btn-sm btn-ghost px-2 py-1 text-xs"
aria-label="Effacer la recherche"
(click)="clearSearch()"
>
@@ -94,10 +94,10 @@ interface TagSection {
@for (section of displayedSections(); track section.letter) {
<section class="space-y-2">
<header class="flex items-center justify-between">
<h2 class="text-xs font-semibold uppercase tracking-wide text-obs-l-text-muted dark:text-obs-d-text-muted">
<h2 class="text-xs font-semibold uppercase tracking-wide text-text-muted">
{{ section.letter }}
</h2>
<span class="text-[0.65rem] text-obs-l-text-muted/70 dark:text-obs-d-text-muted/70">
<span class="text-[0.65rem] text-text-muted/70">
{{ section.tags.length }} tag(s)
</span>
</header>
@@ -105,14 +105,14 @@ interface TagSection {
@for (tag of section.tags; track tag.name) {
<button
type="button"
class="inline-flex items-center gap-2 rounded-full border border-obs-l-border/60 bg-obs-l-bg-main/80 px-3 py-1 text-xs font-medium text-obs-l-text-muted transition hover:bg-obs-l-bg-main dark:border-obs-d-border/60 dark:bg-obs-d-bg-main/70 dark:text-obs-d-text-muted dark:hover:bg-obs-d-bg-main"
class="chip justify-between px-3 py-1.5 text-sm"
(click)="tagSelected.emit(tag.name)"
>
<span class="text-sm">🔖</span>
<span class="truncate">{{ tag.name }}</span>
<span class="rounded-full bg-obs-l-bg-secondary/80 px-2 py-0.5 text-[0.65rem] text-obs-l-text-muted dark:bg-obs-d-bg-secondary/80 dark:text-obs-d-text-muted">
{{ tag.count }}
<span class="flex items-center gap-2 text-text-main">
<span aria-hidden="true">🔖</span>
<span class="truncate">{{ tag.name }}</span>
</span>
<span class="badge text-text-muted">{{ tag.count }}</span>
</button>
}
</div>
@@ -120,23 +120,20 @@ interface TagSection {
}
</div>
} @else {
<div class="flex h-full items-center justify-center rounded-lg border border-dashed border-obs-l-border/60 bg-obs-l-bg-secondary/50 px-4 py-6 text-center text-sm text-obs-l-text-muted dark:border-obs-d-border/60 dark:bg-obs-d-bg-secondary/50 dark:text-obs-d-text-muted">
<div class="flex h-full items-center justify-center rounded-lg border border-dashed border-border bg-bg-muted px-4 py-6 text-center text-sm text-text-muted">
Aucun tag ne correspond à votre recherche.
</div>
}
</div>
<nav class="custom-scrollbar sticky top-0 flex h-full w-12 flex-col items-center justify-start gap-2 self-start rounded-lg border border-obs-l-border/60 bg-obs-l-bg-secondary/70 px-2 py-3 text-[0.7rem] font-medium text-obs-l-text-muted shadow-sm dark:border-obs-d-border/60 dark:bg-obs-d-bg-secondary/70 dark:text-obs-d-text-muted overflow-y-auto">
<span class="text-[0.6rem] uppercase tracking-wide text-obs-l-text-muted/70 dark:text-obs-d-text-muted/70">A–Z</span>
<nav class="custom-scrollbar sticky top-0 flex h-full w-12 flex-col items-center justify-start gap-2 self-start rounded-lg border border-border bg-card px-2 py-3 text-[0.7rem] font-medium text-text-muted shadow-subtle overflow-y-auto">
<span class="text-[0.6rem] uppercase tracking-wide text-text-muted/70">A–Z</span>
<div class="grid grid-cols-1 gap-1">
@for (letter of availableLetters(); track letter) {
<button
type="button"
class="flex h-7 w-7 items-center justify-center rounded-full text-[0.7rem] transition"
[class.bg-obs-l-bg-main]="activeLetter() === letter"
[class.dark:bg-obs-d-bg-main]="activeLetter() === letter"
[class.text-obs-l-text-main]="activeLetter() === letter"
[class.dark:text-obs-d-text-main]="activeLetter() === letter"
class="flex h-9 w-9 items-center justify-center rounded-full border border-transparent text-[0.7rem] transition"
[ngClass]="activeLetter() === letter ? 'border-border bg-bg-muted text-text-main font-semibold' : 'text-text-muted hover:border-border hover:bg-bg-muted'"
[attr.aria-pressed]="activeLetter() === letter"
(click)="onLetterClick(letter)"
>