feat: add Nimbus UI mode with adaptive scrollbars and layout improvements
This commit is contained in:
parent
5a95b33081
commit
55a7a06daa
@ -30,7 +30,13 @@
|
||||
},
|
||||
"configurations": {
|
||||
"production": {
|
||||
"outputHashing": "all"
|
||||
"outputHashing": "all",
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.prod.ts"
|
||||
}
|
||||
]
|
||||
},
|
||||
"development": {
|
||||
"optimization": false,
|
||||
|
||||
21
index.html
21
index.html
@ -25,5 +25,26 @@
|
||||
</head>
|
||||
<body>
|
||||
<app-root></app-root>
|
||||
<script>
|
||||
(function () {
|
||||
const body = document.body;
|
||||
let timer = null;
|
||||
const IDLE_DELAY = 900;
|
||||
const setIdle = () => {
|
||||
body.classList.remove('scrollbar-moving');
|
||||
body.classList.add('scrollbar-idle');
|
||||
};
|
||||
const setMoving = () => {
|
||||
body.classList.add('scrollbar-moving');
|
||||
body.classList.remove('scrollbar-idle');
|
||||
if (timer) clearTimeout(timer);
|
||||
timer = setTimeout(setIdle, IDLE_DELAY);
|
||||
};
|
||||
setIdle();
|
||||
window.addEventListener('mousemove', setMoving, { passive: true });
|
||||
window.addEventListener('wheel', setMoving, { passive: true });
|
||||
window.addEventListener('touchmove', setMoving, { passive: true });
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@ -6,6 +6,7 @@
|
||||
"scripts": {
|
||||
"dev": "ng serve",
|
||||
"build": "ng build",
|
||||
"prod": "ng build --configuration=production",
|
||||
"build:workers": "ng build",
|
||||
"preview": "ng serve --configuration=production --port 3000 --host 127.0.0.1",
|
||||
"test": "ng test",
|
||||
|
||||
@ -1,4 +1,32 @@
|
||||
<!-- ObsiViewer - Application optimisée pour mobile et desktop -->
|
||||
@if (uiMode.isNimbusMode()) {
|
||||
<app-shell-nimbus-layout
|
||||
[vaultName]="vaultName()"
|
||||
[effectiveFileTree]="effectiveFileTree()"
|
||||
[selectedNoteId]="selectedNoteId()"
|
||||
[selectedNote]="selectedNote()"
|
||||
[renderedNoteContent]="renderedNoteContent()"
|
||||
[tableOfContents]="tableOfContents()"
|
||||
[isSidebarOpen]="isSidebarOpen()"
|
||||
[isOutlineOpen]="isOutlineOpen()"
|
||||
[leftSidebarWidth]="leftSidebarWidth()"
|
||||
[rightSidebarWidth]="rightSidebarWidth()"
|
||||
[centerPanelWidth]="centerPanelWidth()"
|
||||
[searchTerm]="sidebarSearchTerm()"
|
||||
[tags]="allTags()"
|
||||
(noteSelected)="selectNote($event)"
|
||||
(tagClicked)="handleTagClick($event)"
|
||||
(wikiLinkActivated)="handleWikiLink($event)"
|
||||
(leftResizeStart)="startLeftResize($event)"
|
||||
(rightResizeStart)="startRightResize($event)"
|
||||
(centerResizeStart)="startCenterResize($event)"
|
||||
(toggleSidebarRequest)="toggleSidebar()"
|
||||
(toggleOutlineRequest)="toggleOutline()"
|
||||
(navigateHeading)="scrollToHeading($event)"
|
||||
(searchTermChange)="onSidebarSearchTermChange($event)"
|
||||
(searchOptionsChange)="onHeaderSearchOptionsChange($event)"
|
||||
></app-shell-nimbus-layout>
|
||||
} @else {
|
||||
<main class="relative flex min-h-screen flex-col bg-bg-main text-text-main lg:flex-row lg:h-screen lg:overflow-hidden">
|
||||
@if (isRawViewOpen()) {
|
||||
<app-raw-view-overlay
|
||||
@ -364,6 +392,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost rounded px-2 py-1 text-xs font-semibold"
|
||||
(click)="toggleUIMode()"
|
||||
[attr.aria-label]="'Basculer vers ' + (uiMode.isNimbusMode() ? 'ancienne' : 'nouvelle') + ' interface'"
|
||||
title="Basculer d'interface"
|
||||
>
|
||||
@if (uiMode.isNimbusMode()) { 🔧 Legacy } @else { ✨ Nimbus }
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleOutline()"
|
||||
class="btn btn-icon btn-ghost"
|
||||
@ -435,6 +472,15 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" /></svg>
|
||||
}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost rounded px-2 py-1 text-xs font-semibold"
|
||||
(click)="toggleUIMode()"
|
||||
[attr.aria-label]="'Basculer vers ' + (uiMode.isNimbusMode() ? 'ancienne' : 'nouvelle') + ' interface'"
|
||||
title="Basculer d'interface"
|
||||
>
|
||||
@if (uiMode.isNimbusMode()) { 🔧 Legacy } @else { ✨ Nimbus }
|
||||
</button>
|
||||
<button
|
||||
(click)="toggleOutline()"
|
||||
class="btn btn-icon btn-ghost"
|
||||
@ -612,4 +658,5 @@
|
||||
</aside>
|
||||
}
|
||||
</main>
|
||||
}
|
||||
|
||||
@ -9,6 +9,7 @@ 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';
|
||||
import { UiModeService } from './app/shared/services/ui-mode.service';
|
||||
|
||||
// Components
|
||||
import { FileExplorerComponent } from './components/file-explorer/file-explorer.component';
|
||||
@ -19,6 +20,7 @@ import { MarkdownCalendarComponent } from './components/markdown-calendar/markdo
|
||||
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 { AppShellNimbusLayoutComponent } from './app/layout/app-shell-nimbus/app-shell-nimbus.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';
|
||||
@ -29,6 +31,7 @@ import { SearchHistoryService } from './core/search/search-history.service';
|
||||
import { GraphIndexService } from './core/graph/graph-index.service';
|
||||
import { SearchIndexService } from './core/search/search-index.service';
|
||||
import { SearchOrchestratorService } from './core/search/search-orchestrator.service';
|
||||
import { LayoutModule } from '@angular/cdk/layout';
|
||||
|
||||
// Types
|
||||
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
|
||||
@ -42,6 +45,7 @@ interface TocEntry {
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [
|
||||
LayoutModule,
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
FileExplorerComponent,
|
||||
@ -56,6 +60,7 @@ interface TocEntry {
|
||||
SearchInputWithAssistantComponent,
|
||||
SearchPanelComponent,
|
||||
DrawingsEditorComponent,
|
||||
AppShellNimbusLayoutComponent,
|
||||
],
|
||||
templateUrl: './app.component.simple.html',
|
||||
styleUrls: ['./app.component.css'],
|
||||
@ -67,6 +72,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private markdownViewerService = inject(MarkdownViewerService);
|
||||
private downloadService = inject(DownloadService);
|
||||
private readonly themeService = inject(ThemeService);
|
||||
readonly uiMode = inject(UiModeService);
|
||||
private readonly bookmarksService = inject(BookmarksService);
|
||||
private readonly searchHistoryService = inject(SearchHistoryService);
|
||||
private readonly graphIndexService = inject(GraphIndexService);
|
||||
@ -87,6 +93,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
tableOfContents = signal<TocEntry[]>([]);
|
||||
leftSidebarWidth = signal<number>(288);
|
||||
rightSidebarWidth = signal<number>(288);
|
||||
centerPanelWidth = signal<number>(384);
|
||||
isRawViewOpen = signal<boolean>(false);
|
||||
isRawViewWrapped = signal<boolean>(true);
|
||||
showAddBookmarkModal = signal<boolean>(false);
|
||||
@ -94,6 +101,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
readonly LEFT_MAX_WIDTH = 520;
|
||||
readonly RIGHT_MIN_WIDTH = 220;
|
||||
readonly RIGHT_MAX_WIDTH = 520;
|
||||
readonly CENTER_MIN_WIDTH = 260;
|
||||
readonly CENTER_MAX_WIDTH = 640;
|
||||
private rawViewTriggerElement: HTMLElement | null = null;
|
||||
private viewportWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||
private resizeHandler = () => {
|
||||
@ -101,6 +110,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
this.viewportWidth.set(window.innerWidth);
|
||||
};
|
||||
|
||||
|
||||
// --- Search cross-UI sync ---
|
||||
onHeaderSearchOptionsChange(opts: { caseSensitive: boolean; regexMode: boolean; highlight: boolean }): void {
|
||||
this.lastCaseSensitive.set(!!opts.caseSensitive);
|
||||
@ -109,6 +119,37 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
this.scheduleApplyDocumentHighlight();
|
||||
}
|
||||
|
||||
startCenterResize(event: PointerEvent): void {
|
||||
event.preventDefault();
|
||||
const handle = event.currentTarget as HTMLElement | null;
|
||||
handle?.setPointerCapture(event.pointerId);
|
||||
|
||||
const startX = event.clientX;
|
||||
const startWidth = this.centerPanelWidth();
|
||||
|
||||
const moveHandler = (moveEvent: PointerEvent) => {
|
||||
const delta = moveEvent.clientX - startX;
|
||||
let newWidth = startWidth + delta;
|
||||
newWidth = Math.max(this.CENTER_MIN_WIDTH, Math.min(this.CENTER_MAX_WIDTH, newWidth));
|
||||
this.centerPanelWidth.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);
|
||||
}
|
||||
|
||||
onFileNodeSelected(noteId: string): void {
|
||||
if (!noteId) return;
|
||||
const meta = this.vaultService.getFastMetaById(noteId);
|
||||
@ -632,6 +673,10 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
this.themeService.toggleTheme();
|
||||
}
|
||||
|
||||
toggleUIMode(): void {
|
||||
this.uiMode.toggleUIMode();
|
||||
}
|
||||
|
||||
toggleSidebar(): void {
|
||||
this.isSidebarOpen.update(value => !value);
|
||||
}
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MobileNavService } from '../../shared/services/mobile-nav.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-bottom-navigation',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<nav class="fixed bottom-0 left-0 right-0 bg-white dark:bg-gray-900 border-t border-gray-200 dark:border-gray-800 h-16 flex justify-around items-center z-50">
|
||||
<button *ngFor="let tab of tabs"
|
||||
(click)="setActiveTab(tab.id)"
|
||||
class="flex-1 flex flex-col items-center justify-center gap-1 text-xs py-2 px-1 text-gray-600 dark:text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-800"
|
||||
[class.text-nimbus-500]="mobileNav.activeTab() === tab.id">
|
||||
<span class="text-lg">{{ tab.icon }}</span>
|
||||
<span class="truncate">{{ tab.label }}</span>
|
||||
</button>
|
||||
</nav>
|
||||
`
|
||||
})
|
||||
export class AppBottomNavigationComponent {
|
||||
mobileNav = inject(MobileNavService);
|
||||
|
||||
tabs = [
|
||||
{ id: 'sidebar', icon: '📁', label: 'Dossiers' },
|
||||
{ id: 'list', icon: '🔍', label: 'Liste' },
|
||||
{ id: 'page', icon: '📄', label: 'Page' },
|
||||
{ id: 'toc', icon: '📋', label: 'Sommaire' }
|
||||
];
|
||||
|
||||
setActiveTab(tabId: 'sidebar' | 'list' | 'page' | 'toc') {
|
||||
this.mobileNav.setActiveTab(tabId);
|
||||
}
|
||||
}
|
||||
95
src/app/features/list/notes-list.component.ts
Normal file
95
src/app/features/list/notes-list.component.ts
Normal file
@ -0,0 +1,95 @@
|
||||
import { Component, EventEmitter, Output, computed, signal, effect } from '@angular/core';
|
||||
import { input } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import type { Note } from '../../../types';
|
||||
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-notes-list',
|
||||
standalone: true,
|
||||
imports: [CommonModule, ScrollableOverlayDirective],
|
||||
template: `
|
||||
<div class="h-full flex flex-col">
|
||||
<div class="p-2 border-b border-gray-200 dark:border-gray-800">
|
||||
<input type="text"
|
||||
[value]="query()"
|
||||
(input)="onQuery($any($event.target).value)"
|
||||
placeholder="Rechercher..."
|
||||
class="w-full rounded border border-gray-300 dark:border-gray-700 bg-white dark:bg-gray-900 px-3 py-2 text-sm" />
|
||||
</div>
|
||||
<div class="flex-1 min-h-0 overflow-y-auto list-scroll" appScrollableOverlay>
|
||||
<ul class="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
<li *ngFor="let n of filtered()" class="p-3 hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer" (click)="openNote.emit(n.id)">
|
||||
<div class="text-sm font-semibold truncate">{{ n.title }}</div>
|
||||
<div class="text-xs text-gray-500 truncate">{{ n.filePath }}</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
styles: [`
|
||||
:host {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-height: 0; /* critical for nested flex scrolling */
|
||||
}
|
||||
|
||||
/* Smooth, bounded vertical scrolling only on the list area */
|
||||
.list-scroll {
|
||||
overscroll-behavior: contain; /* prevent parent scroll chaining */
|
||||
-webkit-overflow-scrolling: touch; /* momentum scrolling on iOS */
|
||||
scroll-behavior: smooth; /* smooth programmatic scrolls */
|
||||
scrollbar-gutter: stable both-edges; /* avoid layout shift when scrollbar shows */
|
||||
max-height: 100%; /* cap to available space within the central section */
|
||||
contain: content; /* small perf win for large lists */
|
||||
}
|
||||
`]
|
||||
})
|
||||
export class NotesListComponent {
|
||||
notes = input<Note[]>([]);
|
||||
folderFilter = input<string | null>(null); // like "folder/subfolder"
|
||||
query = input<string>('');
|
||||
tagFilter = input<string | null>(null);
|
||||
|
||||
@Output() openNote = new EventEmitter<string>();
|
||||
@Output() queryChange = new EventEmitter<string>();
|
||||
|
||||
private q = signal('');
|
||||
private syncQuery = effect(() => {
|
||||
this.q.set(this.query() || '');
|
||||
});
|
||||
|
||||
filtered = computed(() => {
|
||||
const q = (this.q() || '').toLowerCase().trim();
|
||||
const folder = (this.folderFilter() || '').toLowerCase();
|
||||
const tag = (this.tagFilter() || '').toLowerCase();
|
||||
let list = this.notes();
|
||||
|
||||
if (folder) {
|
||||
list = list.filter(n => (n.originalPath || '').toLowerCase().startsWith(folder));
|
||||
}
|
||||
|
||||
if (tag) {
|
||||
list = list.filter(n => Array.isArray(n.tags) && n.tags.some(t => (t || '').toLowerCase() === tag));
|
||||
}
|
||||
|
||||
// Apply query if present
|
||||
if (q) {
|
||||
list = list.filter(n => {
|
||||
const title = (n.title || '').toLowerCase();
|
||||
const filePath = (n.filePath || '').toLowerCase();
|
||||
return title.includes(q) || filePath.includes(q);
|
||||
});
|
||||
}
|
||||
|
||||
// Sort by most recent first (mtime desc; fallback updatedAt/createdAt)
|
||||
const parseDate = (s?: string) => (s ? Date.parse(s) : 0) || 0;
|
||||
const score = (n: Note) => n.mtime || parseDate(n.updatedAt) || parseDate(n.createdAt) || 0;
|
||||
return [...list].sort((a, b) => (score(b) - score(a)));
|
||||
});
|
||||
|
||||
onQuery(v: string) {
|
||||
this.q.set(v);
|
||||
this.queryChange.emit(v);
|
||||
}
|
||||
}
|
||||
33
src/app/features/note-view/app-toc-overlay.component.ts
Normal file
33
src/app/features/note-view/app-toc-overlay.component.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-toc-overlay',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="fixed inset-0 z-50 bg-black/50" (click)="close.emit()"></div>
|
||||
<div class="fixed inset-x-0 bottom-0 z-50 max-h-[70vh] rounded-t-2xl bg-white dark:bg-gray-900 shadow-2xl border-t border-gray-200 dark:border-gray-800">
|
||||
<div class="p-4 flex items-center justify-between border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 class="text-base font-semibold">Sommaire</h2>
|
||||
<button (click)="close.emit()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">✕</button>
|
||||
</div>
|
||||
<div class="p-3 overflow-y-auto">
|
||||
<ul class="space-y-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
<li *ngFor="let h of headings">
|
||||
<a (click)="onGo(h.id)" class="block px-2 py-1 rounded hover:bg-gray-50 dark:hover:bg-gray-800 cursor-pointer" [style.paddingLeft.rem]="(h.level - 1) * 0.75">{{ h.text }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class AppTocOverlayComponent {
|
||||
@Input() headings: Array<{ level: number; text: string; id: string }> = [];
|
||||
@Output() go = new EventEmitter<string>();
|
||||
@Output() close = new EventEmitter<void>();
|
||||
|
||||
onGo(id: string) {
|
||||
this.go.emit(id);
|
||||
}
|
||||
}
|
||||
26
src/app/features/quick-links/quick-links.component.ts
Normal file
26
src/app/features/quick-links/quick-links.component.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import { Component, EventEmitter, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
@Component({
|
||||
selector: 'app-quick-links',
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
template: `
|
||||
<div class="p-3">
|
||||
<ul class="text-sm">
|
||||
<li><button (click)="select('all')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>🗂️</span> <span>All pages</span></button></li>
|
||||
<li><button (click)="select('favorites')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>❤️</span> <span>Favorites</span></button></li>
|
||||
<li><button (click)="select('templates')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>📑</span> <span>Templates</span></button></li>
|
||||
<li><button (click)="select('import')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>⬇️</span> <span>Import</span></button></li>
|
||||
<li><button (click)="select('tasks')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>🗒️</span> <span>Tasks</span></button></li>
|
||||
<li><button (click)="select('chat')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>💬</span> <span>Chat</span></button></li>
|
||||
<li><button (click)="select('activity')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>🕒</span> <span>Activity Panel</span></button></li>
|
||||
<li><button (click)="select('portal')" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 flex items-center gap-2"><span>📰</span> <span>Portal</span></button></li>
|
||||
</ul>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class QuickLinksComponent {
|
||||
@Output() quickLinkSelected = new EventEmitter<string>();
|
||||
select(id: string) { this.quickLinkSelected.emit(id); }
|
||||
}
|
||||
43
src/app/features/sidebar/app-sidebar-drawer.component.ts
Normal file
43
src/app/features/sidebar/app-sidebar-drawer.component.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { MobileNavService } from '../../shared/services/mobile-nav.service';
|
||||
import { FileExplorerComponent } from '../../../components/file-explorer/file-explorer.component';
|
||||
import type { VaultNode } from '../../../types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-sidebar-drawer',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FileExplorerComponent],
|
||||
template: `
|
||||
<aside class="fixed left-0 top-0 bottom-0 w-80 max-w-[80vw] bg-white dark:bg-gray-900 shadow-lg z-40 transform transition-transform duration-300 ease-in-out"
|
||||
[class.-translate-x-full]="!mobileNav.sidebarOpen()">
|
||||
<div class="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 class="text-lg font-semibold">Navigation</h2>
|
||||
<button (click)="mobileNav.toggleSidebar()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">✕</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto p-2">
|
||||
<app-file-explorer [nodes]="nodes" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" (folderSelected)="onFolder($event)" (fileSelected)="onSelect($event)"></app-file-explorer>
|
||||
</div>
|
||||
</aside>
|
||||
<div *ngIf="mobileNav.sidebarOpen()" (click)="mobileNav.toggleSidebar()" class="fixed inset-0 bg-black/50 z-30"></div>
|
||||
`
|
||||
})
|
||||
export class AppSidebarDrawerComponent {
|
||||
mobileNav = inject(MobileNavService);
|
||||
|
||||
@Input() nodes: VaultNode[] = [];
|
||||
@Input() selectedNoteId: string | null = null;
|
||||
@Output() noteSelected = new EventEmitter<string>();
|
||||
@Output() folderSelected = new EventEmitter<string>();
|
||||
|
||||
onSelect(id: string) {
|
||||
this.noteSelected.emit(id);
|
||||
this.mobileNav.sidebarOpen.set(false);
|
||||
}
|
||||
|
||||
onFolder(path: string) {
|
||||
if (path) {
|
||||
this.folderSelected.emit(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
src/app/features/sidebar/nimbus-sidebar.component.ts
Normal file
98
src/app/features/sidebar/nimbus-sidebar.component.ts
Normal file
@ -0,0 +1,98 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FileExplorerComponent } from '../../../components/file-explorer/file-explorer.component';
|
||||
import { QuickLinksComponent } from '../quick-links/quick-links.component';
|
||||
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
|
||||
import type { VaultNode, TagInfo } from '../../../types';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nimbus-sidebar',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FileExplorerComponent, QuickLinksComponent, ScrollableOverlayDirective],
|
||||
host: { class: 'block h-full' },
|
||||
template: `
|
||||
<div class="h-full flex flex-col overflow-hidden select-none">
|
||||
<!-- Header -->
|
||||
<div class="h-12 flex items-center justify-between px-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<div class="text-sm font-semibold truncate">{{ vaultName }}</div>
|
||||
<button (click)="toggleSidebarRequest.emit()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" title="Hide Sidebar">⟨⟨</button>
|
||||
</div>
|
||||
|
||||
<!-- Content (scroll) -->
|
||||
<div class="flex-1 overflow-y-auto min-h-0" appScrollableOverlay>
|
||||
<!-- Quick Links accordion -->
|
||||
<section class="border-b border-gray-200 dark:border-gray-800">
|
||||
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
(click)="open.quick = !open.quick">
|
||||
<span>Quick Links</span>
|
||||
<span class="text-xs text-gray-500">{{ open.quick ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div *ngIf="open.quick" class="pt-1">
|
||||
<app-quick-links (quickLinkSelected)="onQuickLink($event)"></app-quick-links>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Folders accordion -->
|
||||
<section class="border-b border-gray-200 dark:border-gray-800">
|
||||
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
(click)="open.folders = !open.folders">
|
||||
<span>Folders</span>
|
||||
<span class="text-xs text-gray-500">{{ open.folders ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div *ngIf="open.folders" class="px-1 py-1">
|
||||
<app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" (folderSelected)="folderSelected.emit($event)" (fileSelected)="fileSelected.emit($event)"></app-file-explorer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Tags accordion -->
|
||||
<section class="border-b border-gray-200 dark:border-gray-800">
|
||||
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
(click)="open.tags = !open.tags">
|
||||
<span>Tags</span>
|
||||
<span class="text-xs text-gray-500">{{ open.tags ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div *ngIf="open.tags" class="px-2 py-2">
|
||||
<ul class="space-y-0.5 text-sm">
|
||||
<li *ngFor="let t of tags" class="flex items-center gap-2">
|
||||
<button (click)="tagSelected.emit(t.name)" class="flex-1 text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 truncate">
|
||||
<span>🏷️</span>
|
||||
<span class="ml-1">{{ t.name }}</span>
|
||||
</button>
|
||||
<span class="text-xs text-gray-500">{{ t.count }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Trash accordion -->
|
||||
<section class="border-b border-gray-200 dark:border-gray-800">
|
||||
<button class="w-full flex items-center justify-between px-3 py-2 text-sm font-semibold hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||
(click)="open.trash = !open.trash">
|
||||
<span>Trash</span>
|
||||
<span class="text-xs text-gray-500">{{ open.trash ? '▾' : '▸' }}</span>
|
||||
</button>
|
||||
<div *ngIf="open.trash" class="px-3 py-3 text-sm text-gray-500 dark:text-gray-400">Empty</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Footer placeholder -->
|
||||
<div class="h-14 border-t border-gray-200 dark:border-gray-800 flex items-center px-3 text-xs text-gray-500">ObsiViewer</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class NimbusSidebarComponent {
|
||||
@Input() vaultName = '';
|
||||
@Input() effectiveFileTree: VaultNode[] = [];
|
||||
@Input() selectedNoteId: string | null = null;
|
||||
@Input() tags: TagInfo[] = [];
|
||||
|
||||
@Output() toggleSidebarRequest = new EventEmitter<void>();
|
||||
@Output() folderSelected = new EventEmitter<string>();
|
||||
@Output() fileSelected = new EventEmitter<string>();
|
||||
@Output() tagSelected = new EventEmitter<string>();
|
||||
@Output() quickLinkSelected = new EventEmitter<string>();
|
||||
|
||||
open = { quick: true, folders: true, tags: false, trash: false };
|
||||
|
||||
onQuickLink(id: string) { this.quickLinkSelected.emit(id); }
|
||||
}
|
||||
306
src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts
Normal file
306
src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts
Normal file
@ -0,0 +1,306 @@
|
||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { UiModeService } from '../../shared/services/ui-mode.service';
|
||||
import { ResponsiveService } from '../../shared/services/responsive.service';
|
||||
import { MobileNavService } from '../../shared/services/mobile-nav.service';
|
||||
import { FileExplorerComponent } from '../../../components/file-explorer/file-explorer.component';
|
||||
import { NoteViewerComponent } from '../../../components/tags-view/note-viewer/note-viewer.component';
|
||||
import { VaultService } from '../../../services/vault.service';
|
||||
import type { VaultNode, Note, TagInfo } from '../../../types';
|
||||
import { AppBottomNavigationComponent } from '../../features/bottom-nav/app-bottom-navigation.component';
|
||||
import { AppSidebarDrawerComponent } from '../../features/sidebar/app-sidebar-drawer.component';
|
||||
import { AppTocOverlayComponent } from '../../features/note-view/app-toc-overlay.component';
|
||||
import { SwipeNavDirective } from '../../shared/directives/swipe-nav.directive';
|
||||
import { NotesListComponent } from '../../features/list/notes-list.component';
|
||||
import { NimbusSidebarComponent } from '../../features/sidebar/nimbus-sidebar.component';
|
||||
import { QuickLinksComponent } from '../../features/quick-links/quick-links.component';
|
||||
import { ScrollableOverlayDirective } from '../../shared/overlay-scrollbar/scrollable-overlay.directive';
|
||||
|
||||
@Component({
|
||||
selector: 'app-shell-nimbus-layout',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FileExplorerComponent, NoteViewerComponent, AppBottomNavigationComponent, AppSidebarDrawerComponent, AppTocOverlayComponent, SwipeNavDirective, NotesListComponent, NimbusSidebarComponent, QuickLinksComponent, ScrollableOverlayDirective],
|
||||
template: `
|
||||
<div class="h-screen flex flex-col bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100">
|
||||
<!-- Header (desktop/tablet), compact on mobile) -->
|
||||
<header class="flex items-center justify-between px-4 border-b border-gray-200 dark:border-gray-800 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm" [class.h-14]="responsive.isDesktop() || responsive.isTablet()" [class.h-12]="responsive.isMobile()">
|
||||
<div class="flex items-center gap-2 min-w-0">
|
||||
<button *ngIf="responsive.isMobile()" (click)="mobileNav.toggleSidebar()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">☰</button>
|
||||
<span class="inline-flex items-center rounded-lg border border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800 px-2.5 py-1 text-xs font-semibold uppercase tracking-wide text-gray-600 dark:text-gray-300 truncate">{{ vaultName }}</span>
|
||||
<h1 class="hidden sm:block text-base font-semibold truncate">ObsiViewer · Nimbus</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button class="hidden lg:inline-flex p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (click)="toggleSidebarRequest.emit()" [attr.aria-expanded]="isSidebarOpen" title="Basculer la barre latérale gauche">
|
||||
<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"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M9 3v18"/><path d="m16 15-3-3 3-3"/></svg>
|
||||
</button>
|
||||
<button class="hidden xl:inline-flex p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (click)="toggleOutlineRequest.emit()" [attr.aria-expanded]="isOutlineOpen" title="Basculer la barre latérale droite">
|
||||
<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"><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M15 3v18"/><path d="m10 15-3-3 3-3"/></svg>
|
||||
</button>
|
||||
<button (click)="ui.toggleUIMode()" class="px-2 py-1 rounded text-xs font-semibold border border-gray-300 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800">
|
||||
{{ ui.isNimbusMode() ? '🔧 Legacy' : '✨ Nimbus' }}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Desktop 3-column layout -->
|
||||
<div *ngIf="responsive.isDesktop()" class="flex-1 flex overflow-hidden relative">
|
||||
<!-- Left: full sidebar or collapsed rail -->
|
||||
<ng-container *ngIf="isSidebarOpen; else collapsedRail">
|
||||
<aside class="flex flex-col border-r border-gray-200 dark:border-gray-800 min-h-0" [style.width.px]="leftSidebarWidth">
|
||||
<app-nimbus-sidebar
|
||||
[vaultName]="vaultName"
|
||||
[effectiveFileTree]="effectiveFileTree"
|
||||
[selectedNoteId]="selectedNoteId"
|
||||
[tags]="tags"
|
||||
(toggleSidebarRequest)="toggleSidebarRequest.emit()"
|
||||
(folderSelected)="onFolderSelected($event)"
|
||||
(fileSelected)="noteSelected.emit($event)"
|
||||
(tagSelected)="onTagSelected($event)"
|
||||
(quickLinkSelected)="onQuickLink($event)"
|
||||
/>
|
||||
</aside>
|
||||
</ng-container>
|
||||
<ng-template #collapsedRail>
|
||||
<aside class="border-r border-gray-200 dark:border-gray-800 h-full w-14 flex flex-col items-center py-3 gap-3">
|
||||
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (click)="toggleSidebarRequest.emit()" title="Show Sidebar">☰</button>
|
||||
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (mouseenter)="openFlyout('quick')" (mouseleave)="scheduleCloseFlyout()" title="Quick Links">▦</button>
|
||||
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (mouseenter)="openFlyout('folders')" (mouseleave)="scheduleCloseFlyout()" title="Folders">📁</button>
|
||||
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (mouseenter)="openFlyout('tags')" (mouseleave)="scheduleCloseFlyout()" title="Tags">🏷️</button>
|
||||
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (mouseenter)="openFlyout('trash')" (mouseleave)="scheduleCloseFlyout()" title="Trash">🗑️</button>
|
||||
</aside>
|
||||
|
||||
<!-- Flyouts -->
|
||||
<div class="absolute left-14 top-0 bottom-0 w-80 max-w-[70vw] bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-800 shadow-xl" *ngIf="hoveredFlyout as f" (mouseenter)="cancelCloseFlyout()" (mouseleave)="scheduleCloseFlyout()">
|
||||
<div class="h-12 flex items-center justify-between px-3 border-b border-gray-200 dark:border-gray-800">
|
||||
<div class="text-sm font-semibold">{{ f === 'quick' ? 'Quick Links' : (f === 'folders' ? 'Folders' : (f === 'tags' ? 'Tags' : 'Trash')) }}</div>
|
||||
<button class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800" (click)="hoveredFlyout=null">✕</button>
|
||||
</div>
|
||||
<div class="h-[calc(100%-3rem)] overflow-y-auto" appScrollableOverlay>
|
||||
<ng-container [ngSwitch]="f">
|
||||
<app-quick-links *ngSwitchCase="'quick'" (quickLinkSelected)="onQuickLink($event)"></app-quick-links>
|
||||
<div *ngSwitchCase="'folders'" class="p-2">
|
||||
<app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" (folderSelected)="onFolderSelected($event)" (fileSelected)="noteSelected.emit($event)"></app-file-explorer>
|
||||
</div>
|
||||
<div *ngSwitchCase="'tags'" class="p-2">
|
||||
<ul class="space-y-0.5 text-sm">
|
||||
<li *ngFor="let t of tags">
|
||||
<button (click)="onTagSelected(t.name)" class="w-full text-left px-2 py-1 rounded hover:bg-gray-100 dark:hover:bg-gray-800 truncate">🏷️ {{ t.name }} <span class="text-xs text-gray-500">{{ t.count }}</span></button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div *ngSwitchDefault class="p-3 text-sm text-gray-500">Empty</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<!-- Left Resizer -->
|
||||
<div class="h-full w-1 cursor-col-resize hover:bg-gray-200/50 dark:hover:bg-gray-700/50" (pointerdown)="leftResizeStart.emit($event)" role="separator" aria-orientation="vertical" aria-label="Redimensionner la barre latérale gauche"></div>
|
||||
|
||||
<!-- Center List -->
|
||||
<section class="border-r border-gray-200 dark:border-gray-800 overflow-hidden" [style.width.px]="centerPanelWidth">
|
||||
<div class="h-full flex flex-col">
|
||||
<app-notes-list class="flex-1"
|
||||
[notes]="vault.allNotes()"
|
||||
[folderFilter]="folderFilter"
|
||||
[tagFilter]="tagFilter"
|
||||
[query]="listQuery"
|
||||
(openNote)="onOpenNote($event)"
|
||||
(queryChange)="listQuery = $event"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Center Resizer (between list and note) -->
|
||||
<div class="h-full w-1 cursor-col-resize hover:bg-gray-200/50 dark:hover:bg-gray-700/50" (pointerdown)="centerResizeStart.emit($event)" role="separator" aria-orientation="vertical" aria-label="Redimensionner la zone de liste"></div>
|
||||
|
||||
<!-- Note View + ToC -->
|
||||
<section class="flex-1 relative min-w-0 flex">
|
||||
<div class="note-content-area flex-1 overflow-y-auto px-4 py-4 lg:px-8" appScrollableOverlay>
|
||||
<app-note-viewer
|
||||
[note]="selectedNote || null"
|
||||
[noteHtmlContent]="renderedNoteContent"
|
||||
[allNotes]="vault.allNotes()"
|
||||
(noteLinkClicked)="noteSelected.emit($event)"
|
||||
(tagClicked)="tagClicked.emit($event)"
|
||||
(wikiLinkActivated)="wikiLinkActivated.emit($event)"
|
||||
></app-note-viewer>
|
||||
</div>
|
||||
<aside class="hidden xl:block border-l border-gray-200 dark:border-gray-800 overflow-y-auto" appScrollableOverlay [style.width.px]="isOutlineOpen ? rightSidebarWidth : 0" [class.opacity-0]="!isOutlineOpen" [class.pointer-events-none]="!isOutlineOpen">
|
||||
<div class="p-3">
|
||||
<h2 class="text-sm font-semibold mb-2">Sommaire</h2>
|
||||
<ul class="space-y-1 text-sm text-gray-600 dark:text-gray-300">
|
||||
<li *ngFor="let h of tableOfContents">
|
||||
<a class="block truncate hover:text-gray-900 dark:hover:text-white cursor-pointer" (click)="navigateHeading.emit(h.id)" [style.paddingLeft.rem]="(h.level - 1) * 0.75">{{ h.text }}</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Tablet: simple tabbed areas -->
|
||||
<div *ngIf="responsive.isTablet()" class="flex-1 flex flex-col overflow-hidden">
|
||||
<div class="h-12 border-b border-gray-200 dark:border-gray-800 flex items-center">
|
||||
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'sidebar'" (click)="mobileNav.setActiveTab('sidebar')">Sidebar</button>
|
||||
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'list'" (click)="mobileNav.setActiveTab('list')">Liste</button>
|
||||
<button class="flex-1 h-full text-sm" [class.text-nimbus-500]="mobileNav.activeTab() === 'page'" (click)="mobileNav.setActiveTab('page')">Page</button>
|
||||
</div>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<div [hidden]="mobileNav.activeTab() !== 'sidebar'" class="h-full overflow-y-auto p-2" appScrollableOverlay>
|
||||
<app-file-explorer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" [foldersOnly]="true" (folderSelected)="onFolderSelected($event)" (fileSelected)="onOpenNote($event)"></app-file-explorer>
|
||||
</div>
|
||||
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full overflow-y-auto" appScrollableOverlay>
|
||||
<app-notes-list [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [query]="listQuery" (queryChange)="listQuery=$event" (openNote)="onOpenNote($event)"></app-notes-list>
|
||||
</div>
|
||||
<div [hidden]="mobileNav.activeTab() !== 'page'" class="h-full overflow-y-auto px-3 py-4" appScrollableOverlay>
|
||||
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)"></app-note-viewer>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Mobile: bottom nav + drawer + swipe -->
|
||||
<div *ngIf="responsive.isMobile()" class="flex-1 relative overflow-hidden" appSwipeNav (swipeLeft)="nextTab()" (swipeRight)="prevTab()">
|
||||
<app-sidebar-drawer [nodes]="effectiveFileTree" [selectedNoteId]="selectedNoteId" (noteSelected)="onNoteSelectedMobile($event)" (folderSelected)="onFolderSelectedFromDrawer($event)"></app-sidebar-drawer>
|
||||
|
||||
<div [hidden]="mobileNav.activeTab() !== 'list'" class="h-full flex flex-col overflow-hidden">
|
||||
<app-notes-list class="flex-1" [notes]="vault.allNotes()" [folderFilter]="folderFilter" [tagFilter]="tagFilter" [query]="listQuery" (queryChange)="listQuery=$event" (openNote)="onNoteSelectedMobile($event)"></app-notes-list>
|
||||
</div>
|
||||
|
||||
<div [hidden]="mobileNav.activeTab() !== 'page'" class="h-full overflow-y-auto px-3 py-3" appScrollableOverlay>
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h2 class="text-base font-semibold truncate">{{ selectedNote?.title || 'Aucune page' }}</h2>
|
||||
<button *ngIf="tableOfContents.length > 0" (click)="mobileNav.toggleToc()" class="p-2 rounded hover:bg-gray-100 dark:hover:bg-gray-800">📋</button>
|
||||
</div>
|
||||
<app-note-viewer [note]="selectedNote || null" [noteHtmlContent]="renderedNoteContent" [allNotes]="vault.allNotes()" (noteLinkClicked)="noteSelected.emit($event)" (tagClicked)="tagClicked.emit($event)" (wikiLinkActivated)="wikiLinkActivated.emit($event)"></app-note-viewer>
|
||||
</div>
|
||||
|
||||
<app-toc-overlay *ngIf="mobileNav.tocOpen()" [headings]="tableOfContents" (go)="navigateHeading.emit($event); mobileNav.toggleToc()" (close)="mobileNav.toggleToc()"></app-toc-overlay>
|
||||
|
||||
<app-bottom-navigation></app-bottom-navigation>
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
})
|
||||
export class AppShellNimbusLayoutComponent {
|
||||
ui = inject(UiModeService);
|
||||
vault = inject(VaultService);
|
||||
responsive = inject(ResponsiveService);
|
||||
mobileNav = inject(MobileNavService);
|
||||
|
||||
@Input() vaultName = '';
|
||||
@Input() effectiveFileTree: VaultNode[] = [];
|
||||
@Input() selectedNoteId: string | null = '';
|
||||
@Input() selectedNote: Note | undefined;
|
||||
@Input() renderedNoteContent = '';
|
||||
@Input() tableOfContents: Array<{ level: number; text: string; id: string }> = [];
|
||||
@Input() isSidebarOpen = true;
|
||||
@Input() isOutlineOpen = true;
|
||||
@Input() leftSidebarWidth = 288;
|
||||
@Input() rightSidebarWidth = 288;
|
||||
@Input() searchTerm = '';
|
||||
@Input() centerPanelWidth = 384;
|
||||
@Input() tags: TagInfo[] = [];
|
||||
|
||||
@Output() noteSelected = new EventEmitter<string>();
|
||||
@Output() tagClicked = new EventEmitter<string>();
|
||||
@Output() wikiLinkActivated = new EventEmitter<any>();
|
||||
@Output() toggleSidebarRequest = new EventEmitter<void>();
|
||||
@Output() toggleOutlineRequest = new EventEmitter<void>();
|
||||
@Output() leftResizeStart = new EventEmitter<PointerEvent>();
|
||||
@Output() rightResizeStart = new EventEmitter<PointerEvent>();
|
||||
@Output() centerResizeStart = new EventEmitter<PointerEvent>();
|
||||
@Output() navigateHeading = new EventEmitter<string>();
|
||||
@Output() searchTermChange = new EventEmitter<string>();
|
||||
@Output() searchOptionsChange = new EventEmitter<any>();
|
||||
|
||||
folderFilter: string | null = null;
|
||||
listQuery: string = '';
|
||||
hoveredFlyout: 'quick' | 'folders' | 'tags' | 'trash' | null = null;
|
||||
private flyoutCloseTimer: any = null;
|
||||
tagFilter: string | null = null;
|
||||
|
||||
nextTab() {
|
||||
const order: Array<'sidebar' | 'list' | 'page' | 'toc'> = ['sidebar', 'list', 'page', 'toc'];
|
||||
const idx = order.indexOf(this.mobileNav.activeTab());
|
||||
const next = order[Math.min(order.length - 1, idx + 1)] as any;
|
||||
this.mobileNav.setActiveTab(next);
|
||||
}
|
||||
|
||||
prevTab() {
|
||||
const order: Array<'sidebar' | 'list' | 'page' | 'toc'> = ['sidebar', 'list', 'page', 'toc'];
|
||||
const idx = order.indexOf(this.mobileNav.activeTab());
|
||||
const prev = order[Math.max(0, idx - 1)] as any;
|
||||
this.mobileNav.setActiveTab(prev);
|
||||
}
|
||||
|
||||
onOpenNote(noteId: string) {
|
||||
this.noteSelected.emit(noteId);
|
||||
}
|
||||
|
||||
onNoteSelectedMobile(noteId: string) {
|
||||
if (!noteId) return;
|
||||
this.noteSelected.emit(noteId);
|
||||
this.mobileNav.setActiveTab('page');
|
||||
}
|
||||
|
||||
onFolderSelected(path: string) {
|
||||
this.folderFilter = path || null;
|
||||
this.tagFilter = null; // clear tag when focusing folder
|
||||
if (this.responsive.isMobile() || this.responsive.isTablet()) {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
}
|
||||
|
||||
onFolderSelectedFromDrawer(path: string) {
|
||||
this.folderFilter = path || null;
|
||||
this.mobileNav.setActiveTab('list');
|
||||
this.mobileNav.sidebarOpen.set(false);
|
||||
}
|
||||
|
||||
onQuickLink(_id: string) {
|
||||
if (_id === 'all') {
|
||||
// Show all pages: clear filters and focus list
|
||||
this.folderFilter = null;
|
||||
this.tagFilter = null;
|
||||
this.listQuery = '';
|
||||
if (!this.responsive.isDesktop()) {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
// If flyout is open, keep it or close? Close gracefully
|
||||
this.scheduleCloseFlyout(150);
|
||||
}
|
||||
}
|
||||
|
||||
onTagSelected(tagName: string) {
|
||||
const norm = (tagName || '').replace(/^#/, '').trim();
|
||||
if (!norm) return;
|
||||
this.tagFilter = norm;
|
||||
this.folderFilter = null; // clear folder when focusing tag
|
||||
if (!this.responsive.isDesktop()) {
|
||||
this.mobileNav.setActiveTab('list');
|
||||
}
|
||||
// If from flyout, do not close immediately; small delay allows click feedback
|
||||
this.scheduleCloseFlyout(200);
|
||||
}
|
||||
|
||||
openFlyout(which: 'quick' | 'folders' | 'tags' | 'trash') {
|
||||
this.cancelCloseFlyout();
|
||||
this.hoveredFlyout = which;
|
||||
}
|
||||
|
||||
scheduleCloseFlyout(delay = 200) {
|
||||
this.cancelCloseFlyout();
|
||||
this.flyoutCloseTimer = setTimeout(() => {
|
||||
this.hoveredFlyout = null;
|
||||
this.flyoutCloseTimer = null;
|
||||
}, delay);
|
||||
}
|
||||
|
||||
cancelCloseFlyout() {
|
||||
if (this.flyoutCloseTimer) {
|
||||
clearTimeout(this.flyoutCloseTimer);
|
||||
this.flyoutCloseTimer = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/app/shared/directives/adaptive-scrollbar.directive.ts
Normal file
91
src/app/shared/directives/adaptive-scrollbar.directive.ts
Normal file
@ -0,0 +1,91 @@
|
||||
import { Directive, ElementRef, NgZone, OnDestroy } from '@angular/core';
|
||||
import { signal } from '@angular/core';
|
||||
|
||||
type ScrollState = 'idle' | 'mini' | 'active';
|
||||
|
||||
@Directive({
|
||||
selector: '[appAdaptiveScrollbar]',
|
||||
standalone: true,
|
||||
host: {
|
||||
class: 'adaptive-scrollbar',
|
||||
'[attr.data-scroll-state]': 'state()'
|
||||
}
|
||||
})
|
||||
export class AdaptiveScrollbarDirective implements OnDestroy {
|
||||
readonly state = signal<ScrollState>('idle');
|
||||
private idleDelay = 1500;
|
||||
private edgeThreshold = 16;
|
||||
private idleTimer: any = null;
|
||||
private cleanup: Array<() => void> = [];
|
||||
|
||||
constructor(private el: ElementRef<HTMLElement>, private zone: NgZone) {
|
||||
this.zone.runOutsideAngular(() => {
|
||||
const element = this.el.nativeElement;
|
||||
|
||||
const onMouseEnter = () => this.transitionTo('mini');
|
||||
const onMouseLeave = () => this.scheduleIdle();
|
||||
const onMouseMove = (event: MouseEvent) => this.handlePointerMove(event.clientX, event.clientY);
|
||||
const onTouchMove = () => this.transitionTo('mini');
|
||||
const onWheel = () => this.transitionTo('mini');
|
||||
const onScroll = () => this.transitionTo('mini');
|
||||
|
||||
this.register(element, 'mouseenter', onMouseEnter, { passive: true });
|
||||
this.register(element, 'mouseleave', onMouseLeave, { passive: true });
|
||||
this.register(element, 'mousemove', onMouseMove, { passive: true });
|
||||
this.register(element, 'touchstart', onTouchMove, { passive: true });
|
||||
this.register(element, 'touchmove', onTouchMove, { passive: true });
|
||||
this.register(element, 'wheel', onWheel, { passive: true });
|
||||
this.register(element, 'scroll', onScroll, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearIdleTimer();
|
||||
for (const dispose of this.cleanup) {
|
||||
try { dispose(); } catch { /* ignore */ }
|
||||
}
|
||||
this.cleanup = [];
|
||||
}
|
||||
|
||||
private transitionTo(target: ScrollState): void {
|
||||
this.clearIdleTimer();
|
||||
if (this.state() !== target) {
|
||||
this.state.set(target);
|
||||
}
|
||||
if (target !== 'idle') {
|
||||
this.scheduleIdle();
|
||||
}
|
||||
}
|
||||
|
||||
private handlePointerMove(clientX: number, clientY: number): void {
|
||||
const rect = this.el.nativeElement.getBoundingClientRect();
|
||||
const nearRightEdge = clientX >= rect.right - this.edgeThreshold && clientX <= rect.right + 4;
|
||||
const withinVerticalBounds = clientY >= rect.top && clientY <= rect.bottom;
|
||||
|
||||
if (nearRightEdge && withinVerticalBounds) {
|
||||
this.transitionTo('active');
|
||||
} else {
|
||||
this.transitionTo('mini');
|
||||
}
|
||||
}
|
||||
|
||||
private scheduleIdle(): void {
|
||||
this.clearIdleTimer();
|
||||
this.idleTimer = setTimeout(() => {
|
||||
this.state.set('idle');
|
||||
this.idleTimer = null;
|
||||
}, this.idleDelay);
|
||||
}
|
||||
|
||||
private clearIdleTimer(): void {
|
||||
if (this.idleTimer) {
|
||||
clearTimeout(this.idleTimer);
|
||||
this.idleTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private register<T extends Event>(element: HTMLElement, type: string, handler: (event: any) => void, options?: AddEventListenerOptions): void {
|
||||
element.addEventListener(type, handler as any, options);
|
||||
this.cleanup.push(() => element.removeEventListener(type, handler as any, options));
|
||||
}
|
||||
}
|
||||
36
src/app/shared/directives/lazy-load.directive.ts
Normal file
36
src/app/shared/directives/lazy-load.directive.ts
Normal file
@ -0,0 +1,36 @@
|
||||
import { Directive, ElementRef, Input, OnInit } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: 'img[appLazyLoad]',
|
||||
standalone: true,
|
||||
})
|
||||
export class LazyLoadDirective implements OnInit {
|
||||
@Input() src!: string;
|
||||
@Input() alt = '';
|
||||
|
||||
constructor(private el: ElementRef<HTMLImageElement>) {}
|
||||
|
||||
ngOnInit() {
|
||||
const img = this.el.nativeElement;
|
||||
const set = () => {
|
||||
if (!this.src) return;
|
||||
img.src = this.src;
|
||||
if (this.alt) img.alt = this.alt;
|
||||
};
|
||||
|
||||
if ('IntersectionObserver' in window) {
|
||||
const observer = new IntersectionObserver(entries => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
set();
|
||||
observer.unobserve(img);
|
||||
}
|
||||
});
|
||||
});
|
||||
observer.observe(img);
|
||||
} else {
|
||||
// Fallback
|
||||
set();
|
||||
}
|
||||
}
|
||||
}
|
||||
32
src/app/shared/directives/swipe-nav.directive.ts
Normal file
32
src/app/shared/directives/swipe-nav.directive.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import { Directive, Output, EventEmitter, HostListener } from '@angular/core';
|
||||
|
||||
@Directive({
|
||||
selector: '[appSwipeNav]',
|
||||
standalone: true,
|
||||
})
|
||||
export class SwipeNavDirective {
|
||||
@Output() swipeLeft = new EventEmitter<void>();
|
||||
@Output() swipeRight = new EventEmitter<void>();
|
||||
|
||||
private startX = 0;
|
||||
private startY = 0;
|
||||
private threshold = 50; // min horizontal distance
|
||||
private restraint = 100; // max vertical deflection
|
||||
|
||||
@HostListener('touchstart', ['$event'])
|
||||
onTouchStart(e: TouchEvent) {
|
||||
const t = e.touches[0];
|
||||
this.startX = t.clientX;
|
||||
this.startY = t.clientY;
|
||||
}
|
||||
|
||||
@HostListener('touchend', ['$event'])
|
||||
onTouchEnd(e: TouchEvent) {
|
||||
const t = e.changedTouches[0];
|
||||
const dx = this.startX - t.clientX;
|
||||
const dy = Math.abs(this.startY - t.clientY);
|
||||
if (Math.abs(dx) >= this.threshold && dy <= this.restraint) {
|
||||
if (dx > 0) this.swipeLeft.emit(); else this.swipeRight.emit();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,10 @@
|
||||
<div class="ovsb__root" [class.ovsb--hidden]="state==='hidden'" [class.ovsb--mini]="state==='mini'" [class.ovsb--active]="state==='active'" [style.right.px]="right">
|
||||
<div class="ovsb__track" aria-hidden="true"></div>
|
||||
<div
|
||||
class="ovsb__thumb"
|
||||
role="scrollbar"
|
||||
[attr.aria-controls]="ariaControls || null"
|
||||
aria-orientation="vertical"
|
||||
tabindex="0"
|
||||
></div>
|
||||
</div>
|
||||
@ -0,0 +1,63 @@
|
||||
.ovsb {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ovsb__root {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: var(--ovsb-right, 2px);
|
||||
width: var(--ovsb-active-width, 10px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: stretch;
|
||||
z-index: 20;
|
||||
transition: opacity var(--ovsb-trans, 240ms ease), width var(--ovsb-trans, 240ms ease);
|
||||
opacity: 0;
|
||||
pointer-events: none; /* let scroll/wheel go to the host */
|
||||
}
|
||||
|
||||
.ovsb--hidden { opacity: 0; pointer-events: none; }
|
||||
.ovsb--mini { opacity: 1; }
|
||||
.ovsb--active { opacity: 1; }
|
||||
|
||||
.ovsb__track {
|
||||
position: absolute;
|
||||
top: 0; bottom: 0;
|
||||
right: 0; width: var(--ovsb-active-width, 10px);
|
||||
background: var(--ovsb-track-bg, rgba(0,0,0,.18));
|
||||
border-radius: 9999px;
|
||||
opacity: var(--ovsb-track-op, 0);
|
||||
transition: opacity var(--ovsb-trans, 240ms ease);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ovsb--active .ovsb__track { opacity: 0.35; }
|
||||
|
||||
.ovsb__thumb {
|
||||
position: absolute;
|
||||
top: var(--ovsb-thumb-top, 0px);
|
||||
height: var(--ovsb-thumb-height, 24px);
|
||||
width: var(--ovsb-mini-width, 6px);
|
||||
right: 0;
|
||||
border-radius: 9999px;
|
||||
background: var(--ovsb-thumb-bg, rgba(255,255,255,.65));
|
||||
box-shadow: 0 0 0 1px rgba(0,0,0,.12);
|
||||
pointer-events: auto; /* only the thumb is interactive */
|
||||
cursor: var(--ovsb-cursor, grab);
|
||||
transition: background-color var(--ovsb-trans, 240ms ease), width var(--ovsb-trans, 240ms ease), opacity var(--ovsb-trans, 240ms ease);
|
||||
}
|
||||
|
||||
.ovsb--active .ovsb__thumb {
|
||||
width: var(--ovsb-active-width, 10px);
|
||||
background: var(--ovsb-thumb-bg-active, rgba(255,255,255,.9));
|
||||
cursor: var(--ovsb-cursor-active, grabbing);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ovsb__root,
|
||||
.ovsb__thumb,
|
||||
.ovsb__track { transition: none !important; }
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import { Component, Input, ChangeDetectionStrategy, ElementRef, Renderer2 } from '@angular/core';
|
||||
|
||||
export type OvsbState = 'hidden' | 'mini' | 'active';
|
||||
|
||||
@Component({
|
||||
selector: 'app-overlay-scrollbar',
|
||||
standalone: true,
|
||||
templateUrl: './overlay-scrollbar.component.html',
|
||||
styleUrl: './overlay-scrollbar.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: {
|
||||
class: 'ovsb',
|
||||
}
|
||||
})
|
||||
export class OverlayScrollbarComponent {
|
||||
@Input() state: OvsbState = 'hidden';
|
||||
@Input() ariaControls: string | null = null;
|
||||
@Input() orientation: 'vertical' | 'horizontal' = 'vertical';
|
||||
@Input() miniWidth = 6; // px
|
||||
@Input() activeWidth = 10; // px
|
||||
@Input() right = 2; // px
|
||||
@Input() minHandle = 24; // px
|
||||
|
||||
constructor(public el: ElementRef<HTMLElement>, private r: Renderer2) {}
|
||||
|
||||
setCSSVar(name: string, value: string) {
|
||||
this.r.setStyle(this.el.nativeElement, name, value);
|
||||
}
|
||||
}
|
||||
228
src/app/shared/overlay-scrollbar/scrollable-overlay.directive.ts
Normal file
228
src/app/shared/overlay-scrollbar/scrollable-overlay.directive.ts
Normal file
@ -0,0 +1,228 @@
|
||||
import { Directive, ElementRef, Input, NgZone, OnDestroy, OnInit, Renderer2, inject, ViewContainerRef, ComponentRef } from '@angular/core';
|
||||
import { OverlayScrollbarComponent, OvsbState } from './overlay-scrollbar.component';
|
||||
|
||||
@Directive({
|
||||
selector: '[appScrollableOverlay]',
|
||||
standalone: true,
|
||||
})
|
||||
export class ScrollableOverlayDirective implements OnInit, OnDestroy {
|
||||
@Input() ovsbHideDelay = 1500;
|
||||
@Input() ovsbMiniWidth = 6;
|
||||
@Input() ovsbActiveWidth = 10;
|
||||
@Input() ovsbMinHandle = 24;
|
||||
@Input() ovsbRight = 2;
|
||||
|
||||
private host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private zone = inject(NgZone);
|
||||
private r = inject(Renderer2);
|
||||
private vcr = inject(ViewContainerRef);
|
||||
|
||||
private compRef: ComponentRef<OverlayScrollbarComponent> | null = null;
|
||||
private state: OvsbState = 'hidden';
|
||||
private idleTimer: any = null;
|
||||
private dragging = false;
|
||||
private dragStartY = 0;
|
||||
private dragStartTop = 0;
|
||||
private cleanup: Array<() => void> = [];
|
||||
private resizeObs?: ResizeObserver;
|
||||
|
||||
ngOnInit(): void {
|
||||
// Ensure host is scrollable container
|
||||
const el = this.host.nativeElement;
|
||||
const style = getComputedStyle(el);
|
||||
if (style.position === 'static') {
|
||||
this.r.setStyle(el, 'position', 'relative');
|
||||
}
|
||||
this.r.addClass(el, 'ovsb-host');
|
||||
|
||||
this.zone.runOutsideAngular(() => {
|
||||
// Create overlay component if not already present
|
||||
this.compRef = this.vcr.createComponent(OverlayScrollbarComponent);
|
||||
const overlayEl = this.compRef.location.nativeElement as HTMLElement;
|
||||
this.r.appendChild(el, overlayEl);
|
||||
|
||||
// Apply initial vars
|
||||
this.setCSSVar('--ovsb-mini-width', `${this.ovsbMiniWidth}px`);
|
||||
this.setCSSVar('--ovsb-active-width', `${this.ovsbActiveWidth}px`);
|
||||
this.setCSSVar('--ovsb-thumb-height', `${this.ovsbMinHandle}px`);
|
||||
this.setCSSVar('--ovsb-right', `${this.ovsbRight}px`);
|
||||
|
||||
// Set ARIA attrs on thumb
|
||||
const thumb = overlayEl.querySelector('.ovsb__thumb') as HTMLElement;
|
||||
if (thumb) {
|
||||
thumb.setAttribute('aria-valuemin', '0');
|
||||
thumb.setAttribute('aria-orientation', 'vertical');
|
||||
}
|
||||
|
||||
// Listeners
|
||||
this.register(el, 'mouseenter', this.onMouseEnter, { passive: true });
|
||||
this.register(el, 'mouseleave', this.onMouseLeave, { passive: true });
|
||||
this.register(el, 'mousemove', this.onMouseMove, { passive: true });
|
||||
this.register(el, 'wheel', this.onWheel, { passive: true });
|
||||
this.register(el, 'scroll', this.onScroll, { passive: true });
|
||||
this.register(el, 'touchstart', this.onTouchMove, { passive: true });
|
||||
this.register(el, 'touchmove', this.onTouchMove, { passive: true });
|
||||
this.register(el, 'touchend', this.onTouchEnd, { passive: true });
|
||||
|
||||
if (thumb) {
|
||||
this.register(thumb, 'mousedown', this.onDragStart);
|
||||
this.register(thumb, 'keydown', this.onKeyDown);
|
||||
}
|
||||
this.register(window, 'mousemove', this.onDragMove, { passive: true });
|
||||
this.register(window, 'mouseup', this.onDragEnd, { passive: true });
|
||||
|
||||
// Resize observer to recompute
|
||||
this.resizeObs = new ResizeObserver(() => this.updateThumb());
|
||||
this.resizeObs.observe(el);
|
||||
|
||||
// Initial compute
|
||||
const scrollable = this.updateThumb();
|
||||
if (scrollable) {
|
||||
this.toMini();
|
||||
} else {
|
||||
// If not scrollable, keep native behavior and hide overlay
|
||||
this.setState('hidden');
|
||||
this.r.removeClass(el, 'ovsb-host');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.clearIdle();
|
||||
this.resizeObs?.disconnect();
|
||||
for (const c of this.cleanup) try { c(); } catch {}
|
||||
this.cleanup = [];
|
||||
this.compRef?.destroy();
|
||||
this.compRef = null;
|
||||
}
|
||||
|
||||
// Event handlers (bound with .bind(this))
|
||||
private onMouseEnter = () => { this.toMini(); };
|
||||
private onMouseLeave = () => { this.scheduleIdle(); };
|
||||
private onMouseMove = (e: MouseEvent) => { this.pointerActivity(e.clientX, e.clientY); };
|
||||
private onWheel = () => { this.toMini(); };
|
||||
private onScroll = () => { this.toMini(); this.updateThumb(); };
|
||||
private onTouchMove = () => { this.toMini(); };
|
||||
private onTouchEnd = () => { this.scheduleIdle(); };
|
||||
|
||||
private onDragStart = (e: MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const overlayEl = this.compRef?.location.nativeElement as HTMLElement;
|
||||
const thumb = overlayEl?.querySelector('.ovsb__thumb') as HTMLElement;
|
||||
if (!thumb) return;
|
||||
this.dragging = true;
|
||||
this.setState('active');
|
||||
this.dragStartY = e.clientY;
|
||||
this.dragStartTop = parseFloat(getComputedStyle(thumb).top || '0');
|
||||
this.setCSSVar('--ovsb-cursor', 'grabbing');
|
||||
};
|
||||
|
||||
private onDragMove = (e: MouseEvent) => {
|
||||
if (!this.dragging) return;
|
||||
const el = this.host.nativeElement;
|
||||
const overlayEl = this.compRef?.location.nativeElement as HTMLElement;
|
||||
const thumb = overlayEl?.querySelector('.ovsb__thumb') as HTMLElement;
|
||||
if (!thumb) return;
|
||||
|
||||
const delta = e.clientY - this.dragStartY;
|
||||
const containerH = el.clientHeight;
|
||||
const handleH = Math.max(this.ovsbMinHandle, (el.clientHeight / Math.max(el.scrollHeight, 1)) * el.clientHeight);
|
||||
const maxThumbTop = Math.max(containerH - handleH, 0);
|
||||
const newTop = Math.min(Math.max(this.dragStartTop + delta, 0), maxThumbTop);
|
||||
|
||||
// Map to scrollTop
|
||||
const maxScroll = Math.max(el.scrollHeight - el.clientHeight, 1);
|
||||
const ratio = newTop / Math.max(maxThumbTop, 1);
|
||||
el.scrollTop = ratio * maxScroll;
|
||||
this.updateThumb();
|
||||
};
|
||||
|
||||
private onDragEnd = () => {
|
||||
if (!this.dragging) return;
|
||||
this.dragging = false;
|
||||
this.toMini();
|
||||
this.setCSSVar('--ovsb-cursor', 'grab');
|
||||
};
|
||||
|
||||
private onKeyDown = (e: KeyboardEvent) => {
|
||||
const el = this.host.nativeElement;
|
||||
const step = Math.max(24, Math.floor(el.clientHeight * 0.1));
|
||||
switch (e.key) {
|
||||
case 'ArrowUp': el.scrollTop -= step; e.preventDefault(); break;
|
||||
case 'ArrowDown': el.scrollTop += step; e.preventDefault(); break;
|
||||
case 'PageUp': el.scrollTop -= el.clientHeight; e.preventDefault(); break;
|
||||
case 'PageDown': el.scrollTop += el.clientHeight; e.preventDefault(); break;
|
||||
case 'Home': el.scrollTop = 0; e.preventDefault(); break;
|
||||
case 'End': el.scrollTop = el.scrollHeight; e.preventDefault(); break;
|
||||
default: return;
|
||||
}
|
||||
this.toMini();
|
||||
this.updateThumb();
|
||||
};
|
||||
|
||||
// State helpers
|
||||
private setState(next: OvsbState) {
|
||||
if (this.state === next) return;
|
||||
this.state = next;
|
||||
if (this.compRef) {
|
||||
this.compRef.instance.state = next;
|
||||
this.compRef.changeDetectorRef.detectChanges();
|
||||
}
|
||||
}
|
||||
private toMini() { this.clearIdle(); this.setState('mini'); this.scheduleIdle(); }
|
||||
private scheduleIdle() { this.clearIdle(); this.idleTimer = setTimeout(() => this.setState('hidden'), this.ovsbHideDelay); }
|
||||
private clearIdle() { if (this.idleTimer) { clearTimeout(this.idleTimer); this.idleTimer = null; } }
|
||||
|
||||
private pointerActivity(clientX: number, clientY: number) {
|
||||
const el = this.host.nativeElement;
|
||||
const rect = el.getBoundingClientRect();
|
||||
const nearRight = clientX >= rect.right - (this.ovsbActiveWidth + 6) && clientX <= rect.right + 4 && clientY >= rect.top && clientY <= rect.bottom;
|
||||
if (nearRight) this.setState('active'); else this.toMini();
|
||||
}
|
||||
|
||||
private updateThumb(): boolean {
|
||||
const el = this.host.nativeElement;
|
||||
const overlayEl = this.compRef?.location.nativeElement as HTMLElement;
|
||||
if (!overlayEl) return false;
|
||||
|
||||
const viewportH = el.clientHeight;
|
||||
const scrollH = Math.max(el.scrollHeight, 1);
|
||||
const containerH = viewportH;
|
||||
|
||||
const scrollable = scrollH > viewportH + 1; // allow small epsilon
|
||||
if (!scrollable) {
|
||||
// no scrolling needed
|
||||
this.setCSSVar('--ovsb-thumb-height', `${this.ovsbMinHandle}px`);
|
||||
this.setCSSVar('--ovsb-thumb-top', `0px`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const handleH = Math.max(this.ovsbMinHandle, (viewportH / scrollH) * containerH);
|
||||
const maxScroll = Math.max(scrollH - viewportH, 1);
|
||||
const maxThumbTop = Math.max(containerH - handleH, 0);
|
||||
const handleTop = Math.min(Math.max((el.scrollTop / maxScroll) * maxThumbTop, 0), maxThumbTop);
|
||||
|
||||
// CSS vars for component
|
||||
this.setCSSVar('--ovsb-thumb-height', `${handleH}px`);
|
||||
this.setCSSVar('--ovsb-thumb-top', `${handleTop}px`);
|
||||
|
||||
// ARIA attrs
|
||||
const thumb = overlayEl.querySelector('.ovsb__thumb') as HTMLElement;
|
||||
if (thumb) {
|
||||
thumb.setAttribute('aria-valuemax', `${maxScroll}`);
|
||||
thumb.setAttribute('aria-valuenow', `${Math.round(el.scrollTop)}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private setCSSVar(name: string, value: string) {
|
||||
const overlayEl = this.compRef?.location.nativeElement as HTMLElement;
|
||||
if (overlayEl) this.r.setStyle(overlayEl, name, value);
|
||||
}
|
||||
|
||||
private register(target: HTMLElement | Window, type: string, handler: any, options?: AddEventListenerOptions) {
|
||||
const bound = handler.bind(this);
|
||||
target.addEventListener(type, bound, options);
|
||||
this.cleanup.push(() => target.removeEventListener(type, bound, options));
|
||||
}
|
||||
}
|
||||
48
src/app/shared/services/mobile-nav.service.ts
Normal file
48
src/app/shared/services/mobile-nav.service.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { Injectable, signal, effect } from '@angular/core';
|
||||
|
||||
type MobileTab = 'sidebar' | 'list' | 'page' | 'toc';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class MobileNavService {
|
||||
activeTab = signal<MobileTab>('list');
|
||||
sidebarOpen = signal<boolean>(false);
|
||||
tocOpen = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
// Load from storage
|
||||
try {
|
||||
const t = localStorage.getItem('obsiviewer-mobile-tab');
|
||||
if (t === 'sidebar' || t === 'list' || t === 'page' || t === 'toc') {
|
||||
this.activeTab.set(t);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
effect(() => {
|
||||
try {
|
||||
localStorage.setItem('obsiviewer-mobile-tab', this.activeTab());
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
setActiveTab(tab: MobileTab) {
|
||||
this.activeTab.set(tab);
|
||||
if (tab === 'sidebar') {
|
||||
this.sidebarOpen.set(true);
|
||||
this.tocOpen.set(false);
|
||||
} else if (tab === 'toc') {
|
||||
this.tocOpen.set(true);
|
||||
this.sidebarOpen.set(false);
|
||||
} else {
|
||||
this.sidebarOpen.set(false);
|
||||
this.tocOpen.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
toggleSidebar() {
|
||||
this.sidebarOpen.update(v => !v);
|
||||
}
|
||||
|
||||
toggleToc() {
|
||||
this.tocOpen.update(v => !v);
|
||||
}
|
||||
}
|
||||
17
src/app/shared/services/responsive.service.ts
Normal file
17
src/app/shared/services/responsive.service.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import { Injectable, signal, inject } from '@angular/core';
|
||||
import { BreakpointObserver } from '@angular/cdk/layout';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class ResponsiveService {
|
||||
private breakpointObserver = inject(BreakpointObserver);
|
||||
|
||||
isMobile = signal<boolean>(false);
|
||||
isTablet = signal<boolean>(false);
|
||||
isDesktop = signal<boolean>(false);
|
||||
|
||||
constructor() {
|
||||
this.breakpointObserver.observe('(max-width: 767px)').subscribe(r => this.isMobile.set(r.matches));
|
||||
this.breakpointObserver.observe('(min-width: 768px) and (max-width: 1023px)').subscribe(r => this.isTablet.set(r.matches));
|
||||
this.breakpointObserver.observe('(min-width: 1024px)').subscribe(r => this.isDesktop.set(r.matches));
|
||||
}
|
||||
}
|
||||
30
src/app/shared/services/ui-mode.service.ts
Normal file
30
src/app/shared/services/ui-mode.service.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import { Injectable, signal, effect } from '@angular/core';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class UiModeService {
|
||||
isNimbusMode = signal<boolean>(this.loadUIMode());
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
try {
|
||||
if (typeof localStorage !== 'undefined') {
|
||||
localStorage.setItem('obsiviewer-ui-mode', this.isNimbusMode() ? 'nimbus' : 'legacy');
|
||||
}
|
||||
} catch {}
|
||||
});
|
||||
}
|
||||
|
||||
toggleUIMode(): void {
|
||||
this.isNimbusMode.set(!this.isNimbusMode());
|
||||
}
|
||||
|
||||
private loadUIMode(): boolean {
|
||||
try {
|
||||
if (typeof localStorage === 'undefined') return true;
|
||||
const saved = localStorage.getItem('obsiviewer-ui-mode');
|
||||
if (saved === 'nimbus') return true;
|
||||
if (saved === 'legacy') return false;
|
||||
} catch {}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@ -13,7 +13,7 @@ import { VaultService } from '../../services/vault.service';
|
||||
@let folder = node;
|
||||
<div>
|
||||
<div
|
||||
(click)="toggleFolder(folder)"
|
||||
(click)="onFolderClick(folder)"
|
||||
class="flex items-center cursor-pointer p-2 rounded my-0.5 text-sm hover:bg-obs-l-bg-main dark:hover:bg-obs-d-bg-main"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 transition-transform text-obs-l-text-muted dark:text-obs-d-text-muted" [class.rotate-90]="folder.isOpen" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
@ -27,12 +27,15 @@ import { VaultService } from '../../services/vault.service';
|
||||
<app-file-explorer
|
||||
[nodes]="folder.children"
|
||||
[selectedNoteId]="selectedNoteId()"
|
||||
[foldersOnly]="foldersOnly()"
|
||||
(folderSelected)="folderSelected.emit($event)"
|
||||
(fileSelected)="onFileSelected($event)"
|
||||
></app-file-explorer>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
@if (!foldersOnly()) {
|
||||
@let file = node;
|
||||
<div
|
||||
(click)="onFileSelected(file.id)"
|
||||
@ -48,6 +51,7 @@ import { VaultService } from '../../services/vault.service';
|
||||
<span class="truncate">{{ file.name }}</span>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
@ -58,7 +62,9 @@ import { VaultService } from '../../services/vault.service';
|
||||
export class FileExplorerComponent {
|
||||
nodes = input.required<VaultNode[]>();
|
||||
selectedNoteId = input<string | null>(null);
|
||||
foldersOnly = input<boolean>(false);
|
||||
fileSelected = output<string>();
|
||||
folderSelected = output<string>();
|
||||
|
||||
private vaultService = inject(VaultService);
|
||||
|
||||
@ -68,6 +74,13 @@ export class FileExplorerComponent {
|
||||
}
|
||||
}
|
||||
|
||||
onFolderClick(folder: VaultFolder) {
|
||||
this.toggleFolder(folder);
|
||||
if (folder?.path) {
|
||||
this.folderSelected.emit(folder.path);
|
||||
}
|
||||
}
|
||||
|
||||
toggleFolder(folder: VaultFolder) {
|
||||
this.vaultService.toggleFolder(folder.path);
|
||||
}
|
||||
|
||||
@ -35,24 +35,6 @@ interface TagGroup {
|
||||
--tv-scroll-thumb: rgba(200, 200, 200, 0.2);
|
||||
}
|
||||
|
||||
.custom-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--tv-scroll-thumb) var(--tv-scroll-track);
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: var(--tv-scroll-track);
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
@ -180,7 +162,7 @@ interface TagGroup {
|
||||
</div>
|
||||
|
||||
<!-- Tags list -->
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
||||
<div class="flex-1 overflow-y-auto" appScrollableOverlay>
|
||||
@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">
|
||||
|
||||
10
src/environments/environment.prod.ts
Normal file
10
src/environments/environment.prod.ts
Normal file
@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Configuration de l'environnement de production.
|
||||
* Cette configuration est utilisée lors de la construction pour la production avec `ng build --configuration=production`.
|
||||
*
|
||||
* @property {boolean} production - Indique si l'application est en mode production (toujours true dans ce fichier).
|
||||
*/
|
||||
export const environment = {
|
||||
production: true,
|
||||
serviceURL: "/AuMenuManager",
|
||||
};
|
||||
11
src/environments/environment.ts
Normal file
11
src/environments/environment.ts
Normal file
@ -0,0 +1,11 @@
|
||||
/**
|
||||
* Configuration de l'environnement de développement.
|
||||
* Cette configuration est utilisée lors du développement local avec `ng serve`.
|
||||
*
|
||||
* @property {boolean} production - Indique si l'application est en mode production (false en développement).
|
||||
*/
|
||||
export const environment = {
|
||||
production: false,
|
||||
serviceURL: "http://localhost:8080/AuMenuManager",
|
||||
// serviceURL: "https://public-tomcat.guru.lan/AuMenuManager",
|
||||
};
|
||||
@ -1,4 +1,5 @@
|
||||
@import './styles-test.css';
|
||||
@import './styles/_overlay-scrollbar.css';
|
||||
|
||||
/* Excalidraw CSS variables (thème sombre) */
|
||||
/* .excalidraw {
|
||||
@ -256,6 +257,8 @@ excalidraw-editor .excalidraw .layer-ui__wrapper {
|
||||
--btn-hover-background: color-mix(in srgb, var(--bg-muted) 42%, transparent);
|
||||
--btn-focus-ring: color-mix(in srgb, var(--border) 45%, transparent);
|
||||
--btn-muted-text: var(--text-muted);
|
||||
--scrollbar-thumb-color: rgba(148, 163, 184, 0.45);
|
||||
--scrollbar-thumb-color-active: rgba(148, 163, 184, 0.75);
|
||||
}
|
||||
|
||||
.dark {
|
||||
@ -270,6 +273,8 @@ excalidraw-editor .excalidraw .layer-ui__wrapper {
|
||||
--btn-hover-background: color-mix(in srgb, var(--bg-muted) 36%, transparent);
|
||||
--btn-focus-ring: color-mix(in srgb, var(--border) 55%, transparent);
|
||||
--btn-muted-text: var(--text-muted);
|
||||
--scrollbar-thumb-color: rgba(148, 163, 184, 0.35);
|
||||
--scrollbar-thumb-color-active: rgba(226, 232, 240, 0.72);
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
@ -281,6 +286,57 @@ excalidraw-editor .excalidraw .layer-ui__wrapper {
|
||||
}
|
||||
|
||||
@layer components {
|
||||
/* Adaptive scrollbar states */
|
||||
.adaptive-scrollbar {
|
||||
position: relative;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
--scrollbar-thumb-transition: 240ms ease;
|
||||
}
|
||||
|
||||
.adaptive-scrollbar[data-scroll-state="idle"] {
|
||||
scrollbar-width: none;
|
||||
scrollbar-color: transparent transparent;
|
||||
}
|
||||
|
||||
.adaptive-scrollbar[data-scroll-state="mini"] {
|
||||
scrollbar-color: color-mix(in srgb, var(--scrollbar-thumb-color) 80%, transparent) transparent;
|
||||
}
|
||||
|
||||
.adaptive-scrollbar[data-scroll-state="active"] {
|
||||
scrollbar-color: color-mix(in srgb, var(--scrollbar-thumb-color-active) 100%, transparent) transparent;
|
||||
}
|
||||
|
||||
.adaptive-scrollbar::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
.adaptive-scrollbar::-webkit-scrollbar-thumb {
|
||||
border-radius: 9999px;
|
||||
background-color: transparent;
|
||||
box-shadow: inset 0 0 0 1px transparent;
|
||||
transition: background-color var(--scrollbar-thumb-transition), box-shadow var(--scrollbar-thumb-transition), opacity var(--scrollbar-thumb-transition);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.adaptive-scrollbar[data-scroll-state="mini"]::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-thumb-color);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.08);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.adaptive-scrollbar[data-scroll-state="active"]::-webkit-scrollbar-thumb {
|
||||
background-color: var(--scrollbar-thumb-color-active);
|
||||
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.18);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.adaptive-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.calendar-compact {
|
||||
/* @apply flex min-h-[180px] max-h-[200px] flex-col; */
|
||||
display: flex;
|
||||
|
||||
209
src/styles/_overlay-scrollbar.css
Normal file
209
src/styles/_overlay-scrollbar.css
Normal file
@ -0,0 +1,209 @@
|
||||
/* ===============================
|
||||
Overlay Scrollbar — Cross-browser (Edge/Firefox/Chrome/Safari)
|
||||
Fichier : _overlay-scrollbar.css
|
||||
=============================== */
|
||||
|
||||
/* ---------- Thèmes & variables ---------- */
|
||||
:root {
|
||||
/* Couleurs (clair) */
|
||||
--ovsb-track-bg: rgba(15, 23, 42, 0.12);
|
||||
--ovsb-thumb-bg: rgba(100, 116, 139, 0.55);
|
||||
--ovsb-thumb-bg-active: rgba(59, 130, 246, 0.75);
|
||||
|
||||
/* Géométrie & transitions */
|
||||
--ovsb-trans: 240ms ease;
|
||||
--ovsb-right: 2px; /* Décalage à droite de l’overlay */
|
||||
--ovsb-thumb-height: 24px; /* Hauteur mini du thumb */
|
||||
|
||||
/* Épaisseurs / états
|
||||
Astuce : on anime l’épaisseur via scaleX pour éviter les changements de hit-area.
|
||||
Le track garde une largeur de hit-test stable. */
|
||||
--ovsb-active-width: 10px; /* largeur visuelle en état "active" */
|
||||
--ovsb-mini-width: 2px; /* largeur visuelle en état "mini" */
|
||||
|
||||
/* Ratio (mini vs active) → sert à scaleX. Si vous modifiez les largeurs, adaptez ce ratio. */
|
||||
--ovsb-mini-scale: 0.2; /* 2 / 10 = 0.2 */
|
||||
}
|
||||
|
||||
.dark,
|
||||
[data-theme="dark"] {
|
||||
--ovsb-track-bg: rgba(148, 163, 184, 0.28);
|
||||
--ovsb-thumb-bg: rgba(226, 232, 240, 0.72);
|
||||
--ovsb-thumb-bg-active: rgba(241, 245, 249, 0.92);
|
||||
}
|
||||
|
||||
/* Reduced motion */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.ovsb,
|
||||
.ovsb__root,
|
||||
.ovsb__track,
|
||||
.ovsb__thumb {
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
1) Conteneur scrollable (hôte de l’overlay)
|
||||
======================================== */
|
||||
|
||||
/* IMPORTANT : appliquer .ovsb-host sur l’élément QUI SCROLLE réellement */
|
||||
.ovsb-host {
|
||||
position: relative; /* nécessaire pour positionner l’overlay en absolute */
|
||||
overflow: auto; /* c’est bien LUI qui scrolle */
|
||||
/* Masquer la scrollbar native (Firefox + Edge/Chrome/Safari) */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
-ms-overflow-style: none; /* Edge/IE legacy */
|
||||
}
|
||||
.ovsb-host::-webkit-scrollbar {
|
||||
width: 0 !important; /* Chrome/Edge/Safari */
|
||||
height: 0 !important;
|
||||
background: transparent !important;
|
||||
}
|
||||
.ovsb-host::-webkit-scrollbar-thumb,
|
||||
.ovsb-host::-webkit-scrollbar-track,
|
||||
.ovsb-host::-webkit-scrollbar-corner {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Cas où le DOCUMENT scrolle (évitez si possible). Décommentez si nécessaire. */
|
||||
/*
|
||||
html, body {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
html::-webkit-scrollbar,
|
||||
body::-webkit-scrollbar {
|
||||
width: 0 !important;
|
||||
height: 0 !important;
|
||||
}
|
||||
*/
|
||||
|
||||
/* Si vous décidez de garder la native (non recommandé ici), stabilisez le gutter : */
|
||||
/*
|
||||
@supports (scrollbar-gutter: stable) {
|
||||
.ovsb-host-native {
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
/* ========================================
|
||||
2) Overlay (track + thumb)
|
||||
======================================== */
|
||||
|
||||
/* Racine overlay : ne capture pas les events hors track/thumb */
|
||||
.ovsb {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: var(--ovsb-right);
|
||||
bottom: 0;
|
||||
width: 12px; /* largeur de HIT-TEST stable (garde la zone de survol constante) */
|
||||
pointer-events: none; /* la racine ne capte pas, on laisse le contenu défiler naturellement */
|
||||
z-index: 10;
|
||||
contain: layout paint; /* isolation pour perf */
|
||||
}
|
||||
|
||||
/* Nœud interne (utile si vous voulez des wrappers) */
|
||||
.ovsb__root {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
/* Piste (track) — zone cliquable/drag */
|
||||
.ovsb__track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 12px; /* hit-area stable */
|
||||
border-radius: 9999px;
|
||||
background: var(--ovsb-track-bg);
|
||||
opacity: 0; /* géré par états */
|
||||
pointer-events: auto; /* le track doit capter pour hover/drag */
|
||||
transition: opacity var(--ovsb-trans);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* Thumb (manette) — épaisseur animée via scaleX (pas de shift) */
|
||||
.ovsb__thumb {
|
||||
position: absolute;
|
||||
top: 0; /* la position Y est appliquée via transform translateY */
|
||||
right: 1px; /* léger décalage pour équilibre visuel */
|
||||
width: var(--ovsb-active-width);
|
||||
height: var(--ovsb-thumb-height);
|
||||
border-radius: 9999px;
|
||||
background: var(--ovsb-thumb-bg);
|
||||
/* Position & épaisseur animées :
|
||||
translateY: mis à jour dynamiquement (via style inline ou CSS var)
|
||||
scaleX: mini/active
|
||||
*/
|
||||
transform: translateY(var(--ovsb-thumb-y, 0px)) scaleX(var(--ovsb-mini-scale));
|
||||
transform-origin: right center;
|
||||
transition: transform var(--ovsb-trans), background-color var(--ovsb-trans), opacity var(--ovsb-trans);
|
||||
opacity: 0; /* masqué par défaut */
|
||||
pointer-events: auto; /* doit capter pour le drag */
|
||||
will-change: transform, opacity, background-color;
|
||||
cursor: grab;
|
||||
}
|
||||
.ovsb__thumb:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
3) États (Hidden / Mini / Active)
|
||||
======================================== */
|
||||
|
||||
/* HIDDEN : invisible, aucune interactivité */
|
||||
.ovsb--hidden .ovsb__track,
|
||||
.ovsb--hidden .ovsb__thumb {
|
||||
opacity: 0;
|
||||
pointer-events: none; /* rien ne capte, laisse le contenu réagir librement */
|
||||
}
|
||||
|
||||
/* MINI : visible, thumb mince (scaleX mini), track discret */
|
||||
.ovsb--mini .ovsb__track {
|
||||
opacity: 1;
|
||||
}
|
||||
.ovsb--mini .ovsb__thumb {
|
||||
opacity: 1;
|
||||
background: var(--ovsb-thumb-bg);
|
||||
transform: translateY(var(--ovsb-thumb-y, 0px)) scaleX(var(--ovsb-mini-scale));
|
||||
}
|
||||
|
||||
/* ACTIVE : au survol de la zone scrollbar → thumb plus épais + couleur active */
|
||||
.ovsb--active .ovsb__track {
|
||||
opacity: 1;
|
||||
}
|
||||
.ovsb--active .ovsb__thumb {
|
||||
opacity: 1;
|
||||
background: var(--ovsb-thumb-bg-active);
|
||||
transform: translateY(var(--ovsb-thumb-y, 0px)) scaleX(1);
|
||||
}
|
||||
|
||||
/* Hover direct sur la zone track → active visuelle (utile en desktop) */
|
||||
.ovsb__track:hover ~ .ovsb__thumb,
|
||||
.ovsb__thumb:hover {
|
||||
background: var(--ovsb-thumb-bg-active);
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
4) Divers (compat & accessibilité visuelle)
|
||||
======================================== */
|
||||
|
||||
/* Améliore la réactivité GPU (Edge/Chrome) */
|
||||
.ovsb__track,
|
||||
.ovsb__thumb {
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
/* Quand overlay affiché, éviter le scroll chaining vers parent si souhaité */
|
||||
.ovsb-host {
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
|
||||
/* Cas tactile : permettre drag propre sans scroll involontaire horizontal */
|
||||
.ovsb__thumb,
|
||||
.ovsb__track {
|
||||
touch-action: none;
|
||||
}
|
||||
@ -9,6 +9,14 @@ module.exports = {
|
||||
darkMode: ['class', '[data-theme="dark"]'],
|
||||
theme: {
|
||||
extend: {
|
||||
screens: {
|
||||
xs: '320px',
|
||||
sm: '640px',
|
||||
md: '768px',
|
||||
lg: '1024px',
|
||||
xl: '1280px',
|
||||
'2xl': '1536px',
|
||||
},
|
||||
colors: {
|
||||
'bg-main': 'var(--bg-main)',
|
||||
'bg-muted': 'var(--bg-muted)',
|
||||
@ -25,7 +33,12 @@ module.exports = {
|
||||
warning: 'var(--warning)',
|
||||
danger: 'var(--danger)',
|
||||
info: 'var(--info)',
|
||||
ring: 'var(--ring)'
|
||||
ring: 'var(--ring)',
|
||||
nimbus: {
|
||||
50: '#f0f9ff',
|
||||
500: '#0ea5e9',
|
||||
900: '#0c4a6e'
|
||||
}
|
||||
},
|
||||
ringColor: {
|
||||
DEFAULT: 'var(--ring)'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user