902 lines
27 KiB
TypeScript
902 lines
27 KiB
TypeScript
import { Component, ChangeDetectionStrategy, HostListener, inject, signal, computed, effect, ElementRef, OnDestroy, OnInit } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { FormsModule } from '@angular/forms';
|
|
// Services
|
|
import { VaultService } from './services/vault.service';
|
|
import { MarkdownService } from './services/markdown.service';
|
|
import { MarkdownViewerService } from './services/markdown-viewer.service';
|
|
import { DownloadService } from './core/services/download.service';
|
|
import { ThemeService } from './app/core/services/theme.service';
|
|
import { LogService } from './core/logging/log.service';
|
|
|
|
// Components
|
|
import { FileExplorerComponent } from './components/file-explorer/file-explorer.component';
|
|
import { NoteViewerComponent, WikiLinkActivation } from './components/tags-view/note-viewer/note-viewer.component';
|
|
import { GraphViewContainerV2Component } from './components/graph-view-container-v2/graph-view-container-v2.component';
|
|
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 { 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 { SearchHistoryService } from './core/search/search-history.service';
|
|
import { GraphIndexService } from './core/graph/graph-index.service';
|
|
|
|
// Types
|
|
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
|
|
|
|
interface TocEntry {
|
|
level: number;
|
|
text: string;
|
|
id: string;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-root',
|
|
imports: [
|
|
CommonModule,
|
|
FormsModule,
|
|
FileExplorerComponent,
|
|
NoteViewerComponent,
|
|
GraphViewContainerV2Component,
|
|
TagsViewComponent,
|
|
MarkdownCalendarComponent,
|
|
RawViewOverlayComponent,
|
|
BookmarksPanelComponent,
|
|
AddBookmarkModalComponent,
|
|
GraphInlineSettingsComponent,
|
|
SearchInputWithAssistantComponent,
|
|
],
|
|
templateUrl: './app.component.simple.html',
|
|
styleUrls: ['./app.component.css'],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
export class AppComponent implements OnInit, OnDestroy {
|
|
readonly vaultService = inject(VaultService);
|
|
private markdownService = inject(MarkdownService);
|
|
private markdownViewerService = inject(MarkdownViewerService);
|
|
private downloadService = inject(DownloadService);
|
|
private readonly themeService = inject(ThemeService);
|
|
private readonly bookmarksService = inject(BookmarksService);
|
|
private readonly searchHistoryService = inject(SearchHistoryService);
|
|
private readonly graphIndexService = inject(GraphIndexService);
|
|
private readonly logService = inject(LogService);
|
|
private elementRef = inject(ElementRef);
|
|
|
|
// --- State Signals ---
|
|
isSidebarOpen = signal<boolean>(true);
|
|
isOutlineOpen = signal<boolean>(true);
|
|
outlineTab = signal<'outline' | 'settings'>('outline');
|
|
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'>('files');
|
|
selectedNoteId = signal<string>('');
|
|
sidebarSearchTerm = signal<string>('');
|
|
tableOfContents = signal<TocEntry[]>([]);
|
|
leftSidebarWidth = signal<number>(288);
|
|
rightSidebarWidth = signal<number>(288);
|
|
isRawViewOpen = signal<boolean>(false);
|
|
isRawViewWrapped = signal<boolean>(true);
|
|
showAddBookmarkModal = signal<boolean>(false);
|
|
readonly LEFT_MIN_WIDTH = 220;
|
|
readonly LEFT_MAX_WIDTH = 520;
|
|
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;
|
|
}
|
|
this.viewportWidth.set(window.innerWidth);
|
|
};
|
|
|
|
isDesktopView = computed<boolean>(() => this.viewportWidth() >= 1024);
|
|
private wasDesktop = false;
|
|
|
|
calendarResults = signal<FileMetadata[]>([]);
|
|
calendarSearchState = signal<'idle' | 'loading' | 'error'>('idle');
|
|
calendarSearchError = signal<string | null>(null);
|
|
calendarSelectionLabel = signal<string | null>(null);
|
|
calendarOverlayVisible = computed<boolean>(() =>
|
|
this.calendarSearchState() === 'loading' ||
|
|
!!this.calendarSearchError() ||
|
|
this.calendarResults().length > 0
|
|
);
|
|
private calendarSearchTriggered = false;
|
|
private pendingWikiNavigation = signal<{ noteId: string; heading?: string; block?: string } | null>(null);
|
|
|
|
readonly isDarkMode = this.themeService.isDark;
|
|
|
|
// Bookmark state
|
|
readonly isCurrentNoteBookmarked = computed(() => {
|
|
const noteId = this.selectedNoteId();
|
|
if (!noteId) return false;
|
|
|
|
const note = this.selectedNote();
|
|
if (!note) return false;
|
|
|
|
const doc = this.bookmarksService.doc();
|
|
const notePath = note.filePath;
|
|
|
|
const findBookmark = (items: any[]): boolean => {
|
|
for (const item of items) {
|
|
if (item.type === 'file' && item.path === notePath) {
|
|
return true;
|
|
}
|
|
if (item.type === 'group' && item.items) {
|
|
if (findBookmark(item.items)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
return findBookmark(doc.items);
|
|
});
|
|
|
|
// --- Data Signals ---
|
|
fileTree = this.vaultService.fileTree;
|
|
graphData = this.vaultService.graphData;
|
|
allTags = this.vaultService.tags;
|
|
vaultName = this.vaultService.vaultName;
|
|
|
|
// --- Computed Signals ---
|
|
selectedNote = computed<Note | undefined>(() => {
|
|
const id = this.selectedNoteId();
|
|
return id ? this.vaultService.getNoteById(id) : undefined;
|
|
});
|
|
|
|
renderedNoteContent = computed<string>(() => {
|
|
const note = this.selectedNote();
|
|
if (!note) return '';
|
|
const allNotes = this.vaultService.allNotes();
|
|
return this.markdownService.render(note.content, allNotes, note);
|
|
});
|
|
|
|
rawNoteContent = computed<string>(() => {
|
|
const note = this.selectedNote();
|
|
if (!note) {
|
|
return '';
|
|
}
|
|
return note.rawContent ?? note.content ?? '';
|
|
});
|
|
|
|
rawNoteFilename = computed<string>(() => {
|
|
const note = this.selectedNote();
|
|
if (!note) {
|
|
return this.buildFallbackFilename();
|
|
}
|
|
const name = note.fileName?.trim();
|
|
return name && name.length > 0 ? name : this.buildFallbackFilename();
|
|
});
|
|
|
|
selectedNoteBreadcrumb = computed<string[]>(() => {
|
|
const note = this.selectedNote();
|
|
if (!note) {
|
|
return [];
|
|
}
|
|
|
|
const vaultTitle = this.vaultName().trim();
|
|
const breadcrumb: string[] = [vaultTitle || 'Vault'];
|
|
const pathSegments = note.originalPath.split('/').filter(Boolean);
|
|
|
|
if (pathSegments.length === 0) {
|
|
breadcrumb.push(note.fileName.replace(/\.md$/i, ''));
|
|
return breadcrumb;
|
|
}
|
|
|
|
const displaySegments = [...pathSegments];
|
|
displaySegments[displaySegments.length - 1] = note.fileName.replace(/\.md$/i, '');
|
|
return breadcrumb.concat(displaySegments);
|
|
});
|
|
|
|
filteredTags = computed<TagInfo[]>(() => {
|
|
const term = this.sidebarSearchTerm().trim().toLowerCase();
|
|
const cleanedTerm = term.startsWith('#') ? term.slice(1) : term;
|
|
if (!cleanedTerm) return this.allTags();
|
|
return this.allTags().filter(tag => tag.name.toLowerCase().includes(cleanedTerm));
|
|
});
|
|
|
|
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));
|
|
});
|
|
|
|
activeTagFilter = computed<string | null>(() => {
|
|
const rawTerm = this.sidebarSearchTerm().trim();
|
|
if (!rawTerm.startsWith('#')) {
|
|
return null;
|
|
}
|
|
|
|
const tag = rawTerm.slice(1).trim();
|
|
return tag ? tag.toLowerCase() : null;
|
|
});
|
|
|
|
activeTagDisplay = computed<string | null>(() => {
|
|
const rawTerm = this.sidebarSearchTerm().trim();
|
|
if (!rawTerm.startsWith('#')) {
|
|
return null;
|
|
}
|
|
|
|
const displayTag = rawTerm.slice(1).trim();
|
|
return displayTag || null;
|
|
});
|
|
|
|
searchResults = computed<Note[]>(() => {
|
|
const notes = this.vaultService.allNotes();
|
|
const tagFilter = this.activeTagFilter();
|
|
|
|
if (tagFilter) {
|
|
return notes.filter(note => note.tags.some(tag => tag.toLowerCase() === tagFilter));
|
|
}
|
|
|
|
const term = this.sidebarSearchTerm().trim().toLowerCase();
|
|
if (!term) return [];
|
|
const cleanedTerm = term.startsWith('#') ? term.slice(1) : term;
|
|
return notes.filter(note =>
|
|
note.title.toLowerCase().includes(cleanedTerm) ||
|
|
note.content.toLowerCase().includes(cleanedTerm) ||
|
|
note.tags.some(tag => tag.toLowerCase().includes(cleanedTerm))
|
|
);
|
|
});
|
|
|
|
constructor() {
|
|
this.themeService.initFromStorage();
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.addEventListener('resize', this.resizeHandler, { passive: true });
|
|
}
|
|
|
|
this.wasDesktop = this.isDesktopView();
|
|
if (!this.isDesktopView()) {
|
|
this.isSidebarOpen.set(false);
|
|
this.isOutlineOpen.set(false);
|
|
}
|
|
|
|
// Initialize outline tab from localStorage
|
|
if (typeof window !== 'undefined') {
|
|
const savedTab = window.localStorage.getItem('graphPaneTab');
|
|
if (savedTab === 'settings' || savedTab === 'outline') {
|
|
this.outlineTab.set(savedTab as 'outline' | 'settings');
|
|
}
|
|
}
|
|
|
|
effect(() => {
|
|
const isDesktop = this.isDesktopView();
|
|
if (isDesktop && !this.wasDesktop) {
|
|
this.isSidebarOpen.set(true);
|
|
this.isOutlineOpen.set(true);
|
|
}
|
|
if (!isDesktop && this.wasDesktop) {
|
|
this.isSidebarOpen.set(false);
|
|
this.isOutlineOpen.set(false);
|
|
}
|
|
this.wasDesktop = isDesktop;
|
|
});
|
|
|
|
// Effect to generate Table of Contents when the note changes
|
|
effect(() => {
|
|
const note = this.selectedNote();
|
|
this.markdownViewerService.setCurrentNote(note ?? null);
|
|
const html = this.renderedNoteContent();
|
|
if (html && note) {
|
|
this.generateToc(html);
|
|
} else {
|
|
this.tableOfContents.set([]);
|
|
}
|
|
});
|
|
|
|
// Effect to rebuild graph index when notes change
|
|
effect(() => {
|
|
const notes = this.vaultService.allNotes();
|
|
this.graphIndexService.rebuildIndex(notes);
|
|
});
|
|
|
|
// Persist outline tab
|
|
effect(() => {
|
|
const tab = this.outlineTab();
|
|
if (typeof window !== 'undefined') {
|
|
window.localStorage.setItem('graphPaneTab', tab);
|
|
}
|
|
});
|
|
|
|
effect(() => {
|
|
if (!this.selectedNote()) {
|
|
this.isRawViewOpen.set(false);
|
|
}
|
|
});
|
|
|
|
// Check for reduced motion preference for testing
|
|
if (typeof window !== 'undefined') {
|
|
const params = new URLSearchParams(window.location.search);
|
|
if (params.has('reduced-motion')) {
|
|
document.body.classList.add('prefers-reduced-motion');
|
|
}
|
|
}
|
|
|
|
effect(() => {
|
|
const pending = this.pendingWikiNavigation();
|
|
const activeNoteId = this.selectedNoteId();
|
|
const html = this.renderedNoteContent();
|
|
if (!pending || pending.noteId !== activeNoteId || !html) {
|
|
return;
|
|
}
|
|
|
|
queueMicrotask(() => {
|
|
if (pending.heading) {
|
|
this.scrollToHeading(pending.heading);
|
|
} else if (pending.block) {
|
|
this.scrollToBlock(pending.block);
|
|
}
|
|
this.pendingWikiNavigation.set(null);
|
|
});
|
|
});
|
|
|
|
effect(() => {
|
|
if (typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
const vaultTitle = this.vaultName().trim();
|
|
document.title = `ObsiWatcher - ${vaultTitle || 'Vault'}`;
|
|
});
|
|
|
|
// Effect to select first available note when vault data loads
|
|
effect(() => {
|
|
const notes = this.vaultService.allNotes();
|
|
const currentId = this.selectedNoteId();
|
|
if (!notes.length) {
|
|
return;
|
|
}
|
|
|
|
const currentExists = notes.some(note => note.id === currentId);
|
|
if (!currentExists) {
|
|
const firstNote = notes[0];
|
|
this.vaultService.ensureFolderOpen(firstNote.originalPath);
|
|
this.selectedNoteId.set(firstNote.id);
|
|
}
|
|
});
|
|
}
|
|
|
|
ngOnInit(): void {
|
|
// Log app start
|
|
this.logService.log('APP_START', {
|
|
viewport: {
|
|
width: typeof window !== 'undefined' ? window.innerWidth : 0,
|
|
height: typeof window !== 'undefined' ? window.innerHeight : 0,
|
|
},
|
|
});
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
// Log app stop
|
|
this.logService.log('APP_STOP');
|
|
|
|
if (typeof window !== 'undefined') {
|
|
window.removeEventListener('resize', this.resizeHandler);
|
|
}
|
|
}
|
|
|
|
// --- Methods ---
|
|
toggleTheme(): void {
|
|
this.themeService.toggleTheme();
|
|
}
|
|
|
|
toggleSidebar(): void {
|
|
this.isSidebarOpen.update(value => !value);
|
|
}
|
|
|
|
closeSidebar(): void {
|
|
this.isSidebarOpen.set(false);
|
|
}
|
|
|
|
toggleSidebarTo(state: boolean): void {
|
|
this.isSidebarOpen.set(state);
|
|
}
|
|
|
|
toggleOutline(): void {
|
|
this.isOutlineOpen.update(value => !value);
|
|
}
|
|
|
|
closeOutlinePanel(): void {
|
|
this.isOutlineOpen.set(false);
|
|
}
|
|
|
|
toggleOutlineTo(state: boolean): void {
|
|
this.isOutlineOpen.set(state);
|
|
}
|
|
|
|
setOutlineTab(tab: 'outline' | 'settings'): void {
|
|
this.outlineTab.set(tab);
|
|
}
|
|
|
|
isDesktop(): boolean {
|
|
return this.isDesktopView();
|
|
}
|
|
|
|
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,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
onCalendarSearchStateChange(state: 'idle' | 'loading' | 'error'): void {
|
|
this.calendarSearchState.set(state);
|
|
if (state === 'loading') {
|
|
this.calendarSearchTriggered = true;
|
|
}
|
|
}
|
|
|
|
onCalendarSearchErrorChange(message: string | null): void {
|
|
this.calendarSearchError.set(message);
|
|
if (message) {
|
|
this.isSidebarOpen.set(true);
|
|
this.activeView.set('search');
|
|
this.calendarSearchTriggered = false;
|
|
}
|
|
}
|
|
|
|
onCalendarSelectionSummaryChange(summary: string | null): void {
|
|
this.calendarSelectionLabel.set(summary);
|
|
}
|
|
|
|
onCalendarRequestSearchPanel(): void {
|
|
if (this.activeView() === 'calendar') {
|
|
if (!this.isSidebarOpen()) {
|
|
this.isSidebarOpen.set(true);
|
|
}
|
|
return;
|
|
}
|
|
this.isSidebarOpen.set(true);
|
|
this.activeView.set('search');
|
|
}
|
|
|
|
/**
|
|
* Clear the current calendar search results and related UI state.
|
|
* Used by the "Effacer" button in the search panel.
|
|
*/
|
|
clearCalendarResults(): void {
|
|
this.calendarResults.set([]);
|
|
this.calendarSearchState.set('idle');
|
|
this.calendarSearchError.set(null);
|
|
this.calendarSelectionLabel.set(null);
|
|
}
|
|
|
|
startLeftResize(event: PointerEvent): void {
|
|
if (!this.isSidebarOpen()) {
|
|
this.isSidebarOpen.set(true);
|
|
}
|
|
|
|
event.preventDefault();
|
|
const handle = event.currentTarget as HTMLElement | null;
|
|
handle?.setPointerCapture(event.pointerId);
|
|
|
|
const startX = event.clientX;
|
|
const startWidth = this.leftSidebarWidth();
|
|
|
|
const moveHandler = (moveEvent: PointerEvent) => {
|
|
const delta = moveEvent.clientX - startX;
|
|
let newWidth = startWidth + delta;
|
|
newWidth = Math.max(this.LEFT_MIN_WIDTH, Math.min(this.LEFT_MAX_WIDTH, newWidth));
|
|
this.leftSidebarWidth.set(newWidth);
|
|
};
|
|
|
|
const cleanup = () => {
|
|
window.removeEventListener('pointermove', moveHandler);
|
|
window.removeEventListener('pointerup', cleanup);
|
|
window.removeEventListener('pointercancel', cleanup);
|
|
if (handle && handle.hasPointerCapture?.(event.pointerId)) {
|
|
handle.releasePointerCapture(event.pointerId);
|
|
}
|
|
handle?.removeEventListener('lostpointercapture', cleanup);
|
|
};
|
|
|
|
window.addEventListener('pointermove', moveHandler);
|
|
window.addEventListener('pointerup', cleanup);
|
|
window.addEventListener('pointercancel', cleanup);
|
|
handle?.addEventListener('lostpointercapture', cleanup);
|
|
}
|
|
|
|
startRightResize(event: PointerEvent): void {
|
|
if (!this.isOutlineOpen()) {
|
|
this.isOutlineOpen.set(true);
|
|
}
|
|
|
|
event.preventDefault();
|
|
const handle = event.currentTarget as HTMLElement | null;
|
|
handle?.setPointerCapture(event.pointerId);
|
|
|
|
const startX = event.clientX;
|
|
const startWidth = this.rightSidebarWidth();
|
|
|
|
const moveHandler = (moveEvent: PointerEvent) => {
|
|
const delta = moveEvent.clientX - startX;
|
|
let newWidth = startWidth - delta;
|
|
newWidth = Math.max(this.RIGHT_MIN_WIDTH, Math.min(this.RIGHT_MAX_WIDTH, newWidth));
|
|
this.rightSidebarWidth.set(newWidth);
|
|
};
|
|
|
|
const cleanup = () => {
|
|
window.removeEventListener('pointermove', moveHandler);
|
|
window.removeEventListener('pointerup', cleanup);
|
|
window.removeEventListener('pointercancel', cleanup);
|
|
if (handle && handle.hasPointerCapture?.(event.pointerId)) {
|
|
handle.releasePointerCapture(event.pointerId);
|
|
}
|
|
handle?.removeEventListener('lostpointercapture', cleanup);
|
|
};
|
|
|
|
window.addEventListener('pointermove', moveHandler);
|
|
window.addEventListener('pointerup', cleanup);
|
|
window.addEventListener('pointercancel', cleanup);
|
|
handle?.addEventListener('lostpointercapture', cleanup);
|
|
}
|
|
|
|
setView(view: 'files' | 'graph' | 'tags' | 'search' | 'calendar' | 'bookmarks'): void {
|
|
const previousView = this.activeView();
|
|
this.activeView.set(view);
|
|
this.sidebarSearchTerm.set('');
|
|
|
|
// Log view changes
|
|
if (view === 'bookmarks' && previousView !== 'bookmarks') {
|
|
this.logService.log('BOOKMARKS_OPEN');
|
|
} else if (view === 'graph' && previousView !== 'graph') {
|
|
this.logService.log('GRAPH_VIEW_OPEN');
|
|
}
|
|
}
|
|
|
|
selectNote(noteId: string): void {
|
|
const note = this.vaultService.getNoteById(noteId);
|
|
if (!note) {
|
|
return;
|
|
}
|
|
|
|
this.vaultService.ensureFolderOpen(note.originalPath);
|
|
this.selectedNoteId.set(note.id);
|
|
this.markdownViewerService.setCurrentNote(note);
|
|
|
|
if (!this.isDesktopView() && this.activeView() === 'search') {
|
|
this.isSidebarOpen.set(false);
|
|
}
|
|
}
|
|
|
|
selectNoteFromGraph(noteId: string): void {
|
|
this.selectNote(noteId);
|
|
this.activeView.set('files');
|
|
}
|
|
|
|
handleTagClick(tagName: string): void {
|
|
const normalized = tagName.replace(/^#/, '').trim();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
|
|
this.isSidebarOpen.set(true);
|
|
this.activeView.set('search');
|
|
this.sidebarSearchTerm.set(`#${normalized}`);
|
|
}
|
|
|
|
updateSearchTerm(term: string, focusSearch = false): void {
|
|
this.sidebarSearchTerm.set(term ?? '');
|
|
if (focusSearch || (term && term.trim().length > 0)) {
|
|
this.activeView.set('search');
|
|
}
|
|
}
|
|
|
|
onSearchSubmit(query: string): void {
|
|
if (query && query.trim()) {
|
|
// History is already handled by SearchInputWithAssistant using its [context]
|
|
// Update search term and switch to search view
|
|
this.sidebarSearchTerm.set(query);
|
|
this.activeView.set('search');
|
|
|
|
// Log search execution
|
|
this.logService.log('SEARCH_EXECUTED', {
|
|
query: query.trim(),
|
|
queryLength: query.trim().length,
|
|
});
|
|
}
|
|
}
|
|
|
|
toggleRawView(): void {
|
|
if (!this.selectedNote()) {
|
|
return;
|
|
}
|
|
if (this.isRawViewOpen()) {
|
|
this.closeRawView();
|
|
} else {
|
|
this.openRawView();
|
|
}
|
|
}
|
|
|
|
openRawView(): void {
|
|
if (!this.selectedNote()) {
|
|
return;
|
|
}
|
|
if (typeof document !== 'undefined') {
|
|
this.rawViewTriggerElement = document.activeElement as HTMLElement | null;
|
|
}
|
|
this.isRawViewOpen.set(true);
|
|
}
|
|
|
|
closeRawView(): void {
|
|
this.isRawViewOpen.set(false);
|
|
const target = this.rawViewTriggerElement;
|
|
this.rawViewTriggerElement = null;
|
|
if (target && typeof target.focus === 'function') {
|
|
queueMicrotask(() => target.focus());
|
|
}
|
|
}
|
|
|
|
toggleRawWrap(): void {
|
|
this.isRawViewWrapped.update(value => !value);
|
|
}
|
|
|
|
downloadCurrentNote(): void {
|
|
const note = this.selectedNote();
|
|
if (!note) {
|
|
return;
|
|
}
|
|
const filename = this.rawNoteFilename();
|
|
const content = this.rawNoteContent();
|
|
if (!content) {
|
|
console.warn('[ObsiViewer] Aucun contenu à télécharger.');
|
|
return;
|
|
}
|
|
this.downloadService.downloadText(content, filename);
|
|
}
|
|
|
|
toggleBookmarkModal(): void {
|
|
this.showAddBookmarkModal.update(v => !v);
|
|
}
|
|
|
|
closeBookmarkModal(): void {
|
|
this.showAddBookmarkModal.set(false);
|
|
}
|
|
|
|
onBookmarkSave(data: BookmarkFormData): void {
|
|
const note = this.selectedNote();
|
|
if (!note) return;
|
|
|
|
// Check if bookmark already exists
|
|
const doc = this.bookmarksService.doc();
|
|
let existingCtime: number | null = null;
|
|
|
|
const findExisting = (items: any[]): number | null => {
|
|
for (const item of items) {
|
|
if (item.type === 'file' && item.path === note.filePath) {
|
|
return item.ctime;
|
|
}
|
|
if (item.type === 'group' && item.items) {
|
|
const found = findExisting(item.items);
|
|
if (found) return found;
|
|
}
|
|
}
|
|
return null;
|
|
};
|
|
|
|
existingCtime = findExisting(doc.items);
|
|
|
|
if (existingCtime) {
|
|
// Update existing bookmark
|
|
this.bookmarksService.updateBookmark(existingCtime, {
|
|
title: data.title,
|
|
});
|
|
|
|
// If group changed, move it
|
|
if (data.groupCtime !== null) {
|
|
this.bookmarksService.moveBookmark(existingCtime, data.groupCtime, 0);
|
|
}
|
|
|
|
// Log bookmark modification
|
|
this.logService.log('BOOKMARKS_MODIFY', {
|
|
action: 'update',
|
|
path: data.path,
|
|
});
|
|
} else {
|
|
// Create new bookmark
|
|
this.bookmarksService.createFileBookmark(data.path, data.title, data.groupCtime);
|
|
|
|
// Log bookmark modification
|
|
this.logService.log('BOOKMARKS_MODIFY', {
|
|
action: 'add',
|
|
path: data.path,
|
|
});
|
|
}
|
|
|
|
this.closeBookmarkModal();
|
|
}
|
|
|
|
onBookmarkDelete(event: BookmarkDeleteEvent): void {
|
|
this.bookmarksService.removePathEverywhere(event.path);
|
|
|
|
// Log bookmark deletion
|
|
this.logService.log('BOOKMARKS_MODIFY', {
|
|
action: 'delete',
|
|
path: event.path,
|
|
});
|
|
|
|
this.closeBookmarkModal();
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
|
|
@HostListener('window:keydown', ['$event'])
|
|
handleGlobalKeydown(event: KeyboardEvent): void {
|
|
if (!event.altKey || event.repeat) {
|
|
return;
|
|
}
|
|
|
|
const key = event.key.toLowerCase();
|
|
if (key === 'r') {
|
|
if (!this.selectedNote()) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
this.toggleRawView();
|
|
}
|
|
if (key === 'd') {
|
|
if (!this.selectedNote()) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
this.downloadCurrentNote();
|
|
}
|
|
}
|
|
|
|
private buildFallbackFilename(): string {
|
|
const now = new Date();
|
|
const pad = (value: number) => value.toString().padStart(2, '0');
|
|
const year = now.getFullYear();
|
|
const month = pad(now.getMonth() + 1);
|
|
const day = pad(now.getDate());
|
|
const hours = pad(now.getHours());
|
|
const minutes = pad(now.getMinutes());
|
|
return `note-${year}${month}${day}-${hours}${minutes}.md`;
|
|
}
|
|
|
|
handleWikiLink(link: WikiLinkActivation): void {
|
|
const target = link.target?.trim();
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
const note = this.resolveWikiTarget(target);
|
|
if (!note) {
|
|
console.warn('[ObsiViewer] Wiki link target not found:', link);
|
|
return;
|
|
}
|
|
|
|
this.pendingWikiNavigation.set({
|
|
noteId: note.id,
|
|
heading: link.heading,
|
|
block: link.block
|
|
});
|
|
this.selectNote(note.id);
|
|
}
|
|
|
|
private generateToc(html: string): void {
|
|
const toc: TocEntry[] = [];
|
|
const parser = new DOMParser();
|
|
const doc = parser.parseFromString(html, 'text/html');
|
|
const headings = doc.querySelectorAll('h1, h2, h3, h4, h5, h6');
|
|
|
|
headings.forEach(heading => {
|
|
if (!heading.id || !heading.textContent) {
|
|
return;
|
|
}
|
|
toc.push({
|
|
level: parseInt(heading.tagName.substring(1), 10),
|
|
text: heading.textContent,
|
|
id: heading.id
|
|
});
|
|
});
|
|
|
|
this.tableOfContents.set(toc);
|
|
}
|
|
|
|
private scrollToHeading(id: string): void {
|
|
const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
|
|
if (!contentArea) {
|
|
return;
|
|
}
|
|
const element = contentArea.querySelector(`#${id}`);
|
|
if (element) {
|
|
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
}
|
|
|
|
private scrollToBlock(blockId: string | undefined): void {
|
|
if (!blockId) {
|
|
return;
|
|
}
|
|
const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
|
|
if (!contentArea) {
|
|
return;
|
|
}
|
|
const selectors = [`[data-block-id="${blockId}"]`, `#${blockId}`];
|
|
for (const selector of selectors) {
|
|
const element = contentArea.querySelector(selector);
|
|
if (element) {
|
|
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
private resolveWikiTarget(rawTarget: string): Note | undefined {
|
|
const normalized = rawTarget.trim();
|
|
if (!normalized) {
|
|
return undefined;
|
|
}
|
|
|
|
const lower = normalized.toLowerCase();
|
|
const slug = this.slugifyForWiki(normalized);
|
|
const normalizedPath = this.normalizePath(normalized);
|
|
const notes = this.vaultService.allNotes();
|
|
|
|
return notes.find(note => {
|
|
const title = note.title?.trim() ?? '';
|
|
const titleLower = title.toLowerCase();
|
|
const titleSlug = this.slugifyForWiki(title);
|
|
const fileBase = note.fileName.replace(/\.md$/i, '').trim();
|
|
const fileLower = fileBase.toLowerCase();
|
|
const filePathNormalized = this.normalizePath(fileBase);
|
|
const originalPath = this.normalizePath(note.originalPath);
|
|
const aliasMatch = Array.isArray(note.frontmatter?.aliases) && (note.frontmatter.aliases as string[]).some(alias => {
|
|
const trimmed = alias.trim();
|
|
const aliasLower = trimmed.toLowerCase();
|
|
return aliasLower === lower || this.slugifyForWiki(trimmed) === slug;
|
|
});
|
|
return (
|
|
note.id === lower ||
|
|
note.id === slug ||
|
|
titleLower === lower ||
|
|
titleSlug === slug ||
|
|
fileLower === lower ||
|
|
filePathNormalized === normalizedPath ||
|
|
originalPath === normalizedPath ||
|
|
aliasMatch
|
|
);
|
|
});
|
|
}
|
|
|
|
private slugifyForWiki(value: string): string {
|
|
return value
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9\s-]/g, '')
|
|
.trim()
|
|
.replace(/\s+/g, '-');
|
|
}
|
|
|
|
private normalizePath(path: string): string {
|
|
return path
|
|
.replace(/\\/g, '/')
|
|
.replace(/\.md$/i, '')
|
|
.replace(/^\/+/, '')
|
|
.toLowerCase();
|
|
}
|
|
|
|
} |