- Implemented multi-selection for notes with Ctrl+click, long-press, and keyboard shortcuts (Ctrl+A, Escape) - Added Gemini API integration with environment configuration and routes - Enhanced code block UI with improved copy feedback animation and visual polish - Added sort order toggle (asc/desc) for note lists with persistent state
1105 lines
48 KiB
TypeScript
1105 lines
48 KiB
TypeScript
import { Component, EventEmitter, Output, input, signal, computed, effect, inject, OnInit, OnDestroy, ViewChild, HostListener } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
import { ScrollingModule, CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
|
|
import { PaginationService, NoteMetadata } from '../../services/pagination.service';
|
|
import type { Note } from '../../../types';
|
|
import { VaultService } from '../../../services/vault.service';
|
|
import { FileTypeDetectorService } from '../../../services/file-type-detector.service';
|
|
import { TagFilterStore } from '../../core/stores/tag-filter.store';
|
|
import { NotesListStateService, SortBy, ViewMode } from '../../services/notes-list-state.service';
|
|
import { FilterService } from '../../services/filter.service';
|
|
import { NoteContextMenuService } from '../../services/note-context-menu.service';
|
|
import { EditorStateService } from '../../../services/editor-state.service';
|
|
import { NoteContextMenuComponent } from '../../../components/note-context-menu/note-context-menu.component';
|
|
import { KeyboardShortcutsService } from '../../services/keyboard-shortcuts.service';
|
|
import { WarningPanelComponent } from '../../components/warning-panel/warning-panel.component';
|
|
import { FilterBadgeComponent } from '../../components/filter-badge/filter-badge.component';
|
|
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
|
|
import { Subject } from 'rxjs';
|
|
import { takeUntil } from 'rxjs/operators';
|
|
|
|
@Component({
|
|
selector: 'app-paginated-notes-list',
|
|
standalone: true,
|
|
imports: [CommonModule, ScrollingModule, ScrollableOverlayDirective, NoteContextMenuComponent, WarningPanelComponent, FilterBadgeComponent],
|
|
template: `
|
|
<div class="h-full flex flex-col">
|
|
<!-- Search and filters header -->
|
|
<div class="p-2 border-b border-border dark:border-gray-800 space-y-2">
|
|
<!-- Kind-only badges row -->
|
|
<div class="flex flex-wrap items-center gap-1.5 min-h-[1.75rem]">
|
|
<app-filter-badge *ngFor="let b of badgesKindOnly()"
|
|
[label]="b.label" [icon]="b.icon" (remove)="filter.removeBadge(b)"></app-filter-badge>
|
|
</div>
|
|
<div *ngIf="activeTag() as t" class="flex items-center gap-2 text-xs">
|
|
<span class="inline-flex items-center gap-1 rounded-full bg-surface1 dark:bg-card text-main dark:text-main px-2 py-1">
|
|
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M7 7h.01"/><path d="M3 7h5a2 2 0 0 1 1.414.586l7 7a2 2 0 0 1 0 2.828l-3.172 3.172a2 2 0 0 1-2.828 0l-7-7A2 2 0 0 1 3 12V7Z"/></svg>
|
|
Filtre: #{{ t }}
|
|
</span>
|
|
<button type="button" (click)="clearTagFilter()" class="rounded-full hover:bg-slate-500/10 dark:hover:bg-surface2/10 w-6 h-6 inline-flex items-center justify-center" title="Effacer le filtre">
|
|
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
<div *ngIf="quickLinkFilter() && getQuickLinkDisplay(quickLinkFilter()) as ql" class="flex items-center gap-2 text-xs">
|
|
<span class="inline-flex items-center gap-1 rounded-full bg-surface1 dark:bg-card text-main dark:text-main px-2 py-1">
|
|
{{ ql.icon }} {{ ql.name }}
|
|
</span>
|
|
<button type="button" (click)="clearQuickLinkFilter.emit()" class="rounded-full hover:bg-slate-500/10 dark:hover:bg-surface2/10 w-6 h-6 inline-flex items-center justify-center" title="Effacer le filtre">
|
|
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<input type="text"
|
|
[value]="query()"
|
|
(input)="onQuery($any($event.target).value)"
|
|
(keydown.enter)="onSearchEnter()"
|
|
placeholder="Rechercher..."
|
|
class="w-full rounded border border-border dark:border-border bg-card dark:bg-main px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring" />
|
|
|
|
<!-- Action Buttons (Sort + View Mode + Order) -->
|
|
<div class="action-buttons flex justify-between items-center">
|
|
<div class="flex items-center gap-2 relative">
|
|
<button type="button" (click)="toggleSortMenu()" class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors" title="Trier par">
|
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M7 12h10"/><path d="M10 18h4"/></svg>
|
|
</button>
|
|
<button type="button" (click)="toggleViewModeMenu()" class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors" title="Mode d'affichage">
|
|
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg>
|
|
</button>
|
|
<button type="button" (click)="toggleSortOrder()" class="inline-flex items-center justify-center w-8 h-8 rounded hover:bg-surface2/50 dark:hover:bg-surface2/50 transition-colors" title="Ordre">
|
|
<svg *ngIf="state.sortOrder() === 'desc'" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
|
<svg *ngIf="state.sortOrder() === 'asc'" class="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 15 12 9 18 15"/></svg>
|
|
</button>
|
|
|
|
<!-- Sort Dropdown -->
|
|
<div *ngIf="sortMenuOpen()" class="absolute top-full left-0 mt-1 bg-card dark:bg-main border border-border dark:border-gray-700 rounded shadow-lg z-10 min-w-max">
|
|
<button type="button" *ngFor="let s of sortOptions" (click)="setSortBy(s)" [class.bg-surface1]="state.sortBy() === s" class="w-full text-left px-3 py-2 text-xs hover:bg-surface1 dark:hover:bg-surface2 transition-colors">{{ getSortLabel(s) }}</button>
|
|
</div>
|
|
|
|
<!-- View Mode Dropdown -->
|
|
<div *ngIf="viewModeMenuOpen()" class="absolute top-full left-10 mt-1 bg-card dark:bg-main border border-border dark:border-gray-700 rounded shadow-lg z-10 min-w-max">
|
|
<button type="button" *ngFor="let m of viewModes" (click)="setViewMode(m)" [class.bg-surface1]="state.viewMode() === m" class="w-full text-left px-3 py-2 text-xs hover:bg-surface1 dark:hover:bg-surface2 transition-colors">{{ getViewModeLabel(m) }}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Count (avoid flashing 0 during initial load) -->
|
|
<div class="flex items-center gap-1 text-xs text-muted">
|
|
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="currentColor"><circle cx="12" cy="12" r="9"/></svg>
|
|
{{ (isLoadingMore() && totalLoaded() === 0) ? '…' : visibleNotes().length }}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Virtual scroll viewport -->
|
|
<div class="flex-1 min-h-0 overflow-hidden">
|
|
<cdk-virtual-scroll-viewport
|
|
#viewport
|
|
itemSize="60"
|
|
class="h-full w-full"
|
|
(scrolledIndexChange)="onScroll($event)"
|
|
appScrollableOverlay>
|
|
|
|
<ul class="notes-list">
|
|
<!-- Virtual items -->
|
|
<li *cdkVirtualFor="let note of visibleNotes(); trackBy: trackByFn"
|
|
class="note-row note-card group cursor-pointer relative"
|
|
[ngClass]="getListItemClasses()"
|
|
[ngStyle]="getNoteGradientStyleById(note.id)"
|
|
[attr.data-note-id]="note.id"
|
|
[class.active]="(selectedId() ?? selectedNoteId()) === note.id"
|
|
[class.selected-for-action]="isSelected(note.id)"
|
|
(click)="onRowClick($event, note)"
|
|
(mousedown)="onRowMouseDown($event, note)"
|
|
(mouseup)="onRowMouseUp($event)"
|
|
(mouseleave)="onRowMouseUp($event)"
|
|
(contextmenu)="openContextMenu($event, note.id)">
|
|
|
|
<!-- Action Buttons (hover reveal) -->
|
|
<div class="note-card-actions absolute top-2 right-2 flex gap-1 opacity-0 group-hover:opacity-100 transition-opacity duration-200 z-10">
|
|
<button type="button" class="action-btn edit inline-flex items-center justify-center w-7 h-7 rounded-lg bg-card/80 dark:bg-main/80 hover:bg-surface1 dark:hover:bg-surface2 transition-colors backdrop-blur-sm" title="Éditer la note" (click)="$event.stopPropagation(); editNote(note)">
|
|
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>
|
|
</button>
|
|
<button type="button" class="action-btn delete inline-flex items-center justify-center w-7 h-7 rounded-lg bg-card/80 dark:bg-main/80 hover:bg-red-100 dark:hover:bg-red-950 transition-colors backdrop-blur-sm" title="Supprimer la note" (click)="$event.stopPropagation(); openDeleteWarning(note)">
|
|
<svg class="w-4 h-4 text-red-600 dark:text-red-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Compact View -->
|
|
<div *ngIf="state.viewMode() === 'compact'" class="note-inner flex items-center gap-2">
|
|
<span class="note-color-dot flex-shrink-0" [style.backgroundColor]="getNoteColorById(note.id)" aria-hidden="true"></span>
|
|
<span class="flex-shrink-0" title="Type">{{ typeIcon(note.filePath) }}</span>
|
|
<div class="title text-xs truncate">{{ note.title }}</div>
|
|
</div>
|
|
|
|
<!-- Comfortable View (default) -->
|
|
<div *ngIf="state.viewMode() === 'comfortable'" class="note-inner flex items-start gap-2">
|
|
<span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColorById(note.id)" aria-hidden="true"></span>
|
|
<span class="flex-shrink-0 mt-0.5" title="Type">{{ typeIcon(note.filePath) }}</span>
|
|
<div class="min-w-0 flex-1">
|
|
<div class="title text-sm truncate">{{ note.title }}</div>
|
|
<div class="meta text-xs truncate">{{ note.filePath }}</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Detailed View -->
|
|
<div *ngIf="state.viewMode() === 'detailed'" class="note-inner flex items-start gap-2 space-y-0">
|
|
<span class="note-color-dot flex-shrink-0 mt-1" [style.backgroundColor]="getNoteColorById(note.id)" aria-hidden="true"></span>
|
|
<span class="flex-shrink-0 mt-0.5" title="Type">{{ typeIcon(note.filePath) }}</span>
|
|
<div class="min-w-0 flex-1 space-y-1.5">
|
|
<div class="title text-sm truncate">{{ note.title }}</div>
|
|
<div class="meta text-xs truncate">{{ note.filePath }}</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- Loading indicator -->
|
|
<li *ngIf="isLoadingMore()" class="p-4 text-center text-muted min-h-[60px] flex items-center justify-center">
|
|
<div class="inline-flex items-center gap-2">
|
|
<div class="inline-block animate-spin rounded-full h-4 w-4 border-b-2 border-primary"></div>
|
|
<span>Chargement...</span>
|
|
</div>
|
|
</li>
|
|
|
|
<!-- End of list indicator -->
|
|
<li *ngIf="!hasMore() && totalLoaded() > 0" class="p-4 text-center text-muted min-h-[60px] flex items-center justify-center">
|
|
{{ totalLoaded() }} notes chargées
|
|
</li>
|
|
|
|
<!-- Empty state -->
|
|
<li *ngIf="totalLoaded() === 0 && !isLoadingMore()" class="p-4 text-center text-muted min-h-[60px] flex items-center justify-center">
|
|
Aucune note trouvée
|
|
</li>
|
|
</ul>
|
|
</cdk-virtual-scroll-viewport>
|
|
</div>
|
|
|
|
<!-- Floating Selection Bar -->
|
|
<div *ngIf="selectionMode()" class="selection-bar bg-primary/95 dark:bg-primary/90 backdrop-blur-sm text-primary-foreground px-4 py-2 rounded-lg shadow-lg flex items-center gap-3 min-w-[280px] max-w-[520px]">
|
|
<span class="font-semibold">{{ selectedCount() }} note(s) sélectionnée(s)</span>
|
|
<div class="flex-1"></div>
|
|
<button type="button" (click)="clearSelection()" class="px-2 py-1 rounded hover:bg-primary-foreground/10 transition-colors text-xs" title="Effacer la sélection">
|
|
✕
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Note Context Menu -->
|
|
<app-note-context-menu
|
|
[x]="contextMenu.x()"
|
|
[y]="contextMenu.y()"
|
|
[visible]="contextMenu.visible()"
|
|
[note]="contextMenu.targetNote()"
|
|
(action)="onContextMenuAction($event)"
|
|
(color)="onContextMenuColor($event)"
|
|
(closed)="contextMenu.close()">
|
|
</app-note-context-menu>
|
|
|
|
<!-- Delete Warning Modal -->
|
|
<app-warning-panel
|
|
[visible]="deleteWarningOpen()"
|
|
[title]="'Delete this note?'"
|
|
[message]="'The note will be moved to the trash folder and can be restored later.'"
|
|
[confirmText]="'Delete'"
|
|
[cancelText]="'Cancel'"
|
|
[confirmColor]="'danger'"
|
|
(delete)="confirmDelete()"
|
|
(cancel)="closeDeleteWarning()"></app-warning-panel>
|
|
`,
|
|
styles: [`
|
|
:host {
|
|
display: block;
|
|
height: 100%;
|
|
min-height: 0;
|
|
background: var(--list-panel-bg);
|
|
position: relative;
|
|
z-index: 0;
|
|
}
|
|
|
|
/* Subtle vertical depth overlay */
|
|
:host::before {
|
|
content: "";
|
|
position: absolute;
|
|
inset: 0;
|
|
pointer-events: none;
|
|
background: linear-gradient(
|
|
to bottom,
|
|
color-mix(in oklab, var(--card) 100%, transparent 0%) 0%,
|
|
color-mix(in oklab, var(--card) 96%, black 0%) 35%,
|
|
color-mix(in oklab, var(--card) 92%, black 0%) 100%
|
|
);
|
|
opacity: 0.6;
|
|
}
|
|
|
|
/* Theming variables per color scheme */
|
|
:host {
|
|
--row-radius: 8px;
|
|
--row-pad-v: 12px;
|
|
--row-pad-h: 16px;
|
|
--row-gap: 2px;
|
|
--active-line: var(--primary, #3b82f6);
|
|
--meta-color: var(--text-muted);
|
|
--row-bg: color-mix(in oklab, var(--card) 97%, black 0%);
|
|
--row-bg-hover: color-mix(in oklab, var(--card) 100%, white 6%);
|
|
--row-shadow-active: 0 2px 10px color-mix(in oklab, var(--active-line) 18%, transparent 82%);
|
|
--list-panel-bg: color-mix(in oklab, var(--card) 92%, black 8%);
|
|
}
|
|
|
|
:host-context(html.dark) {
|
|
--row-bg: color-mix(in oklab, var(--card) 94%, white 0%);
|
|
--row-bg-hover: color-mix(in oklab, var(--card) 90%, white 10%);
|
|
--list-panel-bg: color-mix(in oklab, var(--card) 86%, black 14%);
|
|
}
|
|
|
|
:host-context(html:not(.dark)) {
|
|
--row-bg: color-mix(in oklab, var(--card) 94%, black 6%);
|
|
--row-bg-hover: color-mix(in oklab, var(--card) 90%, black 10%);
|
|
--list-panel-bg: color-mix(in oklab, var(--card) 96%, black 4%);
|
|
}
|
|
|
|
cdk-virtual-scroll-viewport {
|
|
height: 100%;
|
|
}
|
|
|
|
/* Notes list and rows (match non-virtual list) */
|
|
.notes-list {
|
|
margin: 0;
|
|
padding: 2px 0;
|
|
list-style: none;
|
|
}
|
|
|
|
.note-row {
|
|
position: relative;
|
|
margin: var(--row-gap) 1px;
|
|
border-radius: var(--row-radius);
|
|
background: var(--row-bg);
|
|
transition: all 0.2s ease-in-out;
|
|
min-height: 60px;
|
|
display: flex;
|
|
flex-direction: column;
|
|
justify-content: center;
|
|
}
|
|
|
|
.note-row:hover { background: var(--row-bg-hover); }
|
|
|
|
.note-row.active::before,
|
|
.note-row.active::after {
|
|
content: "";
|
|
position: absolute;
|
|
left: 0; right: 0; height: 2px;
|
|
background: var(--active-line);
|
|
border-radius: 2px;
|
|
}
|
|
.note-row.active::before { top: 0; }
|
|
.note-row.active::after { bottom: 0; }
|
|
.note-row.active { box-shadow: var(--row-shadow-active); }
|
|
|
|
.note-inner { padding: var(--row-pad-v) var(--row-pad-h); }
|
|
.title { color: var(--text-main, #111); font-weight: 500; }
|
|
:host-context(html.dark) .title { color: var(--text-main, #e5e7eb); }
|
|
.note-row.active .title { font-weight: 600; }
|
|
.meta { color: var(--meta-color, #6b7280); opacity: 0.8; }
|
|
:host-context(html.dark) .meta { color: var(--meta-color, #94a3b8); opacity: 0.9; }
|
|
.excerpt { color: var(--meta-color); opacity: 0.75; display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; text-overflow: ellipsis; }
|
|
:host::before { z-index: 0; }
|
|
|
|
/* Action buttons container */
|
|
.action-buttons { position: relative; display: flex; align-items: center; justify-content: space-between; width: 100%; gap: 0.5rem; }
|
|
|
|
/* Enhanced note card with color indicator and action buttons */
|
|
.note-card { transition: all 0.3s ease-in-out; background-repeat: no-repeat; background-size: 100% 120px; background-position: top center; }
|
|
.note-card:hover { transform: translateY(-1px); }
|
|
|
|
/* Color dot indicator */
|
|
.note-color-dot { width: 8px; height: 8px; border-radius: 50%; box-shadow: 0 0 0 1px color-mix(in oklab, var(--text-main) 15%, transparent 85%); transition: all 0.2s ease-in-out; }
|
|
.note-row:hover .note-color-dot { box-shadow: 0 0 0 2px color-mix(in oklab, var(--text-main) 25%, transparent 75%); }
|
|
|
|
/* Action buttons */
|
|
.note-card-actions { pointer-events: auto; }
|
|
.action-btn { display: inline-flex; align-items: center; justify-content: center; cursor: pointer; font-size: 0; }
|
|
.action-btn.edit { color: var(--primary, #3b82f6); }
|
|
.action-btn.delete { color: #dc2626; }
|
|
:host-context(html.dark) .action-btn.delete { color: #ef4444; }
|
|
|
|
/* Multi-selection visuals */
|
|
.note-row.selected-for-action {
|
|
outline: 2px solid var(--primary);
|
|
box-shadow: 0 0 0 2px color-mix(in oklab, var(--primary) 15%, transparent 85%);
|
|
}
|
|
.note-row.selected-for-action .title { font-weight: 600; color: var(--primary); }
|
|
.note-row.selected-for-action::before {
|
|
content: "✓";
|
|
position: absolute; top: 8px; left: 8px; width: 20px; height: 20px;
|
|
background: var(--primary); color: var(--primary-foreground);
|
|
border-radius: 9999px; display: flex; align-items: center; justify-content: center;
|
|
font-size: 12px; font-weight: 700; z-index: 5;
|
|
}
|
|
|
|
.selection-bar { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); z-index: 50; }
|
|
`]
|
|
})
|
|
export class PaginatedNotesListComponent implements OnInit, OnDestroy {
|
|
private paginationService = inject(PaginationService);
|
|
private store = inject(TagFilterStore);
|
|
private vault = inject(VaultService);
|
|
private fileTypes = inject(FileTypeDetectorService);
|
|
readonly state = inject(NotesListStateService);
|
|
readonly filter = inject(FilterService);
|
|
readonly contextMenu = inject(NoteContextMenuService);
|
|
private editorState = inject(EditorStateService);
|
|
private keyboard = inject(KeyboardShortcutsService);
|
|
private destroy$ = new Subject<void>();
|
|
private preservedOffset: number | null = null;
|
|
private useUnifiedSync = true;
|
|
private lastSyncKey = signal<string>('');
|
|
|
|
// Multi-selection state
|
|
selectedIds = signal<Set<string>>(new Set());
|
|
selectedCount = computed(() => this.selectedIds().size);
|
|
selectionMode = computed(() => this.selectedIds().size > 0);
|
|
private longPressTimer: any = null;
|
|
private longPressThreshold = 500; // ms
|
|
|
|
// Header shows only kind badges (IMAGE, PDF, VIDEO, etc.)
|
|
badgesKindOnly = computed(() => (this.filter.badges() || []).filter((b: any) => b?.type === 'kind'));
|
|
|
|
@ViewChild(CdkVirtualScrollViewport) viewport?: CdkVirtualScrollViewport;
|
|
|
|
// Inputs
|
|
folderFilter = input<string | null>(null);
|
|
query = input<string>('');
|
|
tagFilter = input<string | null>(null);
|
|
quickLinkFilter = input<'favoris' | 'publish' | 'draft' | 'template' | 'task' | 'private' | 'archive' | null>(null);
|
|
selectedId = input<string | null>(null);
|
|
kindFilter = input<'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code' | 'all' | null>(null);
|
|
|
|
// Outputs
|
|
@Output() openNote = new EventEmitter<string>();
|
|
@Output() queryChange = new EventEmitter<string>();
|
|
@Output() clearQuickLinkFilter = new EventEmitter<void>();
|
|
@Output() clearFolderFilter = new EventEmitter<void>();
|
|
|
|
// Local state
|
|
private q = signal('');
|
|
selectedNoteId = signal<string | null>(null);
|
|
activeTag = signal<string | null>(null);
|
|
sortMenuOpen = signal<boolean>(false);
|
|
viewModeMenuOpen = signal<boolean>(false);
|
|
readonly sortOptions: SortBy[] = ['title', 'created', 'updated'];
|
|
readonly viewModes: ViewMode[] = ['compact', 'comfortable', 'detailed'];
|
|
|
|
// Delete warning modal state
|
|
deleteWarningOpen = signal<boolean>(false);
|
|
private deleteTargetId: string | null = null;
|
|
|
|
// Pagination state
|
|
paginatedNotes = this.paginationService.allItems;
|
|
isLoadingMore = this.paginationService.isLoadingMore;
|
|
hasMore = this.paginationService.hasMore;
|
|
totalLoaded = this.paginationService.totalLoaded;
|
|
canLoadMore = this.paginationService.canLoadMore;
|
|
|
|
// Visible notes with fallback and filters
|
|
visibleNotes = computed<NoteMetadata[]>(() => {
|
|
let items = this.paginatedNotes();
|
|
const dbg = (() => { try { const w: any = (globalThis as any).window; return !!(w && (w.__LIST_DEBUG__ || localStorage.getItem('LIST_DEBUG') === '1')); } catch { return false; } })();
|
|
let usedFallback = false;
|
|
const vaultNotes = (() => {
|
|
try { return this.vault.allNotes() || []; } catch { return []; }
|
|
})();
|
|
const byId = new Map<string, any>(vaultNotes.map(n => [n.id, n]));
|
|
if (dbg) console.log('[List] start', { paginated: (items?.length||0) });
|
|
if (!items || items.length === 0) {
|
|
try {
|
|
const all = this.vault.allNotes();
|
|
items = (all || []).map(n => ({
|
|
id: n.id,
|
|
title: n.title,
|
|
filePath: n.filePath,
|
|
createdAt: n.createdAt as any,
|
|
updatedAt: (n.updatedAt as any) || (n.mtime ? new Date(n.mtime).toISOString() : '')
|
|
}));
|
|
usedFallback = true;
|
|
} catch {
|
|
items = [];
|
|
}
|
|
}
|
|
|
|
// Folder filter
|
|
const folder = (this.folderFilter() || '').toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
if (folder) {
|
|
if (folder === '.trash') {
|
|
items = items.filter(n => {
|
|
const fp = (n.filePath || '').toLowerCase().replace(/\\/g, '/');
|
|
return fp.startsWith('.trash/') || fp.includes('/.trash/');
|
|
});
|
|
} else {
|
|
items = items.filter(n => {
|
|
const op = (n.filePath || '').toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
return op === folder || op.startsWith(folder + '/');
|
|
});
|
|
}
|
|
} else {
|
|
// Exclude trash by default
|
|
items = items.filter(n => {
|
|
const fp = (n.filePath || '').toLowerCase().replace(/\\/g, '/');
|
|
return !fp.startsWith('.trash/') && !fp.includes('/.trash/');
|
|
});
|
|
}
|
|
if (dbg) console.log('[List] after folder filter', { folder, count: items.length });
|
|
|
|
// Secondary fallback: if folder filter produced 0 items, rebuild from vault notes
|
|
if (items.length === 0 && folder) {
|
|
try {
|
|
usedFallback = true;
|
|
const all = vaultNotes; // already loaded above
|
|
let rebuilt = (all || []).map(n => ({
|
|
id: n.id,
|
|
title: n.title,
|
|
filePath: n.filePath,
|
|
createdAt: (n as any).createdAt,
|
|
updatedAt: (n as any).updatedAt || (n.mtime ? new Date(n.mtime).toISOString() : '')
|
|
}));
|
|
// Apply same folder/trash constraint
|
|
if (folder === '.trash') {
|
|
rebuilt = rebuilt.filter(n => {
|
|
const fp = (n.filePath || '').toLowerCase().replace(/\\/g, '/');
|
|
return fp.startsWith('.trash/') || fp.includes('/.trash/');
|
|
});
|
|
} else {
|
|
rebuilt = rebuilt.filter(n => {
|
|
const op = (n.filePath || '').toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
return op === folder || op.startsWith(folder + '/');
|
|
});
|
|
}
|
|
items = rebuilt;
|
|
} catch {}
|
|
}
|
|
|
|
// If Tag or Quick is active, use full vault notes (markdown) to ensure tags/frontmatter are present
|
|
const quickKey = String(this.quickLinkFilter() || '').toLowerCase();
|
|
const urlTag = (this.tagFilter() || '').toLowerCase();
|
|
const tagActive = !!urlTag || this.filter.tags().length > 0;
|
|
if (tagActive || !!quickKey) {
|
|
usedFallback = true;
|
|
const mdVaultNotes = vaultNotes.filter(v => this.fileTypes.getViewerType(v.filePath, v.rawContent ?? v.content ?? '') === 'markdown');
|
|
items = mdVaultNotes.map(n => ({
|
|
id: n.id,
|
|
title: n.title,
|
|
filePath: n.filePath,
|
|
createdAt: (n as any).createdAt,
|
|
updatedAt: (n as any).updatedAt || (n.mtime ? new Date(n.mtime).toISOString() : '')
|
|
}));
|
|
// Apply folder/trash constraint to the rebuilt list as well
|
|
if (folder) {
|
|
items = items.filter(n => {
|
|
const op = (n.filePath || '').toLowerCase().replace(/^\/+|\/+$/g, '');
|
|
const fp = (n.filePath || '').toLowerCase().replace(/\\/g, '/');
|
|
return folder === '.trash'
|
|
? (fp.startsWith('.trash/') || fp.includes('/.trash/'))
|
|
: (op === folder || op.startsWith(folder + '/'));
|
|
});
|
|
} else {
|
|
items = items.filter(n => {
|
|
const fp = (n.filePath || '').toLowerCase().replace(/\\/g, '/');
|
|
return !fp.startsWith('.trash/') && !fp.includes('/.trash/');
|
|
});
|
|
}
|
|
}
|
|
if (dbg) console.log('[List] after tag/quick normalization', { tagActive, quickKey, count: items.length });
|
|
|
|
// Kind filters
|
|
const kinds = this.filter.kinds();
|
|
const urlKind = this.kindFilter();
|
|
const folderActive = !!folder;
|
|
const quickActive = !!quickKey;
|
|
let allowedKinds: Set<string>;
|
|
if (kinds.length > 0) {
|
|
// explicit multi-kind selection from chips
|
|
allowedKinds = new Set<string>(kinds);
|
|
} else if (folderActive) {
|
|
// In Folders view with no chips selected -> 'Tout' => no restriction by kind
|
|
allowedKinds = new Set<string>();
|
|
} else if (urlKind && urlKind !== 'all') {
|
|
// fallback to URL kind when not in folder view
|
|
allowedKinds = new Set<string>([urlKind]);
|
|
} else {
|
|
// default: no restriction
|
|
allowedKinds = new Set<string>();
|
|
}
|
|
|
|
// Tags/Quick enforce markdown-only regardless of kind chips
|
|
if (tagActive || quickActive) {
|
|
allowedKinds = new Set<string>(['markdown']);
|
|
}
|
|
if (dbg) console.log('[List] kinds', { kinds, urlKind, allowed: Array.from(allowedKinds) });
|
|
|
|
if (allowedKinds.size > 0) {
|
|
items = items.filter(n => Array.from(allowedKinds).some(k => this.matchesKind(n.filePath, k as any)));
|
|
}
|
|
if (dbg) console.log('[List] after kind filter', { count: items.length });
|
|
|
|
// Query filtering (always apply client-side as extra guard)
|
|
const q = (this.q() || '').toLowerCase().trim();
|
|
if (q) {
|
|
items = items.filter(n => (n.title || '').toLowerCase().includes(q) || (n.filePath || '').toLowerCase().includes(q));
|
|
}
|
|
if (dbg) console.log('[List] after query filter', { q, count: items.length });
|
|
|
|
// Tag and Quick Link filters using vault metadata when available
|
|
const urlTag2 = (this.tagFilter() || '').toLowerCase();
|
|
const localTags = this.filter.tags().map(t => (t || '').toLowerCase());
|
|
const quickKey2 = String(this.quickLinkFilter() || '').toLowerCase();
|
|
if (urlTag2 || localTags.length > 0) {
|
|
items = items.filter(n => {
|
|
const full = byId.get(n.id);
|
|
const ntags: string[] = Array.isArray(full?.tags) ? full.tags.map((t: string) => (t || '').toLowerCase()) : [];
|
|
if (urlTag2 && !ntags.includes(urlTag2)) return false;
|
|
for (const t of localTags) { if (!ntags.includes(t)) return false; }
|
|
// Tags view must show markdown only
|
|
return this.matchesKind(n.filePath, 'markdown');
|
|
});
|
|
}
|
|
if (quickKey2) {
|
|
items = items.filter(n => {
|
|
const full = byId.get(n.id);
|
|
const fm = full?.frontmatter || {};
|
|
return fm[quickKey2] === true && this.matchesKind(n.filePath, 'markdown');
|
|
});
|
|
}
|
|
|
|
// If allowed kinds include any non-markdown type OR no kinds selected at all (default 'all'),
|
|
// ensure those files appear even if pagination didn't include them (server may return only markdown)
|
|
const needMergeForKinds = (allowedKinds.size > 0 && Array.from(allowedKinds).some(k => k !== 'markdown'))
|
|
|| (allowedKinds.size === 0 && !quickActive && !tagActive);
|
|
if (needMergeForKinds) {
|
|
const presentPath = new Set(items.map(n => String(n.filePath || '').toLowerCase().replace(/\\/g, '/')));
|
|
const metas = (() => { try { return this.vault.allFilesMetadata() || []; } catch { return []; } })();
|
|
let merged = 0;
|
|
for (const meta of metas) {
|
|
const filePath = (meta.path || (meta as any).filePath || '').toLowerCase().replace(/\\/g, '/');
|
|
if (!filePath) continue;
|
|
const viewer = this.fileTypes.getViewerType(filePath, '');
|
|
if (viewer === 'markdown') continue;
|
|
const allowByKind = allowedKinds.size === 0 ? true : allowedKinds.has(viewer);
|
|
if (!allowByKind) continue;
|
|
if (presentPath.has(filePath)) continue;
|
|
const op = filePath.replace(/^\/+|\/+$/g, '');
|
|
const includeByFolder = folder
|
|
? (folder === '.trash'
|
|
? (filePath.startsWith('.trash/') || filePath.includes('/.trash/'))
|
|
: (op === folder || op.startsWith(folder + '/')))
|
|
: (!filePath.startsWith('.trash/') && !filePath.includes('/.trash/'));
|
|
if (!includeByFolder) continue;
|
|
if (q) {
|
|
const titleLc = String(meta.title || '').toLowerCase();
|
|
if (!titleLc.includes(q) && !filePath.includes(q)) continue;
|
|
}
|
|
const id = String((meta as any).id || filePath);
|
|
const title = String(meta.title || filePath.split('/').pop() || filePath);
|
|
const createdAt = (meta as any).createdAt || undefined;
|
|
const updatedAt = (meta as any).updatedAt || undefined;
|
|
items.push({ id, title, filePath: filePath, createdAt: createdAt as any, updatedAt: updatedAt as any });
|
|
presentPath.add(filePath);
|
|
merged++;
|
|
}
|
|
if (dbg) console.log('[List] merged non-markdown from meta index', { merged, before: items.length - merged, after: items.length });
|
|
}
|
|
|
|
// Final de-duplication by filePath (case-insensitive) to avoid duplicates pointing to same file
|
|
const byPath = new Map<string, NoteMetadata>();
|
|
for (const it of items) {
|
|
const key = String(it.filePath || '').toLowerCase().replace(/\\/g, '/');
|
|
if (!key) continue;
|
|
if (!byPath.has(key)) byPath.set(key, it);
|
|
}
|
|
items = Array.from(byPath.values());
|
|
if (dbg) console.log('[List] final', { count: items.length });
|
|
|
|
// Sorting (title/created/updated) with asc/desc
|
|
const parseDate = (s?: string) => (s ? Date.parse(s) : 0) || 0;
|
|
const sortBy = this.state.sortBy();
|
|
const dir = this.state.sortOrder() === 'asc' ? 1 : -1;
|
|
items = [...items].sort((a, b) => {
|
|
switch (sortBy) {
|
|
case 'title': {
|
|
const cmp = (a.title || '').localeCompare(b.title || '');
|
|
return cmp * dir;
|
|
}
|
|
case 'created': {
|
|
const da = parseDate(a.createdAt);
|
|
const db = parseDate(b.createdAt);
|
|
return (da - db) * dir;
|
|
}
|
|
case 'updated':
|
|
default: {
|
|
const mb = byId.get(b.id)?.mtime; const ma = byId.get(a.id)?.mtime;
|
|
const ub = parseDate(b.updatedAt) || (mb ? Number(mb) : 0);
|
|
const ua = parseDate(a.updatedAt) || (ma ? Number(ma) : 0);
|
|
return (ua - ub) * dir * -1; // normalize to (valueA - valueB) * dir
|
|
}
|
|
}
|
|
});
|
|
|
|
return items;
|
|
});
|
|
|
|
private prefetchScheduled = false;
|
|
private prefetched = new Set<string>();
|
|
private schedulePrefetch(): void {
|
|
if (this.prefetchScheduled) return;
|
|
this.prefetchScheduled = true;
|
|
setTimeout(() => {
|
|
this.prefetchScheduled = false;
|
|
const items = this.visibleNotes();
|
|
if (!items || items.length === 0) return;
|
|
const slice = items.slice(0, 40);
|
|
const paths: string[] = [];
|
|
for (const it of slice) {
|
|
const p = it.filePath || '';
|
|
if (!p) continue;
|
|
if (this.prefetched.has(p)) continue;
|
|
const kind = this.fileTypes.getViewerType(p, '');
|
|
if (kind === 'markdown' || kind === 'excalidraw') {
|
|
paths.push(p);
|
|
}
|
|
}
|
|
if (paths.length === 0) return;
|
|
(async () => {
|
|
// Limit concurrency
|
|
const concurrency = 6;
|
|
let i = 0;
|
|
const runNext = async (): Promise<void> => {
|
|
if (i >= paths.length) return;
|
|
const path = paths[i++];
|
|
try {
|
|
this.prefetched.add(path);
|
|
await this.vault.ensureNoteLoadedByPath(path);
|
|
} catch {}
|
|
await runNext();
|
|
};
|
|
const workers: Promise<void>[] = [];
|
|
for (let k = 0; k < Math.min(concurrency, paths.length); k++) {
|
|
workers.push(runNext());
|
|
}
|
|
await Promise.allSettled(workers);
|
|
})();
|
|
}, 50);
|
|
}
|
|
|
|
private prefetchEffect = effect(() => {
|
|
this.visibleNotes();
|
|
this.schedulePrefetch();
|
|
});
|
|
|
|
// Effects
|
|
// Capture current scroll anchor BEFORE a data reset (e.g., new search), then restore it after reload
|
|
private preserveOnReset = effect(() => {
|
|
// react to reset notifications
|
|
const _tick = this.paginationService.onWillReset();
|
|
// when incremented, capture current rendered start index as anchor
|
|
const vp = this.viewport;
|
|
if (vp) {
|
|
try {
|
|
this.preservedOffset = Math.max(0, vp.measureScrollOffset());
|
|
} catch {
|
|
this.preservedOffset = 0;
|
|
}
|
|
} else {
|
|
this.preservedOffset = 0;
|
|
}
|
|
});
|
|
|
|
// After items recompute following a reset, scroll back to the preserved index once
|
|
private restoreAfterReset = effect(() => {
|
|
// Trigger on recompute
|
|
this.visibleNotes();
|
|
const offset = this.preservedOffset;
|
|
if (offset != null) {
|
|
// Defer to next microtask to ensure viewport has measured
|
|
queueMicrotask(() => {
|
|
const vp = this.viewport;
|
|
if (!vp) return;
|
|
try { vp.scrollToOffset(offset, 'auto'); } catch {}
|
|
this.preservedOffset = null;
|
|
});
|
|
}
|
|
});
|
|
private syncQuery = effect(() => {
|
|
this.q.set(this.query() || '');
|
|
});
|
|
|
|
private syncTagFromStore = effect(() => {
|
|
const inputTag = this.tagFilter();
|
|
if (inputTag !== null && inputTag !== undefined) {
|
|
this.activeTag.set(inputTag || null);
|
|
return;
|
|
}
|
|
this.activeTag.set(this.store.get());
|
|
});
|
|
|
|
// Sync folder filter with PaginationService
|
|
private syncFolderFilter = effect(() => {
|
|
if (this.useUnifiedSync) { return; }
|
|
const folder = this.folderFilter();
|
|
const currentFolder = this.paginationService.getFolderFilter();
|
|
// Only reload if folder actually changed to avoid infinite loops
|
|
if (folder !== currentFolder) {
|
|
console.log('[PaginatedNotesList] Folder filter changed:', { from: currentFolder, to: folder });
|
|
this.paginationService.setFolderFilter(folder).catch(err => {
|
|
console.error('[PaginatedNotesList] Failed to set folder filter:', err);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Sync tag filter with PaginationService
|
|
private syncTagFilterToPagination = effect(() => {
|
|
if (this.useUnifiedSync) { return; }
|
|
const tag = this.tagFilter();
|
|
const currentTag = this.paginationService.getTagFilter();
|
|
// Only reload if tag actually changed
|
|
if (tag !== currentTag) {
|
|
console.log('[PaginatedNotesList] Tag filter changed:', { from: currentTag, to: tag });
|
|
this.paginationService.setTagFilter(tag).catch(err => {
|
|
console.error('[PaginatedNotesList] Failed to set tag filter:', err);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Sync quick link filter with PaginationService
|
|
private syncQuickLinkFilter = effect(() => {
|
|
if (this.useUnifiedSync) { return; }
|
|
const quick = this.quickLinkFilter();
|
|
const currentQuick = this.paginationService.getQuickLinkFilter();
|
|
// Only reload if quick link actually changed
|
|
if (quick !== currentQuick) {
|
|
console.log('[PaginatedNotesList] Quick link filter changed:', { from: currentQuick, to: quick });
|
|
this.paginationService.setQuickLinkFilter(quick).catch(err => {
|
|
console.error('[PaginatedNotesList] Failed to set quick link filter:', err);
|
|
});
|
|
}
|
|
});
|
|
|
|
// Unified synchronization effect: apply search + folder + tag + quick together
|
|
private syncAllFilters = effect(() => {
|
|
if (!this.useUnifiedSync) { return; }
|
|
const search = this.query() || '';
|
|
const folder = this.folderFilter();
|
|
const tag = this.tagFilter();
|
|
const quick = this.quickLinkFilter();
|
|
const key = `${search}||${folder ?? ''}||${tag ?? ''}||${quick ?? ''}`;
|
|
if (this.lastSyncKey() === key) return;
|
|
this.lastSyncKey.set(key);
|
|
this.paginationService.loadInitial(search, folder, tag, quick).catch(err => {
|
|
console.error('[PaginatedNotesList] Failed to load initial with unified filters:', err);
|
|
});
|
|
});
|
|
|
|
ngOnInit() {
|
|
// No-op: effects (syncQuery, syncFolderFilter, syncTagFilterToPagination, syncQuickLinkFilter) perform the initial load
|
|
}
|
|
|
|
ngOnDestroy() {
|
|
this.destroy$.next();
|
|
this.destroy$.complete();
|
|
}
|
|
|
|
// Handle virtual scroll
|
|
onScroll(index: number) {
|
|
const items = this.visibleNotes();
|
|
// Load more when approaching the end (20 items before the end)
|
|
if (index > items.length - 20 && this.canLoadMore()) {
|
|
this.paginationService.loadNextPage();
|
|
}
|
|
}
|
|
|
|
// Select a note
|
|
selectNote(note: NoteMetadata) {
|
|
try { console.debug('[NotesList] click', { id: note.id, path: note.filePath }); } catch {}
|
|
this.selectedNoteId.set(note.id);
|
|
this.openNote.emit(note.filePath || note.id);
|
|
}
|
|
|
|
// --- Multi-selection handlers ---
|
|
onRowClick(event: MouseEvent, meta: NoteMetadata): void {
|
|
if (event.ctrlKey || event.metaKey) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
this.toggleSelection(meta.id);
|
|
return;
|
|
}
|
|
if (this.selectionMode()) {
|
|
// Exit selection mode and open the clicked note
|
|
this.clearSelection();
|
|
}
|
|
this.selectNote(meta);
|
|
}
|
|
|
|
onRowMouseDown(event: MouseEvent, meta: NoteMetadata): void {
|
|
if (event.button !== 0) return; // only left click
|
|
this.longPressTimer = setTimeout(() => {
|
|
this.toggleSelection(meta.id);
|
|
this.longPressTimer = null;
|
|
}, this.longPressThreshold);
|
|
}
|
|
|
|
onRowMouseUp(_event: MouseEvent): void {
|
|
if (this.longPressTimer) {
|
|
clearTimeout(this.longPressTimer);
|
|
this.longPressTimer = null;
|
|
}
|
|
}
|
|
|
|
toggleSelection(id: string): void {
|
|
const next = new Set(this.selectedIds());
|
|
if (next.has(id)) next.delete(id); else next.add(id);
|
|
this.selectedIds.set(next);
|
|
this.pushSelectionToKeyboard();
|
|
}
|
|
|
|
isSelected(id: string): boolean { return this.selectedIds().has(id); }
|
|
|
|
clearSelection(): void {
|
|
if (this.selectedIds().size === 0) return;
|
|
this.selectedIds.set(new Set());
|
|
this.pushSelectionToKeyboard();
|
|
}
|
|
|
|
selectAll(): void {
|
|
const all = new Set<string>((this.visibleNotes() || []).map(n => n.id));
|
|
this.selectedIds.set(all);
|
|
this.pushSelectionToKeyboard();
|
|
}
|
|
|
|
private pushSelectionToKeyboard(): void {
|
|
// Map selected IDs -> full Note objects
|
|
const ids = Array.from(this.selectedIds());
|
|
const notes: Note[] = [] as any;
|
|
for (const id of ids) {
|
|
const full = this.getFullNoteById(id) as Note | null;
|
|
if (full) notes.push(full);
|
|
}
|
|
this.keyboard.setSelectedNotes(notes);
|
|
}
|
|
|
|
// Keyboard shortcuts
|
|
@HostListener('document:keydown.control.a', ['$event'])
|
|
@HostListener('document:keydown.meta.a', ['$event'])
|
|
onSelectAllKeyboard(event: KeyboardEvent): void {
|
|
event.preventDefault();
|
|
this.selectAll();
|
|
}
|
|
|
|
@HostListener('document:keydown.escape', ['$event'])
|
|
onEscapeKeyboard(event: KeyboardEvent): void {
|
|
if (this.selectionMode()) { event.preventDefault(); this.clearSelection(); }
|
|
}
|
|
|
|
// Search
|
|
onQuery(v: string) {
|
|
this.q.set(v);
|
|
this.queryChange.emit(v);
|
|
// Trigger search with pagination
|
|
this.paginationService.search(v);
|
|
}
|
|
|
|
onSearchEnter(): void {
|
|
const first = this.visibleNotes()[0];
|
|
if (first) this.openNote.emit(first.id);
|
|
}
|
|
|
|
// Clear tag filter
|
|
clearTagFilter(): void {
|
|
this.activeTag.set(null);
|
|
if (this.tagFilter() == null) {
|
|
this.store.set(null);
|
|
}
|
|
}
|
|
|
|
// Track by function for virtual scroll
|
|
trackByFn(index: number, item: NoteMetadata): string {
|
|
return item.id;
|
|
}
|
|
|
|
// Quick link display
|
|
getQuickLinkDisplay(quickLink: string): { icon: string; name: string } | null {
|
|
const displays: Record<string, { icon: string; name: string }> = {
|
|
'favoris': { icon: '❤️', name: 'Favoris' },
|
|
'publish': { icon: '🌐', name: 'Publish' },
|
|
'draft': { icon: '📝', name: 'Draft' },
|
|
'template': { icon: '📑', name: 'Template' },
|
|
'task': { icon: '🗒️', name: 'Task' },
|
|
'private': { icon: '🔒', name: 'Private' },
|
|
'archive': { icon: '🗃️', name: 'Archive' }
|
|
};
|
|
return displays[quickLink] || null;
|
|
}
|
|
|
|
// Helpers
|
|
private matchesKind(filePath: string, kind: 'image' | 'video' | 'pdf' | 'markdown' | 'excalidraw' | 'code'): boolean {
|
|
try {
|
|
const t = this.fileTypes.getViewerType(filePath, '');
|
|
return t === kind;
|
|
} catch {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
// UI helpers
|
|
getListItemClasses(): string {
|
|
const mode = this.state.viewMode();
|
|
if (mode === 'compact') return 'px-3 py-1.5';
|
|
if (mode === 'detailed') return 'p-3 space-y-1.5';
|
|
return 'p-3';
|
|
}
|
|
|
|
// Color and gradient
|
|
private getFullNoteById(id: string): any | null {
|
|
// 1) Direct by ID (already loaded)
|
|
try {
|
|
const n = (this.vault as any).getNoteById?.(id);
|
|
if (n) return n;
|
|
} catch {}
|
|
|
|
// 2) Fallback: find current metadata item to derive path -> slug ID
|
|
try {
|
|
const meta = (this.visibleNotes() || []).find(x => x.id === id);
|
|
const path = meta?.filePath || '';
|
|
if (path) {
|
|
const slug = (this.vault as any).buildSlugIdFromPath?.(path) || '';
|
|
if (slug) {
|
|
const loaded = (this.vault as any).getNoteById?.(slug);
|
|
if (loaded) return loaded;
|
|
// On-demand load for markdown/excalidraw to populate frontmatter (color)
|
|
const kind = this.fileTypes.getViewerType(path, '');
|
|
if (kind === 'markdown' || kind === 'excalidraw') {
|
|
try { (this.vault as any).ensureNoteLoadedByPath?.(path); } catch {}
|
|
const reloaded = (this.vault as any).getNoteById?.(slug);
|
|
if (reloaded) return reloaded;
|
|
}
|
|
}
|
|
}
|
|
} catch {}
|
|
|
|
// 3) Last resort: scan already-loaded notes by id
|
|
try {
|
|
const list = this.vault.allNotes() || [];
|
|
for (const n of list) if ((n as any).id === id) return n;
|
|
} catch {}
|
|
return null;
|
|
}
|
|
|
|
getNoteColorById(id: string): string {
|
|
const full = this.getFullNoteById(id);
|
|
return full?.frontmatter?.color || 'var(--text-muted)';
|
|
}
|
|
|
|
getNoteGradientStyleById(id: string): Record<string, string> | null {
|
|
const full = this.getFullNoteById(id);
|
|
const color = full?.frontmatter?.color;
|
|
if (!color) return null;
|
|
const hexMatch = /^#([0-9a-fA-F]{6})$/.exec(color);
|
|
let gradientColor = color;
|
|
if (hexMatch) {
|
|
const hex = hexMatch[1];
|
|
const r = parseInt(hex.slice(0,2), 16);
|
|
const g = parseInt(hex.slice(2,4), 16);
|
|
const b = parseInt(hex.slice(4,6), 16);
|
|
gradientColor = `rgba(${r}, ${g}, ${b}, 0.14)`;
|
|
}
|
|
return { backgroundImage: `linear-gradient(to left, ${gradientColor} 0%, transparent 65%)` } as Record<string, string>;
|
|
}
|
|
|
|
typeIcon(filePath: string): string {
|
|
try {
|
|
const t = this.fileTypes.getViewerType(filePath, '');
|
|
switch (t) {
|
|
case 'markdown': return '📝';
|
|
case 'excalidraw': return '✏️';
|
|
case 'pdf': return '📄';
|
|
case 'image': return '🖼️';
|
|
case 'video': return '🎬';
|
|
case 'code': return '</>';
|
|
default: return '📎';
|
|
}
|
|
} catch { return '📎'; }
|
|
}
|
|
|
|
// Sort/View menus
|
|
toggleSortMenu(): void { this.sortMenuOpen.set(!this.sortMenuOpen()); this.viewModeMenuOpen.set(false); }
|
|
toggleViewModeMenu(): void { this.viewModeMenuOpen.set(!this.viewModeMenuOpen()); this.sortMenuOpen.set(false); }
|
|
setSortBy(sort: SortBy): void { this.state.setSortBy(sort); this.sortMenuOpen.set(false); }
|
|
setViewMode(mode: ViewMode): void {
|
|
this.state.setViewMode(mode);
|
|
this.viewModeMenuOpen.set(false);
|
|
}
|
|
|
|
toggleSortOrder(): void {
|
|
this.state.toggleSortOrder();
|
|
}
|
|
getSortLabel(sort: SortBy): string {
|
|
const labels: Record<SortBy, string> = { title: 'Titre', created: 'Date création', updated: 'Date modification' };
|
|
return labels[sort];
|
|
}
|
|
getViewModeLabel(mode: ViewMode): string {
|
|
const labels: Record<ViewMode, string> = { compact: 'Compact', comfortable: 'Confortable', detailed: 'Détaillé' };
|
|
return labels[mode];
|
|
}
|
|
|
|
// Context menu and delete
|
|
openContextMenu(event: MouseEvent, noteId: string) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
const full = this.getFullNoteById(noteId);
|
|
if (full) this.contextMenu.openForNote(full, { x: event.clientX, y: event.clientY });
|
|
}
|
|
|
|
async onContextMenuAction(action: string) {
|
|
const note = this.contextMenu.targetNote();
|
|
if (!note) return;
|
|
switch (action) {
|
|
case 'duplicate': await this.contextMenu.duplicateNote(note); break;
|
|
case 'share': await this.contextMenu.shareNote(note); break;
|
|
case 'fullscreen': this.contextMenu.openFullScreen(note); break;
|
|
case 'copy-link': await this.contextMenu.copyInternalLink(note); break;
|
|
case 'favorite': await this.contextMenu.toggleFavorite(note); break;
|
|
case 'info': this.contextMenu.showPageInfo(note); break;
|
|
case 'readonly': await this.contextMenu.toggleReadOnly(note); break;
|
|
case 'delete': this.openDeleteWarningById(note.id); break;
|
|
}
|
|
}
|
|
|
|
async onContextMenuColor(color: string) {
|
|
const note = this.contextMenu.targetNote();
|
|
if (!note) return;
|
|
await this.contextMenu.changeNoteColor(note, color);
|
|
}
|
|
|
|
openDeleteWarning(note: NoteMetadata) { this.openDeleteWarningById(note.id); }
|
|
openDeleteWarningById(id: string) { this.deleteTargetId = id; this.deleteWarningOpen.set(true); }
|
|
closeDeleteWarning() { this.deleteWarningOpen.set(false); this.deleteTargetId = null; }
|
|
async confirmDelete() {
|
|
const id = this.deleteTargetId; if (!id) { this.closeDeleteWarning(); return; }
|
|
const full = this.getFullNoteById(id); if (!full) { this.closeDeleteWarning(); return; }
|
|
try { await this.contextMenu.deleteNoteConfirmed(full); this.closeDeleteWarning(); this.contextMenu.close(); } catch {}
|
|
}
|
|
|
|
// Edit
|
|
editNote(note: NoteMetadata): void {
|
|
try {
|
|
const full = this.getFullNoteById(note.id);
|
|
if (full?.filePath) {
|
|
const content = (full as any).rawContent ?? full.content ?? '';
|
|
this.editorState.enterEditMode(full.filePath, content);
|
|
this.openNote.emit(note.id);
|
|
}
|
|
} catch { this.openNote.emit(note.id); }
|
|
}
|
|
|
|
// Scroll selected into view
|
|
private scrollToSelectedEffect = effect(() => {
|
|
const id = this.selectedId();
|
|
if (!id || !this.viewport) return;
|
|
const idx = this.visibleNotes().findIndex(n => n.id === id);
|
|
if (idx >= 0) {
|
|
try { this.viewport.scrollToIndex(idx, 'smooth'); } catch {}
|
|
}
|
|
});
|
|
}
|