456 lines
14 KiB
TypeScript
456 lines
14 KiB
TypeScript
import { Component, ChangeDetectionStrategy, inject, signal, computed, effect, ElementRef, OnDestroy } 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';
|
|
|
|
// Components
|
|
import { FileExplorerComponent } from './components/file-explorer/file-explorer.component';
|
|
import { NoteViewerComponent } from './components/note-viewer/note-viewer.component';
|
|
import { GraphViewComponent } from './components/graph-view/graph-view.component';
|
|
import { TagsViewComponent } from './components/tags-view/tags-view.component';
|
|
import { MarkdownCalendarComponent } from './components/markdown-calendar/markdown-calendar.component';
|
|
|
|
// 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,
|
|
GraphViewComponent,
|
|
TagsViewComponent,
|
|
MarkdownCalendarComponent,
|
|
],
|
|
templateUrl: './app.component.simple.html',
|
|
styleUrls: ['./app.component.css'],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
})
|
|
export class AppComponent implements OnDestroy {
|
|
private vaultService = inject(VaultService);
|
|
private markdownService = inject(MarkdownService);
|
|
private elementRef = inject(ElementRef);
|
|
|
|
// --- State Signals ---
|
|
isDarkMode = signal<boolean>(true);
|
|
isSidebarOpen = signal<boolean>(true);
|
|
isOutlineOpen = signal<boolean>(true);
|
|
activeView = signal<'files' | 'graph' | 'tags' | 'search' | 'calendar'>('files');
|
|
selectedNoteId = signal<string>('');
|
|
sidebarSearchTerm = signal<string>('');
|
|
tableOfContents = signal<TocEntry[]>([]);
|
|
leftSidebarWidth = signal<number>(288);
|
|
rightSidebarWidth = signal<number>(288);
|
|
readonly LEFT_MIN_WIDTH = 220;
|
|
readonly LEFT_MAX_WIDTH = 520;
|
|
readonly RIGHT_MIN_WIDTH = 220;
|
|
readonly RIGHT_MAX_WIDTH = 520;
|
|
|
|
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;
|
|
|
|
// --- 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);
|
|
});
|
|
|
|
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;
|
|
});
|
|
|
|
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() {
|
|
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);
|
|
}
|
|
|
|
// Effect to update the DOM with the dark class
|
|
effect(() => {
|
|
if (this.isDarkMode()) {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
});
|
|
|
|
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 html = this.renderedNoteContent();
|
|
if (html && this.selectedNote()) {
|
|
this.generateToc(html);
|
|
} else {
|
|
this.tableOfContents.set([]);
|
|
}
|
|
});
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
|
|
ngOnDestroy(): void {
|
|
if (typeof window !== 'undefined') {
|
|
window.removeEventListener('resize', this.resizeHandler);
|
|
}
|
|
}
|
|
|
|
// --- Methods ---
|
|
toggleTheme(): void {
|
|
this.isDarkMode.update(value => !value);
|
|
}
|
|
|
|
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);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
|
|
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');
|
|
}
|
|
|
|
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'): void {
|
|
this.activeView.set(view);
|
|
this.sidebarSearchTerm.set('');
|
|
}
|
|
|
|
selectNote(noteId: string): void {
|
|
const note = this.vaultService.getNoteById(noteId);
|
|
if (!note) {
|
|
return;
|
|
}
|
|
|
|
this.vaultService.ensureFolderOpen(note.originalPath);
|
|
this.selectedNoteId.set(note.id);
|
|
|
|
if (!this.isDesktopView() && this.activeView() === 'search') {
|
|
this.isSidebarOpen.set(false);
|
|
}
|
|
}
|
|
|
|
handleTagClick(tagName: string): void {
|
|
const normalized = tagName.replace(/^#/, '').trim();
|
|
if (!normalized) {
|
|
return;
|
|
}
|
|
this.setView('search');
|
|
this.sidebarSearchTerm.set(`#${normalized}`);
|
|
}
|
|
|
|
clearTagFilter(): void {
|
|
this.sidebarSearchTerm.set('');
|
|
}
|
|
|
|
clearCalendarResults(): void {
|
|
this.calendarResults.set([]);
|
|
this.calendarSearchState.set('idle');
|
|
this.calendarSearchError.set(null);
|
|
this.calendarSelectionLabel.set(null);
|
|
this.calendarSearchTriggered = false;
|
|
}
|
|
|
|
updateSearchTerm(term: string, focusSearch = false): void {
|
|
this.sidebarSearchTerm.set(term ?? '');
|
|
if (focusSearch || (term && term.trim().length > 0)) {
|
|
this.activeView.set('search');
|
|
}
|
|
}
|
|
|
|
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) {
|
|
toc.push({
|
|
level: parseInt(heading.tagName.substring(1), 10),
|
|
text: heading.textContent,
|
|
id: heading.id
|
|
});
|
|
}
|
|
});
|
|
this.tableOfContents.set(toc);
|
|
}
|
|
|
|
scrollToHeading(id: string): void {
|
|
// The note viewer component's content area is what scrolls
|
|
const contentArea = (this.elementRef.nativeElement as HTMLElement).querySelector('.note-content-area');
|
|
if(contentArea) {
|
|
const element = contentArea.querySelector(`#${id}`);
|
|
if (element) {
|
|
element.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
}
|
|
}
|
|
}
|
|
}
|