diff --git a/angular.json b/angular.json index f30c166..a69aeb5 100644 --- a/angular.json +++ b/angular.json @@ -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, diff --git a/index.html b/index.html index abc4f2d..7cb6691 100644 --- a/index.html +++ b/index.html @@ -25,5 +25,26 @@ + \ No newline at end of file diff --git a/package.json b/package.json index 964a683..cb73aab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app.component.simple.html b/src/app.component.simple.html index d4b26c8..2b34c02 100644 --- a/src/app.component.simple.html +++ b/src/app.component.simple.html @@ -1,4 +1,32 @@ +@if (uiMode.isNimbusMode()) { + +} @else {
@if (isRawViewOpen()) { } + +
+} \ No newline at end of file diff --git a/src/app.component.ts b/src/app.component.ts index 1e9565f..a5af903 100644 --- a/src/app.component.ts +++ b/src/app.component.ts @@ -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([]); leftSidebarWidth = signal(288); rightSidebarWidth = signal(288); + centerPanelWidth = signal(384); isRawViewOpen = signal(false); isRawViewWrapped = signal(true); showAddBookmarkModal = signal(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(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); } diff --git a/src/app/features/bottom-nav/app-bottom-navigation.component.ts b/src/app/features/bottom-nav/app-bottom-navigation.component.ts new file mode 100644 index 0000000..5400f5e --- /dev/null +++ b/src/app/features/bottom-nav/app-bottom-navigation.component.ts @@ -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: ` + + ` +}) +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); + } +} diff --git a/src/app/features/list/notes-list.component.ts b/src/app/features/list/notes-list.component.ts new file mode 100644 index 0000000..a4ef46d --- /dev/null +++ b/src/app/features/list/notes-list.component.ts @@ -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: ` +
+
+ +
+
+
    +
  • +
    {{ n.title }}
    +
    {{ n.filePath }}
    +
  • +
+
+
+ `, + 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([]); + folderFilter = input(null); // like "folder/subfolder" + query = input(''); + tagFilter = input(null); + + @Output() openNote = new EventEmitter(); + @Output() queryChange = new EventEmitter(); + + 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); + } +} diff --git a/src/app/features/note-view/app-toc-overlay.component.ts b/src/app/features/note-view/app-toc-overlay.component.ts new file mode 100644 index 0000000..8d28ce4 --- /dev/null +++ b/src/app/features/note-view/app-toc-overlay.component.ts @@ -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: ` +
+
+
+

Sommaire

+ +
+
+ +
+
+ ` +}) +export class AppTocOverlayComponent { + @Input() headings: Array<{ level: number; text: string; id: string }> = []; + @Output() go = new EventEmitter(); + @Output() close = new EventEmitter(); + + onGo(id: string) { + this.go.emit(id); + } +} diff --git a/src/app/features/quick-links/quick-links.component.ts b/src/app/features/quick-links/quick-links.component.ts new file mode 100644 index 0000000..10b70cc --- /dev/null +++ b/src/app/features/quick-links/quick-links.component.ts @@ -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: ` +
+
    +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
  • +
+
+ ` +}) +export class QuickLinksComponent { + @Output() quickLinkSelected = new EventEmitter(); + select(id: string) { this.quickLinkSelected.emit(id); } +} diff --git a/src/app/features/sidebar/app-sidebar-drawer.component.ts b/src/app/features/sidebar/app-sidebar-drawer.component.ts new file mode 100644 index 0000000..ba3e164 --- /dev/null +++ b/src/app/features/sidebar/app-sidebar-drawer.component.ts @@ -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: ` + +
+ ` +}) +export class AppSidebarDrawerComponent { + mobileNav = inject(MobileNavService); + + @Input() nodes: VaultNode[] = []; + @Input() selectedNoteId: string | null = null; + @Output() noteSelected = new EventEmitter(); + @Output() folderSelected = new EventEmitter(); + + onSelect(id: string) { + this.noteSelected.emit(id); + this.mobileNav.sidebarOpen.set(false); + } + + onFolder(path: string) { + if (path) { + this.folderSelected.emit(path); + } + } +} diff --git a/src/app/features/sidebar/nimbus-sidebar.component.ts b/src/app/features/sidebar/nimbus-sidebar.component.ts new file mode 100644 index 0000000..63ffb32 --- /dev/null +++ b/src/app/features/sidebar/nimbus-sidebar.component.ts @@ -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: ` +
+ +
+
{{ vaultName }}
+ +
+ + +
+ +
+ +
+ +
+
+ + +
+ +
+ +
+
+ + +
+ +
+
    +
  • + + {{ t.count }} +
  • +
+
+
+ + +
+ +
Empty
+
+
+ + +
ObsiViewer
+
+ ` +}) +export class NimbusSidebarComponent { + @Input() vaultName = ''; + @Input() effectiveFileTree: VaultNode[] = []; + @Input() selectedNoteId: string | null = null; + @Input() tags: TagInfo[] = []; + + @Output() toggleSidebarRequest = new EventEmitter(); + @Output() folderSelected = new EventEmitter(); + @Output() fileSelected = new EventEmitter(); + @Output() tagSelected = new EventEmitter(); + @Output() quickLinkSelected = new EventEmitter(); + + open = { quick: true, folders: true, tags: false, trash: false }; + + onQuickLink(id: string) { this.quickLinkSelected.emit(id); } +} diff --git a/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts b/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts new file mode 100644 index 0000000..6a310cd --- /dev/null +++ b/src/app/layout/app-shell-nimbus/app-shell-nimbus.component.ts @@ -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: ` +
+ +
+
+ + {{ vaultName }} +

ObsiViewer · Nimbus

+
+
+ + + +
+
+ + +
+ + + + + + + + +
+
+
{{ f === 'quick' ? 'Quick Links' : (f === 'folders' ? 'Folders' : (f === 'tags' ? 'Tags' : 'Trash')) }}
+ +
+
+ + +
+ +
+
+
    +
  • + +
  • +
+
+
Empty
+
+
+
+
+ + + + + +
+
+ +
+
+ + + + + +
+
+ +
+ +
+
+ + +
+
+ + + +
+
+
+ +
+
+ +
+
+ +
+
+
+ + +
+ + +
+ +
+ +
+
+

{{ selectedNote?.title || 'Aucune page' }}

+ +
+ +
+ + + + +
+
+ ` +}) +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(); + @Output() tagClicked = new EventEmitter(); + @Output() wikiLinkActivated = new EventEmitter(); + @Output() toggleSidebarRequest = new EventEmitter(); + @Output() toggleOutlineRequest = new EventEmitter(); + @Output() leftResizeStart = new EventEmitter(); + @Output() rightResizeStart = new EventEmitter(); + @Output() centerResizeStart = new EventEmitter(); + @Output() navigateHeading = new EventEmitter(); + @Output() searchTermChange = new EventEmitter(); + @Output() searchOptionsChange = new EventEmitter(); + + 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; + } + } +} diff --git a/src/app/shared/directives/adaptive-scrollbar.directive.ts b/src/app/shared/directives/adaptive-scrollbar.directive.ts new file mode 100644 index 0000000..2a3528f --- /dev/null +++ b/src/app/shared/directives/adaptive-scrollbar.directive.ts @@ -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('idle'); + private idleDelay = 1500; + private edgeThreshold = 16; + private idleTimer: any = null; + private cleanup: Array<() => void> = []; + + constructor(private el: ElementRef, 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(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)); + } +} diff --git a/src/app/shared/directives/lazy-load.directive.ts b/src/app/shared/directives/lazy-load.directive.ts new file mode 100644 index 0000000..b80e3f6 --- /dev/null +++ b/src/app/shared/directives/lazy-load.directive.ts @@ -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) {} + + 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(); + } + } +} diff --git a/src/app/shared/directives/swipe-nav.directive.ts b/src/app/shared/directives/swipe-nav.directive.ts new file mode 100644 index 0000000..a271265 --- /dev/null +++ b/src/app/shared/directives/swipe-nav.directive.ts @@ -0,0 +1,32 @@ +import { Directive, Output, EventEmitter, HostListener } from '@angular/core'; + +@Directive({ + selector: '[appSwipeNav]', + standalone: true, +}) +export class SwipeNavDirective { + @Output() swipeLeft = new EventEmitter(); + @Output() swipeRight = new EventEmitter(); + + 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(); + } + } +} diff --git a/src/app/shared/overlay-scrollbar/overlay-scrollbar.component.html b/src/app/shared/overlay-scrollbar/overlay-scrollbar.component.html new file mode 100644 index 0000000..ac349fd --- /dev/null +++ b/src/app/shared/overlay-scrollbar/overlay-scrollbar.component.html @@ -0,0 +1,10 @@ +
+ +
+
diff --git a/src/app/shared/overlay-scrollbar/overlay-scrollbar.component.scss b/src/app/shared/overlay-scrollbar/overlay-scrollbar.component.scss new file mode 100644 index 0000000..eb6b4c4 --- /dev/null +++ b/src/app/shared/overlay-scrollbar/overlay-scrollbar.component.scss @@ -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; } +} diff --git a/src/app/shared/overlay-scrollbar/overlay-scrollbar.component.ts b/src/app/shared/overlay-scrollbar/overlay-scrollbar.component.ts new file mode 100644 index 0000000..0c5c4a3 --- /dev/null +++ b/src/app/shared/overlay-scrollbar/overlay-scrollbar.component.ts @@ -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, private r: Renderer2) {} + + setCSSVar(name: string, value: string) { + this.r.setStyle(this.el.nativeElement, name, value); + } +} diff --git a/src/app/shared/overlay-scrollbar/scrollable-overlay.directive.ts b/src/app/shared/overlay-scrollbar/scrollable-overlay.directive.ts new file mode 100644 index 0000000..061473f --- /dev/null +++ b/src/app/shared/overlay-scrollbar/scrollable-overlay.directive.ts @@ -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); + private zone = inject(NgZone); + private r = inject(Renderer2); + private vcr = inject(ViewContainerRef); + + private compRef: ComponentRef | 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)); + } +} diff --git a/src/app/shared/services/mobile-nav.service.ts b/src/app/shared/services/mobile-nav.service.ts new file mode 100644 index 0000000..d715a7c --- /dev/null +++ b/src/app/shared/services/mobile-nav.service.ts @@ -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('list'); + sidebarOpen = signal(false); + tocOpen = signal(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); + } +} diff --git a/src/app/shared/services/responsive.service.ts b/src/app/shared/services/responsive.service.ts new file mode 100644 index 0000000..dfa1539 --- /dev/null +++ b/src/app/shared/services/responsive.service.ts @@ -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(false); + isTablet = signal(false); + isDesktop = signal(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)); + } +} diff --git a/src/app/shared/services/ui-mode.service.ts b/src/app/shared/services/ui-mode.service.ts new file mode 100644 index 0000000..b8bb54b --- /dev/null +++ b/src/app/shared/services/ui-mode.service.ts @@ -0,0 +1,30 @@ +import { Injectable, signal, effect } from '@angular/core'; + +@Injectable({ providedIn: 'root' }) +export class UiModeService { + isNimbusMode = signal(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; + } +} diff --git a/src/components/file-explorer/file-explorer.component.ts b/src/components/file-explorer/file-explorer.component.ts index 418c0f9..b951d1e 100644 --- a/src/components/file-explorer/file-explorer.component.ts +++ b/src/components/file-explorer/file-explorer.component.ts @@ -13,7 +13,7 @@ import { VaultService } from '../../services/vault.service'; @let folder = node;
@@ -27,26 +27,30 @@ import { VaultService } from '../../services/vault.service';
}
} @else { - @let file = node; -
- - - - {{ file.name }} -
+ @if (!foldersOnly()) { + @let file = node; +
+ + + + {{ file.name }} +
+ } } } @@ -58,7 +62,9 @@ import { VaultService } from '../../services/vault.service'; export class FileExplorerComponent { nodes = input.required(); selectedNoteId = input(null); + foldersOnly = input(false); fileSelected = output(); + folderSelected = output(); 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); } diff --git a/src/components/tags-view/tags-view.component.ts b/src/components/tags-view/tags-view.component.ts index 3f0928b..673fae2 100644 --- a/src/components/tags-view/tags-view.component.ts +++ b/src/components/tags-view/tags-view.component.ts @@ -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 { -
+
@if (displayedGroups().length === 0) {
diff --git a/src/environments/environment.prod.ts b/src/environments/environment.prod.ts new file mode 100644 index 0000000..f2a944c --- /dev/null +++ b/src/environments/environment.prod.ts @@ -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", +}; diff --git a/src/environments/environment.ts b/src/environments/environment.ts new file mode 100644 index 0000000..ab34137 --- /dev/null +++ b/src/environments/environment.ts @@ -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", +}; \ No newline at end of file diff --git a/src/styles.css b/src/styles.css index 4db6869..c5561eb 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/src/styles/_overlay-scrollbar.css b/src/styles/_overlay-scrollbar.css new file mode 100644 index 0000000..bd1df13 --- /dev/null +++ b/src/styles/_overlay-scrollbar.css @@ -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; +} diff --git a/tailwind.config.js b/tailwind.config.js index 275fb02..c10a768 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -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)'