chore: update TypeScript build info cache
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user