chore: update TypeScript build info cache
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()))
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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('<script>');
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
private escapeAttribute(value: string): string {
|
||||
return value
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user