chore: update TypeScript build info cache

This commit is contained in:
2025-10-01 08:20:34 -04:00
parent fe4c968367
commit 89c8315820
140 changed files with 4635 additions and 81773 deletions
+18 -14
View File
@@ -175,9 +175,7 @@
@case ('tags') {
<app-tags-view [tags]="filteredTags()" (tagSelected)="handleTagClick($event)"></app-tags-view>
}
@case ('graph') {
<div class="h-full p-2"><app-graph-view [graphData]="graphData()" (nodeSelected)="selectNote($event)"></app-graph-view></div>
}
@case ('search') {
<div class="space-y-4 p-3">
<div class="flex flex-col gap-3">
@@ -510,18 +508,24 @@
</div>
</header>
<div class="min-h-0 px-4 py-6 note-content-area lg:flex-1 lg:overflow-y-auto lg:px-8">
@if (selectedNote(); as note) {
<app-note-viewer
[note]="note"
[noteHtmlContent]="renderedNoteContent()"
(noteLinkClicked)="selectNote($event)"
(tagClicked)="handleTagClick($event)"
(wikiLinkActivated)="handleWikiLink($event)"
></app-note-viewer>
} @else {
<div class="flex h-full items-center justify-center">
<p class="text-text-muted">Sélectionnez une note pour commencer</p>
@if (activeView() === 'graph') {
<div class="h-[calc(100vh-180px)] lg:h-[calc(100vh-140px)] rounded-xl border border-border bg-card p-2">
<app-graph-view [graphData]="graphData()" (nodeSelected)="selectNote($event)"></app-graph-view>
</div>
} @else {
@if (selectedNote(); as note) {
<app-note-viewer
[note]="note"
[noteHtmlContent]="renderedNoteContent()"
(noteLinkClicked)="selectNote($event)"
(tagClicked)="handleTagClick($event)"
(wikiLinkActivated)="handleWikiLink($event)"
></app-note-viewer>
} @else {
<div class="flex h-full items-center justify-center">
<p class="text-text-muted">Sélectionnez une note pour commencer</p>
</div>
}
}
</div>
</section>
@@ -0,0 +1,326 @@
import { Component, ChangeDetectionStrategy, output, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
export interface GraphFilters {
showTags: boolean;
showAttachments: boolean;
existingFilesOnly: boolean;
showOrphans: boolean;
searchQuery: string;
}
export interface GraphDisplay {
showArrows: boolean;
textFadeThreshold: number;
nodeSize: number;
linkThickness: number;
}
export interface GraphForces {
chargeStrength: number;
linkDistance: number;
centerStrength: number;
}
export interface GraphOptions {
filters: GraphFilters;
display: GraphDisplay;
forces: GraphForces;
}
@Component({
selector: 'app-graph-options-panel',
standalone: true,
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="graph-options-panel bg-white dark:bg-gray-800 border-l border-gray-200 dark:border-gray-700 h-full overflow-y-auto">
<div class="p-4 space-y-6">
<!-- Filters Section -->
<section>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center gap-2">
<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="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
Filters
</h3>
<div class="space-y-2">
<label class="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
[(ngModel)]="filters().showTags"
(change)="emitOptionsChange()"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100">Tags</span>
</label>
<label class="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
[(ngModel)]="filters().showAttachments"
(change)="emitOptionsChange()"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100">Attachments</span>
</label>
<label class="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
[(ngModel)]="filters().existingFilesOnly"
(change)="emitOptionsChange()"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100">Existing files only</span>
</label>
<label class="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
[(ngModel)]="filters().showOrphans"
(change)="emitOptionsChange()"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100">Orphans</span>
</label>
</div>
<div class="mt-3">
<input
type="text"
[(ngModel)]="filters().searchQuery"
(ngModelChange)="emitOptionsChange()"
placeholder="🔍 Search files…"
class="w-full px-3 py-2 text-sm border border-gray-300 dark:border-gray-600 rounded-md bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 placeholder-gray-500 dark:placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</section>
<!-- Groups Section -->
<section>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center gap-2">
<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="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z" />
</svg>
Groups
</h3>
<button
type="button"
class="w-full px-3 py-2 text-sm bg-purple-600 hover:bg-purple-700 text-white rounded-md transition-colors">
New group
</button>
</section>
<!-- Display Section -->
<section>
<h3 class="text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3 flex items-center gap-2">
<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="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" />
</svg>
Display
</h3>
<div class="space-y-4">
<label class="flex items-center gap-2 cursor-pointer group">
<input
type="checkbox"
[(ngModel)]="display().showArrows"
(change)="emitOptionsChange()"
class="rounded border-gray-300 text-blue-600 focus:ring-blue-500 dark:border-gray-600 dark:bg-gray-700">
<span class="text-sm text-gray-700 dark:text-gray-300 group-hover:text-gray-900 dark:group-hover:text-gray-100">Arrows</span>
</label>
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-1">
<span>Text fade threshold</span>
<span class="font-mono text-xs">{{ display().textFadeThreshold }}</span>
</label>
<input
type="range"
min="0"
max="100"
[(ngModel)]="display().textFadeThreshold"
(input)="emitOptionsChange()"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
</div>
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-1">
<span>Node size</span>
<span class="font-mono text-xs">{{ display().nodeSize }}</span>
</label>
<input
type="range"
min="1"
max="20"
[(ngModel)]="display().nodeSize"
(input)="emitOptionsChange()"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
</div>
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-1">
<span>Link thickness</span>
<span class="font-mono text-xs">{{ display().linkThickness }}</span>
</label>
<input
type="range"
min="1"
max="10"
[(ngModel)]="display().linkThickness"
(input)="emitOptionsChange()"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
</div>
</div>
<button
type="button"
(click)="animateRequested.emit()"
class="w-full mt-3 px-3 py-2 text-sm bg-purple-600 hover:bg-purple-700 text-white rounded-md transition-colors">
Animate
</button>
</section>
<!-- Forces Section (Collapsible) -->
<section>
<button
type="button"
(click)="forcesExpanded.set(!forcesExpanded())"
class="w-full flex items-center justify-between text-sm font-semibold text-gray-900 dark:text-gray-100 mb-3">
<span class="flex items-center gap-2">
<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="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
Forces
</span>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-4 w-4 transition-transform"
[class.rotate-180]="forcesExpanded()"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
</svg>
</button>
@if (forcesExpanded()) {
<div class="space-y-4">
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-1">
<span>Charge</span>
<span class="font-mono text-xs">{{ forces().chargeStrength }}</span>
</label>
<input
type="range"
min="-200"
max="0"
[(ngModel)]="forces().chargeStrength"
(input)="emitOptionsChange()"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
</div>
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-1">
<span>Link distance</span>
<span class="font-mono text-xs">{{ forces().linkDistance }}</span>
</label>
<input
type="range"
min="10"
max="500"
[(ngModel)]="forces().linkDistance"
(input)="emitOptionsChange()"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
</div>
<div>
<label class="flex items-center justify-between text-sm text-gray-700 dark:text-gray-300 mb-1">
<span>Center strength</span>
<span class="font-mono text-xs">{{ forces().centerStrength.toFixed(2) }}</span>
</label>
<input
type="range"
min="0"
max="1"
step="0.01"
[(ngModel)]="forces().centerStrength"
(input)="emitOptionsChange()"
class="w-full h-2 bg-gray-200 dark:bg-gray-700 rounded-lg appearance-none cursor-pointer accent-blue-600">
</div>
</div>
}
</section>
</div>
</div>
`,
styles: [`
.graph-options-panel {
width: 300px;
min-width: 300px;
}
@media (max-width: 768px) {
.graph-options-panel {
width: 100%;
min-width: 100%;
border-left: none;
border-top: 1px solid;
}
}
input[type="range"]::-webkit-slider-thumb {
appearance: none;
width: 16px;
height: 16px;
border-radius: 50%;
background: currentColor;
cursor: pointer;
}
input[type="range"]::-moz-range-thumb {
width: 16px;
height: 16px;
border-radius: 50%;
background: currentColor;
cursor: pointer;
border: none;
}
`]
})
export class GraphOptionsPanelComponent {
filters = signal<GraphFilters>({
showTags: true,
showAttachments: true,
existingFilesOnly: true,
showOrphans: true,
searchQuery: ''
});
display = signal<GraphDisplay>({
showArrows: true,
textFadeThreshold: 50,
nodeSize: 5,
linkThickness: 1
});
forces = signal<GraphForces>({
chargeStrength: -100,
linkDistance: 100,
centerStrength: 0.05
});
forcesExpanded = signal(false);
optionsChanged = output<GraphOptions>();
animateRequested = output<void>();
emitOptionsChange(): void {
this.optionsChanged.emit({
filters: this.filters(),
display: this.display(),
forces: this.forces()
});
}
}
@@ -0,0 +1,157 @@
import { Component, ChangeDetectionStrategy, input, output, signal, computed, inject, effect } from '@angular/core';
import { CommonModule } from '@angular/common';
import { GraphViewComponent, GraphDisplayOptions } from '../graph-view/graph-view.component';
import { GraphOptionsPanelComponent, GraphOptions } from '../graph-options-panel/graph-options-panel.component';
import { GraphData } from '../../types';
import { NoteIndexService } from '../../services/note-index.service';
@Component({
selector: 'app-graph-view-container',
standalone: true,
imports: [CommonModule, GraphViewComponent, GraphOptionsPanelComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="graph-view-container flex h-full w-full">
<!-- Graph View -->
<div class="flex-1 relative">
<app-graph-view
[graphData]="graphData()"
[displayOptions]="displayOptions()"
(nodeSelected)="onNodeSelected($event)">
</app-graph-view>
<!-- Toggle Options Button (Mobile) -->
<button
type="button"
(click)="toggleOptionsPanel()"
class="md:hidden absolute bottom-4 right-4 bg-blue-600 hover:bg-blue-700 text-white p-3 rounded-full shadow-lg transition-colors z-10">
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6V4m0 2a2 2 0 100 4m0-4a2 2 0 110 4m-6 8a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4m6 6v10m6-2a2 2 0 100-4m0 4a2 2 0 110-4m0 4v2m0-6V4" />
</svg>
</button>
</div>
<!-- Options Panel -->
<div
class="options-panel-wrapper"
[class.open]="optionsPanelOpen()"
[class.hidden]="!optionsPanelOpen() && isMobile()">
<app-graph-options-panel
(optionsChanged)="onOptionsChanged($event)"
(animateRequested)="onAnimateRequested()">
</app-graph-options-panel>
</div>
<!-- Overlay for mobile -->
@if (optionsPanelOpen() && isMobile()) {
<div
class="fixed inset-0 bg-black bg-opacity-50 z-40 md:hidden"
(click)="toggleOptionsPanel()">
</div>
}
</div>
`,
styles: [`
.graph-view-container {
position: relative;
}
.options-panel-wrapper {
transition: transform 0.3s ease-out;
}
@media (max-width: 768px) {
.options-panel-wrapper {
position: fixed;
bottom: 0;
right: 0;
left: 0;
max-height: 70vh;
transform: translateY(100%);
z-index: 50;
box-shadow: 0 -4px 6px -1px rgba(0, 0, 0, 0.1);
}
.options-panel-wrapper.open {
transform: translateY(0);
}
}
@media (min-width: 769px) {
.options-panel-wrapper {
display: block !important;
}
}
`]
})
export class GraphViewContainerComponent {
centerNoteId = input<string>();
nodeSelected = output<string>();
private noteIndexService = inject(NoteIndexService);
optionsPanelOpen = signal(false);
graphData = computed<GraphData>(() => {
const centerNote = this.centerNoteId();
if (!centerNote) {
return this.noteIndexService.buildFullGraphData();
}
return this.noteIndexService.buildGraphData(centerNote, 2);
});
displayOptions = signal<GraphDisplayOptions>({
showArrows: true,
textFadeThreshold: 50,
nodeSize: 5,
linkThickness: 1,
chargeStrength: -100,
linkDistance: 100,
centerStrength: 0.05,
centerNodeId: undefined
});
constructor() {
effect(() => {
const centerNote = this.centerNoteId();
if (centerNote) {
this.displayOptions.update(opts => ({
...opts,
centerNodeId: centerNote
}));
}
});
}
onOptionsChanged(options: GraphOptions): void {
this.displayOptions.update(current => ({
...current,
showArrows: options.display.showArrows,
textFadeThreshold: options.display.textFadeThreshold,
nodeSize: options.display.nodeSize,
linkThickness: options.display.linkThickness,
chargeStrength: options.forces.chargeStrength,
linkDistance: options.forces.linkDistance,
centerStrength: options.forces.centerStrength
}));
}
onAnimateRequested(): void {
// Trigger animation restart
// The graph component will handle this via its effect on displayOptions
this.displayOptions.update(opts => ({ ...opts }));
}
onNodeSelected(nodeId: string): void {
this.nodeSelected.emit(nodeId);
}
toggleOptionsPanel(): void {
this.optionsPanelOpen.update(open => !open);
}
isMobile(): boolean {
return typeof window !== 'undefined' && window.innerWidth < 769;
}
}
+301 -119
View File
@@ -1,177 +1,359 @@
import { Component, ChangeDetectionStrategy, input, output, signal, viewChild, ElementRef, afterNextRender, OnDestroy, effect } from '@angular/core';
import { Component, ChangeDetectionStrategy, input, output, signal, viewChild, ElementRef, afterNextRender, OnDestroy, effect, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { GraphData, GraphEdge, GraphNode } from '../../types';
import * as d3 from 'd3-force';
import { select, Selection } from 'd3-selection';
import { zoom, zoomIdentity, ZoomBehavior, ZoomTransform } from 'd3-zoom';
interface SimulatedNode extends GraphNode {
x: number;
y: number;
vx: number;
vy: number;
x?: number;
y?: number;
vx?: number;
vy?: number;
fx?: number | null;
fy?: number | null;
}
interface SimulationLink {
source: SimulatedNode | string;
target: SimulatedNode | string;
}
export interface GraphDisplayOptions {
showArrows: boolean;
textFadeThreshold: number;
nodeSize: number;
linkThickness: number;
chargeStrength: number;
linkDistance: number;
centerStrength: number;
centerNodeId?: string;
}
@Component({
selector: 'app-graph-view',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<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-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-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>
</svg>
<div class="relative h-full w-full bg-gray-50 dark:bg-gray-900">
<svg #graphSvg class="w-full h-full">
<defs>
<marker
id="arrowhead"
viewBox="0 0 10 10"
refX="15"
refY="5"
markerWidth="6"
markerHeight="6"
orient="auto"
class="fill-gray-400 dark:fill-gray-600">
<path d="M 0 0 L 10 5 L 0 10 z" />
</marker>
</defs>
<g class="graph-layer" [attr.transform]="zoomTransformString()">
<g class="links">
@for (edge of edges(); track edgeKey(edge)) {
<line
[attr.x1]="getNodeX(edge.source)"
[attr.y1]="getNodeY(edge.source)"
[attr.x2]="getNodeX(edge.target)"
[attr.y2]="getNodeY(edge.target)"
[attr.stroke-width]="displayOptions().linkThickness"
[attr.marker-end]="displayOptions().showArrows ? 'url(#arrowhead)' : null"
class="stroke-gray-400 dark:stroke-gray-600 transition-all"
stroke-opacity="0.6">
</line>
}
</g>
<g class="nodes">
@for (node of simulatedNodes(); track node.id) {
<g
[attr.transform]="'translate(' + (node.x ?? 0) + ',' + (node.y ?? 0) + ')'"
(click)="selectNode(node.id)"
(mousedown)="startDrag(node, $event)"
class="cursor-pointer group">
<circle
[attr.r]="displayOptions().nodeSize"
[class.ring-4]="node.id === displayOptions().centerNodeId"
[class.ring-blue-500]="node.id === displayOptions().centerNodeId"
class="fill-blue-500 dark:fill-blue-400 transition-all group-hover:fill-blue-600 dark:group-hover:fill-blue-300"
[attr.opacity]="getNodeOpacity(node)">
</circle>
<text
[attr.y]="-displayOptions().nodeSize - 4"
text-anchor="middle"
class="pointer-events-none text-xs fill-gray-900 dark:fill-gray-100 font-medium select-none"
[attr.opacity]="getTextOpacity(node)">
{{ node.label }}
</text>
</g>
}
</g>
</g>
</svg>
<!-- Info overlay -->
<div class="absolute top-4 left-4 bg-white dark:bg-gray-800 px-3 py-2 rounded-lg shadow-md text-xs text-gray-700 dark:text-gray-300">
<div><strong>Nodes:</strong> {{ simulatedNodes().length }}</div>
<div><strong>Links:</strong> {{ edges().length }}</div>
</div>
</div>
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
styles: [`
:host {
display: block;
height: 100%;
width: 100%;
}
svg {
cursor: grab;
}
svg.dragging {
cursor: grabbing;
}
`]
})
export class GraphViewComponent implements OnDestroy {
graphData = input.required<GraphData>();
displayOptions = input<GraphDisplayOptions>({
showArrows: true,
textFadeThreshold: 50,
nodeSize: 5,
linkThickness: 1,
chargeStrength: -100,
linkDistance: 100,
centerStrength: 0.05
});
nodeSelected = output<string>();
nodeHovered = output<string>();
svg = viewChild.required<ElementRef<SVGElement>>('graphSvg');
svg = viewChild.required<ElementRef<SVGSVGElement>>('graphSvg');
simulatedNodes = signal<SimulatedNode[]>([]);
edges = signal<SimulationLink[]>([]);
private zoomTransform = signal<ZoomTransform>(zoomIdentity);
zoomTransformString = computed(() => {
const t = this.zoomTransform();
return `translate(${t.x},${t.y}) scale(${t.k})`;
});
private animationFrameId: number | null = null;
private width = 300;
private height = 400;
private simulation: d3.Simulation<SimulatedNode, SimulationLink> | null = null;
private width = 800;
private height = 600;
private draggedNode: SimulatedNode | null = null;
private zoomBehavior?: ZoomBehavior<SVGSVGElement, unknown>;
private zoomSelection?: Selection<SVGSVGElement, unknown, any, any>;
private zoomInitialized = false;
constructor() {
afterNextRender(() => {
this.initSimulation();
this.setupZoomPan();
});
effect(() => {
this.graphData();
this.initSimulation();
this.graphData();
this.displayOptions();
this.restartSimulation();
});
}
ngOnDestroy(): void {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
if (this.simulation) {
this.simulation.stop();
}
}
initSimulation() {
if (this.animationFrameId) {
cancelAnimationFrame(this.animationFrameId);
this.animationFrameId = null;
}
initSimulation(): void {
const svgElement = this.svg().nativeElement;
this.width = svgElement.clientWidth || 800;
this.height = svgElement.clientHeight || 600;
this.restartSimulation();
}
restartSimulation(): void {
const data = this.graphData();
if (!data || !this.svg()) return;
if (!data || !data.nodes.length) {
this.simulatedNodes.set([]);
this.edges.set([]);
this.resetZoom();
return;
}
this.width = this.svg().nativeElement.clientWidth || 300;
this.height = this.svg().nativeElement.clientHeight || 400;
// Arrêter la simulation précédente
if (this.simulation) {
this.simulation.stop();
}
// Créer des copies des nœuds et des liens
const nodes: SimulatedNode[] = data.nodes.map(n => ({
...n,
x: this.width / 2 + (Math.random() - 0.5) * 50,
y: this.height / 2 + (Math.random() - 0.5) * 50,
vx: 0,
vy: 0,
x: this.width / 2 + (Math.random() - 0.5) * 100,
y: this.height / 2 + (Math.random() - 0.5) * 100
}));
const links: SimulationLink[] = data.edges.map(e => ({
...e,
source: e.source,
target: e.target
}));
const opts = this.displayOptions();
// Créer la simulation d3-force
this.simulation = d3.forceSimulation<SimulatedNode, SimulationLink>(nodes)
.force('link', d3.forceLink<SimulatedNode, SimulationLink>(links)
.id(d => d.id)
.distance(opts.linkDistance))
.force('charge', d3.forceManyBody<SimulatedNode>()
.strength(opts.chargeStrength))
.force('center', d3.forceCenter<SimulatedNode>(this.width / 2, this.height / 2)
.strength(opts.centerStrength))
.force('collision', d3.forceCollide<SimulatedNode>().radius(opts.nodeSize * 2))
.alphaDecay(0.01)
.velocityDecay(0.3);
// Mettre à jour les positions à chaque tick
this.simulation.on('tick', () => {
this.simulatedNodes.set([...nodes]);
this.edges.set([...links]);
});
this.simulatedNodes.set(nodes);
this.runSimulation();
this.edges.set(links);
this.resetZoom();
}
runSimulation() {
const step = () => {
this.updatePositions();
this.animationFrameId = requestAnimationFrame(step);
animate(): void {
if (this.simulation) {
this.simulation.alpha(1).restart();
}
}
setupZoomPan(): void {
if (this.zoomInitialized || !this.svg()) {
return;
}
const svgElement = this.svg().nativeElement as SVGSVGElement;
const svgSelection = select<SVGSVGElement, unknown>(svgElement);
const behavior = zoom<SVGSVGElement, unknown>()
.scaleExtent([0.2, 5])
.on('zoom', (event) => {
this.zoomTransform.set(event.transform);
});
svgSelection.call(behavior as any);
svgSelection.on('dblclick.zoom', null);
this.zoomBehavior = behavior;
this.zoomSelection = svgSelection;
this.zoomInitialized = true;
}
startDrag(node: SimulatedNode, event: MouseEvent): void {
event.stopPropagation();
this.draggedNode = node;
if (this.simulation) {
node.fx = node.x;
node.fy = node.y;
}
const onMouseMove = (e: MouseEvent) => {
if (!this.draggedNode) return;
const svgElement = this.svg().nativeElement;
const rect = svgElement.getBoundingClientRect();
const transform = this.zoomTransform();
const graphX = (e.clientX - rect.left - transform.x) / transform.k;
const graphY = (e.clientY - rect.top - transform.y) / transform.k;
this.draggedNode.fx = graphX;
this.draggedNode.fy = graphY;
if (this.simulation) {
this.simulation.alpha(0.3).restart();
}
};
this.animationFrameId = requestAnimationFrame(step);
const onMouseUp = () => {
if (this.draggedNode) {
this.draggedNode.fx = null;
this.draggedNode.fy = null;
this.draggedNode = null;
}
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
}
updatePositions() {
let nodes = this.simulatedNodes();
const edges = this.graphData().edges;
if (nodes.length === 0) return;
const k = Math.sqrt((this.width * this.height) / (nodes.length + 1));
const repulsionStrength = -300;
const attractionStrength = 0.05;
const damping = 0.95;
const forceNodes = nodes.map(n => ({...n, fx: 0, fy: 0}));
for (let i = 0; i < forceNodes.length; i++) {
for (let j = i + 1; j < forceNodes.length; j++) {
const nodeA = forceNodes[i];
const nodeB = forceNodes[j];
const dx = nodeB.x - nodeA.x;
const dy = nodeB.y - nodeA.y;
let distance = Math.sqrt(dx * dx + dy * dy);
if (distance < 1) distance = 1;
const force = (repulsionStrength * k * k) / (distance * distance);
const fx = (dx / distance) * force;
const fy = (dy / distance) * force;
nodeA.fx += fx;
nodeA.fy += fy;
nodeB.fx -= fx;
nodeB.fy -= fy;
}
private resetZoom(): void {
if (this.zoomBehavior && this.zoomSelection) {
this.zoomBehavior.transform(this.zoomSelection, zoomIdentity);
}
this.zoomTransform.set(zoomIdentity);
}
for (const edge of edges) {
const source = forceNodes.find(n => n.id === edge.source);
const target = forceNodes.find(n => n.id === edge.target);
if (!source || !target) continue;
const dx = target.x - source.x;
const dy = target.y - source.y;
const distance = Math.sqrt(dx * dx + dy * dy);
if (distance > 0) {
const force = attractionStrength * (distance - k);
const fx = (dx / distance) * force;
const fy = (dy / distance) * force;
source.fx += fx;
source.fy += fy;
target.fx -= fx;
target.fy -= fy;
}
getNodeX(nodeOrId: SimulatedNode | string): number {
if (typeof nodeOrId === 'string') {
const node = this.simulatedNodes().find(n => n.id === nodeOrId);
return node?.x ?? 0;
}
return nodeOrId.x ?? 0;
}
this.simulatedNodes.update(currentNodes =>
currentNodes.map((node, i) => {
const forceNode = forceNodes[i];
let { vx, vy, x, y } = node;
getNodeY(nodeOrId: SimulatedNode | string): number {
if (typeof nodeOrId === 'string') {
const node = this.simulatedNodes().find(n => n.id === nodeOrId);
return node?.y ?? 0;
}
return nodeOrId.y ?? 0;
}
const gx = (this.width / 2 - x) * 0.01;
const gy = (this.height / 2 - y) * 0.01;
vx = (vx + forceNode.fx + gx) * damping;
vy = (vy + forceNode.fy + gy) * damping;
x += vx;
y += vy;
x = Math.max(10, Math.min(this.width - 10, x));
y = Math.max(10, Math.min(this.height - 10, y));
return { ...node, x, y, vx, vy };
})
getNodeOpacity(node: SimulatedNode): number {
const opts = this.displayOptions();
if (!opts.centerNodeId) return 1;
// Nœud central: toujours visible
if (node.id === opts.centerNodeId) return 1;
// Nœuds connectés au nœud central
const isConnected = this.edges().some(e =>
(typeof e.source !== 'string' && e.source.id === opts.centerNodeId && (typeof e.target !== 'string' ? e.target.id : e.target) === node.id) ||
(typeof e.target !== 'string' && e.target.id === opts.centerNodeId && (typeof e.source !== 'string' ? e.source.id : e.source) === node.id)
);
return isConnected ? 1 : 0.3;
}
getEdgePath(edge: GraphEdge): string | null {
const nodes = this.simulatedNodes();
const sourceNode = nodes.find(n => n.id === edge.source);
const targetNode = nodes.find(n => n.id === edge.target);
if (!sourceNode || !targetNode) return null;
return `M${sourceNode.x},${sourceNode.y}L${targetNode.x},${targetNode.y}`;
getTextOpacity(node: SimulatedNode): number {
const threshold = this.displayOptions().textFadeThreshold / 100;
const nodeOpacity = this.getNodeOpacity(node);
return nodeOpacity >= threshold ? 1 : 0;
}
selectNode(nodeId: string) {
edgeKey(edge: SimulationLink): string {
const sourceId = typeof edge.source === 'string' ? edge.source : edge.source.id;
const targetId = typeof edge.target === 'string' ? edge.target : edge.target.id;
return `${sourceId}-${targetId}`;
}
selectNode(nodeId: string): void {
this.nodeSelected.emit(nodeId);
}
}
@@ -0,0 +1,36 @@
/* Styles for Note Preview Card */
:host {
display: block;
animation: fadeIn 0.15s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.preview-card {
max-height: 400px;
width: 400px;
}
.line-clamp-5 {
display: -webkit-box;
-webkit-line-clamp: 5;
line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (max-width: 640px) {
.preview-card {
width: calc(100vw - 2rem);
max-width: 400px;
}
}
@@ -0,0 +1,95 @@
import { Component, ChangeDetectionStrategy, input, output, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
export interface PreviewData {
title: string;
excerpt: string;
noteId: string;
}
@Component({
selector: 'app-note-preview-card',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
class="preview-card bg-white dark:bg-gray-800 rounded-2xl shadow-xl border border-gray-200 dark:border-gray-700 p-4 max-w-md overflow-hidden"
(mouseenter)="mouseEnter.emit()"
(mouseleave)="mouseLeave.emit()">
<h2 class="font-bold text-lg mb-2 text-gray-900 dark:text-gray-100 truncate">
{{ previewData().title }}
</h2>
<div
class="prose prose-sm dark:prose-invert max-w-none line-clamp-5 text-gray-700 dark:text-gray-300"
[innerHTML]="sanitizedExcerpt()">
</div>
<div class="mt-3 flex justify-end">
<button
(click)="openNote.emit()"
class="text-blue-600 hover:text-blue-700 dark:text-blue-400 dark:hover:text-blue-300 transition-colors p-1 rounded hover:bg-gray-100 dark:hover:bg-gray-700"
aria-label="Ouvrir la note">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" />
</svg>
</button>
</div>
</div>
`,
styles: [`
:host {
display: block;
animation: fadeIn 0.15s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.preview-card {
max-height: 400px;
width: 400px;
}
.line-clamp-5 {
display: -webkit-box;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
overflow: hidden;
}
@media (max-width: 640px) {
.preview-card {
width: calc(100vw - 2rem);
max-width: 400px;
}
}
`]
})
export class NotePreviewCardComponent {
previewData = input.required<PreviewData>();
openNote = output<void>();
mouseEnter = output<void>();
mouseLeave = output<void>();
sanitizedExcerpt(): string {
// Retourne le HTML directement - Angular le sanitize automatiquement
return this.previewData().excerpt || '';
}
@HostListener('document:keydown.escape')
onEscapeKey(): void {
this.mouseLeave.emit();
}
}
@@ -14,6 +14,7 @@ import {
import { CommonModule } from '@angular/common';
import { Note } from '../../../types';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { NotePreviewService, PreviewData } from '../../../services/note-preview.service';
import mermaid from 'mermaid';
type MermaidLib = typeof mermaid;
@@ -179,12 +180,14 @@ interface MetadataEntry {
export class NoteViewerComponent implements OnDestroy {
note = input.required<Note>();
noteHtmlContent = input.required<string>();
allNotes = input<Note[]>([]);
noteLinkClicked = output<string>();
wikiLinkActivated = output<WikiLinkActivation>();
tagClicked = output<string>();
private readonly elementRef = inject(ElementRef<HTMLElement>);
private readonly sanitizer = inject(DomSanitizer);
private readonly previewService = inject(NotePreviewService);
private readonly tagPaletteSize = 12;
private readonly tagColorCache = new Map<string, number>();
private readonly copyFeedbackTimers = new Map<HTMLElement, number>();
@@ -317,6 +320,7 @@ export class NoteViewerComponent implements OnDestroy {
this.scheduleMermaidRender();
this.scheduleAttachmentHandlers();
this.scheduleMathRender();
this.setupWikiLinkHoverHandlers();
});
}
@@ -328,6 +332,48 @@ export class NoteViewerComponent implements OnDestroy {
this.mathJaxLoader = null;
this.attachmentErrorCleanup?.();
this.attachmentErrorCleanup = null;
this.previewService.hidePreviewImmediately();
}
private setupWikiLinkHoverHandlers(): void {
const hostElement = this.elementRef.nativeElement as HTMLElement;
const wikiLinks = hostElement.querySelectorAll<HTMLAnchorElement>('a.md-wiki-link');
wikiLinks.forEach(link => {
link.addEventListener('mouseenter', () => {
const targetValue = link.getAttribute('data-target') ?? '';
const previewData = this.buildPreviewData(targetValue);
if (previewData) {
this.previewService.showPreview(targetValue, link, previewData);
}
});
link.addEventListener('mouseleave', () => {
this.previewService.hidePreview();
});
});
}
private buildPreviewData(noteTarget: string): PreviewData | null {
const allNotes = this.allNotes();
const targetNote = allNotes.find(n =>
n.id.toLowerCase() === noteTarget.toLowerCase() ||
n.title.toLowerCase() === noteTarget.toLowerCase() ||
n.fileName?.replace(/\.md$/, '').toLowerCase() === noteTarget.toLowerCase()
);
if (!targetNote) return null;
// Extraire un extrait (3-5 premières lignes)
const lines = targetNote.content.split('\n').filter(line => line.trim());
const excerpt = lines.slice(0, 5).join('\n');
return {
title: targetNote.title,
excerpt: excerpt,
noteId: targetNote.id
};
}
private scheduleMathRender(): void {
+24 -5
View File
@@ -343,7 +343,7 @@ export class MarkdownService {
const wikiRegex = /(?<!\!)\[\[([^\]]+)\]\]/g;
text = text.replace(wikiRegex, (_match, inner) => {
const placeholder = `@@__WIKILINK_${wikiLinks.length}__@@`;
const placeholder = `@@WIKILINK::${wikiLinks.length}@@`;
const [targetPartRaw, aliasRaw] = `${inner}`.split('|');
let targetPart = targetPartRaw ?? '';
let alias = aliasRaw ?? '';
@@ -380,7 +380,7 @@ export class MarkdownService {
});
const addMathPlaceholder = (expression: string, display: 'block' | 'inline') => {
const placeholder = `@@__MATH_${display.toUpperCase()}_${math.length}__@@`;
const placeholder = `@@MATH::${display.toUpperCase()}::${math.length}@@`;
math.push({ placeholder, expression: expression.trim(), display });
return placeholder;
};
@@ -403,7 +403,10 @@ export class MarkdownService {
return html;
}
return links.reduce((acc, link) => {
// Build once all replacements and apply for both current and legacy placeholder forms
let out = html;
for (let i = 0; i < links.length; i++) {
const link = links[i];
const attrs: string[] = ['class="md-wiki-link"'];
if (link.target) {
attrs.push(`data-target="${this.escapeAttribute(link.target)}"`);
@@ -418,8 +421,24 @@ export class MarkdownService {
attrs.push(`data-block="${this.escapeAttribute(link.block)}"`);
}
const replacement = `<a ${attrs.join(' ')}>${this.escapeHtml(link.alias)}</a>`;
return acc.split(link.placeholder).join(replacement);
}, html);
// Replace the current placeholder exactly
if (link.placeholder) {
out = out.split(link.placeholder).join(replacement);
}
// Replace legacy placeholders that may exist from older renders:
// @@WIKILINK_0@@ or @@__WIKILINK_0__@@
const legacySimple = `@@WIKILINK_${i}@@`;
const legacyUnderscore = `@@__WIKILINK_${i}__@@`;
if (out.includes(legacySimple)) {
out = out.split(legacySimple).join(replacement);
}
if (out.includes(legacyUnderscore)) {
out = out.split(legacyUnderscore).join(replacement);
}
}
return out;
}
private restoreMath(html: string, placeholders: MathPlaceholder[]): string {
+258
View File
@@ -0,0 +1,258 @@
import { Injectable, signal, computed, inject } from '@angular/core';
import { Note, GraphData, GraphNode, GraphEdge } from '../types';
import { WikiLinkParserService } from './wikilink-parser.service';
export interface NoteNode {
id: string;
title: string;
path: string;
tags: string[];
backlinks: string[];
outlinks: string[];
}
@Injectable({
providedIn: 'root'
})
export class NoteIndexService {
private wikiLinkParser = inject(WikiLinkParserService);
private nodes = signal<NoteNode[]>([]);
private notesMap = new Map<string, NoteNode>();
/**
* Scan le vault et construit l'index
*/
scanVault(notes: Note[]): void {
const nodeList: NoteNode[] = [];
const tempMap = new Map<string, NoteNode>();
// Phase 1: Créer tous les nœuds
for (const note of notes) {
const node: NoteNode = {
id: note.id,
title: note.title,
path: note.filePath || note.originalPath,
tags: note.tags || [],
backlinks: [],
outlinks: []
};
// Parser les wikilinks pour extraire les outlinks
const wikiLinks = this.wikiLinkParser.parseWikiLinks(note.rawContent || note.content);
node.outlinks = Array.from(new Set(wikiLinks.map(link => link.note.toLowerCase())));
nodeList.push(node);
tempMap.set(note.id.toLowerCase(), node);
// Index aussi par titre
tempMap.set(note.title.toLowerCase(), node);
// Index par nom de fichier
if (note.fileName) {
const fileNameKey = note.fileName.replace(/\.md$/, '').toLowerCase();
tempMap.set(fileNameKey, node);
}
}
// Phase 2: Construire les backlinks
for (const node of nodeList) {
for (const outlinkTarget of node.outlinks) {
const targetNode = tempMap.get(outlinkTarget.toLowerCase());
if (targetNode && targetNode.id !== node.id) {
if (!targetNode.backlinks.includes(node.id)) {
targetNode.backlinks.push(node.id);
}
}
}
}
this.nodes.set(nodeList);
this.notesMap = tempMap;
}
/**
* Récupère un nœud par ID
*/
getNode(id: string): NoteNode | undefined {
return this.notesMap.get(id.toLowerCase());
}
/**
* Récupère les voisins d'une note (notes liées)
*/
getNeighbors(noteId: string): NoteNode[] {
const node = this.getNode(noteId);
if (!node) return [];
const neighborIds = new Set([...node.backlinks, ...node.outlinks]);
const neighbors: NoteNode[] = [];
for (const id of neighborIds) {
const neighbor = this.getNode(id);
if (neighbor) {
neighbors.push(neighbor);
}
}
return neighbors;
}
/**
* Récupère les backlinks d'une note
*/
getBacklinks(noteId: string): NoteNode[] {
const node = this.getNode(noteId);
if (!node) return [];
return node.backlinks
.map(id => this.getNode(id))
.filter((n): n is NoteNode => n !== undefined);
}
/**
* Recherche des notes par requête
*/
searchNodes(query: string): NoteNode[] {
if (!query.trim()) return [];
const lowerQuery = query.toLowerCase();
return this.nodes().filter(node =>
node.title.toLowerCase().includes(lowerQuery) ||
node.tags.some(tag => tag.toLowerCase().includes(lowerQuery))
);
}
/**
* Construit les données du graphe pour une note centrale
*/
buildGraphData(centerNoteId: string, depth: number = 1): GraphData {
const centerNode = this.getNode(centerNoteId);
if (!centerNode) {
return { nodes: [], edges: [] };
}
const includedNodes = new Set<string>([centerNode.id]);
const nodesToProcess = [{ node: centerNode, currentDepth: 0 }];
const processedNodes = new Set<string>();
// BFS pour collecter les nœuds à la profondeur spécifiée
while (nodesToProcess.length > 0) {
const { node, currentDepth } = nodesToProcess.shift()!;
if (processedNodes.has(node.id)) continue;
processedNodes.add(node.id);
if (currentDepth < depth) {
// Ajouter les voisins
const neighborIds = [...node.backlinks, ...node.outlinks];
for (const neighborId of neighborIds) {
const neighbor = this.getNode(neighborId);
if (neighbor && !processedNodes.has(neighbor.id)) {
includedNodes.add(neighbor.id);
nodesToProcess.push({ node: neighbor, currentDepth: currentDepth + 1 });
}
}
}
}
// Construire les nœuds du graphe
const graphNodes: GraphNode[] = [];
const nodeIdToGraphId = new Map<string, string>();
for (const nodeId of includedNodes) {
const node = this.getNode(nodeId);
if (node) {
const graphId = node.id;
graphNodes.push({
id: graphId,
label: node.title
});
nodeIdToGraphId.set(node.id, graphId);
}
}
// Construire les arêtes
const edges: GraphEdge[] = [];
const edgeSet = new Set<string>();
for (const nodeId of includedNodes) {
const node = this.getNode(nodeId);
if (!node) continue;
const sourceId = nodeIdToGraphId.get(node.id);
if (!sourceId) continue;
for (const outlinkTarget of node.outlinks) {
const targetNode = this.getNode(outlinkTarget);
if (targetNode && includedNodes.has(targetNode.id)) {
const targetId = nodeIdToGraphId.get(targetNode.id);
if (targetId) {
const edgeKey = `${sourceId}->${targetId}`;
if (!edgeSet.has(edgeKey)) {
edges.push({ source: sourceId, target: targetId });
edgeSet.add(edgeKey);
}
}
}
}
}
return { nodes: graphNodes, edges };
}
/**
* Construit le graphe complet de toutes les notes
*/
buildFullGraphData(): GraphData {
const graphNodes: GraphNode[] = this.nodes().map(node => ({
id: node.id,
label: node.title
}));
const edges: GraphEdge[] = [];
const edgeSet = new Set<string>();
for (const node of this.nodes()) {
for (const outlinkTarget of node.outlinks) {
const targetNode = this.getNode(outlinkTarget);
if (targetNode) {
const edgeKey = `${node.id}->${targetNode.id}`;
if (!edgeSet.has(edgeKey)) {
edges.push({ source: node.id, target: targetNode.id });
edgeSet.add(edgeKey);
}
}
}
}
return { nodes: graphNodes, edges };
}
/**
* Retourne tous les nœuds
*/
getAllNodes(): NoteNode[] {
return this.nodes();
}
/**
* Filtre les nœuds orphelins (sans liens)
*/
getOrphanNodes(): NoteNode[] {
return this.nodes().filter(node =>
node.backlinks.length === 0 && node.outlinks.length === 0
);
}
/**
* Filtre les nœuds par tags
*/
getNodesByTags(tags: string[]): NoteNode[] {
const lowerTags = tags.map(t => t.toLowerCase());
return this.nodes().filter(node =>
node.tags.some(tag => lowerTags.includes(tag.toLowerCase()))
);
}
}
+260
View File
@@ -0,0 +1,260 @@
import { Injectable, ComponentRef, inject, ApplicationRef, createComponent, EnvironmentInjector } from '@angular/core';
import { Overlay, OverlayRef, OverlayPositionBuilder, ConnectedPosition } from '@angular/cdk/overlay';
import { ComponentPortal } from '@angular/cdk/portal';
export interface PreviewData {
title: string;
excerpt: string;
noteId: string;
}
/**
* Simple LRU Cache pour les previews
*/
class LRUCache<K, V> {
private cache = new Map<K, V>();
constructor(private maxSize: number) {}
get(key: K): V | undefined {
const value = this.cache.get(key);
if (value !== undefined) {
// Move to end (most recently used)
this.cache.delete(key);
this.cache.set(key, value);
}
return value;
}
set(key: K, value: V): void {
if (this.cache.has(key)) {
this.cache.delete(key);
} else if (this.cache.size >= this.maxSize) {
// Remove least recently used (first item)
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
this.cache.set(key, value);
}
has(key: K): boolean {
return this.cache.has(key);
}
clear(): void {
this.cache.clear();
}
}
@Injectable({
providedIn: 'root'
})
export class NotePreviewService {
private overlay = inject(Overlay);
private positionBuilder = inject(OverlayPositionBuilder);
private appRef = inject(ApplicationRef);
private injector = inject(EnvironmentInjector);
private cache = new LRUCache<string, PreviewData>(50);
private overlayRef?: OverlayRef;
private componentRef?: ComponentRef<any>;
private showTimeout?: number;
private hideTimeout?: number;
private currentNoteId?: string;
/**
* Affiche l'aperçu d'une note avec un délai de 300ms
*/
showPreview(noteId: string, origin: HTMLElement, previewData: PreviewData): void {
// Clear any pending hide
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = undefined;
}
// Si déjà affiché pour la même note, ne rien faire
if (this.currentNoteId === noteId && this.overlayRef) {
return;
}
// Clear any pending show
if (this.showTimeout) {
clearTimeout(this.showTimeout);
}
// Debounce: attendre 300ms avant d'afficher
this.showTimeout = window.setTimeout(() => {
this.showTimeout = undefined;
this.doShowPreview(noteId, origin, previewData);
}, 300);
}
/**
* Cache l'aperçu avec un délai de 150ms
*/
hidePreview(): void {
// Clear any pending show
if (this.showTimeout) {
clearTimeout(this.showTimeout);
this.showTimeout = undefined;
}
// Clear any existing hide timeout
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
}
// Debounce: attendre 150ms avant de cacher
this.hideTimeout = window.setTimeout(() => {
this.hideTimeout = undefined;
this.doHidePreview();
}, 150);
}
/**
* Cache immédiatement l'aperçu
*/
hidePreviewImmediately(): void {
if (this.showTimeout) {
clearTimeout(this.showTimeout);
this.showTimeout = undefined;
}
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = undefined;
}
this.doHidePreview();
}
/**
* Précharge un aperçu dans le cache
*/
preloadPreview(noteId: string, data: PreviewData): void {
this.cache.set(noteId, data);
}
/**
* Récupère un aperçu du cache
*/
getCachedPreview(noteId: string): PreviewData | undefined {
return this.cache.get(noteId);
}
private doShowPreview(noteId: string, origin: HTMLElement, previewData: PreviewData): void {
// Cacher l'overlay existant
if (this.overlayRef) {
this.doHidePreview();
}
// Mettre en cache
this.cache.set(noteId, previewData);
this.currentNoteId = noteId;
// Créer l'overlay
const positions: ConnectedPosition[] = [
{
originX: 'center',
originY: 'bottom',
overlayX: 'center',
overlayY: 'top',
offsetY: 8
},
{
originX: 'center',
originY: 'top',
overlayX: 'center',
overlayY: 'bottom',
offsetY: -8
},
{
originX: 'end',
originY: 'center',
overlayX: 'start',
overlayY: 'center',
offsetX: 8
},
{
originX: 'start',
originY: 'center',
overlayX: 'end',
overlayY: 'center',
offsetX: -8
}
];
const positionStrategy = this.positionBuilder
.flexibleConnectedTo(origin)
.withPositions(positions)
.withPush(true)
.withViewportMargin(16);
this.overlayRef = this.overlay.create({
positionStrategy,
scrollStrategy: this.overlay.scrollStrategies.reposition(),
hasBackdrop: false,
panelClass: 'note-preview-overlay'
});
// Lazy load du composant pour éviter les dépendances circulaires
import('../components/note-preview-card/note-preview-card.component').then(m => {
if (!this.overlayRef) return;
const portal = new ComponentPortal(m.NotePreviewCardComponent, null, this.injector);
this.componentRef = this.overlayRef.attach(portal);
// Passer les données au composant
this.componentRef.setInput('previewData', previewData);
// Écouter les événements du composant
this.componentRef.instance.openNote.subscribe(() => {
this.handleOpenNote(noteId);
});
this.componentRef.instance.mouseEnter.subscribe(() => {
// Annuler le hide si la souris entre dans la card
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = undefined;
}
});
this.componentRef.instance.mouseLeave.subscribe(() => {
// Cacher quand la souris quitte la card
this.hidePreview();
});
// Détecter les clics en dehors
this.overlayRef.outsidePointerEvents().subscribe(() => {
this.hidePreviewImmediately();
});
});
}
private doHidePreview(): void {
if (this.componentRef) {
this.componentRef.destroy();
this.componentRef = undefined;
}
if (this.overlayRef) {
this.overlayRef.dispose();
this.overlayRef = undefined;
}
this.currentNoteId = undefined;
}
private handleOpenNote(noteId: string): void {
this.hidePreviewImmediately();
// L'événement de navigation sera géré par le composant parent
// via un service de routing ou un event emitter
}
/**
* Nettoie les ressources
*/
destroy(): void {
this.hidePreviewImmediately();
this.cache.clear();
}
}
@@ -0,0 +1,159 @@
import { TestBed } from '@angular/core/testing';
import { WikiLinkParserService } from './wikilink-parser.service';
describe('WikiLinkParserService', () => {
let service: WikiLinkParserService;
beforeEach(() => {
TestBed.configureTestingModule({});
service = TestBed.inject(WikiLinkParserService);
});
it('should be created', () => {
expect(service).toBeTruthy();
});
describe('parseWikiLinks', () => {
it('should parse simple wikilink', () => {
const result = service.parseWikiLinks('This is a [[test]] link');
expect(result).toEqual([
{ note: 'test' }
]);
});
it('should parse wikilink with alias', () => {
const result = service.parseWikiLinks('This is [[test|My Test]] link');
expect(result).toEqual([
{ note: 'test', alias: 'My Test' }
]);
});
it('should parse wikilink with heading', () => {
const result = service.parseWikiLinks('Link to [[test#Introduction]]');
expect(result).toEqual([
{ note: 'test', fragment: 'Introduction', type: 'heading' }
]);
});
it('should parse wikilink with block reference', () => {
const result = service.parseWikiLinks('Link to [[test#^block-id]]');
expect(result).toEqual([
{ note: 'test', fragment: 'block-id', type: 'block' }
]);
});
it('should parse multiple wikilinks', () => {
const markdown = 'First [[note1]] and second [[note2|Alias]]';
const result = service.parseWikiLinks(markdown);
expect(result.length).toBe(2);
expect(result[0].note).toBe('note1');
expect(result[1].note).toBe('note2');
expect(result[1].alias).toBe('Alias');
});
it('should handle complex combinations', () => {
const result = service.parseWikiLinks('[[note#section|Custom Title]]');
expect(result).toEqual([
{ note: 'note', alias: 'Custom Title', fragment: 'section', type: 'heading' }
]);
});
it('should ignore embedded files (![[...]])', () => {
const result = service.parseWikiLinks('Image: ![[image.png]] and [[link]]');
expect(result.length).toBe(1);
expect(result[0].note).toBe('link');
});
it('should handle empty input', () => {
expect(service.parseWikiLinks('')).toEqual([]);
expect(service.parseWikiLinks('No links here')).toEqual([]);
});
});
describe('resolveNotePath', () => {
const notePaths = [
'folder/test-note.md',
'another-folder/example.md',
'root-note.md'
];
it('should resolve exact match', () => {
const link = { note: 'test-note' };
const result = service.resolveNotePath(link, notePaths);
expect(result).toBe('folder/test-note');
});
it('should resolve with different case', () => {
const link = { note: 'TEST-NOTE' };
const result = service.resolveNotePath(link, notePaths);
expect(result).toBe('folder/test-note');
});
it('should resolve partial match', () => {
const link = { note: 'example' };
const result = service.resolveNotePath(link, notePaths);
expect(result).toBe('another-folder/example');
});
it('should return null for non-existent note', () => {
const link = { note: 'non-existent' };
const result = service.resolveNotePath(link, notePaths);
expect(result).toBeNull();
});
});
describe('replaceWithHtml', () => {
it('should replace simple wikilink with HTML', () => {
const markdown = 'Text with [[note]] link';
const html = service.replaceWithHtml(markdown);
expect(html).toContain('class="md-wiki-link wikilink-orphan"');
expect(html).toContain('data-target="note"');
expect(html).toContain('>note</a>');
});
it('should use alias as link text', () => {
const markdown = '[[note|My Alias]]';
const html = service.replaceWithHtml(markdown);
expect(html).toContain('>My Alias</a>');
});
it('should include fragment data', () => {
const markdown = '[[note#section]]';
const html = service.replaceWithHtml(markdown);
expect(html).toContain('data-fragment="section"');
expect(html).toContain('data-fragment-type="heading"');
});
it('should use resolver when provided', () => {
const markdown = '[[test-note]]';
const resolver = (link: any) => link.note === 'test-note' ? '/notes/test' : null;
const html = service.replaceWithHtml(markdown, resolver);
expect(html).toContain('href="/notes/test"');
expect(html).toContain('class="md-wiki-link"');
expect(html).not.toContain('wikilink-orphan');
});
it('should escape HTML in content', () => {
const markdown = '[[<script>alert("xss")</script>]]';
const html = service.replaceWithHtml(markdown);
expect(html).not.toContain('<script>');
expect(html).toContain('&lt;script&gt;');
});
});
describe('Performance', () => {
it('should parse 100 wikilinks in <50ms', () => {
let markdown = '';
for (let i = 0; i < 100; i++) {
markdown += `Link to [[note-${i}]] and [[note-${i}|Alias ${i}]]\n`;
}
const start = performance.now();
const result = service.parseWikiLinks(markdown);
const duration = performance.now() - start;
expect(result.length).toBe(200);
expect(duration).toBeLessThan(50);
});
});
});
+150
View File
@@ -0,0 +1,150 @@
import { Injectable } from '@angular/core';
export interface WikiLink {
note: string;
alias?: string;
fragment?: string;
type?: 'heading' | 'block';
}
@Injectable({
providedIn: 'root'
})
export class WikiLinkParserService {
private readonly WIKILINK_REGEX = /\[\[([^\]|#^]+)(?:\|([^\]]+))?(?:#(\^?[^\]]+))?\]\]/g;
/**
* Parse tous les wikilinks d'un texte markdown
*/
parseWikiLinks(markdown: string): WikiLink[] {
const links: WikiLink[] = [];
const matches = markdown.matchAll(this.WIKILINK_REGEX);
for (const match of matches) {
const note = match[1]?.trim() || '';
const alias = match[2]?.trim();
const fragment = match[3]?.trim();
if (!note) continue;
const link: WikiLink = { note };
if (alias) {
link.alias = alias;
}
if (fragment) {
if (fragment.startsWith('^')) {
link.fragment = fragment.substring(1);
link.type = 'block';
} else {
link.fragment = fragment;
link.type = 'heading';
}
}
links.push(link);
}
return links;
}
/**
* Remplace les wikilinks par du HTML (utilisé pour post-processing)
*/
replaceWithHtml(markdown: string, resolver?: (link: WikiLink) => string | null): string {
return markdown.replace(this.WIKILINK_REGEX, (match, note, alias, fragment) => {
const wikiLink: WikiLink = {
note: note?.trim() || '',
alias: alias?.trim(),
fragment: fragment?.trim()
};
if (fragment?.startsWith('^')) {
wikiLink.type = 'block';
wikiLink.fragment = fragment.substring(1);
} else if (fragment) {
wikiLink.type = 'heading';
}
const path = resolver ? resolver(wikiLink) : null;
const display = wikiLink.alias || wikiLink.fragment || wikiLink.note;
const classes = path ? 'md-wiki-link' : 'md-wiki-link wikilink-orphan';
const attrs = [
`class="${classes}"`,
`data-target="${this.escapeAttribute(wikiLink.note)}"`
];
if (wikiLink.fragment) {
attrs.push(`data-fragment="${this.escapeAttribute(wikiLink.fragment)}"`);
}
if (wikiLink.type) {
attrs.push(`data-fragment-type="${wikiLink.type}"`);
}
if (path) {
attrs.push(`href="${path}"`);
}
return `<a ${attrs.join(' ')}>${this.escapeHtml(display)}</a>`;
});
}
/**
* Résout un wikilink vers un chemin de note
* Retourne le slug de la note ou null si introuvable
*/
resolveNotePath(wikilink: WikiLink, allNotePaths: string[]): string | null {
const target = wikilink.note.toLowerCase().trim();
// Recherche exacte
const exactMatch = allNotePaths.find(path => {
const fileName = path.split('/').pop()?.replace(/\.md$/, '').toLowerCase();
return fileName === target;
});
if (exactMatch) {
return this.pathToSlug(exactMatch);
}
// Recherche partielle
const partialMatch = allNotePaths.find(path => {
const fileName = path.split('/').pop()?.replace(/\.md$/, '').toLowerCase();
return fileName?.includes(target);
});
return partialMatch ? this.pathToSlug(partialMatch) : null;
}
/**
* Convertit un chemin de fichier en slug pour le routing
*/
private pathToSlug(path: string): string {
return path
.replace(/\.md$/, '')
.replace(/\\/g, '/')
.toLowerCase()
.replace(/[^\w\/-]/g, '-')
.replace(/-+/g, '-');
}
private escapeHtml(unsafe: string): string {
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
private escapeAttribute(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
}
+23
View File
@@ -137,6 +137,29 @@
text-decoration-thickness: 2px;
}
.md-wiki-link.wikilink-orphan {
color: var(--wiki-link-orphan, #94a3b8);
text-decoration-style: dashed;
}
.md-wiki-link.wikilink-orphan:hover,
.md-wiki-link.wikilink-orphan:focus-visible {
color: var(--wiki-link-orphan-hover, #64748b);
}
/* Note preview overlay styles */
.note-preview-overlay {
z-index: 1000;
}
.cdk-overlay-backdrop {
background: rgba(0, 0, 0, 0.32);
}
.cdk-overlay-backdrop.cdk-overlay-backdrop-showing {
opacity: 1;
}
.md-math-inline {
font-family: "Cambria", "Times New Roman", serif;
background-color: rgba(148, 163, 184, 0.2);