feat: add Meilisearch backend integration with Docker Compose setup and Excalidraw support
This commit is contained in:
@@ -185,7 +185,7 @@
|
||||
></app-file-explorer>
|
||||
}
|
||||
@case ('tags') {
|
||||
<app-tags-view [tags]="filteredTags()" (tagSelected)="handleTagClick($event)"></app-tags-view>
|
||||
<app-tags-view [tags]="allTags()" (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>
|
||||
|
||||
+69
-106
@@ -175,108 +175,36 @@
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
@switch (activeView()) {
|
||||
@case ('files') {
|
||||
<div class="px-2 pt-2 pb-2">
|
||||
<input
|
||||
type="search"
|
||||
[value]="fileExplorerFilter()"
|
||||
(input)="fileExplorerFilter.set(($any($event.target).value || '').toString())"
|
||||
placeholder="Filtrer fichiers et dossiers..."
|
||||
class="w-full rounded-full border border-border bg-card px-3 py-1.5 text-sm text-text-main placeholder:text-text-muted"
|
||||
aria-label="Filtrer arborescence"
|
||||
/>
|
||||
</div>
|
||||
<app-file-explorer
|
||||
[nodes]="filteredFileTree()"
|
||||
[nodes]="filteredExplorerTree()"
|
||||
[selectedNoteId]="selectedNoteId()"
|
||||
(fileSelected)="selectNote($event)"
|
||||
(fileSelected)="onFileNodeSelected($event)"
|
||||
></app-file-explorer>
|
||||
}
|
||||
@case ('tags') {
|
||||
<app-tags-view [tags]="filteredTags()" (tagSelected)="handleTagClick($event)"></app-tags-view>
|
||||
<app-tags-view [tags]="allTags()" (tagSelected)="handleTagClick($event)"></app-tags-view>
|
||||
}
|
||||
|
||||
@case ('search') {
|
||||
<div class="space-y-4 p-3">
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="relative">
|
||||
<app-search-input-with-assistant
|
||||
[value]="sidebarSearchTerm()"
|
||||
(valueChange)="updateSearchTerm($event)"
|
||||
(submit)="onSearchSubmit($event)"
|
||||
[placeholder]="'Rechercher dans la voûte...'"
|
||||
[context]="'vault-sidebar'"
|
||||
[showSearchIcon]="true"
|
||||
[showExamples]="false"
|
||||
[inputClass]="'w-full rounded-full border border-border bg-bg-muted/70 py-2.5 pr-4 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring transition-all dark:border-gray-600 dark:bg-gray-800/80 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:ring-blue-500'"
|
||||
/>
|
||||
</div>
|
||||
@if (activeTagDisplay(); as tagDisplay) {
|
||||
<div class="flex items-center justify-between rounded-lg border border-border bg-card px-3 py-2">
|
||||
<div class="flex items-center gap-2 text-sm font-small text-text-main">
|
||||
<span>🔖</span>
|
||||
<span class="truncate">{{ tagDisplay }}</span>
|
||||
</div>
|
||||
<button class="btn btn-sm btn-ghost" (click)="clearTagFilter()">Effacer</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<h3 class="text-xs font-semibold uppercase tracking-wide text-text-muted">Résultats</h3>
|
||||
<span class="text-xs text-text-muted">{{ searchResults().length }}</span>
|
||||
</div>
|
||||
@if (searchResults().length > 0) {
|
||||
<ul class="space-y-1">
|
||||
@for (note of searchResults(); track note.id) {
|
||||
<li
|
||||
(click)="selectNote(note.id)"
|
||||
class="cursor-pointer rounded-lg border border-transparent bg-card px-3 py-2 transition hover:border-border hover:bg-bg-muted"
|
||||
[ngClass]="{ 'border-border bg-bg-muted': selectedNoteId() === note.id }"
|
||||
>
|
||||
<div class="truncate text-sm font-semibold text-text-main">{{ note.title }}</div>
|
||||
<div class="truncate text-xs text-text-muted">{{ note.content.substring(0, 100) }}</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
} @else {
|
||||
<p class="rounded-lg border border-dashed border-border px-3 py-3 text-sm text-text-muted">Aucun résultat pour cette recherche.</p>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (calendarSelectionLabel() || calendarSearchState() !== 'idle' || calendarResults().length > 0 || calendarSearchError()) {
|
||||
<div class="space-y-3 rounded-xl border border-border bg-card p-3 shadow-subtle">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-text-main">Résultats du calendrier</h3>
|
||||
@if (calendarSelectionLabel(); as selectionLabel) {
|
||||
<p class="text-xs text-text-muted">{{ selectionLabel }}</p>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-ghost"
|
||||
(click)="clearCalendarResults()"
|
||||
aria-label="Effacer les résultats du calendrier"
|
||||
>
|
||||
Effacer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (calendarSearchState() === 'loading') {
|
||||
<div class="rounded-lg bg-bg-muted px-3 py-2 text-xs text-text-muted">Recherche en cours...</div>
|
||||
} @else if (calendarSearchError(); as calError) {
|
||||
<div class="rounded-lg border border-red-500/40 bg-red-500/10 px-3 py-2 text-xs text-red-500 dark:border-red-500/30 dark:bg-red-500/15 dark:text-red-400">{{ calError }}</div>
|
||||
} @else if (calendarResults().length === 0) {
|
||||
<div class="rounded-lg bg-bg-muted px-3 py-2 text-xs text-text-muted">Sélectionnez une date dans le calendrier pour voir les notes correspondantes.</div>
|
||||
} @else {
|
||||
<ul class="space-y-2">
|
||||
@for (file of calendarResults(); track file.id) {
|
||||
<li>
|
||||
<button
|
||||
(click)="selectNote(file.id)"
|
||||
class="w-full rounded-lg border border-transparent bg-card px-3 py-2 text-left transition hover:border-border hover:bg-bg-muted"
|
||||
>
|
||||
<div class="truncate text-sm font-semibold text-text-main">{{ file.title }}</div>
|
||||
<div class="mt-1 flex flex-wrap gap-2 text-xs text-text-muted">
|
||||
<span>Créé : {{ file.createdAt | date:'mediumDate' }}</span>
|
||||
<span>Modifié : {{ file.updatedAt | date:'mediumDate' }}</span>
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="h-full p-2">
|
||||
<app-search-panel
|
||||
[placeholder]="'Rechercher dans la voûte...'"
|
||||
[context]="'vault-sidebar'"
|
||||
[initialQuery]="sidebarSearchTerm()"
|
||||
(noteOpen)="selectNote($event.noteId)"
|
||||
(searchTermChange)="onSidebarSearchTermChange($event)"
|
||||
(optionsChange)="onSidebarOptionsChange($event)"
|
||||
></app-search-panel>
|
||||
</div>
|
||||
}
|
||||
@case ('calendar') {
|
||||
@@ -363,6 +291,18 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden items-center gap-2 lg:flex">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-icon btn-ghost"
|
||||
(click)="createNewDrawing()"
|
||||
aria-label="Nouveau dessin Excalidraw"
|
||||
title="Nouveau dessin"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||
<path d="M12 5v14" />
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-icon btn-ghost disabled:opacity-40 disabled:pointer-events-none"
|
||||
@@ -440,6 +380,18 @@
|
||||
</div>
|
||||
<div class="flex flex-col gap-3 lg:flex-row lg:items-center lg:justify-between lg:gap-6">
|
||||
<div class="flex items-center gap-2 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-icon btn-ghost"
|
||||
(click)="createNewDrawing()"
|
||||
aria-label="Nouveau dessin Excalidraw"
|
||||
title="Nouveau dessin"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" class="h-5 w-5">
|
||||
<path d="M12 5v14" />
|
||||
<path d="M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-icon btn-ghost disabled:opacity-40 disabled:pointer-events-none"
|
||||
@@ -506,6 +458,7 @@
|
||||
[context]="'vault-header'"
|
||||
[showSearchIcon]="true"
|
||||
[inputClass]="'w-full rounded-full border border-border bg-bg-muted/70 pl-11 pr-4 py-2.5 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring'"
|
||||
(optionsChange)="onHeaderSearchOptionsChange($event)"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
@@ -527,19 +480,29 @@
|
||||
</app-graph-view-container-v2>
|
||||
</div>
|
||||
} @else {
|
||||
@if (selectedNote(); as note) {
|
||||
<app-note-viewer
|
||||
[note]="note"
|
||||
[noteHtmlContent]="renderedNoteContent()"
|
||||
[allNotes]="vaultService.allNotes()"
|
||||
(noteLinkClicked)="selectNote($event)"
|
||||
(tagClicked)="handleTagClick($event)"
|
||||
(wikiLinkActivated)="handleWikiLink($event)"
|
||||
></app-note-viewer>
|
||||
@if (activeView() === 'drawings') {
|
||||
@if (currentDrawingPath()) {
|
||||
<app-drawings-editor [path]="currentDrawingPath()!"></app-drawings-editor>
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<p class="text-text-muted">Aucun dessin sélectionné</p>
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center">
|
||||
<p class="text-text-muted">Sélectionnez une note pour commencer</p>
|
||||
</div>
|
||||
@if (selectedNote(); as note) {
|
||||
<app-note-viewer
|
||||
[note]="note"
|
||||
[noteHtmlContent]="renderedNoteContent()"
|
||||
[allNotes]="vaultService.allNotes()"
|
||||
(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>
|
||||
|
||||
+329
-44
@@ -1,4 +1,5 @@
|
||||
import { Component, ChangeDetectionStrategy, HostListener, inject, signal, computed, effect, ElementRef, OnDestroy, OnInit } from '@angular/core';
|
||||
import { isObservable } from 'rxjs';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
// Services
|
||||
@@ -16,11 +17,14 @@ import { GraphViewContainerV2Component } from './components/graph-view-container
|
||||
import { TagsViewComponent } from './components/tags-view/tags-view.component';
|
||||
import { MarkdownCalendarComponent } from './components/markdown-calendar/markdown-calendar.component';
|
||||
import { GraphInlineSettingsComponent } from './app/graph/ui/inline-settings-panel.component';
|
||||
import { DrawingsEditorComponent } from './app/features/drawings/drawings-editor.component';
|
||||
import { DrawingsFileService, ExcalidrawScene } from './app/features/drawings/drawings-file.service';
|
||||
import { RawViewOverlayComponent } from './shared/overlays/raw-view-overlay.component';
|
||||
import { BookmarksPanelComponent } from './components/bookmarks-panel/bookmarks-panel.component';
|
||||
import { AddBookmarkModalComponent, type BookmarkFormData, type BookmarkDeleteEvent } from './components/add-bookmark-modal/add-bookmark-modal.component';
|
||||
import { BookmarksService } from './core/bookmarks/bookmarks.service';
|
||||
import { SearchInputWithAssistantComponent } from './components/search-input-with-assistant/search-input-with-assistant.component';
|
||||
import { SearchPanelComponent } from './components/search-panel/search-panel.component';
|
||||
import { SearchHistoryService } from './core/search/search-history.service';
|
||||
import { GraphIndexService } from './core/graph/graph-index.service';
|
||||
import { SearchIndexService } from './core/search/search-index.service';
|
||||
@@ -50,6 +54,8 @@ interface TocEntry {
|
||||
AddBookmarkModalComponent,
|
||||
GraphInlineSettingsComponent,
|
||||
SearchInputWithAssistantComponent,
|
||||
SearchPanelComponent,
|
||||
DrawingsEditorComponent,
|
||||
],
|
||||
templateUrl: './app.component.simple.html',
|
||||
styleUrls: ['./app.component.css'],
|
||||
@@ -68,12 +74,14 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private readonly searchOrchestrator = inject(SearchOrchestratorService);
|
||||
private readonly logService = inject(LogService);
|
||||
private elementRef = inject(ElementRef);
|
||||
private readonly drawingsFiles = inject(DrawingsFileService);
|
||||
|
||||
// --- State Signals ---
|
||||
isSidebarOpen = signal<boolean>(true);
|
||||
isOutlineOpen = signal<boolean>(true);
|
||||
outlineTab = signal<'outline' | 'settings'>('outline');
|
||||
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'>('files');
|
||||
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings'>('files');
|
||||
currentDrawingPath = signal<string | null>(null);
|
||||
selectedNoteId = signal<string>('');
|
||||
sidebarSearchTerm = signal<string>('');
|
||||
tableOfContents = signal<TocEntry[]>([]);
|
||||
@@ -87,15 +95,186 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
readonly RIGHT_MIN_WIDTH = 220;
|
||||
readonly RIGHT_MAX_WIDTH = 520;
|
||||
private rawViewTriggerElement: HTMLElement | null = null;
|
||||
|
||||
private viewportWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||
private resizeHandler = () => {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
if (typeof window === 'undefined') return;
|
||||
this.viewportWidth.set(window.innerWidth);
|
||||
};
|
||||
|
||||
// --- Search cross-UI sync ---
|
||||
onHeaderSearchOptionsChange(opts: { caseSensitive: boolean; regexMode: boolean; highlight: boolean }): void {
|
||||
this.lastCaseSensitive.set(!!opts.caseSensitive);
|
||||
this.lastRegexMode.set(!!opts.regexMode);
|
||||
this.lastHighlightEnabled.set(opts.highlight !== false);
|
||||
this.scheduleApplyDocumentHighlight();
|
||||
}
|
||||
|
||||
onFileNodeSelected(noteId: string): void {
|
||||
if (!noteId) return;
|
||||
const meta = this.vaultService.getFastMetaById(noteId);
|
||||
const p = meta?.path || '';
|
||||
if (/\.excalidraw(?:\.md)?$/i.test(p)) {
|
||||
this.openDrawing(p);
|
||||
return;
|
||||
}
|
||||
this.selectNote(noteId);
|
||||
}
|
||||
|
||||
openDrawing(path: string): void {
|
||||
const p = (path || '').replace(/^\/+/, '');
|
||||
if (!p) return;
|
||||
this.currentDrawingPath.set(p);
|
||||
this.activeView.set('drawings');
|
||||
}
|
||||
|
||||
async createNewDrawing(): Promise<void> {
|
||||
// Build a timestamped filename inside Inbox/ by default
|
||||
const now = new Date();
|
||||
const y = now.getFullYear();
|
||||
const m = String(now.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(now.getDate()).padStart(2, '0');
|
||||
const hh = String(now.getHours()).padStart(2, '0');
|
||||
const mm = String(now.getMinutes()).padStart(2, '0');
|
||||
const ss = String(now.getSeconds()).padStart(2, '0');
|
||||
const filename = `Drawing ${y}-${m}-${d} ${hh}.${mm}.${ss}.excalidraw.md`;
|
||||
const path = filename;
|
||||
|
||||
const scene: ExcalidrawScene = { elements: [], appState: {}, files: {} };
|
||||
try {
|
||||
await this.drawingsFiles.put(path, scene).toPromise();
|
||||
this.openDrawing(path);
|
||||
this.logService.log('NAVIGATE', { action: 'DRAWING_CREATED', path });
|
||||
} catch (e) {
|
||||
this.logService.log('NAVIGATE', { action: 'DRAWING_CREATE_FAILED', path, error: String(e) }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
onSidebarSearchTermChange(term: string): void {
|
||||
const t = (term ?? '').trim();
|
||||
this.lastSearchTerm.set(t);
|
||||
this.scheduleApplyDocumentHighlight();
|
||||
}
|
||||
|
||||
onSidebarOptionsChange(opts: { caseSensitive?: boolean; regexMode?: boolean; highlight?: boolean }): void {
|
||||
if (typeof opts.caseSensitive === 'boolean') this.lastCaseSensitive.set(opts.caseSensitive);
|
||||
if (typeof opts.regexMode === 'boolean') this.lastRegexMode.set(opts.regexMode);
|
||||
if (typeof opts.highlight === 'boolean') this.lastHighlightEnabled.set(opts.highlight);
|
||||
this.scheduleApplyDocumentHighlight();
|
||||
}
|
||||
|
||||
// --- Active document highlight ---
|
||||
private highlightScheduled = false;
|
||||
private scheduleApplyDocumentHighlight(): void {
|
||||
if (this.highlightScheduled) return;
|
||||
this.highlightScheduled = true;
|
||||
queueMicrotask(() => {
|
||||
this.highlightScheduled = false;
|
||||
this.applyActiveDocumentHighlight();
|
||||
});
|
||||
}
|
||||
|
||||
private clearActiveDocumentHighlight(): void {
|
||||
const host = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
|
||||
if (!host) return;
|
||||
host.querySelectorAll('mark.doc-highlight').forEach(mark => {
|
||||
const parent = mark.parentNode as Node | null;
|
||||
if (!parent) return;
|
||||
const text = document.createTextNode(mark.textContent || '');
|
||||
parent.replaceChild(text, mark);
|
||||
parent.normalize?.();
|
||||
});
|
||||
}
|
||||
|
||||
private applyActiveDocumentHighlight(): void {
|
||||
const term = this.lastSearchTerm().trim();
|
||||
const enabled = this.lastHighlightEnabled();
|
||||
const caseSensitive = this.lastCaseSensitive();
|
||||
const regexMode = this.lastRegexMode();
|
||||
|
||||
// Clear previous highlights first
|
||||
this.clearActiveDocumentHighlight();
|
||||
|
||||
if (!enabled || !term) return;
|
||||
const container = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
|
||||
if (!container) return;
|
||||
|
||||
// Inner content rendered by NoteViewerComponent
|
||||
const contentRoot = container.querySelector<HTMLElement>('app-note-viewer');
|
||||
const rootEl = contentRoot ?? container;
|
||||
|
||||
const patterns: RegExp[] = [];
|
||||
try {
|
||||
if (regexMode) {
|
||||
const flags = caseSensitive ? 'g' : 'gi';
|
||||
patterns.push(new RegExp(term, flags));
|
||||
} else {
|
||||
const flags = caseSensitive ? 'g' : 'gi';
|
||||
const escaped = term.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
patterns.push(new RegExp(escaped, flags));
|
||||
}
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_TEXT, {
|
||||
acceptNode: (node) => {
|
||||
const parentEl = node.parentElement as HTMLElement | null;
|
||||
if (!parentEl) return NodeFilter.FILTER_REJECT;
|
||||
// Skip inside code/pre
|
||||
const tag = parentEl.tagName.toLowerCase();
|
||||
if (tag === 'code' || tag === 'pre' || parentEl.closest('code,pre')) return NodeFilter.FILTER_REJECT;
|
||||
if (parentEl.closest('button, a.md-wiki-link')) return NodeFilter.FILTER_REJECT;
|
||||
if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
|
||||
return NodeFilter.FILTER_ACCEPT;
|
||||
}
|
||||
} as any);
|
||||
|
||||
const maxMarks = 1000;
|
||||
let count = 0;
|
||||
const nodesToProcess: Text[] = [];
|
||||
while (walker.nextNode()) {
|
||||
nodesToProcess.push(walker.currentNode as Text);
|
||||
if (nodesToProcess.length > 5000) break;
|
||||
}
|
||||
|
||||
for (const textNode of nodesToProcess) {
|
||||
let nodeText = textNode.nodeValue || '';
|
||||
let replaced = false;
|
||||
const fragments: (string | HTMLElement)[] = [];
|
||||
let lastIndex = 0;
|
||||
const pattern = patterns[0];
|
||||
pattern.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = pattern.exec(nodeText)) && count < maxMarks) {
|
||||
const start = m.index;
|
||||
const end = m.index + m[0].length;
|
||||
if (start > lastIndex) fragments.push(nodeText.slice(lastIndex, start));
|
||||
const mark = document.createElement('mark');
|
||||
mark.className = 'doc-highlight bg-yellow-200 dark:bg-yellow-600 text-gray-900 dark:text-gray-900 px-0.5 rounded';
|
||||
mark.textContent = nodeText.slice(start, end);
|
||||
fragments.push(mark);
|
||||
lastIndex = end;
|
||||
replaced = true;
|
||||
count++;
|
||||
if (!regexMode) pattern.lastIndex = end; // ensure forward progress
|
||||
}
|
||||
if (!replaced) continue;
|
||||
if (lastIndex < nodeText.length) fragments.push(nodeText.slice(lastIndex));
|
||||
|
||||
const parent = textNode.parentNode as Node | null;
|
||||
if (!parent) continue;
|
||||
const frag = document.createDocumentFragment();
|
||||
for (const part of fragments) {
|
||||
if (typeof part === 'string') {
|
||||
frag.appendChild(document.createTextNode(part));
|
||||
} else {
|
||||
frag.appendChild(part);
|
||||
}
|
||||
}
|
||||
parent.replaceChild(frag, textNode);
|
||||
}
|
||||
}
|
||||
|
||||
isDesktopView = computed<boolean>(() => this.viewportWidth() >= 1024);
|
||||
private wasDesktop = false;
|
||||
|
||||
@@ -111,8 +290,37 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private calendarSearchTriggered = false;
|
||||
private pendingWikiNavigation = signal<{ noteId: string; heading?: string; block?: string } | null>(null);
|
||||
|
||||
// --- File explorer filter ---
|
||||
fileExplorerFilter = signal<string>('');
|
||||
filteredExplorerTree = computed<VaultNode[]>(() => {
|
||||
const term = this.fileExplorerFilter().trim().toLowerCase();
|
||||
const tree = this.effectiveFileTree();
|
||||
if (!term) return tree;
|
||||
const filterNodes = (nodes: VaultNode[]): VaultNode[] => {
|
||||
const out: VaultNode[] = [];
|
||||
for (const n of nodes) {
|
||||
if (n.type === 'file') {
|
||||
if (n.name.toLowerCase().includes(term)) out.push(n);
|
||||
} else {
|
||||
const children = filterNodes(n.children);
|
||||
if (n.name.toLowerCase().includes(term) || children.length > 0) {
|
||||
out.push({ ...n, children, isOpen: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
return filterNodes(tree);
|
||||
});
|
||||
|
||||
readonly isDarkMode = this.themeService.isDark;
|
||||
|
||||
// --- Cross-UI search/highlight state for active document ---
|
||||
private lastSearchTerm = signal<string>('');
|
||||
private lastHighlightEnabled = signal<boolean>(true);
|
||||
private lastCaseSensitive = signal<boolean>(false);
|
||||
private lastRegexMode = signal<boolean>(false);
|
||||
|
||||
// Bookmark state
|
||||
readonly isCurrentNoteBookmarked = computed(() => {
|
||||
const noteId = this.selectedNoteId();
|
||||
@@ -141,6 +349,11 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
|
||||
// --- Data Signals ---
|
||||
fileTree = this.vaultService.fileTree;
|
||||
fastFileTree = this.vaultService.fastFileTree;
|
||||
effectiveFileTree = computed<VaultNode[]>(() => {
|
||||
const fast = this.fastFileTree();
|
||||
return fast && fast.length ? fast : this.fileTree();
|
||||
});
|
||||
graphData = this.vaultService.graphData;
|
||||
allTags = this.vaultService.tags;
|
||||
vaultName = this.vaultService.vaultName;
|
||||
@@ -204,30 +417,40 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
|
||||
filteredFileTree = computed<VaultNode[]>(() => {
|
||||
const term = this.sidebarSearchTerm().trim().toLowerCase();
|
||||
if (!term || term.startsWith('#')) return this.fileTree();
|
||||
// Simple flat search for files
|
||||
return this.fileTree().filter(node => node.type === 'file' && node.name.toLowerCase().includes(term));
|
||||
const tree = this.effectiveFileTree();
|
||||
if (!term || term.startsWith('#')) return tree;
|
||||
// Simple flat search for files (top-level only in this view)
|
||||
return tree.filter(node => node.type === 'file' && node.name.toLowerCase().includes(term));
|
||||
});
|
||||
|
||||
activeTagFilter = computed<string | null>(() => {
|
||||
const rawTerm = this.sidebarSearchTerm().trim();
|
||||
if (!rawTerm.startsWith('#')) {
|
||||
return null;
|
||||
const raw = this.sidebarSearchTerm().trim();
|
||||
if (!raw) return null;
|
||||
// Support both syntaxes: "#tag" and "tag:foo/bar"
|
||||
if (raw.startsWith('#')) {
|
||||
const v = raw.slice(1).trim();
|
||||
return v ? v.toLowerCase() : null;
|
||||
}
|
||||
|
||||
const tag = rawTerm.slice(1).trim();
|
||||
return tag ? tag.toLowerCase() : null;
|
||||
if (/^tag:/i.test(raw)) {
|
||||
const v = raw.slice(4).trim();
|
||||
return v ? v.toLowerCase() : null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
activeTagDisplay = computed<string | null>(() => {
|
||||
const rawTerm = this.sidebarSearchTerm().trim();
|
||||
if (!rawTerm.startsWith('#')) {
|
||||
return null;
|
||||
const raw = this.sidebarSearchTerm().trim();
|
||||
if (!raw) return null;
|
||||
if (raw.startsWith('#')) {
|
||||
return raw.slice(1).trim() || null;
|
||||
}
|
||||
|
||||
const displayTag = rawTerm.slice(1).trim();
|
||||
return displayTag || null;
|
||||
if (/^tag:/i.test(raw)) {
|
||||
return raw.slice(4).trim() || null;
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
|
||||
|
||||
searchResults = computed<Note[]>(() => {
|
||||
const notes = this.vaultService.allNotes();
|
||||
@@ -239,7 +462,13 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
const tagFilter = this.activeTagFilter();
|
||||
const effectiveQuery = tagFilter ? `tag:${tagFilter}` : rawQuery;
|
||||
|
||||
const results = this.searchOrchestrator.execute(effectiveQuery);
|
||||
const exec = this.searchOrchestrator.execute(effectiveQuery);
|
||||
if (isObservable(exec)) {
|
||||
// Meilisearch path returns Observable; this computed must return synchronously.
|
||||
// Defer to SearchPanel for reactive results; here return empty until other UI handles it.
|
||||
return [];
|
||||
}
|
||||
const results = exec;
|
||||
if (!results.length) {
|
||||
return [];
|
||||
}
|
||||
@@ -295,8 +524,11 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
const html = this.renderedNoteContent();
|
||||
if (html && note) {
|
||||
this.generateToc(html);
|
||||
// Apply search highlights after content render
|
||||
this.scheduleApplyDocumentHighlight();
|
||||
} else {
|
||||
this.tableOfContents.set([]);
|
||||
this.clearActiveDocumentHighlight();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -434,18 +666,37 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
|
||||
onCalendarResultsChange(files: FileMetadata[]): void {
|
||||
this.calendarResults.set(files);
|
||||
if (this.calendarSearchTriggered || files.length > 0 || this.activeView() === 'search') {
|
||||
this.isSidebarOpen.set(true);
|
||||
this.activeView.set('search');
|
||||
this.calendarSearchTriggered = false;
|
||||
|
||||
// Log calendar search execution
|
||||
if (files.length > 0) {
|
||||
this.logService.log('CALENDAR_SEARCH_EXECUTED', {
|
||||
resultsCount: files.length,
|
||||
});
|
||||
}
|
||||
// Build a date query for Meilisearch based on returned files
|
||||
let minT = Number.POSITIVE_INFINITY;
|
||||
let maxT = 0;
|
||||
const toTime = (s?: string | null) => {
|
||||
if (!s) return NaN;
|
||||
const t = Date.parse(s);
|
||||
return Number.isNaN(t) ? NaN : t;
|
||||
};
|
||||
for (const f of files) {
|
||||
const ct = toTime(f.createdAt);
|
||||
const ut = toTime(f.updatedAt);
|
||||
if (!Number.isNaN(ct)) { minT = Math.min(minT, ct); maxT = Math.max(maxT, ct); }
|
||||
if (!Number.isNaN(ut)) { minT = Math.min(minT, ut); maxT = Math.max(maxT, ut); }
|
||||
}
|
||||
if (Number.isFinite(minT) && maxT > 0) {
|
||||
const toYMD = (ms: number) => new Date(ms).toISOString().slice(0, 10);
|
||||
const startYMD = toYMD(minT);
|
||||
const endYMD = toYMD(maxT);
|
||||
const query = startYMD === endYMD ? `date:${startYMD}` : `from:${startYMD} to:${endYMD}`;
|
||||
this.sidebarSearchTerm.set(query);
|
||||
}
|
||||
|
||||
// Open search view and reset loading flag
|
||||
this.isSidebarOpen.set(true);
|
||||
this.activeView.set('search');
|
||||
this.calendarSearchTriggered = false;
|
||||
|
||||
// Log calendar search execution
|
||||
this.logService.log('CALENDAR_SEARCH_EXECUTED', {
|
||||
resultsCount: files.length,
|
||||
});
|
||||
}
|
||||
|
||||
onCalendarSearchStateChange(state: 'idle' | 'loading' | 'error'): void {
|
||||
@@ -560,7 +811,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
handle?.addEventListener('lostpointercapture', cleanup);
|
||||
}
|
||||
|
||||
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'): void {
|
||||
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks' | 'drawings'): void {
|
||||
const previousView = this.activeView();
|
||||
this.activeView.set(view);
|
||||
this.sidebarSearchTerm.set('');
|
||||
@@ -573,12 +824,34 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
selectNote(noteId: string): void {
|
||||
const note = this.vaultService.getNoteById(noteId);
|
||||
async selectNote(noteId: string): Promise<void> {
|
||||
let note = this.vaultService.getNoteById(noteId);
|
||||
if (!note) {
|
||||
return;
|
||||
// Try to lazy-load using Meilisearch/slug mapping
|
||||
const ok = await this.vaultService.ensureNoteLoadedById(noteId);
|
||||
if (!ok) {
|
||||
// Fallback: if we have fast metadata, map to slug id from path
|
||||
const meta = this.vaultService.getFastMetaById(noteId);
|
||||
if (meta?.path) {
|
||||
await this.vaultService.ensureNoteLoadedByPath(meta.path);
|
||||
const slugId = this.vaultService.buildSlugIdFromPath(meta.path);
|
||||
note = this.vaultService.getNoteById(slugId);
|
||||
}
|
||||
} else {
|
||||
note = this.vaultService.getNoteById(noteId);
|
||||
if (!note) {
|
||||
// If Meili id differs from slug id, try mapping
|
||||
const meta = this.vaultService.getFastMetaById(noteId);
|
||||
if (meta?.path) {
|
||||
const slugId = this.vaultService.buildSlugIdFromPath(meta.path);
|
||||
note = this.vaultService.getNoteById(slugId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!note) return;
|
||||
|
||||
this.vaultService.ensureFolderOpen(note.originalPath);
|
||||
this.selectedNoteId.set(note.id);
|
||||
this.markdownViewerService.setCurrentNote(note);
|
||||
@@ -594,14 +867,15 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
handleTagClick(tagName: string): void {
|
||||
const normalized = tagName.replace(/^#/, '').trim();
|
||||
const normalized = tagName.replace(/^#/, '').replace(/\\/g, '/').trim();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSidebarOpen.set(true);
|
||||
this.activeView.set('search');
|
||||
this.sidebarSearchTerm.set(`#${normalized}`);
|
||||
// Use Meilisearch-friendly syntax
|
||||
this.sidebarSearchTerm.set(`tag:${normalized}`);
|
||||
}
|
||||
|
||||
updateSearchTerm(term: string, focusSearch = false): void {
|
||||
@@ -623,6 +897,17 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
query: query.trim(),
|
||||
queryLength: query.trim().length,
|
||||
});
|
||||
|
||||
// Track for document highlight
|
||||
this.lastSearchTerm.set(query.trim());
|
||||
this.scheduleApplyDocumentHighlight();
|
||||
|
||||
// Also emit to sync sidebar options if needed
|
||||
this.onSidebarOptionsChange({
|
||||
caseSensitive: this.lastCaseSensitive(),
|
||||
regexMode: this.lastRegexMode(),
|
||||
highlight: this.lastHighlightEnabled()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -749,11 +1034,11 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
|
||||
onBookmarkNavigate(bookmark: any): void {
|
||||
if (bookmark.type === 'file' && bookmark.path) {
|
||||
// Find note by matching filePath
|
||||
const note = this.vaultService.allNotes().find(n => n.filePath === bookmark.path);
|
||||
if (note) {
|
||||
this.selectNote(note.id);
|
||||
}
|
||||
const path = String(bookmark.path);
|
||||
this.vaultService.ensureNoteLoadedByPath(path).then(() => {
|
||||
const slugId = this.vaultService.buildSlugIdFromPath(path);
|
||||
this.selectNote(slugId);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -831,7 +1116,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
this.tableOfContents.set(toc);
|
||||
}
|
||||
|
||||
private scrollToHeading(id: string): void {
|
||||
scrollToHeading(id: string): void {
|
||||
const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
|
||||
if (!contentArea) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
<div class="flex flex-col h-[calc(100vh-180px)] lg:h-[calc(100vh-140px)] gap-2">
|
||||
<!-- En-tête avec les boutons et les états -->
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<div class="flex items-center gap-2">
|
||||
<!-- Indicateur de sauvegarde (cliquable) -->
|
||||
<button
|
||||
type="button"
|
||||
class="flex items-center gap-2 px-3 py-2 rounded-md transition-colors"
|
||||
[class.cursor-pointer]="!isLoading() && !isSaving()"
|
||||
[class.cursor-wait]="isSaving()"
|
||||
[class.cursor-not-allowed]="isLoading()"
|
||||
(click)="saveNow()"
|
||||
[disabled]="isLoading()"
|
||||
title="{{dirty() ? 'Non sauvegardé - Cliquer pour sauvegarder (Ctrl+S)' : isSaving() ? 'Sauvegarde en cours...' : 'Sauvegardé'}}"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
[class.text-red-500]="dirty() && !isSaving()"
|
||||
[class.text-gray-400]="!dirty() && !isSaving()"
|
||||
[class.text-yellow-500]="isSaving()"
|
||||
[class.animate-pulse]="isSaving()"
|
||||
>
|
||||
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"></path>
|
||||
<polyline points="17 21 17 13 7 13 7 21"></polyline>
|
||||
<polyline points="7 3 7 8 15 8"></polyline>
|
||||
</svg>
|
||||
<span class="text-xs font-medium" [class.text-red-500]="dirty() && !isSaving()" [class.text-gray-400]="!dirty() && !isSaving()" [class.text-yellow-500]="isSaving()">
|
||||
{{isSaving() ? 'Sauvegarde...' : dirty() ? 'Non sauvegardé' : 'Sauvegardé'}}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
(click)="exportPNG()"
|
||||
[disabled]="isLoading() || isSaving() || !excalidrawReady"
|
||||
>
|
||||
🖼️ Export PNG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
(click)="exportSVG()"
|
||||
[disabled]="isLoading() || isSaving() || !excalidrawReady"
|
||||
>
|
||||
🧩 Export SVG
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm"
|
||||
(click)="toggleFullscreen()"
|
||||
[disabled]="isLoading()"
|
||||
[title]="isFullscreen() ? 'Quitter le mode pleine écran' : 'Passer en mode pleine écran'"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
[class.text-blue-500]="isFullscreen()"
|
||||
>
|
||||
<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"></path>
|
||||
<polyline points="10,17 15,12 10,7"></polyline>
|
||||
<line x1="15" x2="3" y1="12" y2="12"></line>
|
||||
</svg>
|
||||
{{isFullscreen() ? 'Quitter FS' : 'Pleine écran'}}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Conflit: fichier modifié sur le disque -->
|
||||
<div *ngIf="hasConflict()" class="rounded-md border border-amber-600 bg-amber-900/30 text-amber-200 px-3 py-2">
|
||||
<div class="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<strong>Conflit détecté</strong> — Le fichier a été modifié sur le disque. Choisissez une action.
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="btn btn-xs" (click)="reloadFromDisk()">Recharger depuis le disque</button>
|
||||
<button class="btn btn-xs btn-danger" (click)="resolveConflictOverwrite()">Écraser par la version locale</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Affichage des erreurs seulement -->
|
||||
<div class="flex items-center gap-4" *ngIf="error() as err">
|
||||
<div class="text-xs text-red-600">
|
||||
{{ err }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Fin de l'en-tête -->
|
||||
</div>
|
||||
|
||||
<!-- État de chargement -->
|
||||
<div *ngIf="isLoading()" class="flex-1 flex items-center justify-center">
|
||||
<div class="text-center">
|
||||
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary mx-auto mb-2"></div>
|
||||
<p class="text-text-muted">Chargement du dessin...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Éditeur Excalidraw -->
|
||||
<div
|
||||
*ngIf="!isLoading()"
|
||||
class="flex-1 min-h-0 rounded-xl border border-border bg-card overflow-hidden relative excalidraw-host"
|
||||
[class.opacity-50]="isSaving()"
|
||||
>
|
||||
<excalidraw-editor
|
||||
#editorEl
|
||||
[initialData]="scene() || { elements: [], appState: { viewBackgroundColor: '#1e1e1e' } }"
|
||||
[theme]="themeName()"
|
||||
[lang]="'fr'"
|
||||
(ready)="console.log('READY', $event); onExcalidrawReady()"
|
||||
style="display:block; height:100%; width:100%"
|
||||
></excalidraw-editor>
|
||||
</div>
|
||||
|
||||
<!-- Toast notifications -->
|
||||
<div *ngIf="toastMessage() as msg" class="fixed bottom-4 right-4 z-50">
|
||||
<div
|
||||
class="px-3 py-2 rounded-md shadow-md text-sm"
|
||||
[ngClass]="{
|
||||
'bg-green-600 text-white': toastType() === 'success',
|
||||
'bg-red-600 text-white': toastType() === 'error',
|
||||
'bg-gray-700 text-white': toastType() === 'info'
|
||||
}"
|
||||
>
|
||||
{{ msg }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,792 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import {
|
||||
AfterViewInit,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
CUSTOM_ELEMENTS_SCHEMA,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
HostListener,
|
||||
Input,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
ViewChild,
|
||||
computed,
|
||||
inject,
|
||||
signal
|
||||
} from '@angular/core';
|
||||
import { firstValueFrom, fromEvent, of, Subscription, interval } from 'rxjs';
|
||||
import { debounceTime, distinctUntilChanged, map, switchMap, tap, catchError, filter } from 'rxjs/operators';
|
||||
import { DrawingsFileService, ExcalidrawScene } from './drawings-file.service';
|
||||
import { ExcalidrawIoService } from './excalidraw-io.service';
|
||||
import { DrawingsPreviewService } from './drawings-preview.service';
|
||||
import { ThemeService } from '../../core/services/theme.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-drawings-editor',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
templateUrl: './drawings-editor.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
schemas: [CUSTOM_ELEMENTS_SCHEMA],
|
||||
})
|
||||
export class DrawingsEditorComponent implements OnInit, AfterViewInit, OnDestroy {
|
||||
@Input() path: string = '';
|
||||
|
||||
@ViewChild('editorEl', { static: false }) editorEl?: ElementRef<HTMLElement & {
|
||||
setScene: (scene: any) => void;
|
||||
__excalidrawAPI: any;
|
||||
}>;
|
||||
|
||||
private readonly files = inject(DrawingsFileService);
|
||||
private readonly excalIo = inject(ExcalidrawIoService);
|
||||
private readonly previews = inject(DrawingsPreviewService);
|
||||
private readonly theme = inject(ThemeService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
// Propriétés réactives accessibles depuis le template
|
||||
scene = signal<ExcalidrawScene | null>(null);
|
||||
error = signal<string | null>(null);
|
||||
isLoading = signal<boolean>(true);
|
||||
isSaving = signal<boolean>(false);
|
||||
dirty = signal<boolean>(false);
|
||||
// Toast and conflict state
|
||||
toastMessage = signal<string | null>(null);
|
||||
toastType = signal<'success' | 'error' | 'info'>('info');
|
||||
hasConflict = signal<boolean>(false);
|
||||
isFullscreen = signal<boolean>(false);
|
||||
|
||||
private saveSub: Subscription | null = null;
|
||||
private dirtyCheckSub: Subscription | null = null;
|
||||
private sceneUpdateSub: Subscription | null = null;
|
||||
private lastSavedHash: string | null = null;
|
||||
private pollSub: Subscription | null = null;
|
||||
excalidrawReady = false;
|
||||
private hostListenersBound = false;
|
||||
private latestSceneFromEvents: ExcalidrawScene | null = null;
|
||||
|
||||
readonly themeName = computed<'light' | 'dark'>(() => (this.theme.isDark() ? 'dark' : 'light'));
|
||||
|
||||
async ngOnInit(): Promise<void> {
|
||||
try {
|
||||
// Lazy register the custom element
|
||||
await import('../../../../web-components/excalidraw/define');
|
||||
this.setupExcalidraw();
|
||||
} catch (err) {
|
||||
console.error('Failed to load Excalidraw:', err);
|
||||
this.error.set('Erreur de chargement de l\'éditeur de dessin.');
|
||||
}
|
||||
}
|
||||
|
||||
private waitForNextSceneChange(host: any, timeoutMs = 300): Promise<ExcalidrawScene | null> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const handler = (e: any) => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
try {
|
||||
const detail = e?.detail as Partial<ExcalidrawScene> | undefined;
|
||||
const normalized = detail ? this.normalizeSceneDetail(detail) : null;
|
||||
resolve(normalized);
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
try { host.addEventListener('scene-change', handler, { once: true }); } catch {}
|
||||
setTimeout(() => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
try { host.removeEventListener('scene-change', handler as any); } catch {}
|
||||
resolve(null);
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Export current canvas as PNG and save alongside the drawing file
|
||||
private async savePreviewPNG(): Promise<void> {
|
||||
const host = this.editorEl?.nativeElement;
|
||||
if (!host || !this.excalidrawReady) return;
|
||||
|
||||
try {
|
||||
const blob = await this.previews.exportPNGFromElement(host, true);
|
||||
if (!blob) return;
|
||||
const base = String(this.path || '').replace(/\.excalidraw(?:\.md)?$/i, '');
|
||||
const target = `${base}.png`;
|
||||
await firstValueFrom(this.files.putBinary(target, blob, 'image/png'));
|
||||
} catch (e) {
|
||||
// Non bloquant
|
||||
console.warn('savePreviewPNG failed', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Overwrite on-disk file with current local scene (resolves conflict)
|
||||
async resolveConflictOverwrite(): Promise<void> {
|
||||
try {
|
||||
const host: any = this.editorEl?.nativeElement;
|
||||
const scene = host?.getScene ? host.getScene() : this.scene();
|
||||
if (!scene) return;
|
||||
this.isSaving.set(true);
|
||||
await firstValueFrom(this.files.putForce(this.path, scene));
|
||||
this.lastSavedHash = this.hashScene(scene);
|
||||
this.dirty.set(false);
|
||||
this.isSaving.set(false);
|
||||
this.hasConflict.set(false);
|
||||
this.showToast('Conflit résolu: fichier écrasé', 'success');
|
||||
} catch (e) {
|
||||
console.error('Overwrite error:', e);
|
||||
this.isSaving.set(false);
|
||||
this.showToast('Échec de l\'écrasement', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Ctrl+S triggers manual save
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
onKeyDown(ev: KeyboardEvent) {
|
||||
if ((ev.ctrlKey || ev.metaKey) && (ev.key === 's' || ev.key === 'S')) {
|
||||
ev.preventDefault();
|
||||
this.saveNow();
|
||||
}
|
||||
// F11 toggles fullscreen
|
||||
if (ev.key === 'F11') {
|
||||
ev.preventDefault();
|
||||
this.toggleFullscreen();
|
||||
}
|
||||
}
|
||||
|
||||
// Manual save now using the current scene from the editor API
|
||||
async saveNow(): Promise<void> {
|
||||
console.log('💾 Manual save triggered');
|
||||
try {
|
||||
const host: any = this.editorEl?.nativeElement;
|
||||
if (!host) {
|
||||
console.error('❌ No host element');
|
||||
this.showToast('Éditeur non prêt', 'error');
|
||||
return;
|
||||
}
|
||||
// Ensure the editor API is ready before proceeding
|
||||
if (!host.__excalidrawAPI) {
|
||||
const gotReady = await this.waitForReady(host, 1500);
|
||||
if (!gotReady) {
|
||||
console.warn('⏳ Save aborted: editor API not ready');
|
||||
this.showToast('Éditeur en cours d’initialisation, réessayez', 'error');
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Proactively ask the web component to dispatch a fresh scene event
|
||||
try { host.__emitSceneChange?.(); } catch {}
|
||||
// Yield to next frames to ensure Excalidraw applied last input
|
||||
await this.awaitNextFrame();
|
||||
await this.awaitNextFrame();
|
||||
// Try multiple ways to get the scene to avoid race conditions
|
||||
let scene = this.getCurrentSceneFromHost(host) || null;
|
||||
const stateScene = this.scene();
|
||||
if (!scene || (Array.isArray((scene as any).elements) ? (scene as any).elements.length : 0) < (Array.isArray((stateScene as any)?.elements) ? (stateScene as any).elements.length : 0)) {
|
||||
scene = stateScene as any;
|
||||
}
|
||||
if (!scene || (Array.isArray((scene as any).elements) ? (scene as any).elements.length : 0) === 0) {
|
||||
// If we have a recent scene from events with elements, use it immediately
|
||||
const latest = this.latestSceneFromEvents;
|
||||
const latestLen = Array.isArray((latest as any)?.elements) ? (latest as any).elements.length : 0;
|
||||
const currentLen = Array.isArray((scene as any)?.elements) ? (scene as any).elements.length : 0;
|
||||
if (latestLen > currentLen) {
|
||||
scene = latest as any;
|
||||
}
|
||||
|
||||
// Register wait BEFORE flushing to not miss the event
|
||||
const waitPromise = this.waitForNextSceneChange(host, 3000);
|
||||
try { host.__emitSceneChange?.(); } catch {}
|
||||
const waited = await waitPromise;
|
||||
const viaHostAfter = this.getCurrentSceneFromHost(host);
|
||||
const candidates = [scene, waited, viaHostAfter, this.latestSceneFromEvents, this.scene()].filter(Boolean) as ExcalidrawScene[];
|
||||
let best: ExcalidrawScene | null = null;
|
||||
for (const c of candidates) {
|
||||
const len = Array.isArray((c as any).elements) ? (c as any).elements.length : 0;
|
||||
const bestLen = best && Array.isArray((best as any).elements) ? (best as any).elements.length : 0;
|
||||
if (!best || len > bestLen) best = c;
|
||||
}
|
||||
scene = best;
|
||||
}
|
||||
if (scene) {
|
||||
console.log('🧩 Snapshot', {
|
||||
elements: Array.isArray((scene as any).elements) ? (scene as any).elements.length : 'n/a',
|
||||
hasFiles: !!(scene as any).files && Object.keys((scene as any).files || {}).length,
|
||||
});
|
||||
}
|
||||
if (!scene) {
|
||||
console.error('❌ No scene data');
|
||||
this.showToast('Aucune donnée à sauvegarder', 'error');
|
||||
return;
|
||||
}
|
||||
console.log('📤 Sending save request...');
|
||||
this.isSaving.set(true);
|
||||
this.error.set(null);
|
||||
const isExMd = /\.excalidraw\.md$/i.test(this.path || '');
|
||||
let result: { rev: string } | null = null;
|
||||
if (isExMd) {
|
||||
// Use same compression as creation: build Obsidian MD with ```compressed-json```
|
||||
const md = this.excalIo.toObsidianMd(scene as any, null);
|
||||
result = await firstValueFrom(this.files.putText(this.path, md));
|
||||
} else {
|
||||
// For .excalidraw or .json let server persist plain JSON
|
||||
result = await firstValueFrom(this.files.put(this.path, scene));
|
||||
}
|
||||
console.log('📥 Save response', result);
|
||||
if (result && typeof result.rev === 'string' && result.rev.length > 0) {
|
||||
const expectedHash = this.hashScene(scene);
|
||||
// Verify by reloading from server and comparing hash
|
||||
const reloaded = await firstValueFrom(this.files.get(this.path));
|
||||
const actualHash = this.hashScene(reloaded);
|
||||
const ok = expectedHash === actualHash;
|
||||
console.log('🔁 Verify after save', {
|
||||
ok,
|
||||
expectedHash: expectedHash.slice(0,10),
|
||||
actualHash: actualHash.slice(0,10),
|
||||
lastSavedHash: this.lastSavedHash?.slice(0,10) || 'none',
|
||||
elementsCount: Array.isArray((scene as any).elements) ? (scene as any).elements.length : 0
|
||||
});
|
||||
|
||||
if (!ok) {
|
||||
this.isSaving.set(false);
|
||||
this.dirty.set(true);
|
||||
this.showToast('Vérification échouée: rechargement conseillé', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastSavedHash = actualHash;
|
||||
this.dirty.set(false);
|
||||
this.isSaving.set(false);
|
||||
this.hasConflict.set(false);
|
||||
console.log('✅ Manual save successful');
|
||||
this.showToast('Sauvegarde réussie', 'success');
|
||||
} else {
|
||||
console.error('❌ Save returned no rev');
|
||||
this.isSaving.set(false);
|
||||
this.showToast('Erreur de sauvegarde', 'error');
|
||||
return;
|
||||
}
|
||||
} catch (e: any) {
|
||||
console.error('❌ Manual save failed:', e);
|
||||
const status = e?.status ?? e?.statusCode;
|
||||
if (status === 409) {
|
||||
this.error.set('Conflit: le fichier a été modifié sur le disque.');
|
||||
this.hasConflict.set(true);
|
||||
this.showToast('Conflit détecté', 'error');
|
||||
} else {
|
||||
this.error.set('Erreur de sauvegarde.');
|
||||
this.showToast('Erreur de sauvegarde', 'error');
|
||||
}
|
||||
this.isSaving.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
// More robust scene retrieval from the web component host / API
|
||||
private getCurrentSceneFromHost(host: any): ExcalidrawScene | null {
|
||||
try {
|
||||
const api = host?.__excalidrawAPI;
|
||||
const hasGetScene = typeof host?.getScene === 'function';
|
||||
const hasUnderscoreGet = typeof host?.__getScene === 'function';
|
||||
const hasLastEventGet = typeof host?.__getLastEventScene === 'function';
|
||||
console.log('🔎 Scene access methods', { hasGetScene, hasUnderscoreGet, hasLastEventGet, apiAvailable: !!api });
|
||||
|
||||
const candidates: ExcalidrawScene[] = [] as any;
|
||||
const viaPublic = hasGetScene ? host.getScene() : null;
|
||||
if (viaPublic && typeof viaPublic === 'object') {
|
||||
try { console.log('📸 viaPublic elements', Array.isArray(viaPublic.elements) ? viaPublic.elements.length : 'n/a'); } catch {}
|
||||
candidates.push(viaPublic);
|
||||
}
|
||||
const viaPrivate = hasUnderscoreGet ? host.__getScene() : null;
|
||||
if (viaPrivate && typeof viaPrivate === 'object') {
|
||||
try { console.log('📸 viaPrivate elements', Array.isArray(viaPrivate.elements) ? viaPrivate.elements.length : 'n/a'); } catch {}
|
||||
candidates.push(viaPrivate);
|
||||
}
|
||||
const viaLast = hasLastEventGet ? host.__getLastEventScene() : null;
|
||||
if (viaLast && typeof viaLast === 'object') {
|
||||
try { console.log('📸 viaLastEvent elements', Array.isArray(viaLast.elements) ? viaLast.elements.length : 'n/a'); } catch {}
|
||||
candidates.push(viaLast);
|
||||
}
|
||||
if (api) {
|
||||
const elements = api.getSceneElements?.() ?? [];
|
||||
const appState = api.getAppState?.() ?? {};
|
||||
const files = api.getFiles?.() ?? {};
|
||||
try { console.log('📸 viaAPI elements', Array.isArray(elements) ? elements.length : 'n/a'); } catch {}
|
||||
candidates.push({ elements, appState, files } as ExcalidrawScene);
|
||||
}
|
||||
|
||||
if (candidates.length) {
|
||||
let best = candidates[0];
|
||||
for (const c of candidates) {
|
||||
const len = Array.isArray((c as any).elements) ? (c as any).elements.length : 0;
|
||||
const bestLen = Array.isArray((best as any).elements) ? (best as any).elements.length : 0;
|
||||
if (len > bestLen) best = c;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.warn('⚠️ Failed to get scene from host/api:', err);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private waitForReady(host: any, timeoutMs = 1500): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
if (host?.__excalidrawAPI) return resolve(true);
|
||||
let done = false;
|
||||
const onReady = () => { if (!done) { done = true; resolve(true); } };
|
||||
try { host.addEventListener('ready', onReady, { once: true }); } catch {}
|
||||
setTimeout(() => {
|
||||
if (done) return;
|
||||
done = true;
|
||||
try { host.removeEventListener('ready', onReady as any); } catch {}
|
||||
resolve(!!host?.__excalidrawAPI);
|
||||
}, timeoutMs);
|
||||
});
|
||||
}
|
||||
|
||||
// Reload from disk to resolve conflict
|
||||
reloadFromDisk(): void {
|
||||
if (!this.path) return;
|
||||
this.isLoading.set(true);
|
||||
this.files.get(this.path).subscribe({
|
||||
next: (scene) => {
|
||||
const transformed = this.prepareSceneData(scene);
|
||||
this.scene.set(transformed);
|
||||
this.lastSavedHash = this.hashScene(transformed);
|
||||
this.dirty.set(false);
|
||||
this.error.set(null);
|
||||
this.hasConflict.set(false);
|
||||
if (this.excalidrawReady && this.editorEl?.nativeElement) {
|
||||
this.updateExcalidrawScene(transformed);
|
||||
}
|
||||
this.isLoading.set(false);
|
||||
this.showToast('Rechargé depuis le disque', 'info');
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Reload error:', err);
|
||||
this.error.set('Erreur lors du rechargement.');
|
||||
this.isLoading.set(false);
|
||||
this.showToast('Erreur de rechargement', 'error');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Simple toast helper
|
||||
private showToast(message: string, type: 'success' | 'error' | 'info' = 'info'): void {
|
||||
this.toastMessage.set(message);
|
||||
this.toastType.set(type);
|
||||
// auto-hide after 2.5s
|
||||
setTimeout(() => {
|
||||
this.toastMessage.set(null);
|
||||
}, 2500);
|
||||
}
|
||||
|
||||
private setupExcalidraw() {
|
||||
if (!this.path) {
|
||||
this.error.set('Aucun chemin de dessin fourni.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isLoading.set(true);
|
||||
|
||||
// Load initial scene
|
||||
this.files.get(this.path).subscribe({
|
||||
next: (scene) => {
|
||||
try {
|
||||
// Transform the scene data to match Excalidraw's expected format
|
||||
const transformedScene = this.prepareSceneData(scene);
|
||||
|
||||
this.scene.set(transformedScene);
|
||||
this.lastSavedHash = this.hashScene(transformedScene);
|
||||
this.dirty.set(false);
|
||||
this.error.set(null);
|
||||
|
||||
// If Excalidraw is already ready, update the scene
|
||||
if (this.excalidrawReady && this.editorEl?.nativeElement) {
|
||||
this.updateExcalidrawScene(transformedScene);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error processing scene data:', err);
|
||||
this.error.set('Format de dessin non supporté.');
|
||||
} finally {
|
||||
this.isLoading.set(false);
|
||||
}
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Error loading drawing:', err);
|
||||
this.error.set('Erreur de chargement du dessin.');
|
||||
this.isLoading.set(false);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private prepareSceneData(scene: ExcalidrawScene) {
|
||||
return {
|
||||
elements: Array.isArray(scene.elements) ? scene.elements : [],
|
||||
appState: {
|
||||
viewBackgroundColor: '#1e1e1e',
|
||||
currentItemFontFamily: 1,
|
||||
theme: this.themeName(),
|
||||
...(scene.appState || {})
|
||||
},
|
||||
scrollToContent: true,
|
||||
...(scene.files && { files: scene.files })
|
||||
};
|
||||
}
|
||||
|
||||
private updateExcalidrawScene(scene: any) {
|
||||
if (!this.editorEl?.nativeElement) return;
|
||||
|
||||
// Try both methods to set the scene
|
||||
if (this.editorEl.nativeElement.setScene) {
|
||||
this.editorEl.nativeElement.setScene(scene);
|
||||
} else if (this.editorEl.nativeElement.__excalidrawAPI?.updateScene) {
|
||||
this.editorEl.nativeElement.__excalidrawAPI.updateScene(scene);
|
||||
} else if (this.editorEl.nativeElement.__excalidrawAPI?.setState) {
|
||||
this.editorEl.nativeElement.__excalidrawAPI.setState({
|
||||
...scene,
|
||||
commitToHistory: false
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
onExcalidrawReady() {
|
||||
console.log('🎨 Excalidraw Ready - Binding listeners');
|
||||
this.excalidrawReady = true;
|
||||
try {
|
||||
const host: any = this.editorEl?.nativeElement;
|
||||
console.log('[Angular] READY handler host check', {
|
||||
hasHost: !!host,
|
||||
hasAPI: !!host?.__excalidrawAPI,
|
||||
hasGetScene: typeof host?.getScene === 'function',
|
||||
has__getScene: typeof host?.__getScene === 'function'
|
||||
});
|
||||
} catch {}
|
||||
const scene = this.scene();
|
||||
if (scene) {
|
||||
this.updateExcalidrawScene(scene);
|
||||
}
|
||||
|
||||
// Bind listeners NOW when the editor is confirmed ready
|
||||
this.bindEditorHostListeners();
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
// Fallback: ensure bindings are attached even if the (ready) event never fires
|
||||
queueMicrotask(() => this.ensureHostListenersFallback());
|
||||
|
||||
// Listen for fullscreen changes
|
||||
document.addEventListener('fullscreenchange', () => {
|
||||
this.isFullscreen.set(!!document.fullscreenElement);
|
||||
});
|
||||
}
|
||||
|
||||
private ensureHostListenersFallback(attempt = 0): void {
|
||||
if (this.hostListenersBound) {
|
||||
return;
|
||||
}
|
||||
const host = this.editorEl?.nativeElement;
|
||||
if (!host) {
|
||||
if (attempt >= 20) {
|
||||
console.warn('[Angular] Fallback binding aborted - host still missing');
|
||||
return;
|
||||
}
|
||||
setTimeout(() => this.ensureHostListenersFallback(attempt + 1), 100);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn('[Angular] Fallback binding listeners without ready event', { attempt });
|
||||
this.excalidrawReady = true;
|
||||
const scene = this.scene();
|
||||
if (scene) {
|
||||
this.updateExcalidrawScene(scene);
|
||||
}
|
||||
this.bindEditorHostListeners();
|
||||
}
|
||||
|
||||
// Ensure we bind to the web component once it exists
|
||||
private bindEditorHostListeners(): void {
|
||||
if (this.hostListenersBound) return;
|
||||
const host = this.editorEl?.nativeElement;
|
||||
if (!host) {
|
||||
console.warn('⚠️ Cannot bind listeners - host element not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔗 Binding Excalidraw host listeners', {
|
||||
path: this.path,
|
||||
lastSavedHash: (this.lastSavedHash || 'null').slice(0, 10)
|
||||
});
|
||||
this.hostListenersBound = true;
|
||||
|
||||
// Raw debug listeners to validate event propagation
|
||||
try {
|
||||
console.log('[Angular] 🎧 Attaching raw event listeners to host', { hostTagName: host.tagName });
|
||||
host.addEventListener('ready', (e: any) => {
|
||||
console.log('[Angular] (raw) READY received on host', e?.detail);
|
||||
});
|
||||
host.addEventListener('scene-change', (e: any) => {
|
||||
const det = e?.detail;
|
||||
const elCount = Array.isArray(det?.elements) ? det.elements.length : 'n/a';
|
||||
console.log('[Angular] (raw) SCENE-CHANGE received on host', { elCount, detail: det });
|
||||
});
|
||||
document.addEventListener('scene-change', (e: any) => {
|
||||
const det = e?.detail;
|
||||
const elCount = Array.isArray(det?.elements) ? det.elements.length : 'n/a';
|
||||
console.log('[Angular] (raw) SCENE-CHANGE received on document', { elCount });
|
||||
});
|
||||
console.log('[Angular] ✅ Raw event listeners attached successfully');
|
||||
} catch (err) {
|
||||
console.error('[Angular] ❌ Failed to attach raw listeners', err);
|
||||
}
|
||||
|
||||
const sceneChange$ = fromEvent<CustomEvent>(host, 'scene-change').pipe(
|
||||
debounceTime(250), // Short debounce for responsiveness
|
||||
map((event) => {
|
||||
const viaHost = this.getCurrentSceneFromHost(host);
|
||||
const detail = event?.detail as Partial<ExcalidrawScene> | undefined;
|
||||
const viaDetail = detail ? this.normalizeSceneDetail(detail) : null;
|
||||
|
||||
const candidates = [viaHost, viaDetail].filter(Boolean) as ExcalidrawScene[];
|
||||
if (candidates.length === 0) {
|
||||
console.warn('[Angular] Scene-change emitted without retrievable scene data');
|
||||
return null;
|
||||
}
|
||||
// prefer the candidate with the highest number of elements
|
||||
let best: ExcalidrawScene = candidates[0];
|
||||
for (const c of candidates) {
|
||||
const len = Array.isArray((c as any).elements) ? (c as any).elements.length : 0;
|
||||
const bestLen = Array.isArray((best as any).elements) ? (best as any).elements.length : 0;
|
||||
if (len > bestLen) best = c;
|
||||
}
|
||||
return best;
|
||||
}),
|
||||
filter((scene): scene is ExcalidrawScene => !!scene),
|
||||
map(scene => ({ scene, hash: this.hashScene(scene) }))
|
||||
);
|
||||
|
||||
// Subscription 1: Update the UI dirty status
|
||||
this.dirtyCheckSub = sceneChange$.subscribe(({ hash }) => {
|
||||
// For UX: immediately mark as dirty on any scene-change
|
||||
console.log('✏️ Dirty flagged (event)', {
|
||||
lastSaved: (this.lastSavedHash || 'null').slice(0, 10),
|
||||
current: hash.slice(0, 10)
|
||||
});
|
||||
if (!this.dirty()) this.dirty.set(true);
|
||||
});
|
||||
|
||||
this.sceneUpdateSub = sceneChange$.subscribe(({ scene }) => {
|
||||
try {
|
||||
const elements = Array.isArray((scene as any).elements) ? (scene as any).elements : [];
|
||||
const appState = (scene as any).appState || {};
|
||||
const files = (scene as any).files || {};
|
||||
const updated = { elements, appState, files } as ExcalidrawScene;
|
||||
this.scene.set(updated);
|
||||
this.latestSceneFromEvents = updated;
|
||||
} catch {}
|
||||
});
|
||||
|
||||
// Fallback polling: update dirty flag even if events don't fire
|
||||
this.pollSub = interval(1000).subscribe(() => {
|
||||
const current = this.getCurrentSceneFromHost(host);
|
||||
if (!current) return;
|
||||
const hash = this.hashScene(current);
|
||||
const isDirty = this.lastSavedHash !== hash;
|
||||
if (isDirty !== this.dirty()) {
|
||||
console.log('🕒 Dirty check (poll)', {
|
||||
lastSaved: (this.lastSavedHash || 'null').slice(0, 10),
|
||||
current: hash.slice(0, 10),
|
||||
isDirty
|
||||
});
|
||||
this.dirty.set(isDirty);
|
||||
}
|
||||
});
|
||||
|
||||
// Subscription 2: Handle auto-saving (disabled temporarily)
|
||||
// this.saveSub = sceneChange$.pipe(
|
||||
// distinctUntilChanged((prev, curr) => prev.hash === curr.hash),
|
||||
// debounceTime(2000),
|
||||
// filter(({ hash }) => this.lastSavedHash !== hash),
|
||||
// filter(() => !this.isSaving()),
|
||||
// tap(({ hash }) => {
|
||||
// console.log('💾 Autosaving... hash:', hash.substring(0, 10));
|
||||
// this.isSaving.set(true);
|
||||
// this.error.set(null);
|
||||
// }),
|
||||
// switchMap(({ scene, hash }) =>
|
||||
// this.files.put(this.path, scene).pipe(
|
||||
// tap(async () => {
|
||||
// console.log('✅ Autosave successful', { newHash: hash.substring(0, 10) });
|
||||
// this.lastSavedHash = hash;
|
||||
// this.dirty.set(false);
|
||||
// this.isSaving.set(false);
|
||||
// this.hasConflict.set(false);
|
||||
// try { await this.savePreviewPNG(); } catch (e) { console.warn('PNG preview update failed', e); }
|
||||
// }),
|
||||
// catchError((e) => {
|
||||
// console.error('❌ Save error:', e);
|
||||
// const status = e?.status ?? e?.statusCode;
|
||||
// if (status === 409) {
|
||||
// this.error.set('Conflit: le fichier a été modifié sur le disque.');
|
||||
// this.hasConflict.set(true);
|
||||
// } else {
|
||||
// this.error.set('Erreur de sauvegarde. Veuillez réessayer.');
|
||||
// }
|
||||
// this.isSaving.set(false);
|
||||
// return of({ rev: '' } as any);
|
||||
// })
|
||||
// )
|
||||
// )
|
||||
// ).subscribe();
|
||||
|
||||
console.log('✓ Dirty check and Autosave subscriptions active');
|
||||
|
||||
// Cleanup on destroy
|
||||
this.destroyRef.onDestroy(() => {
|
||||
console.log('🧹 Cleaning up listeners');
|
||||
this.saveSub?.unsubscribe();
|
||||
this.dirtyCheckSub?.unsubscribe();
|
||||
this.sceneUpdateSub?.unsubscribe();
|
||||
this.pollSub?.unsubscribe();
|
||||
this.saveSub = null;
|
||||
this.dirtyCheckSub = null;
|
||||
this.sceneUpdateSub = null;
|
||||
this.pollSub = null;
|
||||
this.hostListenersBound = false;
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.saveSub?.unsubscribe();
|
||||
// Clean up fullscreen listener
|
||||
document.removeEventListener('fullscreenchange', () => {
|
||||
this.isFullscreen.set(!!document.fullscreenElement);
|
||||
});
|
||||
}
|
||||
|
||||
private awaitNextFrame(): Promise<void> {
|
||||
return new Promise((resolve) => requestAnimationFrame(() => resolve()));
|
||||
}
|
||||
|
||||
@HostListener('window:beforeunload', ['$event'])
|
||||
handleBeforeUnload(event: BeforeUnloadEvent): string | null {
|
||||
if (this.dirty()) {
|
||||
event.preventDefault();
|
||||
// @ts-ignore - for older browsers
|
||||
event.returnValue = '';
|
||||
return '';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async exportPNG(): Promise<void> {
|
||||
try {
|
||||
const host = this.editorEl?.nativeElement;
|
||||
if (!host || !this.excalidrawReady) {
|
||||
console.error('Excalidraw host element not found');
|
||||
this.showToast('Éditeur non prêt', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await this.previews.exportPNGFromElement(host, true);
|
||||
if (!blob) {
|
||||
console.error('Failed to generate PNG blob');
|
||||
this.showToast('Export PNG indisponible', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const base = String(this.path || '').replace(/\.excalidraw(?:\.md)?$/i, '');
|
||||
const target = `${base}.png`;
|
||||
await firstValueFrom(this.files.putBinary(target, blob, 'image/png'));
|
||||
this.error.set(null);
|
||||
} catch (err) {
|
||||
console.error('Error exporting PNG:', err);
|
||||
this.error.set('Erreur lors de l\'export en PNG');
|
||||
this.showToast('Erreur export PNG', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async exportSVG(): Promise<void> {
|
||||
try {
|
||||
const host = this.editorEl?.nativeElement;
|
||||
if (!host || !this.excalidrawReady) {
|
||||
console.error('Excalidraw host element not found or not ready');
|
||||
this.showToast('Éditeur non prêt', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await this.previews.exportSVGFromElement(host, true);
|
||||
if (!blob) {
|
||||
console.error('Failed to generate SVG blob');
|
||||
this.showToast('Export SVG indisponible', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
const base = String(this.path || '').replace(/\.excalidraw(?:\.md)?$/i, '');
|
||||
const target = `${base}.svg`;
|
||||
await firstValueFrom(this.files.putBinary(target, blob, 'image/svg+xml'));
|
||||
this.error.set(null);
|
||||
} catch (err) {
|
||||
console.error('Error exporting SVG:', err);
|
||||
this.error.set('Erreur lors de l\'export en SVG');
|
||||
this.showToast('Erreur export SVG', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
toggleFullscreen(): void {
|
||||
if (!document.fullscreenEnabled) {
|
||||
this.showToast('Mode pleine écran non supporté', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (document.fullscreenElement) {
|
||||
// Sortir du mode pleine écran
|
||||
document.exitFullscreen().catch(err => {
|
||||
console.error('Erreur lors de la sortie du mode pleine écran:', err);
|
||||
this.showToast('Erreur sortie pleine écran', 'error');
|
||||
});
|
||||
} else {
|
||||
// Entrer en mode pleine écran
|
||||
const element = this.editorEl?.nativeElement?.parentElement;
|
||||
if (element) {
|
||||
element.requestFullscreen().catch(err => {
|
||||
console.error('Erreur lors de l\'entrée en mode pleine écran:', err);
|
||||
this.showToast('Erreur entrée pleine écran', 'error');
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private hashScene(scene: ExcalidrawScene | null): string {
|
||||
try {
|
||||
if (!scene) return '';
|
||||
|
||||
// Normalize elements: remove volatile properties
|
||||
const normEls = Array.isArray(scene.elements) ? scene.elements.map((el: any) => {
|
||||
const { version, versionNonce, updated, ...rest } = el || {};
|
||||
return rest;
|
||||
}) : [];
|
||||
|
||||
// Stable sort of elements by id to avoid hash changes due to order
|
||||
const sortedEls = normEls.slice().sort((a: any, b: any) => (a?.id || '').localeCompare(b?.id || ''));
|
||||
|
||||
// Stable sort of files keys to avoid hash changes due to key order
|
||||
const filesObj = scene.files && typeof scene.files === 'object' ? scene.files : {};
|
||||
const sortedFilesKeys = Object.keys(filesObj).sort();
|
||||
const sortedFiles: Record<string, any> = {};
|
||||
for (const k of sortedFilesKeys) sortedFiles[k] = filesObj[k];
|
||||
|
||||
const stable = { elements: sortedEls, files: sortedFiles };
|
||||
return btoa(unescape(encodeURIComponent(JSON.stringify(stable))));
|
||||
} catch (error) {
|
||||
console.error('Error hashing scene:', error);
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
private normalizeSceneDetail(detail: Partial<ExcalidrawScene>): ExcalidrawScene {
|
||||
const elements = Array.isArray(detail?.elements) ? detail.elements : [];
|
||||
const appState = typeof detail?.appState === 'object' && detail?.appState ? detail.appState : {};
|
||||
const files = typeof detail?.files === 'object' && detail?.files ? detail.files : {};
|
||||
return { elements, appState, files };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import { HttpClient, HttpHeaders } from '@angular/common/http';
|
||||
import { Observable, map, tap } from 'rxjs';
|
||||
|
||||
export type ThemeName = 'light' | 'dark';
|
||||
|
||||
export interface ExcalidrawScene {
|
||||
elements: any[];
|
||||
appState?: Record<string, any>;
|
||||
files?: Record<string, any>;
|
||||
}
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DrawingsFileService {
|
||||
private etagCache = new Map<string, string>();
|
||||
|
||||
constructor(private http: HttpClient) {}
|
||||
|
||||
get(path: string): Observable<ExcalidrawScene> {
|
||||
const url = `/api/files`;
|
||||
return this.http.get<ExcalidrawScene>(url, {
|
||||
params: { path: path },
|
||||
observe: 'response'
|
||||
}).pipe(
|
||||
tap((res) => {
|
||||
const etag = res.headers.get('ETag');
|
||||
if (etag) this.etagCache.set(path, etag);
|
||||
}),
|
||||
map((res) => res.body as ExcalidrawScene)
|
||||
);
|
||||
}
|
||||
|
||||
put(path: string, scene: ExcalidrawScene): Observable<{ rev: string }> {
|
||||
const url = `/api/files`;
|
||||
const prev = this.etagCache.get(path);
|
||||
let headers = new HttpHeaders({ 'Content-Type': 'application/json' });
|
||||
if (prev) headers = headers.set('If-Match', prev);
|
||||
|
||||
return this.http.put<{ rev: string }>(url, scene, {
|
||||
params: { path: path },
|
||||
headers,
|
||||
observe: 'response'
|
||||
}).pipe(
|
||||
tap((res) => {
|
||||
const status = res.status;
|
||||
const etag = res.headers.get('ETag');
|
||||
console.log('[FilesService.put] status', status, 'path', path, 'rev', res.body?.rev);
|
||||
if (etag) this.etagCache.set(path, etag);
|
||||
}),
|
||||
map((res) => res.body as { rev: string })
|
||||
);
|
||||
}
|
||||
|
||||
// Write raw text/markdown content (used for pre-compressed Obsidian Excalidraw MD)
|
||||
putText(path: string, markdown: string): Observable<{ rev: string }> {
|
||||
const url = `/api/files`;
|
||||
const prev = this.etagCache.get(path);
|
||||
let headers = new HttpHeaders({ 'Content-Type': 'text/markdown; charset=utf-8' });
|
||||
if (prev) headers = headers.set('If-Match', prev);
|
||||
|
||||
return this.http.put<{ rev: string }>(url, markdown, {
|
||||
params: { path: path },
|
||||
headers,
|
||||
observe: 'response'
|
||||
}).pipe(
|
||||
tap((res) => {
|
||||
const status = res.status;
|
||||
const etag = res.headers.get('ETag');
|
||||
console.log('[FilesService.putText] status', status, 'path', path, 'rev', res.body?.rev);
|
||||
if (etag) this.etagCache.set(path, etag);
|
||||
}),
|
||||
map((res) => res.body as { rev: string })
|
||||
);
|
||||
}
|
||||
|
||||
// Force write without If-Match (overwrite on disk)
|
||||
putForce(path: string, scene: ExcalidrawScene): Observable<{ rev: string }> {
|
||||
const url = `/api/files`;
|
||||
const headers = new HttpHeaders({ 'Content-Type': 'application/json' });
|
||||
return this.http.put<{ rev: string }>(url, scene, {
|
||||
params: { path: path },
|
||||
headers,
|
||||
observe: 'response'
|
||||
}).pipe(
|
||||
tap((res) => {
|
||||
const status = res.status;
|
||||
const etag = res.headers.get('ETag');
|
||||
console.log('[FilesService.putForce] status', status, 'path', path, 'rev', res.body?.rev);
|
||||
if (etag) this.etagCache.set(path, etag);
|
||||
}),
|
||||
map((res) => res.body as { rev: string })
|
||||
);
|
||||
}
|
||||
|
||||
putBinary(path: string, blob: Blob, mime: string): Observable<{ ok: true }> {
|
||||
const url = `/api/files/blob`;
|
||||
return this.http.put<{ ok: true }>(url, blob, {
|
||||
params: { path: path },
|
||||
headers: new HttpHeaders({ 'Content-Type': mime }),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class DrawingsPreviewService {
|
||||
async exportPNGFromElement(el: HTMLElement, withBackground = true): Promise<Blob | undefined> {
|
||||
const anyEl: any = el as any;
|
||||
if (!anyEl?.exportPNG) return undefined;
|
||||
return await anyEl.exportPNG({ withBackground });
|
||||
}
|
||||
|
||||
async exportSVGFromElement(el: HTMLElement, withBackground = true): Promise<Blob | undefined> {
|
||||
const anyEl: any = el as any;
|
||||
if (!anyEl?.exportSVG) return undefined;
|
||||
return await anyEl.exportSVG({ withBackground });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
import { Injectable } from '@angular/core';
|
||||
import * as LZString from 'lz-string';
|
||||
|
||||
export interface ExcalidrawScene {
|
||||
elements: any[];
|
||||
appState?: Record<string, any>;
|
||||
files?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for parsing and serializing Excalidraw files in Obsidian format
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ExcalidrawIoService {
|
||||
|
||||
/**
|
||||
* Extract front matter from markdown content
|
||||
*/
|
||||
extractFrontMatter(md: string): string | null {
|
||||
if (!md) return null;
|
||||
const match = md.match(/^---\s*\n([\s\S]*?)\n---/);
|
||||
return match ? match[0] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Obsidian Excalidraw markdown format
|
||||
* Extracts compressed-json block and decompresses using LZ-String
|
||||
*/
|
||||
parseObsidianMd(md: string): ExcalidrawScene | null {
|
||||
if (!md || typeof md !== 'string') return null;
|
||||
|
||||
// Try to extract compressed-json block
|
||||
const compressedMatch = md.match(/```\s*compressed-json\s*\n([\s\S]*?)\n```/i);
|
||||
|
||||
if (compressedMatch && compressedMatch[1]) {
|
||||
try {
|
||||
// Remove whitespace from base64 data
|
||||
const compressed = compressedMatch[1].replace(/\s+/g, '');
|
||||
|
||||
// Decompress using LZ-String
|
||||
const decompressed = LZString.decompressFromBase64(compressed);
|
||||
|
||||
if (!decompressed) {
|
||||
console.warn('[Excalidraw] LZ-String decompression returned null');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Parse JSON
|
||||
const data = JSON.parse(decompressed);
|
||||
return this.normalizeScene(data);
|
||||
} catch (error) {
|
||||
console.error('[Excalidraw] Failed to parse compressed-json:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: try to extract plain json block
|
||||
const jsonMatch = md.match(/```\s*(?:excalidraw|json)\s*\n([\s\S]*?)\n```/i);
|
||||
|
||||
if (jsonMatch && jsonMatch[1]) {
|
||||
try {
|
||||
const data = JSON.parse(jsonMatch[1].trim());
|
||||
return this.normalizeScene(data);
|
||||
} catch (error) {
|
||||
console.error('[Excalidraw] Failed to parse json block:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse flat JSON format (legacy ObsiViewer format)
|
||||
*/
|
||||
parseFlatJson(text: string): ExcalidrawScene | null {
|
||||
if (!text || typeof text !== 'string') return null;
|
||||
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
|
||||
// Validate it has the expected structure
|
||||
if (data && typeof data === 'object' && Array.isArray(data.elements)) {
|
||||
return this.normalizeScene(data);
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Excalidraw scene to Obsidian markdown format
|
||||
*/
|
||||
toObsidianMd(data: ExcalidrawScene, existingFrontMatter?: string | null): string {
|
||||
// Normalize scene data
|
||||
const scene = this.normalizeScene(data);
|
||||
|
||||
// Serialize to JSON
|
||||
const json = JSON.stringify(scene);
|
||||
|
||||
// Compress using LZ-String
|
||||
const compressed = LZString.compressToBase64(json);
|
||||
|
||||
// Use existing front matter or create default
|
||||
const frontMatter = existingFrontMatter?.trim() || `---
|
||||
excalidraw-plugin: parsed
|
||||
tags: [excalidraw]
|
||||
---`;
|
||||
|
||||
// Banner text (Obsidian standard)
|
||||
const banner = `==⚠ Switch to EXCALIDRAW VIEW in the MORE OPTIONS menu of this document. ⚠==
|
||||
You can decompress Drawing data with the command palette: 'Decompress current Excalidraw file'. For more info check in plugin settings under 'Saving'`;
|
||||
|
||||
// Construct full markdown
|
||||
return `${frontMatter}
|
||||
${banner}
|
||||
|
||||
# Excalidraw Data
|
||||
|
||||
## Text Elements
|
||||
%%
|
||||
## Drawing
|
||||
\`\`\`compressed-json
|
||||
${compressed}
|
||||
\`\`\`
|
||||
%%`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse Excalidraw content from any supported format
|
||||
* Tries Obsidian MD format first, then falls back to flat JSON
|
||||
*/
|
||||
parseAny(text: string): ExcalidrawScene | null {
|
||||
// Try Obsidian format first
|
||||
let data = this.parseObsidianMd(text);
|
||||
if (data) return data;
|
||||
|
||||
// Fallback to flat JSON
|
||||
data = this.parseFlatJson(text);
|
||||
if (data) return data;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize scene structure
|
||||
*/
|
||||
private normalizeScene(data: any): ExcalidrawScene {
|
||||
return {
|
||||
elements: Array.isArray(data?.elements) ? data.elements : [],
|
||||
appState: (data && typeof data.appState === 'object') ? data.appState : {},
|
||||
files: (data && typeof data.files === 'object') ? data.files : {}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate Excalidraw scene structure
|
||||
*/
|
||||
isValidScene(data: any): boolean {
|
||||
if (!data || typeof data !== 'object') return false;
|
||||
if (!Array.isArray(data.elements)) return false;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -47,7 +47,6 @@
|
||||
[cellTemplate]="dayCellTemplate"
|
||||
[weekStartsOn]="1"
|
||||
[events]="[]"
|
||||
(pointerup)="onDatePointerUp()"
|
||||
></mwl-calendar-month-view>
|
||||
</div>
|
||||
|
||||
@@ -69,7 +68,8 @@
|
||||
class="day-cell"
|
||||
[ngClass]="getCellClasses(day)"
|
||||
(pointerdown)="onDatePointerDown(day, $event)"
|
||||
(pointerenter)="onDatePointerEnter(day)"
|
||||
(pointerenter)="onDatePointerEnter(day, $event)"
|
||||
(pointerup)="onDatePointerUp(day, $event)"
|
||||
>
|
||||
<span class="day-number">{{ day.date | date: 'd' }}</span>
|
||||
<div class="indicators">
|
||||
|
||||
@@ -125,6 +125,8 @@ export class MarkdownCalendarComponent {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
const date = this.normalizeDay(day.date);
|
||||
this.dragAnchor = date;
|
||||
this.selectedRange.set({ start: date, end: date });
|
||||
@@ -138,9 +140,12 @@ export class MarkdownCalendarComponent {
|
||||
this.emitSelectionSummary();
|
||||
this.requestSearchPanel.emit();
|
||||
this.refresh$.next();
|
||||
|
||||
// Capture pointer to enable drag across cells
|
||||
(event.target as HTMLElement).setPointerCapture(event.pointerId);
|
||||
}
|
||||
|
||||
onDatePointerEnter(day: CalendarMonthViewDay<Date>): void {
|
||||
onDatePointerEnter(day: CalendarMonthViewDay<Date>, event: PointerEvent): void {
|
||||
if (!this.dragAnchor) {
|
||||
return;
|
||||
}
|
||||
@@ -152,12 +157,13 @@ export class MarkdownCalendarComponent {
|
||||
this.refresh$.next();
|
||||
}
|
||||
|
||||
@HostListener('document:pointerup')
|
||||
finishRangeSelection(): void {
|
||||
@HostListener('document:pointerup', ['$event'])
|
||||
finishRangeSelection(event: PointerEvent): void {
|
||||
this.finalizeSelection();
|
||||
}
|
||||
|
||||
onDatePointerUp(): void {
|
||||
onDatePointerUp(day: CalendarMonthViewDay<Date>, event: PointerEvent): void {
|
||||
event.stopPropagation();
|
||||
this.finalizeSelection();
|
||||
}
|
||||
|
||||
|
||||
@@ -87,6 +87,19 @@ import { SearchOptions } from '../../core/search/search-parser.types';
|
||||
.*
|
||||
</button>
|
||||
|
||||
<!-- Highlight toggle (Hi) -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleHighlight()"
|
||||
[class.bg-accent]="highlight()"
|
||||
[class.text-white]="highlight()"
|
||||
class="btn-standard-xs"
|
||||
[title]="highlight() ? 'Highlight enabled' : 'Highlight disabled'"
|
||||
aria-label="Toggle highlight"
|
||||
>
|
||||
Hi
|
||||
</button>
|
||||
|
||||
<!-- Clear button -->
|
||||
<button
|
||||
*ngIf="query"
|
||||
@@ -133,6 +146,9 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
@Input() showSearchIcon: boolean = true;
|
||||
@Input() inputClass: string = 'w-full rounded-full border border-border bg-bg-muted/70 py-2.5 pr-4 text-sm text-text-main placeholder:text-text-muted shadow-subtle focus:outline-none focus:ring-2 focus:ring-ring transition-all dark:border-gray-600 dark:bg-gray-800/80 dark:text-gray-100 dark:placeholder-gray-400 dark:focus:ring-blue-500';
|
||||
@Input() initialQuery: string = '';
|
||||
@Input() caseSensitiveDefault: boolean = false;
|
||||
@Input() regexDefault: boolean = false;
|
||||
@Input() highlightDefault: boolean = true;
|
||||
|
||||
@Output() search = new EventEmitter<{ query: string; options: SearchOptions }>();
|
||||
@Output() queryChange = new EventEmitter<string>();
|
||||
@@ -145,6 +161,7 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
query = '';
|
||||
caseSensitive = signal(false);
|
||||
regexMode = signal(false);
|
||||
highlight = signal(true);
|
||||
anchorElement: HTMLElement | null = null;
|
||||
private historyIndex = -1;
|
||||
|
||||
@@ -154,6 +171,9 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
if (this.initialQuery) {
|
||||
this.query = this.initialQuery;
|
||||
}
|
||||
this.caseSensitive.set(!!this.caseSensitiveDefault);
|
||||
this.regexMode.set(!!this.regexDefault);
|
||||
this.highlight.set(this.highlightDefault !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -222,7 +242,8 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
query: this.query,
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode()
|
||||
regexMode: this.regexMode(),
|
||||
highlight: this.highlight()
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -274,7 +295,8 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
query: this.query,
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode()
|
||||
regexMode: this.regexMode(),
|
||||
highlight: this.highlight()
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -292,7 +314,23 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
query: this.query,
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode()
|
||||
regexMode: this.regexMode(),
|
||||
highlight: this.highlight()
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** Toggle highlight mode */
|
||||
toggleHighlight(): void {
|
||||
this.highlight.update(v => !v);
|
||||
if (this.query.trim()) {
|
||||
this.search.emit({
|
||||
query: this.query,
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode(),
|
||||
highlight: this.highlight()
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -316,7 +354,8 @@ export class SearchBarComponent implements AfterViewInit, OnInit {
|
||||
query: '',
|
||||
options: {
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode()
|
||||
regexMode: this.regexMode(),
|
||||
highlight: this.highlight()
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+67
-13
@@ -7,13 +7,15 @@ import {
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
ChangeDetectionStrategy,
|
||||
AfterViewInit
|
||||
AfterViewInit,
|
||||
OnInit,
|
||||
inject
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SearchQueryAssistantComponent } from '../search-query-assistant/search-query-assistant.component';
|
||||
import { inject } from '@angular/core';
|
||||
import { SearchHistoryService } from '../../core/search/search-history.service';
|
||||
import { SearchPreferencesService } from '../../core/search/search-preferences.service';
|
||||
|
||||
/**
|
||||
* Search input with integrated query assistant
|
||||
@@ -51,16 +53,25 @@ import { SearchHistoryService } from '../../core/search/search-history.service';
|
||||
[attr.aria-label]="placeholder"
|
||||
/>
|
||||
|
||||
<button
|
||||
*ngIf="value"
|
||||
(click)="clear()"
|
||||
class="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded-full hover:bg-bg-muted dark:hover:bg-gray-700 transition-colors"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 text-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex items-center gap-1">
|
||||
<!-- Case (Aa) -->
|
||||
<button type="button" class="btn-standard-xs" [class.bg-accent]="caseSensitive()" [class.text-white]="caseSensitive()" (click)="toggleCase()" title="Case sensitivity">Aa</button>
|
||||
<!-- Regex (.*) -->
|
||||
<button type="button" class="btn-standard-xs font-mono" [class.bg-accent]="regexMode()" [class.text-white]="regexMode()" (click)="toggleRegex()" title="Regex mode">.*</button>
|
||||
<!-- Highlight (Hi) -->
|
||||
<button type="button" class="btn-standard-xs" [class.bg-accent]="highlight()" [class.text-white]="highlight()" (click)="toggleHighlight()" title="Highlight results">Hi</button>
|
||||
<!-- Clear -->
|
||||
<button
|
||||
*ngIf="value"
|
||||
(click)="clear()"
|
||||
class="btn-standard-icon"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<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="M6 18L18 6M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<app-search-query-assistant
|
||||
@@ -81,7 +92,7 @@ import { SearchHistoryService } from '../../core/search/search-history.service';
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
export class SearchInputWithAssistantComponent implements AfterViewInit, OnInit {
|
||||
@Input() placeholder: string = 'Search...';
|
||||
@Input() value: string = '';
|
||||
@Input() context: string = 'default';
|
||||
@@ -91,15 +102,29 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
|
||||
@Output() valueChange = new EventEmitter<string>();
|
||||
@Output() submit = new EventEmitter<string>();
|
||||
@Output() optionsChange = new EventEmitter<{ caseSensitive: boolean; regexMode: boolean; highlight: boolean }>();
|
||||
|
||||
@ViewChild('searchInput') searchInputRef?: ElementRef<HTMLInputElement>;
|
||||
@ViewChild('assistant') assistantRef?: SearchQueryAssistantComponent;
|
||||
|
||||
anchorElement: HTMLElement | null = null;
|
||||
private historyService = inject(SearchHistoryService);
|
||||
private prefs = inject(SearchPreferencesService);
|
||||
|
||||
caseSensitive = signal(false);
|
||||
regexMode = signal(false);
|
||||
highlight = signal(true);
|
||||
|
||||
constructor(private hostElement: ElementRef<HTMLElement>) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
// Load header search defaults
|
||||
const p = this.prefs.getPreferences('vault-header');
|
||||
this.caseSensitive.set(!!p.caseSensitive);
|
||||
this.regexMode.set(!!p.regexMode);
|
||||
this.highlight.set(p.highlight !== false);
|
||||
}
|
||||
|
||||
ngAfterViewInit(): void {
|
||||
this.anchorElement = this.hostElement.nativeElement;
|
||||
}
|
||||
@@ -150,6 +175,7 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
this.historyService.add(this.context, this.value);
|
||||
}
|
||||
this.submit.emit(this.value);
|
||||
this.emitOptionsChange();
|
||||
if (this.assistantRef) {
|
||||
this.assistantRef.refreshHistoryView();
|
||||
this.assistantRef.close();
|
||||
@@ -194,6 +220,7 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
this.historyService.add(this.context, query);
|
||||
}
|
||||
this.submit.emit(query);
|
||||
this.emitOptionsChange();
|
||||
|
||||
if (this.searchInputRef) {
|
||||
this.searchInputRef.nativeElement.value = query;
|
||||
@@ -217,6 +244,7 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
this.searchInputRef.nativeElement.value = '';
|
||||
this.searchInputRef.nativeElement.focus();
|
||||
}
|
||||
this.emitOptionsChange();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -227,4 +255,30 @@ export class SearchInputWithAssistantComponent implements AfterViewInit {
|
||||
this.searchInputRef.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private emitOptionsChange(): void {
|
||||
this.optionsChange.emit({
|
||||
caseSensitive: this.caseSensitive(),
|
||||
regexMode: this.regexMode(),
|
||||
highlight: this.highlight()
|
||||
});
|
||||
}
|
||||
|
||||
toggleCase(): void {
|
||||
this.caseSensitive.update(v => !v);
|
||||
this.prefs.togglePreference('vault-header', 'caseSensitive');
|
||||
this.emitOptionsChange();
|
||||
}
|
||||
|
||||
toggleRegex(): void {
|
||||
this.regexMode.update(v => !v);
|
||||
this.prefs.togglePreference('vault-header', 'regexMode');
|
||||
this.emitOptionsChange();
|
||||
}
|
||||
|
||||
toggleHighlight(): void {
|
||||
this.highlight.update(v => !v);
|
||||
this.prefs.togglePreference('vault-header', 'highlight');
|
||||
this.emitOptionsChange();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,12 +6,17 @@ import {
|
||||
signal,
|
||||
computed,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
OnChanges,
|
||||
SimpleChanges,
|
||||
inject,
|
||||
ChangeDetectionStrategy,
|
||||
effect,
|
||||
untracked
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { isObservable, Subject } from 'rxjs';
|
||||
import { take, debounceTime, distinctUntilChanged } from 'rxjs/operators';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { SearchBarComponent } from '../search-bar/search-bar.component';
|
||||
import { SearchResultsComponent } from '../search-results/search-results.component';
|
||||
@@ -21,6 +26,8 @@ import { SearchPreferencesService } from '../../core/search/search-preferences.s
|
||||
import { SearchOptions } from '../../core/search/search-parser.types';
|
||||
import { VaultService } from '../../services/vault.service';
|
||||
import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
import { environment } from '../../core/logging/environment';
|
||||
import { parseSearchQuery } from '../../core/search/search-parser';
|
||||
|
||||
/**
|
||||
* Complete search panel with bar and results
|
||||
@@ -38,13 +45,17 @@ import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
<app-search-bar
|
||||
[placeholder]="placeholder"
|
||||
[context]="context"
|
||||
[initialQuery]="initialQuery"
|
||||
[showSearchIcon]="true"
|
||||
[showExamples]="false"
|
||||
[caseSensitiveDefault]="caseSensitive"
|
||||
[regexDefault]="regexMode"
|
||||
[highlightDefault]="highlightEnabled"
|
||||
(search)="onSearch($event)"
|
||||
(queryChange)="onQueryChange($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Search options toggles -->
|
||||
@if (hasSearched() && results().length > 0) {
|
||||
<div class="flex flex-col gap-3 p-4 border-b border-border dark:border-gray-700 bg-bg-muted dark:bg-gray-800">
|
||||
@@ -94,6 +105,17 @@ import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
<!-- Explain search terms panel -->
|
||||
@if (explainSearchTerms && currentQuery().trim()) {
|
||||
<div class="p-4 border-b border-border dark:border-gray-700 bg-bg-primary/60 dark:bg-gray-900/60">
|
||||
<h4 class="text-xs font-semibold uppercase tracking-wide text-text-muted mb-2">Match all of:</h4>
|
||||
<ul class="ml-1 space-y-1 text-xs">
|
||||
@for (line of explanationLines(); track $index) {
|
||||
<li class="text-text-muted dark:text-gray-400">{{ line }}</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Search results -->
|
||||
<div class="flex-1 overflow-hidden">
|
||||
@@ -118,6 +140,7 @@ import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
[collapseAll]="collapseResults"
|
||||
[showMoreContext]="showMoreContext"
|
||||
[contextLines]="contextLines()"
|
||||
[highlight]="lastOptions.highlight !== false"
|
||||
(noteOpen)="onNoteOpen($event)"
|
||||
/>
|
||||
} @else {
|
||||
@@ -138,11 +161,14 @@ import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SearchPanelComponent implements OnInit {
|
||||
export class SearchPanelComponent implements OnInit, OnDestroy, OnChanges {
|
||||
@Input() placeholder: string = 'Search in vault...';
|
||||
@Input() context: string = 'vault';
|
||||
@Input() initialQuery: string = '';
|
||||
|
||||
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
|
||||
@Output() searchTermChange = new EventEmitter<string>();
|
||||
@Output() optionsChange = new EventEmitter<SearchOptions>();
|
||||
|
||||
private orchestrator = inject(SearchOrchestratorService);
|
||||
private searchIndex = inject(SearchIndexService);
|
||||
@@ -156,30 +182,68 @@ export class SearchPanelComponent implements OnInit {
|
||||
currentQuery = signal('');
|
||||
|
||||
private lastOptions: SearchOptions = {};
|
||||
private searchSubject = new Subject<{ query: string; options?: SearchOptions }>();
|
||||
|
||||
// UI toggles
|
||||
collapseResults = false;
|
||||
showMoreContext = false;
|
||||
explainSearchTerms = false;
|
||||
caseSensitive = false;
|
||||
regexMode = false;
|
||||
highlightEnabled = true;
|
||||
|
||||
// Computed context lines based on showMoreContext
|
||||
contextLines = computed(() => this.showMoreContext ? 5 : 2);
|
||||
|
||||
private syncIndexEffect = effect(() => {
|
||||
const notes = this.vaultService.allNotes();
|
||||
this.logger.info('SearchPanel', 'Detected notes change, rebuilding index', {
|
||||
context: this.context,
|
||||
noteCount: notes.length
|
||||
});
|
||||
this.searchIndex.rebuildIndex(notes);
|
||||
// Build explanation lines from the local parser (UI-only, independent of provider)
|
||||
explanationLines = computed(() => {
|
||||
const q = this.currentQuery().trim();
|
||||
if (!q) return [] as string[];
|
||||
try {
|
||||
const parsed = parseSearchQuery(q, { caseSensitive: false, regexMode: false });
|
||||
const lines: string[] = [];
|
||||
const f = parsed.diagnostics?.filters;
|
||||
if (f) {
|
||||
if (f.file?.length) {
|
||||
f.file.forEach(v => lines.push(`Match file name: "${v}"`));
|
||||
}
|
||||
if (f.path?.length) {
|
||||
f.path.forEach(v => lines.push(`Match path: "${v}"`));
|
||||
}
|
||||
if (f.tag?.length) {
|
||||
f.tag.forEach(v => lines.push(`Match tag: #${v}`));
|
||||
}
|
||||
}
|
||||
// Remaining tokens (rough): use diagnostics.tokens minus operators
|
||||
const tokens = parsed.diagnostics?.tokens ?? [];
|
||||
tokens
|
||||
.filter(t => t.toUpperCase() !== 'AND' && t.toUpperCase() !== 'OR')
|
||||
.filter(t => !/^\w+:/.test(t))
|
||||
.forEach(t => lines.push(`Matches text: "${t.replace(/^"|"$/g,'')}"`));
|
||||
return lines;
|
||||
} catch {
|
||||
return [] as string[];
|
||||
}
|
||||
});
|
||||
|
||||
const query = untracked(() => this.currentQuery());
|
||||
if (query && query.trim()) {
|
||||
this.logger.debug('SearchPanel', 'Re-running search after index rebuild', {
|
||||
query,
|
||||
context: this.context
|
||||
private syncIndexEffect = effect(() => {
|
||||
// Only rebuild index if not using Meilisearch
|
||||
if (!environment.USE_MEILI) {
|
||||
const notes = this.vaultService.allNotes();
|
||||
this.logger.info('SearchPanel', 'Detected notes change, rebuilding index', {
|
||||
context: this.context,
|
||||
noteCount: notes.length
|
||||
});
|
||||
this.executeSearch(query);
|
||||
this.searchIndex.rebuildIndex(notes);
|
||||
|
||||
const query = untracked(() => this.currentQuery());
|
||||
if (query && query.trim()) {
|
||||
this.logger.debug('SearchPanel', 'Re-running search after index rebuild', {
|
||||
query,
|
||||
context: this.context
|
||||
});
|
||||
this.executeSearch(query);
|
||||
}
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
|
||||
@@ -189,6 +253,40 @@ export class SearchPanelComponent implements OnInit {
|
||||
this.collapseResults = prefs.collapseResults;
|
||||
this.showMoreContext = prefs.showMoreContext;
|
||||
this.explainSearchTerms = prefs.explainSearchTerms;
|
||||
this.caseSensitive = prefs.caseSensitive;
|
||||
this.regexMode = prefs.regexMode;
|
||||
this.highlightEnabled = prefs.highlight;
|
||||
|
||||
// Setup debounced search for live typing (only when using Meilisearch)
|
||||
if (environment.USE_MEILI) {
|
||||
this.searchSubject.pipe(
|
||||
debounceTime(300),
|
||||
distinctUntilChanged((prev, curr) => prev.query === curr.query)
|
||||
).subscribe(({ query, options }) => {
|
||||
this.executeSearch(query, options);
|
||||
});
|
||||
}
|
||||
|
||||
// Execute initial query if provided
|
||||
const iq = this.initialQuery?.trim();
|
||||
if (iq && iq.length >= 2) {
|
||||
this.currentQuery.set(iq);
|
||||
this.executeSearch(iq);
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.searchSubject.complete();
|
||||
}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['initialQuery']) {
|
||||
const iq = (this.initialQuery ?? '').trim();
|
||||
if (iq && iq !== this.currentQuery()) {
|
||||
this.currentQuery.set(iq);
|
||||
this.executeSearch(iq);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -218,33 +316,72 @@ export class SearchPanelComponent implements OnInit {
|
||||
query: trimmed,
|
||||
options: baseOptions,
|
||||
contextLines: this.contextLines(),
|
||||
context: this.context
|
||||
context: this.context,
|
||||
useMeilisearch: environment.USE_MEILI
|
||||
});
|
||||
|
||||
this.isSearching.set(true);
|
||||
|
||||
setTimeout(() => {
|
||||
// With Meilisearch, execute immediately (no setTimeout needed)
|
||||
// Without Meilisearch, use setTimeout to avoid blocking UI
|
||||
const executeNow = () => {
|
||||
try {
|
||||
const searchResults = this.orchestrator.execute(trimmed, {
|
||||
const exec = this.orchestrator.execute(trimmed, {
|
||||
...baseOptions,
|
||||
contextLines: this.contextLines()
|
||||
});
|
||||
this.logger.info('SearchPanel', 'Search completed', {
|
||||
query: trimmed,
|
||||
resultCount: searchResults.length
|
||||
});
|
||||
this.results.set(searchResults);
|
||||
this.hasSearched.set(true);
|
||||
|
||||
if (isObservable(exec)) {
|
||||
exec.pipe(take(1)).subscribe({
|
||||
next: (arr) => {
|
||||
this.logger.info('SearchPanel', 'Search completed (obs)', {
|
||||
query: trimmed,
|
||||
resultCount: arr.length,
|
||||
firstResult: arr.length > 0 ? { noteId: arr[0].noteId, matchCount: arr[0].matches.length } : null
|
||||
});
|
||||
console.log('[SearchPanel] Results received:', arr);
|
||||
this.results.set(arr);
|
||||
this.hasSearched.set(true);
|
||||
this.isSearching.set(false);
|
||||
},
|
||||
error: (e) => {
|
||||
this.logger.error('SearchPanel', 'Search execution error (obs)', {
|
||||
error: e instanceof Error ? e.message : String(e),
|
||||
stack: e instanceof Error ? e.stack : undefined
|
||||
});
|
||||
console.error('[SearchPanel] Search error:', e);
|
||||
this.results.set([]);
|
||||
this.hasSearched.set(true);
|
||||
this.isSearching.set(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const searchResults = exec;
|
||||
this.logger.info('SearchPanel', 'Search completed', {
|
||||
query: trimmed,
|
||||
resultCount: searchResults.length
|
||||
});
|
||||
this.results.set(searchResults);
|
||||
this.hasSearched.set(true);
|
||||
this.isSearching.set(false);
|
||||
}
|
||||
} catch (error) {
|
||||
this.logger.error('SearchPanel', 'Search execution error', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
this.results.set([]);
|
||||
this.hasSearched.set(true);
|
||||
} finally {
|
||||
this.isSearching.set(false);
|
||||
}
|
||||
}, 0);
|
||||
};
|
||||
|
||||
if (environment.USE_MEILI) {
|
||||
// Execute immediately with Meilisearch (backend handles it)
|
||||
executeNow();
|
||||
} else {
|
||||
// Use setTimeout to avoid blocking UI with local search
|
||||
setTimeout(executeNow, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -255,6 +392,15 @@ export class SearchPanelComponent implements OnInit {
|
||||
|
||||
this.currentQuery.set(query);
|
||||
this.lastOptions = { ...options };
|
||||
// Persist user option toggles
|
||||
this.preferences.updatePreferences(this.context, {
|
||||
caseSensitive: !!options.caseSensitive,
|
||||
regexMode: !!options.regexMode,
|
||||
highlight: options.highlight !== false
|
||||
});
|
||||
// Emit upwards for header to sync highlight to document
|
||||
this.searchTermChange.emit(query);
|
||||
this.optionsChange.emit({ ...options });
|
||||
this.executeSearch(query, options);
|
||||
}
|
||||
|
||||
@@ -264,8 +410,14 @@ export class SearchPanelComponent implements OnInit {
|
||||
onQueryChange(query: string): void {
|
||||
this.currentQuery.set(query);
|
||||
|
||||
// Could implement debounced live search here
|
||||
// For now, only search on Enter
|
||||
// With Meilisearch, enable live search with debounce
|
||||
if (environment.USE_MEILI && query.trim().length >= 2) {
|
||||
this.searchSubject.next({ query, options: this.lastOptions });
|
||||
} else if (!query.trim()) {
|
||||
// Clear results if query is empty
|
||||
this.results.set([]);
|
||||
this.hasSearched.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -294,6 +446,8 @@ export class SearchPanelComponent implements OnInit {
|
||||
this.preferences.updatePreferences(this.context, {
|
||||
collapseResults: this.collapseResults
|
||||
});
|
||||
// No need to re-run search, just update the preference
|
||||
// The search-results component will react to the input change
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -306,7 +460,7 @@ export class SearchPanelComponent implements OnInit {
|
||||
|
||||
// Re-run search with new context lines
|
||||
if (this.currentQuery()) {
|
||||
this.executeSearch(this.currentQuery());
|
||||
this.executeSearch(this.currentQuery(), this.lastOptions);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,10 @@ import {
|
||||
EventEmitter,
|
||||
signal,
|
||||
computed,
|
||||
effect,
|
||||
ChangeDetectionStrategy,
|
||||
inject
|
||||
inject,
|
||||
OnChanges,
|
||||
SimpleChanges
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
@@ -197,7 +198,7 @@ type SortOption = 'relevance' | 'name' | 'modified';
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class SearchResultsComponent {
|
||||
export class SearchResultsComponent implements OnChanges {
|
||||
@Input() set results(value: SearchResult[]) {
|
||||
this._results.set(value);
|
||||
this.buildGroups(value);
|
||||
@@ -206,6 +207,7 @@ export class SearchResultsComponent {
|
||||
@Input() collapseAll: boolean = false;
|
||||
@Input() showMoreContext: boolean = false;
|
||||
@Input() contextLines: number = 2;
|
||||
@Input() highlight: boolean = true;
|
||||
|
||||
@Output() noteOpen = new EventEmitter<{ noteId: string; line?: number }>();
|
||||
|
||||
@@ -216,13 +218,12 @@ export class SearchResultsComponent {
|
||||
private groups = signal<ResultGroup[]>([]);
|
||||
sortBy: SortOption = 'relevance';
|
||||
|
||||
constructor() {
|
||||
// Watch for collapseAll changes
|
||||
effect(() => {
|
||||
if (this.collapseAll !== undefined) {
|
||||
this.applyCollapseAll(this.collapseAll);
|
||||
}
|
||||
});
|
||||
constructor() {}
|
||||
|
||||
ngOnChanges(changes: SimpleChanges): void {
|
||||
if (changes['collapseAll'] && !changes['collapseAll'].firstChange) {
|
||||
this.applyCollapseAll(this.collapseAll);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -347,6 +348,19 @@ export class SearchResultsComponent {
|
||||
return '';
|
||||
}
|
||||
|
||||
// If highlight is disabled, strip any pre-highlighted HTML and return plain text
|
||||
if (!this.highlight) {
|
||||
return this.highlighter.stripHtml(match.context);
|
||||
}
|
||||
|
||||
// If context already contains highlight markup from Meilisearch
|
||||
// (e.g., <mark> or <em> tags), return it as-is to preserve server-side highlights.
|
||||
// We purposely avoid escaping HTML here because it is produced by our backend with safe tags.
|
||||
const hasPreHighlightedHtml = /<(mark|em)>/i.test(match.context);
|
||||
if (hasPreHighlightedHtml) {
|
||||
return match.context;
|
||||
}
|
||||
|
||||
// If we have ranges, use them for precise highlighting
|
||||
if (match.ranges && match.ranges.length > 0) {
|
||||
return this.highlighter.highlightWithRanges(match.context, match.ranges);
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TagsViewComponent } from './tags-view.component';
|
||||
import { TagInfo } from '../../types';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
describe('TagsViewComponent', () => {
|
||||
let component: TagsViewComponent;
|
||||
let fixture: ComponentFixture<TagsViewComponent>;
|
||||
|
||||
const mockTags: TagInfo[] = [
|
||||
{ name: 'AI', count: 6 },
|
||||
{ name: 'angular', count: 1 },
|
||||
{ name: 'debian/server', count: 3 },
|
||||
{ name: 'debian/desktop', count: 2 },
|
||||
{ name: 'automatisation', count: 2 },
|
||||
{ name: 'budget', count: 1 },
|
||||
{ name: 'docker/automation', count: 5 },
|
||||
{ name: 'docker/test', count: 3 },
|
||||
];
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TagsViewComponent]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TagsViewComponent);
|
||||
component = fixture.componentInstance;
|
||||
|
||||
// Set required input
|
||||
fixture.componentRef.setInput('tags', mockTags);
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
describe('Filtering', () => {
|
||||
it('should filter tags by search query', () => {
|
||||
component.searchQuery.set('docker');
|
||||
fixture.detectChanges();
|
||||
|
||||
const displayed = component['filteredTags']();
|
||||
expect(displayed.length).toBe(2);
|
||||
expect(displayed.every(t => t.name.includes('docker'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should be case insensitive', () => {
|
||||
component.searchQuery.set('DOCKER');
|
||||
fixture.detectChanges();
|
||||
|
||||
const displayed = component['filteredTags']();
|
||||
expect(displayed.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should handle accents', () => {
|
||||
component.searchQuery.set('automatisation');
|
||||
fixture.detectChanges();
|
||||
|
||||
const displayed = component['filteredTags']();
|
||||
expect(displayed.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Sorting', () => {
|
||||
it('should sort alphabetically A-Z', () => {
|
||||
component.sortMode.set('alpha-asc');
|
||||
fixture.detectChanges();
|
||||
|
||||
const sorted = component['sortedTags']();
|
||||
expect(sorted[0].name).toBe('AI');
|
||||
expect(sorted[sorted.length - 1].name).toBe('docker/test');
|
||||
});
|
||||
|
||||
it('should sort alphabetically Z-A', () => {
|
||||
component.sortMode.set('alpha-desc');
|
||||
fixture.detectChanges();
|
||||
|
||||
const sorted = component['sortedTags']();
|
||||
expect(sorted[0].name).toBe('docker/test');
|
||||
expect(sorted[sorted.length - 1].name).toBe('AI');
|
||||
});
|
||||
|
||||
it('should sort by frequency descending', () => {
|
||||
component.sortMode.set('freq-desc');
|
||||
fixture.detectChanges();
|
||||
|
||||
const sorted = component['sortedTags']();
|
||||
expect(sorted[0].name).toBe('AI'); // count: 6
|
||||
expect(sorted[1].name).toBe('docker/automation'); // count: 5
|
||||
});
|
||||
|
||||
it('should sort by frequency ascending', () => {
|
||||
component.sortMode.set('freq-asc');
|
||||
fixture.detectChanges();
|
||||
|
||||
const sorted = component['sortedTags']();
|
||||
const firstCount = sorted[0].count;
|
||||
const lastCount = sorted[sorted.length - 1].count;
|
||||
expect(firstCount).toBeLessThanOrEqual(lastCount);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Grouping', () => {
|
||||
it('should not group when mode is "none"', () => {
|
||||
component.groupMode.set('none');
|
||||
fixture.detectChanges();
|
||||
|
||||
const groups = component.displayedGroups();
|
||||
expect(groups.length).toBe(1);
|
||||
expect(groups[0].label).toBe('all');
|
||||
expect(groups[0].tags.length).toBe(mockTags.length);
|
||||
});
|
||||
|
||||
it('should group by hierarchy', () => {
|
||||
component.groupMode.set('hierarchy');
|
||||
fixture.detectChanges();
|
||||
|
||||
const groups = component.displayedGroups();
|
||||
|
||||
// Should have groups: AI, angular, debian, automatisation, budget, docker
|
||||
expect(groups.length).toBeGreaterThan(1);
|
||||
|
||||
const dockerGroup = groups.find(g => g.label === 'docker');
|
||||
expect(dockerGroup).toBeDefined();
|
||||
expect(dockerGroup!.tags.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should group alphabetically', () => {
|
||||
component.groupMode.set('alpha');
|
||||
fixture.detectChanges();
|
||||
|
||||
const groups = component.displayedGroups();
|
||||
|
||||
// Should have groups for A, B, D
|
||||
expect(groups.length).toBeGreaterThan(1);
|
||||
|
||||
const aGroup = groups.find(g => g.label === 'A');
|
||||
expect(aGroup).toBeDefined();
|
||||
expect(aGroup!.tags.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Group expansion', () => {
|
||||
it('should toggle group expansion', () => {
|
||||
component.groupMode.set('hierarchy');
|
||||
fixture.detectChanges();
|
||||
|
||||
const groups = component.displayedGroups();
|
||||
const firstGroup = groups[0];
|
||||
const initialState = firstGroup.isExpanded;
|
||||
|
||||
component.toggleGroup(firstGroup.label);
|
||||
fixture.detectChanges();
|
||||
|
||||
const updatedGroups = component.displayedGroups();
|
||||
const updatedGroup = updatedGroups.find(g => g.label === firstGroup.label);
|
||||
expect(updatedGroup!.isExpanded).toBe(!initialState);
|
||||
});
|
||||
|
||||
it('should auto-expand all groups when switching modes', () => {
|
||||
component.groupMode.set('hierarchy');
|
||||
fixture.detectChanges();
|
||||
|
||||
const groups = component.displayedGroups();
|
||||
expect(groups.every(g => g.isExpanded)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Display helpers', () => {
|
||||
it('should display short name in hierarchy mode', () => {
|
||||
component.groupMode.set('hierarchy');
|
||||
|
||||
const result = component.displayTagName('docker', 'docker/automation');
|
||||
expect(result).toBe('automation');
|
||||
});
|
||||
|
||||
it('should display full name in non-hierarchy mode', () => {
|
||||
component.groupMode.set('none');
|
||||
|
||||
const result = component.displayTagName('docker', 'docker/automation');
|
||||
expect(result).toBe('docker/automation');
|
||||
});
|
||||
|
||||
it('should handle root tag correctly', () => {
|
||||
component.groupMode.set('hierarchy');
|
||||
|
||||
const result = component.displayTagName('docker', 'docker');
|
||||
expect(result).toBe('docker');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Events', () => {
|
||||
it('should emit tagSelected on tag click', (done) => {
|
||||
component.tagSelected.subscribe((tagName: string) => {
|
||||
expect(tagName).toBe('docker/automation');
|
||||
done();
|
||||
});
|
||||
|
||||
component.onTagClick('docker/automation');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Reset', () => {
|
||||
it('should reset all filters', () => {
|
||||
component.searchQuery.set('test');
|
||||
component.sortMode.set('freq-desc');
|
||||
component.groupMode.set('hierarchy');
|
||||
|
||||
component.resetFilters();
|
||||
|
||||
expect(component.searchQuery()).toBe('');
|
||||
expect(component.sortMode()).toBe('alpha-asc');
|
||||
expect(component.groupMode()).toBe('none');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stats', () => {
|
||||
it('should calculate total tags correctly', () => {
|
||||
const total = component.totalTags();
|
||||
expect(total).toBe(mockTags.length);
|
||||
});
|
||||
|
||||
it('should calculate displayed tags after filtering', () => {
|
||||
component.searchQuery.set('docker');
|
||||
fixture.detectChanges();
|
||||
|
||||
const displayed = component.totalDisplayedTags();
|
||||
expect(displayed).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Performance', () => {
|
||||
it('should handle large tag lists efficiently', () => {
|
||||
const largeTags: TagInfo[] = Array.from({ length: 1000 }, (_, i) => ({
|
||||
name: `tag-${i}`,
|
||||
count: Math.floor(Math.random() * 100)
|
||||
}));
|
||||
|
||||
fixture.componentRef.setInput('tags', largeTags);
|
||||
fixture.detectChanges();
|
||||
|
||||
const start = performance.now();
|
||||
component.sortMode.set('freq-desc');
|
||||
fixture.detectChanges();
|
||||
const duration = performance.now() - start;
|
||||
|
||||
expect(duration).toBeLessThan(50); // Should be < 50ms
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -8,149 +8,252 @@ import {
|
||||
computed,
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { TagInfo } from '../../types';
|
||||
|
||||
interface TagSection {
|
||||
letter: string;
|
||||
type SortMode = 'alpha-asc' | 'alpha-desc' | 'freq-desc' | 'freq-asc';
|
||||
type GroupMode = 'none' | 'hierarchy' | 'alpha';
|
||||
|
||||
interface TagGroup {
|
||||
label: string;
|
||||
tags: TagInfo[];
|
||||
isExpanded: boolean;
|
||||
level: number;
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'app-tags-view',
|
||||
imports: [CommonModule],
|
||||
imports: [CommonModule, FormsModule],
|
||||
styles: [
|
||||
`
|
||||
:host {
|
||||
--tv-scroll-thumb-light: color-mix(in srgb, var(--text-main) 35%, transparent);
|
||||
--tv-scroll-thumb-dark: color-mix(in srgb, var(--text-muted) 55%, transparent);
|
||||
--tv-scroll-thumb: rgba(128, 128, 128, 0.3);
|
||||
--tv-scroll-track: transparent;
|
||||
}
|
||||
|
||||
:host .custom-scrollbar {
|
||||
:host-context(.dark) {
|
||||
--tv-scroll-thumb: rgba(200, 200, 200, 0.2);
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--tv-scroll-thumb-light) var(--tv-scroll-track);
|
||||
scrollbar-color: var(--tv-scroll-thumb) var(--tv-scroll-track);
|
||||
}
|
||||
|
||||
:host([data-theme='dark']) .custom-scrollbar,
|
||||
:host-context(.dark) .custom-scrollbar {
|
||||
scrollbar-color: var(--tv-scroll-thumb-dark) var(--tv-scroll-track);
|
||||
}
|
||||
|
||||
:host .custom-scrollbar::-webkit-scrollbar {
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
:host .custom-scrollbar::-webkit-scrollbar-track {
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: var(--tv-scroll-track);
|
||||
}
|
||||
|
||||
:host .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--tv-scroll-thumb-light);
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--tv-scroll-thumb);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.tag-item {
|
||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
.tag-item:hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
|
||||
.group-header {
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.rotate-90 {
|
||||
transform: rotate(90deg);
|
||||
}
|
||||
|
||||
.expand-icon {
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
/* Toolbar layout and compact controls */
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.control-select {
|
||||
min-width: 0;
|
||||
flex: 1 1 0;
|
||||
height: 32px;
|
||||
padding: 0 10px;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
:host([data-theme='dark']) .custom-scrollbar::-webkit-scrollbar-thumb,
|
||||
:host-context(.dark) .custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--tv-scroll-thumb-dark);
|
||||
.icon-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
/* Remove blue focus rectangle and native search decorations */
|
||||
#tag-search,
|
||||
#tag-search:focus,
|
||||
#tag-search:focus-visible {
|
||||
outline: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
#tag-search {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
input[type='search']::-webkit-search-decoration,
|
||||
input[type='search']::-webkit-search-cancel-button,
|
||||
input[type='search']::-webkit-search-results-button,
|
||||
input[type='search']::-webkit-search-results-decoration {
|
||||
display: none;
|
||||
.icon-btn--muted {
|
||||
color: var(--obs-l-text-muted);
|
||||
}
|
||||
`,
|
||||
],
|
||||
template: `
|
||||
<div class="flex h-full flex-col gap-4 p-3 bg-card">
|
||||
<div class="w-full rounded-full border border-border bg-card px-3 py-1.5 shadow-subtle focus-within:outline-none focus-within:ring-0">
|
||||
<label class="sr-only" for="tag-search">Rechercher des tags</label>
|
||||
<div class="flex items-center gap-2.5 text-text-muted">
|
||||
<svg class="h-4 w-4" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-5.2-5.2m0 0A7 7 0 105.8 5.8a7 7 0 0010 10z" />
|
||||
</svg>
|
||||
<div class="flex h-full flex-col bg-obs-l-bg-main dark:bg-obs-d-bg-main">
|
||||
<!-- Toolbar -->
|
||||
<div class="flex flex-col gap-3 border-b border-obs-l-border dark:border-obs-d-border p-3">
|
||||
<!-- Search bar -->
|
||||
<div class="relative">
|
||||
<div class="absolute left-3 top-1/2 -translate-y-1/2 text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
<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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<input
|
||||
id="tag-search"
|
||||
type="search"
|
||||
[value]="searchTerm()"
|
||||
(input)="onSearchChange($event.target?.value ?? '')"
|
||||
[(ngModel)]="searchQuery"
|
||||
(ngModelChange)="onSearchChange()"
|
||||
placeholder="Rechercher un tag..."
|
||||
class="w-full border-0 bg-transparent text-sm text-text-main placeholder:text-text-muted outline-none focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0"
|
||||
class="w-full rounded-md border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary pl-9 pr-3 py-2 text-sm text-obs-l-text-main dark:text-obs-d-text-main placeholder:text-obs-l-text-muted dark:placeholder:text-obs-d-text-muted focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Controls row -->
|
||||
<div class="toolbar-row">
|
||||
<!-- Sort dropdown -->
|
||||
<select
|
||||
[(ngModel)]="sortMode"
|
||||
(ngModelChange)="onSortChange()"
|
||||
class="control-select border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary text-xs text-obs-l-text-main dark:text-obs-d-text-main focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
|
||||
>
|
||||
<option value="alpha-asc">A → Z</option>
|
||||
<option value="alpha-desc">Z → A</option>
|
||||
<option value="freq-desc">Fréquence ↓</option>
|
||||
<option value="freq-asc">Fréquence ↑</option>
|
||||
</select>
|
||||
|
||||
<!-- Group mode dropdown -->
|
||||
<select
|
||||
[(ngModel)]="groupMode"
|
||||
(ngModelChange)="onGroupModeChange()"
|
||||
class="control-select border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary text-xs text-obs-l-text-main dark:text-obs-d-text-main focus:outline-none focus:ring-2 focus:ring-obs-l-accent dark:focus:ring-obs-d-accent"
|
||||
>
|
||||
<option value="none">Sans groupe</option>
|
||||
<option value="hierarchy">Hiérarchie</option>
|
||||
<option value="alpha">Alphabétique</option>
|
||||
</select>
|
||||
|
||||
<!-- Reset button -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="resetFilters()"
|
||||
class="icon-btn border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary text-obs-l-text-muted dark:text-obs-d-text-muted hover:text-obs-l-text-main dark:hover:text-obs-d-text-main hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover transition-colors"
|
||||
title="Réinitialiser les filtres"
|
||||
aria-label="Réinitialiser les filtres"
|
||||
>
|
||||
<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="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<!-- Expand all toggle (only when grouped) -->
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleExpandAll()"
|
||||
[disabled]="groupMode() === 'none'"
|
||||
[attr.aria-pressed]="allExpanded()"
|
||||
class="icon-btn border border-obs-l-border dark:border-obs-d-border bg-obs-l-bg-secondary dark:bg-obs-d-bg-secondary transition-colors"
|
||||
[ngClass]="groupMode() === 'none' ? 'text-obs-l-text-muted/60 dark:text-obs-d-text-muted/60 cursor-not-allowed' : (allExpanded() ? 'text-obs-l-text-main dark:text-obs-d-text-main' : 'text-obs-l-text-muted dark:text-obs-d-text-muted hover:text-obs-l-text-main dark:hover:text-obs-d-text-main hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover')"
|
||||
[title]="allExpanded() ? 'Replier tout' : 'Déplier tout'"
|
||||
[attr.aria-label]="allExpanded() ? 'Replier tout' : 'Déplier tout'"
|
||||
>
|
||||
<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="M4 12h16M4 6h16M4 18h16" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 gap-3 overflow-hidden">
|
||||
<div class="custom-scrollbar flex-1 overflow-y-auto pr-1">
|
||||
@if (displayedSections().length > 0) {
|
||||
<div class="space-y-4">
|
||||
@for (section of displayedSections(); track section.letter) {
|
||||
<section class="space-y-2">
|
||||
<header class="flex items-center justify-between">
|
||||
<h2 class="text-xs font-semibold uppercase tracking-wide text-text-muted">
|
||||
{{ section.letter }}
|
||||
</h2>
|
||||
<span class="text-[0.65rem] text-text-muted/70">
|
||||
{{ section.tags.length }} tag(s)
|
||||
<!-- Tags list -->
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
@if (displayedGroups().length === 0) {
|
||||
<div class="flex flex-col items-center justify-center h-full text-obs-l-text-muted dark:text-obs-d-text-muted p-8">
|
||||
<svg class="h-12 w-12 mb-3 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<p class="text-sm">Aucun tag trouvé</p>
|
||||
</div>
|
||||
} @else {
|
||||
<div class="py-2">
|
||||
@for (group of displayedGroups(); track group.label) {
|
||||
<div class="mb-1">
|
||||
<!-- Group header (if grouped) -->
|
||||
@if (groupMode() !== 'none') {
|
||||
<button
|
||||
type="button"
|
||||
(click)="toggleGroup(group.label)"
|
||||
class="group-header w-full flex items-center justify-between px-3 py-2 text-left hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
class="h-3 w-3 text-obs-l-text-muted dark:text-obs-d-text-muted expand-icon"
|
||||
[class.rotate-90]="group.isExpanded"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
|
||||
</svg>
|
||||
<span class="text-xs font-semibold text-obs-l-text-muted dark:text-obs-d-text-muted uppercase tracking-wide">
|
||||
{{ group.label }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
{{ group.tags.length }}
|
||||
</span>
|
||||
</header>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
@for (tag of section.tags; track tag.name) {
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Tags in group -->
|
||||
@if (groupMode() === 'none' || group.isExpanded) {
|
||||
<div [class.pl-6]="groupMode() !== 'none'">
|
||||
@for (tag of group.tags; track tag.name) {
|
||||
<button
|
||||
type="button"
|
||||
class="chip justify-between px-2 py-0.5 text-[0.7rem]"
|
||||
(click)="tagSelected.emit(tag.name)"
|
||||
(click)="onTagClick(tag.name)"
|
||||
class="tag-item w-full flex items-center justify-between px-3 py-2 text-left hover:bg-obs-l-bg-hover dark:hover:bg-obs-d-bg-hover group"
|
||||
>
|
||||
<span class="flex items-center gap-2 text-text-main">
|
||||
<span aria-hidden="true">🔖</span>
|
||||
<span class="truncate">{{ tag.name }}</span>
|
||||
<div class="flex items-center gap-2 flex-1 min-w-0">
|
||||
<svg class="h-3.5 w-3.5 text-obs-l-text-muted dark:text-obs-d-text-muted flex-shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" />
|
||||
</svg>
|
||||
<span class="text-sm text-obs-l-text-main dark:text-obs-d-text-main truncate">
|
||||
{{ displayTagName(group.label, tag.name) }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted ml-2 flex-shrink-0">
|
||||
{{ tag.count }}
|
||||
</span>
|
||||
<span class="badge text-text-muted">{{ tag.count }}</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="flex h-full items-center justify-center rounded-lg border border-dashed border-border bg-bg-muted px-4 py-6 text-center text-sm text-text-muted">
|
||||
Aucun tag ne correspond à votre recherche.
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<nav class="custom-scrollbar sticky top-0 flex h-full w-12 flex-col items-center justify-start gap-2 self-start rounded-lg border border-border bg-card px-2 py-3 text-[0.7rem] font-medium text-text-muted shadow-subtle overflow-y-auto">
|
||||
<span class="text-[0.6rem] uppercase tracking-wide text-text-muted/70">A–Z</span>
|
||||
<div class="grid grid-cols-1 gap-1">
|
||||
@for (letter of availableLetters(); track letter) {
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full border border-transparent text-[0.7rem] transition"
|
||||
[ngClass]="activeLetter() === letter ? 'border-border bg-bg-muted text-text-main font-semibold' : 'text-text-muted hover:border-border hover:bg-bg-muted'"
|
||||
[attr.aria-pressed]="activeLetter() === letter"
|
||||
(click)="onLetterClick(letter)"
|
||||
>
|
||||
{{ letter }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</nav>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Stats footer -->
|
||||
<div class="border-t border-obs-l-border dark:border-obs-d-border px-3 py-2">
|
||||
<p class="text-xs text-obs-l-text-muted dark:text-obs-d-text-muted">
|
||||
{{ totalDisplayedTags() }} tag(s) affiché(s) sur {{ totalTags() }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
@@ -162,64 +265,199 @@ export class TagsViewComponent {
|
||||
numeric: true,
|
||||
});
|
||||
|
||||
// Inputs/Outputs
|
||||
readonly tags = input.required<TagInfo[]>();
|
||||
readonly tagSelected = output<string>();
|
||||
|
||||
readonly searchTerm = signal('');
|
||||
readonly activeLetter = signal<string | null>(null);
|
||||
// State signals
|
||||
searchQuery = signal('');
|
||||
sortMode = signal<SortMode>('alpha-asc');
|
||||
groupMode = signal<GroupMode>('none');
|
||||
private expandedGroups = signal<Set<string>>(new Set());
|
||||
private userCustomExpansion = signal(false);
|
||||
private lastMode: GroupMode | null = null;
|
||||
|
||||
private readonly sections = computed<TagSection[]>(() => {
|
||||
const rawTerm = this.searchTerm().trim();
|
||||
const normalizedTerm = this.normalize(rawTerm);
|
||||
const allTags = (this.tags() ?? []).slice().sort((a, b) => this.collator.compare(a.name, b.name));
|
||||
// Normalized tags with forward slashes
|
||||
private readonly normalizedTags = computed<TagInfo[]>(() => {
|
||||
return (this.tags() ?? []).map(t => ({
|
||||
...t,
|
||||
name: (t.name ?? '').replace(/\\/g, '/')
|
||||
}));
|
||||
});
|
||||
|
||||
const filtered = rawTerm
|
||||
? allTags.filter((tag) => this.normalize(tag.name).includes(normalizedTerm))
|
||||
: allTags;
|
||||
// Filtered tags based on search query
|
||||
private readonly filteredTags = computed<TagInfo[]>(() => {
|
||||
const query = this.normalize(this.searchQuery().trim());
|
||||
const allTags = this.normalizedTags();
|
||||
|
||||
if (!query) return allTags;
|
||||
|
||||
return allTags.filter(tag =>
|
||||
this.normalize(tag.name).includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
// Sorted tags based on sort mode
|
||||
private readonly sortedTags = computed<TagInfo[]>(() => {
|
||||
const tags = [...this.filteredTags()];
|
||||
const mode = this.sortMode();
|
||||
|
||||
switch (mode) {
|
||||
case 'alpha-asc':
|
||||
return tags.sort((a, b) => this.collator.compare(a.name, b.name));
|
||||
case 'alpha-desc':
|
||||
return tags.sort((a, b) => this.collator.compare(b.name, a.name));
|
||||
case 'freq-desc':
|
||||
return tags.sort((a, b) => b.count - a.count || this.collator.compare(a.name, b.name));
|
||||
case 'freq-asc':
|
||||
return tags.sort((a, b) => a.count - b.count || this.collator.compare(a.name, b.name));
|
||||
default:
|
||||
return tags;
|
||||
}
|
||||
});
|
||||
|
||||
// Grouped tags based on group mode
|
||||
readonly displayedGroups = computed<TagGroup[]>(() => {
|
||||
const tags = this.sortedTags();
|
||||
const mode = this.groupMode();
|
||||
const expanded = this.expandedGroups();
|
||||
|
||||
if (mode === 'none') {
|
||||
return [{
|
||||
label: 'all',
|
||||
tags,
|
||||
isExpanded: true,
|
||||
level: 0
|
||||
}];
|
||||
}
|
||||
|
||||
if (mode === 'hierarchy') {
|
||||
return this.buildHierarchyGroups(tags, expanded);
|
||||
}
|
||||
|
||||
if (mode === 'alpha') {
|
||||
return this.buildAlphaGroups(tags, expanded);
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
// Stats
|
||||
readonly totalTags = computed(() => this.normalizedTags().length);
|
||||
readonly totalDisplayedTags = computed(() => this.filteredTags().length);
|
||||
|
||||
constructor() {
|
||||
// Auto-expand groups on mode change; don't override user toggles
|
||||
effect(() => {
|
||||
const mode = this.groupMode();
|
||||
const labels = this.getCurrentGroupLabels();
|
||||
|
||||
// If switched mode, reset custom flag and initialize expansion
|
||||
if (this.lastMode !== mode) {
|
||||
this.userCustomExpansion.set(false);
|
||||
this.lastMode = mode;
|
||||
}
|
||||
|
||||
if (mode === 'none') {
|
||||
if (this.expandedGroups().size > 0) this.expandedGroups.set(new Set());
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize only when user hasn't interacted yet or when current set mismatches and user hasn't customized
|
||||
const current = this.expandedGroups();
|
||||
const needsInit = current.size === 0 || current.size !== labels.length || labels.some(l => !current.has(l));
|
||||
if (!this.userCustomExpansion() && needsInit) {
|
||||
this.expandedGroups.set(new Set(labels));
|
||||
}
|
||||
}, { allowSignalWrites: true });
|
||||
}
|
||||
|
||||
// Compute current group labels from mode + sorted tags
|
||||
private getCurrentGroupLabels(): string[] {
|
||||
const mode = this.groupMode();
|
||||
const tags = this.sortedTags();
|
||||
if (mode === 'none') return [];
|
||||
if (mode === 'hierarchy') {
|
||||
const roots = new Set<string>();
|
||||
for (const t of tags) {
|
||||
const root = (t.name?.split('/')?.[0] || t.name) ?? '';
|
||||
if (root) roots.add(root);
|
||||
}
|
||||
return Array.from(roots).sort((a, b) => this.collator.compare(a, b));
|
||||
}
|
||||
// alpha
|
||||
const letters = new Set<string>();
|
||||
for (const t of tags) letters.add(this.extractLetter(t.name));
|
||||
return Array.from(letters).sort((a, b) => this.sortLetters(a, b));
|
||||
}
|
||||
|
||||
// Build hierarchy groups (e.g., docker/automation, docker/test -> docker group)
|
||||
private buildHierarchyGroups(tags: TagInfo[], expanded: Set<string>): TagGroup[] {
|
||||
const grouped = new Map<string, TagInfo[]>();
|
||||
for (const tag of filtered) {
|
||||
|
||||
for (const tag of tags) {
|
||||
const parts = tag.name.split('/');
|
||||
const root = parts[0] || tag.name;
|
||||
|
||||
if (!grouped.has(root)) {
|
||||
grouped.set(root, []);
|
||||
}
|
||||
grouped.get(root)!.push(tag);
|
||||
}
|
||||
|
||||
return Array.from(grouped.entries())
|
||||
.sort((a, b) => this.collator.compare(a[0], b[0]))
|
||||
.map(([label, tags]) => ({
|
||||
label,
|
||||
tags: tags.sort((a, b) => this.collator.compare(a.name, b.name)),
|
||||
isExpanded: expanded.has(label),
|
||||
level: 0
|
||||
}));
|
||||
}
|
||||
|
||||
// Build alphabetical groups (A, B, C, etc.)
|
||||
private buildAlphaGroups(tags: TagInfo[], expanded: Set<string>): TagGroup[] {
|
||||
const grouped = new Map<string, TagInfo[]>();
|
||||
|
||||
for (const tag of tags) {
|
||||
const letter = this.extractLetter(tag.name);
|
||||
|
||||
if (!grouped.has(letter)) {
|
||||
grouped.set(letter, []);
|
||||
}
|
||||
grouped.get(letter)!.push(tag);
|
||||
}
|
||||
|
||||
const sortedSections = Array.from(grouped.entries())
|
||||
return Array.from(grouped.entries())
|
||||
.sort((a, b) => this.sortLetters(a[0], b[0]))
|
||||
.map(([letter, tags]) => ({
|
||||
letter,
|
||||
tags: tags.slice().sort((a, b) => this.collator.compare(a.name, b.name)),
|
||||
.map(([label, tags]) => ({
|
||||
label,
|
||||
tags,
|
||||
isExpanded: expanded.has(label),
|
||||
level: 0
|
||||
}));
|
||||
}
|
||||
|
||||
return sortedSections;
|
||||
});
|
||||
private extractLetter(name: string): string {
|
||||
const normalized = this.normalize(name).toUpperCase();
|
||||
const char = normalized.charAt(0);
|
||||
|
||||
if (!char) return '#';
|
||||
if (/[A-Z]/.test(char)) return char;
|
||||
if (/[0-9]/.test(char)) return '#';
|
||||
|
||||
return char;
|
||||
}
|
||||
|
||||
readonly availableLetters = computed(() => this.sections().map((section) => section.letter));
|
||||
|
||||
readonly displayedSections = computed(() => {
|
||||
const active = this.activeLetter();
|
||||
if (!active) {
|
||||
return this.sections();
|
||||
}
|
||||
return this.sections().filter((section) => section.letter === active);
|
||||
});
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
const letters = this.availableLetters();
|
||||
const active = this.activeLetter();
|
||||
if (active && !letters.includes(active)) {
|
||||
this.resetLetterState();
|
||||
}
|
||||
});
|
||||
|
||||
effect(() => {
|
||||
if (!this.searchTerm().trim()) {
|
||||
this.resetLetterState();
|
||||
}
|
||||
});
|
||||
private sortLetters(a: string, b: string): number {
|
||||
const rank = (value: string): number => {
|
||||
if (value === '#') return 0;
|
||||
if (/^[A-Z]$/.test(value)) return value.charCodeAt(0) - 64;
|
||||
return 100 + value.charCodeAt(0);
|
||||
};
|
||||
|
||||
const diff = rank(a) - rank(b);
|
||||
return diff !== 0 ? diff : this.collator.compare(a, b);
|
||||
}
|
||||
|
||||
private normalize(value: string): string {
|
||||
@@ -229,58 +467,80 @@ export class TagsViewComponent {
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
private extractLetter(name: string): string {
|
||||
const normalized = this.normalize(name).toUpperCase();
|
||||
const char = normalized.charAt(0);
|
||||
if (!char) {
|
||||
return '#';
|
||||
}
|
||||
if (/[A-Z]/.test(char)) {
|
||||
return char;
|
||||
}
|
||||
if (/[0-9]/.test(char)) {
|
||||
return '#';
|
||||
}
|
||||
return char;
|
||||
}
|
||||
|
||||
private sortLetters(a: string, b: string): number {
|
||||
const rank = (value: string): number => {
|
||||
if (value === '#') {
|
||||
return 0;
|
||||
/**
|
||||
* Display helper: remove the group prefix from tag name
|
||||
* Example: group="docker", name="docker/automation" => "automation"
|
||||
*/
|
||||
displayTagName(groupLabel: string, tagName: string): string {
|
||||
const mode = this.groupMode();
|
||||
|
||||
// In hierarchy mode, remove the root prefix
|
||||
if (mode === 'hierarchy') {
|
||||
const full = String(tagName ?? '');
|
||||
const prefix = String(groupLabel ?? '');
|
||||
|
||||
if (!prefix || full === prefix) return full;
|
||||
if (full.startsWith(prefix + '/')) {
|
||||
return full.slice(prefix.length + 1);
|
||||
}
|
||||
if (/^[A-Z]$/.test(value)) {
|
||||
return value.charCodeAt(0) - 64;
|
||||
}
|
||||
|
||||
return tagName;
|
||||
}
|
||||
|
||||
// Event handlers
|
||||
onSearchChange(): void {
|
||||
// Search query is bound via ngModel, no action needed
|
||||
}
|
||||
|
||||
onSortChange(): void {
|
||||
// Sort mode is bound via ngModel, computed signals will update
|
||||
}
|
||||
|
||||
onGroupModeChange(): void {
|
||||
// Group mode is bound via ngModel, computed signals will update
|
||||
}
|
||||
|
||||
toggleGroup(label: string): void {
|
||||
this.expandedGroups.update(groups => {
|
||||
const newGroups = new Set(groups);
|
||||
if (newGroups.has(label)) {
|
||||
newGroups.delete(label);
|
||||
} else {
|
||||
newGroups.add(label);
|
||||
}
|
||||
return 100 + value.charCodeAt(0);
|
||||
};
|
||||
const diff = rank(a) - rank(b);
|
||||
return diff !== 0 ? diff : this.collator.compare(a, b);
|
||||
return newGroups;
|
||||
});
|
||||
this.userCustomExpansion.set(true);
|
||||
}
|
||||
|
||||
onSearchChange(raw: string): void {
|
||||
this.searchTerm.set(raw);
|
||||
if (raw.trim()) {
|
||||
this.activeLetter.set(null);
|
||||
onTagClick(tagName: string): void {
|
||||
this.tagSelected.emit(tagName);
|
||||
}
|
||||
|
||||
resetFilters(): void {
|
||||
this.searchQuery.set('');
|
||||
this.sortMode.set('alpha-asc');
|
||||
this.groupMode.set('none');
|
||||
this.userCustomExpansion.set(false);
|
||||
}
|
||||
|
||||
// Expand all toggle for grouped modes
|
||||
allExpanded = computed(() => {
|
||||
if (this.groupMode() === 'none') return false;
|
||||
const labels = this.getCurrentGroupLabels();
|
||||
const current = this.expandedGroups();
|
||||
return labels.length > 0 && labels.every(l => current.has(l));
|
||||
});
|
||||
|
||||
toggleExpandAll(): void {
|
||||
if (this.groupMode() === 'none') return;
|
||||
const labels = this.getCurrentGroupLabels();
|
||||
if (this.allExpanded()) {
|
||||
this.expandedGroups.set(new Set());
|
||||
} else {
|
||||
this.expandedGroups.set(new Set(labels));
|
||||
}
|
||||
}
|
||||
|
||||
clearSearch(): void {
|
||||
this.searchTerm.set('');
|
||||
this.resetLetterState();
|
||||
}
|
||||
|
||||
onLetterClick(letter: string): void {
|
||||
const active = this.activeLetter();
|
||||
if (active === letter) {
|
||||
this.resetLetterState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.activeLetter.set(letter);
|
||||
}
|
||||
|
||||
private resetLetterState(): void {
|
||||
this.activeLetter.set(null);
|
||||
this.userCustomExpansion.set(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
appVersion: '0.1.0',
|
||||
USE_MEILI: true, // Set to true to enable Meilisearch backend search
|
||||
logging: {
|
||||
enabled: true,
|
||||
endpoint: '/api/log',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { isObservable } from 'rxjs';
|
||||
import { SearchOrchestratorService } from './search-orchestrator.service';
|
||||
import { SearchOptions } from './search-parser.types';
|
||||
|
||||
@@ -45,8 +46,12 @@ export class SearchEvaluatorService {
|
||||
*/
|
||||
search(query: string, options?: SearchOptions): SearchResult[] {
|
||||
// Delegate to the new orchestrator
|
||||
const results = this.orchestrator.execute(query, options);
|
||||
|
||||
const exec = this.orchestrator.execute(query, options);
|
||||
if (isObservable(exec)) {
|
||||
// Legacy API is synchronous; when Meili (Observable) is enabled, return empty for compatibility.
|
||||
return [];
|
||||
}
|
||||
const results = exec;
|
||||
// Convert to legacy format (without ranges)
|
||||
return results.map(result => ({
|
||||
noteId: result.noteId,
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { inject, Injectable, signal } from '@angular/core';
|
||||
import { HttpClient, HttpParams } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
import { finalize } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Search hit from Meilisearch with highlighting
|
||||
*/
|
||||
export interface SearchHit {
|
||||
id: string;
|
||||
title: string;
|
||||
path: string;
|
||||
file: string;
|
||||
tags?: string[];
|
||||
properties?: Record<string, unknown>;
|
||||
updatedAt?: number;
|
||||
createdAt?: number;
|
||||
excerpt?: string;
|
||||
_formatted?: {
|
||||
title?: string;
|
||||
content?: string;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Search response from Meilisearch API
|
||||
*/
|
||||
export interface SearchResponse {
|
||||
hits: SearchHit[];
|
||||
estimatedTotalHits: number;
|
||||
facetDistribution?: Record<string, Record<string, number>>;
|
||||
processingTimeMs: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search options
|
||||
*/
|
||||
export interface SearchOptions {
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
sort?: string;
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for server-side search using Meilisearch
|
||||
*/
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class SearchMeilisearchService {
|
||||
private http = inject(HttpClient);
|
||||
|
||||
/** Loading state signal */
|
||||
loading = signal(false);
|
||||
|
||||
/**
|
||||
* Execute a search query
|
||||
*/
|
||||
search(q: string, opts?: SearchOptions): Observable<SearchResponse> {
|
||||
this.loading.set(true);
|
||||
|
||||
let params = new HttpParams().set('q', q);
|
||||
|
||||
if (opts?.limit !== undefined) {
|
||||
params = params.set('limit', String(opts.limit));
|
||||
}
|
||||
if (opts?.offset !== undefined) {
|
||||
params = params.set('offset', String(opts.offset));
|
||||
}
|
||||
if (opts?.sort) {
|
||||
params = params.set('sort', opts.sort);
|
||||
}
|
||||
if (opts?.highlight === false) {
|
||||
params = params.set('highlight', 'false');
|
||||
}
|
||||
|
||||
const url = `/api/search?${params.toString()}`;
|
||||
console.log('[SearchMeilisearchService] Sending request:', url);
|
||||
|
||||
return this.http
|
||||
.get<SearchResponse>('/api/search', { params })
|
||||
.pipe(
|
||||
finalize(() => {
|
||||
console.log('[SearchMeilisearchService] Request completed');
|
||||
this.loading.set(false);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a full reindex of the vault
|
||||
*/
|
||||
reindex(): Observable<{ ok: boolean; indexed: boolean; count: number; elapsedMs: number }> {
|
||||
this.loading.set(true);
|
||||
|
||||
return this.http
|
||||
.post<{ ok: boolean; indexed: boolean; count: number; elapsedMs: number }>(
|
||||
'/api/reindex',
|
||||
{}
|
||||
)
|
||||
.pipe(finalize(() => this.loading.set(false)));
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,10 @@ import { ClientLoggingService } from '../../services/client-logging.service';
|
||||
import { SearchLogService } from '../../app/core/logging/log.service';
|
||||
import { SearchDiagEvent } from '../../app/core/logging/search-log.model';
|
||||
import { ParseDiagnostics } from './search-parser.types';
|
||||
import { SearchMeilisearchService } from './search-meilisearch.service';
|
||||
import { environment } from '../logging/environment';
|
||||
import { Observable, of } from 'rxjs';
|
||||
import { map } from 'rxjs/operators';
|
||||
|
||||
/**
|
||||
* Match range for highlighting
|
||||
@@ -69,11 +73,18 @@ export class SearchOrchestratorService {
|
||||
private searchIndex = inject(SearchIndexService);
|
||||
private logger = inject(ClientLoggingService);
|
||||
private diagnosticsLogger = inject(SearchLogService);
|
||||
private meiliSearch = inject(SearchMeilisearchService);
|
||||
|
||||
/**
|
||||
* Execute a search query and return matching notes with highlights
|
||||
* Delegates to Meilisearch if USE_MEILI is enabled
|
||||
*/
|
||||
execute(query: string, options?: SearchExecutionOptions): SearchResult[] {
|
||||
execute(query: string, options?: SearchExecutionOptions): SearchResult[] | Observable<SearchResult[]> {
|
||||
// Use Meilisearch backend if enabled
|
||||
if (environment.USE_MEILI) {
|
||||
return this.searchWithMeili(query, options);
|
||||
}
|
||||
|
||||
if (!query || !query.trim()) {
|
||||
return [];
|
||||
}
|
||||
@@ -803,4 +814,75 @@ export class SearchOrchestratorService {
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
/**
|
||||
* Search using Meilisearch backend
|
||||
*/
|
||||
private searchWithMeili(query: string, options?: SearchExecutionOptions): Observable<SearchResult[]> {
|
||||
this.logger.info('SearchOrchestrator', 'Using Meilisearch backend', { query });
|
||||
console.log('[SearchOrchestrator] Calling Meilisearch with query:', query);
|
||||
|
||||
return this.meiliSearch.search(query, {
|
||||
limit: options?.maxResults ?? 20,
|
||||
highlight: options?.highlight !== false
|
||||
}).pipe(
|
||||
map(response => {
|
||||
this.logger.info('SearchOrchestrator', 'Meilisearch response', {
|
||||
hitCount: response.hits.length,
|
||||
processingTimeMs: response.processingTimeMs
|
||||
});
|
||||
console.log('[SearchOrchestrator] Raw Meilisearch response:', response);
|
||||
|
||||
// Transform Meilisearch hits to SearchResult format
|
||||
return response.hits.map(hit => {
|
||||
const matches: SearchMatch[] = [];
|
||||
|
||||
// Prefer Meilisearch-provided highlights if available
|
||||
if (hit._formatted?.title && hit._formatted.title !== hit.title) {
|
||||
matches.push({
|
||||
type: 'content',
|
||||
text: hit.title,
|
||||
context: hit._formatted.title,
|
||||
ranges: []
|
||||
});
|
||||
}
|
||||
if (hit._formatted?.content) {
|
||||
matches.push({
|
||||
type: 'content',
|
||||
text: hit.excerpt || '',
|
||||
context: hit._formatted.content,
|
||||
ranges: []
|
||||
});
|
||||
}
|
||||
|
||||
// Fallback when no highlights were returned by Meilisearch
|
||||
if (matches.length === 0) {
|
||||
// Use excerpt if present, otherwise a cropped portion of content
|
||||
const context = hit.excerpt || (typeof (hit as any).content === 'string' ? String((hit as any).content).slice(0, 200) + '…' : '');
|
||||
if (context) {
|
||||
matches.push({
|
||||
type: 'content',
|
||||
text: '',
|
||||
context,
|
||||
ranges: []
|
||||
});
|
||||
}
|
||||
this.logger.debug('SearchOrchestrator', 'No highlight in Meilisearch hit, used fallback context', {
|
||||
id: hit.id,
|
||||
title: hit.title
|
||||
});
|
||||
}
|
||||
|
||||
const result = {
|
||||
noteId: hit.id,
|
||||
matches,
|
||||
score: 100, // Meilisearch has its own ranking
|
||||
allRanges: []
|
||||
};
|
||||
console.log('[SearchOrchestrator] Transformed hit:', { id: hit.id, matchCount: matches.length, result });
|
||||
return result;
|
||||
});
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,4 +119,6 @@ export interface SearchOptions {
|
||||
caseSensitive?: boolean;
|
||||
/** Enable regex mode */
|
||||
regexMode?: boolean;
|
||||
/** Enable UI highlighting in results and active document */
|
||||
highlight?: boolean;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface SearchPreferences {
|
||||
contextLines: number;
|
||||
/** Explain search terms */
|
||||
explainSearchTerms: boolean;
|
||||
/** Enable UI highlighting in results and active document */
|
||||
highlight: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +29,8 @@ const DEFAULT_PREFERENCES: SearchPreferences = {
|
||||
collapseResults: false,
|
||||
showMoreContext: false,
|
||||
contextLines: 2,
|
||||
explainSearchTerms: false
|
||||
explainSearchTerms: false,
|
||||
highlight: true
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -84,7 +87,7 @@ export class SearchPreferencesService {
|
||||
*/
|
||||
togglePreference(
|
||||
context: string,
|
||||
key: keyof Pick<SearchPreferences, 'caseSensitive' | 'regexMode' | 'collapseResults' | 'showMoreContext' | 'explainSearchTerms'>
|
||||
key: keyof Pick<SearchPreferences, 'caseSensitive' | 'regexMode' | 'collapseResults' | 'showMoreContext' | 'explainSearchTerms' | 'highlight'>
|
||||
): void {
|
||||
const current = this.getPreferences(context);
|
||||
this.updatePreferences(context, {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
(window as any).process = {
|
||||
env: { DEBUG: undefined },
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Injectable, signal, computed, OnDestroy } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Note, VaultNode, GraphData, TagInfo, VaultFolder } from '../types';
|
||||
import { Note, VaultNode, GraphData, TagInfo, VaultFolder, FileMetadata } from '../types';
|
||||
import { VaultEventsService, VaultEventPayload } from './vault-events.service';
|
||||
import { Subscription } from 'rxjs';
|
||||
import { Subscription, firstValueFrom } from 'rxjs';
|
||||
|
||||
interface VaultApiNote {
|
||||
id: string;
|
||||
@@ -26,11 +26,17 @@ interface VaultApiResponse {
|
||||
})
|
||||
export class VaultService implements OnDestroy {
|
||||
private notesMap = signal<Map<string, Note>>(new Map());
|
||||
// Fast file tree built from Meilisearch metadata
|
||||
private fastTreeSignal = signal<VaultNode[]>([]);
|
||||
private idToPathFast = new Map<string, string>();
|
||||
private slugIdToPathFast = new Map<string, string>();
|
||||
private metaByPathFast = new Map<string, FileMetadata>();
|
||||
private openFolderPaths = signal(new Set<string>());
|
||||
private initialVaultName = this.resolveVaultName();
|
||||
|
||||
allNotes = computed(() => Array.from(this.notesMap().values()));
|
||||
vaultName = signal<string>(this.initialVaultName);
|
||||
fastFileTree = computed<VaultNode[]>(() => this.fastTreeSignal());
|
||||
|
||||
fileTree = computed<VaultNode[]>(() => {
|
||||
const root: VaultFolder = { type: 'folder', name: 'root', path: '', children: [], isOpen: true };
|
||||
@@ -93,6 +99,193 @@ export class VaultService implements OnDestroy {
|
||||
return root.children;
|
||||
});
|
||||
|
||||
/**
|
||||
* Load fast file tree from Meilisearch-backed metadata
|
||||
*/
|
||||
private loadFastFileTree(): void {
|
||||
this.http.get<FileMetadata[]>(`/api/files/metadata`).subscribe({
|
||||
next: (items) => {
|
||||
try {
|
||||
this.buildFastTree(items || []);
|
||||
} catch (e) {
|
||||
// Ignore
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
// Silent fallback to regular tree
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private buildFastTree(items: FileMetadata[]): void {
|
||||
this.idToPathFast.clear();
|
||||
this.slugIdToPathFast.clear();
|
||||
this.metaByPathFast.clear();
|
||||
|
||||
// Root folder
|
||||
const root: VaultFolder = { type: 'folder', name: 'root', path: '', children: [], isOpen: true };
|
||||
const folders = new Map<string, VaultFolder>([['', root]]);
|
||||
|
||||
// Index
|
||||
for (const it of items) {
|
||||
if (!it?.path) continue;
|
||||
const path = it.path.replace(/\\/g, '/');
|
||||
const slugId = this.buildSlugIdFromPath(path);
|
||||
const safeId = it.id;
|
||||
if (safeId) this.idToPathFast.set(String(safeId), path);
|
||||
if (slugId) this.slugIdToPathFast.set(slugId, path);
|
||||
this.metaByPathFast.set(path, it);
|
||||
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
const folderSegments = parts.slice(0, -1);
|
||||
let currentPath = '';
|
||||
let parentFolder = root;
|
||||
for (const seg of folderSegments) {
|
||||
currentPath = currentPath ? `${currentPath}/${seg}` : seg;
|
||||
let folder = folders.get(currentPath);
|
||||
if (!folder) {
|
||||
folder = { type: 'folder', name: seg, path: currentPath, children: [], isOpen: this.openFolderPaths().has(currentPath) };
|
||||
folders.set(currentPath, folder);
|
||||
parentFolder.children.push(folder);
|
||||
} else {
|
||||
folder.isOpen = this.openFolderPaths().has(currentPath);
|
||||
}
|
||||
parentFolder = folder;
|
||||
}
|
||||
|
||||
parentFolder.children.push({
|
||||
type: 'file',
|
||||
name: parts[parts.length - 1] ?? path,
|
||||
path: path.startsWith('/') ? path : `/${path}`,
|
||||
id: slugId
|
||||
});
|
||||
}
|
||||
|
||||
// Sort children
|
||||
const sortChildren = (node: VaultFolder) => {
|
||||
node.children.sort((a, b) => {
|
||||
if (a.type === 'folder' && b.type === 'file') return -1;
|
||||
if (a.type === 'file' && b.type === 'folder') return 1;
|
||||
return a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
|
||||
});
|
||||
node.children.forEach(child => {
|
||||
if (child.type === 'folder') sortChildren(child);
|
||||
});
|
||||
};
|
||||
sortChildren(root);
|
||||
this.fastTreeSignal.set(root.children);
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply current openFolderPaths state to the fast file tree without rebuilding.
|
||||
*/
|
||||
private applyOpenStateToFastTree(): void {
|
||||
const apply = (nodes: VaultNode[]) => {
|
||||
for (const node of nodes) {
|
||||
if (node.type === 'folder') {
|
||||
node.isOpen = this.openFolderPaths().has(node.path);
|
||||
apply(node.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
const current = this.fastTreeSignal();
|
||||
if (!current || current.length === 0) return;
|
||||
apply(current);
|
||||
// Trigger change detection by setting a new array reference
|
||||
this.fastTreeSignal.set([...current]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build slug id from a file path like "folder/note.md" to match server's id
|
||||
*/
|
||||
buildSlugIdFromPath(filePath: string): string {
|
||||
const noExt = filePath
|
||||
.replace(/\\/g, '/')
|
||||
.replace(/\.(md|excalidraw(?:\.md)?)$/i, '');
|
||||
const segments = noExt.split('/').filter(Boolean);
|
||||
const slugSegments = segments.map(seg => this.slugifySegment(seg));
|
||||
return slugSegments.join('/');
|
||||
}
|
||||
|
||||
private slugifySegment(segment: string): string {
|
||||
const normalized = segment
|
||||
.normalize('NFKD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim();
|
||||
const slug = normalized
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return slug || normalized.toLowerCase() || segment.toLowerCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fast metadata by id (supports both Meilisearch id and slug id)
|
||||
*/
|
||||
getFastMetaById(id: string): FileMetadata | undefined {
|
||||
const path = this.idToPathFast.get(id) || this.slugIdToPathFast.get(id);
|
||||
if (!path) return undefined;
|
||||
return this.metaByPathFast.get(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a note is loaded in memory by Meilisearch or slug id.
|
||||
* Returns true if the note is available after the call.
|
||||
*/
|
||||
async ensureNoteLoadedById(id: string): Promise<boolean> {
|
||||
if (!id) return false;
|
||||
if (this.getNoteById(id)) return true;
|
||||
|
||||
const path = this.idToPathFast.get(id) || this.slugIdToPathFast.get(id);
|
||||
if (!path) return false;
|
||||
|
||||
return this.ensureNoteLoadedByPath(path);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a note is loaded in memory by file path relative to the vault.
|
||||
* Example path: "folder1/example.md"
|
||||
*/
|
||||
async ensureNoteLoadedByPath(path: string): Promise<boolean> {
|
||||
if (!path) return false;
|
||||
const slugId = this.buildSlugIdFromPath(path);
|
||||
if (this.getNoteById(slugId)) return true;
|
||||
|
||||
try {
|
||||
const url = `/vault/${encodeURI(path)}`;
|
||||
const raw = await firstValueFrom(this.http.get(url, { responseType: 'text' as any }));
|
||||
const normalizedRaw = String(raw).replace(/\r\n/g, '\n');
|
||||
const { frontmatter, body } = this.parseFrontmatter(normalizedRaw);
|
||||
const derivedTitle = this.extractTitle(body, slugId);
|
||||
const noteTitle = (frontmatter.title as string) || derivedTitle;
|
||||
const meta = this.metaByPathFast.get(path);
|
||||
const fallbackUpdatedAt = new Date().toISOString();
|
||||
|
||||
const note: Note = {
|
||||
id: slugId,
|
||||
title: noteTitle,
|
||||
content: body,
|
||||
rawContent: normalizedRaw,
|
||||
tags: Array.isArray(frontmatter.tags) ? (frontmatter.tags as string[]) : [],
|
||||
frontmatter,
|
||||
backlinks: [],
|
||||
mtime: Date.now(),
|
||||
fileName: path.split('/').pop() ?? `${slugId}.md`,
|
||||
filePath: path,
|
||||
originalPath: path.replace(/\.md$/i, ''),
|
||||
createdAt: meta?.createdAt ?? undefined,
|
||||
updatedAt: meta?.updatedAt ?? fallbackUpdatedAt,
|
||||
};
|
||||
|
||||
const current = new Map(this.notesMap());
|
||||
current.set(note.id, note);
|
||||
this.notesMap.set(current);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
graphData = computed<GraphData>(() => {
|
||||
const startTime = performance.now();
|
||||
const notes = this.allNotes();
|
||||
@@ -143,8 +336,21 @@ export class VaultService implements OnDestroy {
|
||||
|
||||
tags = computed<TagInfo[]>(() => {
|
||||
const tagCounts = new Map<string, number>();
|
||||
const isValidTag = (raw: string): boolean => {
|
||||
if (!raw) return false;
|
||||
const tag = `${raw}`.trim();
|
||||
if (!tag) return false;
|
||||
// Exclude template placeholders and braces
|
||||
if (/[{}]/.test(tag)) return false;
|
||||
// Exclude pure numeric tags (e.g., 01, 1709)
|
||||
if (/^\d+$/.test(tag)) return false;
|
||||
// Exclude hex-like tags (e.g., 000000, 6dbafa, 383838)
|
||||
if (/^[0-9a-fA-F]{3}([0-9a-fA-F]{3})?$/.test(tag)) return false;
|
||||
return true;
|
||||
};
|
||||
for (const note of this.allNotes()) {
|
||||
for (const tag of note.tags) {
|
||||
if (!isValidTag(tag)) continue;
|
||||
tagCounts.set(tag, (tagCounts.get(tag) || 0) + 1);
|
||||
}
|
||||
}
|
||||
@@ -157,6 +363,8 @@ export class VaultService implements OnDestroy {
|
||||
private refreshTimeoutId: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
constructor(private http: HttpClient, private vaultEvents: VaultEventsService) {
|
||||
// Build a fast file tree from Meilisearch metadata for instant UI
|
||||
this.loadFastFileTree();
|
||||
this.refreshNotes();
|
||||
this.observeVaultEvents();
|
||||
}
|
||||
@@ -185,6 +393,7 @@ export class VaultService implements OnDestroy {
|
||||
}
|
||||
return newPaths;
|
||||
});
|
||||
this.applyOpenStateToFastTree();
|
||||
}
|
||||
|
||||
ensureFolderOpen(originalPath: string): void {
|
||||
@@ -205,6 +414,7 @@ export class VaultService implements OnDestroy {
|
||||
}
|
||||
|
||||
this.openFolderPaths.set(updatedPaths);
|
||||
this.applyOpenStateToFastTree();
|
||||
}
|
||||
|
||||
refresh(): void {
|
||||
|
||||
@@ -1,5 +1,25 @@
|
||||
@import './styles-test.css';
|
||||
|
||||
/* Excalidraw CSS variables (thème sombre) */
|
||||
/* .excalidraw {
|
||||
--color-primary: #ffffff;
|
||||
--color-secondary: #333333;
|
||||
--color-background: #121212;
|
||||
--color-border: #444444;
|
||||
--color-text: #ffffff;
|
||||
--color-ui-element: #2d2d2d;
|
||||
--color-selected: #007bff;
|
||||
--color-hover: #333333;
|
||||
--color-error: #ff4d4f;
|
||||
--color-success: #52c41a;
|
||||
} */
|
||||
|
||||
/* .excalidraw {
|
||||
--color-background: #1e1e1e !important;
|
||||
--color-text: white !important;
|
||||
--color-ui-element: #2d2d2d !important;
|
||||
} */
|
||||
|
||||
.md-attachment-figure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -7,6 +27,30 @@
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
/* Excalidraw host sizing to ensure canvas renders full size */
|
||||
excalidraw-editor {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 480px;
|
||||
position: relative; /* anchor absolutely-positioned children if any */
|
||||
}
|
||||
|
||||
/* Scoped minimal layout for Excalidraw internals */
|
||||
excalidraw-editor .excalidraw {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
excalidraw-editor .excalidraw .excalidraw__canvas-container,
|
||||
excalidraw-editor .excalidraw .layer-ui__wrapper {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Removed global .excalidraw override to avoid conflicting with Excalidraw internal styles */
|
||||
|
||||
.md-attachment-figure img {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user