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": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"outputHashing": "all"
|
"outputHashing": "all",
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.prod.ts"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"development": {
|
"development": {
|
||||||
"optimization": false,
|
"optimization": false,
|
||||||
|
|||||||
21
index.html
21
index.html
@ -25,5 +25,26 @@
|
|||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<app-root></app-root>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@ -6,6 +6,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "ng serve",
|
"dev": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
|
"prod": "ng build --configuration=production",
|
||||||
"build:workers": "ng build",
|
"build:workers": "ng build",
|
||||||
"preview": "ng serve --configuration=production --port 3000 --host 127.0.0.1",
|
"preview": "ng serve --configuration=production --port 3000 --host 127.0.0.1",
|
||||||
"test": "ng test",
|
"test": "ng test",
|
||||||
|
|||||||
@ -1,4 +1,32 @@
|
|||||||
<!-- ObsiViewer - Application optimisée pour mobile et desktop -->
|
<!-- 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">
|
<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()) {
|
@if (isRawViewOpen()) {
|
||||||
<app-raw-view-overlay
|
<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>
|
<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>
|
||||||
|
<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
|
<button
|
||||||
(click)="toggleOutline()"
|
(click)="toggleOutline()"
|
||||||
class="btn btn-icon btn-ghost"
|
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>
|
<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>
|
||||||
|
<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
|
<button
|
||||||
(click)="toggleOutline()"
|
(click)="toggleOutline()"
|
||||||
class="btn btn-icon btn-ghost"
|
class="btn btn-icon btn-ghost"
|
||||||
@ -612,4 +658,5 @@
|
|||||||
</aside>
|
</aside>
|
||||||
}
|
}
|
||||||
</main>
|
</main>
|
||||||
|
}
|
||||||
|
|
||||||
@ -9,6 +9,7 @@ import { MarkdownViewerService } from './services/markdown-viewer.service';
|
|||||||
import { DownloadService } from './core/services/download.service';
|
import { DownloadService } from './core/services/download.service';
|
||||||
import { ThemeService } from './app/core/services/theme.service';
|
import { ThemeService } from './app/core/services/theme.service';
|
||||||
import { LogService } from './core/logging/log.service';
|
import { LogService } from './core/logging/log.service';
|
||||||
|
import { UiModeService } from './app/shared/services/ui-mode.service';
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { FileExplorerComponent } from './components/file-explorer/file-explorer.component';
|
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 { GraphInlineSettingsComponent } from './app/graph/ui/inline-settings-panel.component';
|
||||||
import { DrawingsEditorComponent } from './app/features/drawings/drawings-editor.component';
|
import { DrawingsEditorComponent } from './app/features/drawings/drawings-editor.component';
|
||||||
import { DrawingsFileService, ExcalidrawScene } from './app/features/drawings/drawings-file.service';
|
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 { RawViewOverlayComponent } from './shared/overlays/raw-view-overlay.component';
|
||||||
import { BookmarksPanelComponent } from './components/bookmarks-panel/bookmarks-panel.component';
|
import { BookmarksPanelComponent } from './components/bookmarks-panel/bookmarks-panel.component';
|
||||||
import { AddBookmarkModalComponent, type BookmarkFormData, type BookmarkDeleteEvent } from './components/add-bookmark-modal/add-bookmark-modal.component';
|
import { 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 { GraphIndexService } from './core/graph/graph-index.service';
|
||||||
import { SearchIndexService } from './core/search/search-index.service';
|
import { SearchIndexService } from './core/search/search-index.service';
|
||||||
import { SearchOrchestratorService } from './core/search/search-orchestrator.service';
|
import { SearchOrchestratorService } from './core/search/search-orchestrator.service';
|
||||||
|
import { LayoutModule } from '@angular/cdk/layout';
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
|
import { FileMetadata, Note, TagInfo, VaultNode } from './types';
|
||||||
@ -42,6 +45,7 @@ interface TocEntry {
|
|||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
imports: [
|
imports: [
|
||||||
|
LayoutModule,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
FileExplorerComponent,
|
FileExplorerComponent,
|
||||||
@ -56,6 +60,7 @@ interface TocEntry {
|
|||||||
SearchInputWithAssistantComponent,
|
SearchInputWithAssistantComponent,
|
||||||
SearchPanelComponent,
|
SearchPanelComponent,
|
||||||
DrawingsEditorComponent,
|
DrawingsEditorComponent,
|
||||||
|
AppShellNimbusLayoutComponent,
|
||||||
],
|
],
|
||||||
templateUrl: './app.component.simple.html',
|
templateUrl: './app.component.simple.html',
|
||||||
styleUrls: ['./app.component.css'],
|
styleUrls: ['./app.component.css'],
|
||||||
@ -67,6 +72,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
private markdownViewerService = inject(MarkdownViewerService);
|
private markdownViewerService = inject(MarkdownViewerService);
|
||||||
private downloadService = inject(DownloadService);
|
private downloadService = inject(DownloadService);
|
||||||
private readonly themeService = inject(ThemeService);
|
private readonly themeService = inject(ThemeService);
|
||||||
|
readonly uiMode = inject(UiModeService);
|
||||||
private readonly bookmarksService = inject(BookmarksService);
|
private readonly bookmarksService = inject(BookmarksService);
|
||||||
private readonly searchHistoryService = inject(SearchHistoryService);
|
private readonly searchHistoryService = inject(SearchHistoryService);
|
||||||
private readonly graphIndexService = inject(GraphIndexService);
|
private readonly graphIndexService = inject(GraphIndexService);
|
||||||
@ -87,6 +93,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
tableOfContents = signal<TocEntry[]>([]);
|
tableOfContents = signal<TocEntry[]>([]);
|
||||||
leftSidebarWidth = signal<number>(288);
|
leftSidebarWidth = signal<number>(288);
|
||||||
rightSidebarWidth = signal<number>(288);
|
rightSidebarWidth = signal<number>(288);
|
||||||
|
centerPanelWidth = signal<number>(384);
|
||||||
isRawViewOpen = signal<boolean>(false);
|
isRawViewOpen = signal<boolean>(false);
|
||||||
isRawViewWrapped = signal<boolean>(true);
|
isRawViewWrapped = signal<boolean>(true);
|
||||||
showAddBookmarkModal = signal<boolean>(false);
|
showAddBookmarkModal = signal<boolean>(false);
|
||||||
@ -94,6 +101,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
readonly LEFT_MAX_WIDTH = 520;
|
readonly LEFT_MAX_WIDTH = 520;
|
||||||
readonly RIGHT_MIN_WIDTH = 220;
|
readonly RIGHT_MIN_WIDTH = 220;
|
||||||
readonly RIGHT_MAX_WIDTH = 520;
|
readonly RIGHT_MAX_WIDTH = 520;
|
||||||
|
readonly CENTER_MIN_WIDTH = 260;
|
||||||
|
readonly CENTER_MAX_WIDTH = 640;
|
||||||
private rawViewTriggerElement: HTMLElement | null = null;
|
private rawViewTriggerElement: HTMLElement | null = null;
|
||||||
private viewportWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 0);
|
private viewportWidth = signal<number>(typeof window !== 'undefined' ? window.innerWidth : 0);
|
||||||
private resizeHandler = () => {
|
private resizeHandler = () => {
|
||||||
@ -101,6 +110,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
this.viewportWidth.set(window.innerWidth);
|
this.viewportWidth.set(window.innerWidth);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// --- Search cross-UI sync ---
|
// --- Search cross-UI sync ---
|
||||||
onHeaderSearchOptionsChange(opts: { caseSensitive: boolean; regexMode: boolean; highlight: boolean }): void {
|
onHeaderSearchOptionsChange(opts: { caseSensitive: boolean; regexMode: boolean; highlight: boolean }): void {
|
||||||
this.lastCaseSensitive.set(!!opts.caseSensitive);
|
this.lastCaseSensitive.set(!!opts.caseSensitive);
|
||||||
@ -109,6 +119,37 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
this.scheduleApplyDocumentHighlight();
|
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 {
|
onFileNodeSelected(noteId: string): void {
|
||||||
if (!noteId) return;
|
if (!noteId) return;
|
||||||
const meta = this.vaultService.getFastMetaById(noteId);
|
const meta = this.vaultService.getFastMetaById(noteId);
|
||||||
@ -632,6 +673,10 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
this.themeService.toggleTheme();
|
this.themeService.toggleTheme();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
toggleUIMode(): void {
|
||||||
|
this.uiMode.toggleUIMode();
|
||||||
|
}
|
||||||
|
|
||||||
toggleSidebar(): void {
|
toggleSidebar(): void {
|
||||||
this.isSidebarOpen.update(value => !value);
|
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;
|
@let folder = node;
|
||||||
<div>
|
<div>
|
||||||
<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"
|
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">
|
<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,26 +27,30 @@ import { VaultService } from '../../services/vault.service';
|
|||||||
<app-file-explorer
|
<app-file-explorer
|
||||||
[nodes]="folder.children"
|
[nodes]="folder.children"
|
||||||
[selectedNoteId]="selectedNoteId()"
|
[selectedNoteId]="selectedNoteId()"
|
||||||
|
[foldersOnly]="foldersOnly()"
|
||||||
|
(folderSelected)="folderSelected.emit($event)"
|
||||||
(fileSelected)="onFileSelected($event)"
|
(fileSelected)="onFileSelected($event)"
|
||||||
></app-file-explorer>
|
></app-file-explorer>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@let file = node;
|
@if (!foldersOnly()) {
|
||||||
<div
|
@let file = node;
|
||||||
(click)="onFileSelected(file.id)"
|
<div
|
||||||
class="flex items-center cursor-pointer p-2 rounded my-0.5 text-sm ml-5"
|
(click)="onFileSelected(file.id)"
|
||||||
[class.bg-obs-l-bg-main]="selectedNoteId() === file.id"
|
class="flex items-center cursor-pointer p-2 rounded my-0.5 text-sm ml-5"
|
||||||
[class.dark:bg-obs-d-bg-main]="selectedNoteId() === file.id"
|
[class.bg-obs-l-bg-main]="selectedNoteId() === file.id"
|
||||||
[class.hover:bg-obs-l-bg-main]="selectedNoteId() !== file.id"
|
[class.dark:bg-obs-d-bg-main]="selectedNoteId() === file.id"
|
||||||
[class.dark:hover:bg-obs-d-bg-main]="selectedNoteId() !== file.id"
|
[class.hover:bg-obs-l-bg-main]="selectedNoteId() !== file.id"
|
||||||
>
|
[class.dark:hover:bg-obs-d-bg-main]="selectedNoteId() !== file.id"
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0 text-obs-l-text-muted dark:text-obs-d-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 mr-2 flex-shrink-0 text-obs-l-text-muted dark:text-obs-d-text-muted" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
</svg>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
<span class="truncate">{{ file.name }}</span>
|
</svg>
|
||||||
</div>
|
<span class="truncate">{{ file.name }}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@ -58,7 +62,9 @@ import { VaultService } from '../../services/vault.service';
|
|||||||
export class FileExplorerComponent {
|
export class FileExplorerComponent {
|
||||||
nodes = input.required<VaultNode[]>();
|
nodes = input.required<VaultNode[]>();
|
||||||
selectedNoteId = input<string | null>(null);
|
selectedNoteId = input<string | null>(null);
|
||||||
|
foldersOnly = input<boolean>(false);
|
||||||
fileSelected = output<string>();
|
fileSelected = output<string>();
|
||||||
|
folderSelected = output<string>();
|
||||||
|
|
||||||
private vaultService = inject(VaultService);
|
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) {
|
toggleFolder(folder: VaultFolder) {
|
||||||
this.vaultService.toggleFolder(folder.path);
|
this.vaultService.toggleFolder(folder.path);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,24 +35,6 @@ interface TagGroup {
|
|||||||
--tv-scroll-thumb: rgba(200, 200, 200, 0.2);
|
--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 {
|
.tag-item {
|
||||||
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.15s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
@ -180,7 +162,7 @@ interface TagGroup {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tags list -->
|
<!-- Tags list -->
|
||||||
<div class="flex-1 overflow-y-auto custom-scrollbar">
|
<div class="flex-1 overflow-y-auto" appScrollableOverlay>
|
||||||
@if (displayedGroups().length === 0) {
|
@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">
|
<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">
|
<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-test.css';
|
||||||
|
@import './styles/_overlay-scrollbar.css';
|
||||||
|
|
||||||
/* Excalidraw CSS variables (thème sombre) */
|
/* Excalidraw CSS variables (thème sombre) */
|
||||||
/* .excalidraw {
|
/* .excalidraw {
|
||||||
@ -256,6 +257,8 @@ excalidraw-editor .excalidraw .layer-ui__wrapper {
|
|||||||
--btn-hover-background: color-mix(in srgb, var(--bg-muted) 42%, transparent);
|
--btn-hover-background: color-mix(in srgb, var(--bg-muted) 42%, transparent);
|
||||||
--btn-focus-ring: color-mix(in srgb, var(--border) 45%, transparent);
|
--btn-focus-ring: color-mix(in srgb, var(--border) 45%, transparent);
|
||||||
--btn-muted-text: var(--text-muted);
|
--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 {
|
.dark {
|
||||||
@ -270,6 +273,8 @@ excalidraw-editor .excalidraw .layer-ui__wrapper {
|
|||||||
--btn-hover-background: color-mix(in srgb, var(--bg-muted) 36%, transparent);
|
--btn-hover-background: color-mix(in srgb, var(--bg-muted) 36%, transparent);
|
||||||
--btn-focus-ring: color-mix(in srgb, var(--border) 55%, transparent);
|
--btn-focus-ring: color-mix(in srgb, var(--border) 55%, transparent);
|
||||||
--btn-muted-text: var(--text-muted);
|
--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) {
|
@media (min-width: 1024px) {
|
||||||
@ -281,6 +286,57 @@ excalidraw-editor .excalidraw .layer-ui__wrapper {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@layer components {
|
@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 {
|
.calendar-compact {
|
||||||
/* @apply flex min-h-[180px] max-h-[200px] flex-col; */
|
/* @apply flex min-h-[180px] max-h-[200px] flex-col; */
|
||||||
display: flex;
|
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"]'],
|
darkMode: ['class', '[data-theme="dark"]'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
screens: {
|
||||||
|
xs: '320px',
|
||||||
|
sm: '640px',
|
||||||
|
md: '768px',
|
||||||
|
lg: '1024px',
|
||||||
|
xl: '1280px',
|
||||||
|
'2xl': '1536px',
|
||||||
|
},
|
||||||
colors: {
|
colors: {
|
||||||
'bg-main': 'var(--bg-main)',
|
'bg-main': 'var(--bg-main)',
|
||||||
'bg-muted': 'var(--bg-muted)',
|
'bg-muted': 'var(--bg-muted)',
|
||||||
@ -25,7 +33,12 @@ module.exports = {
|
|||||||
warning: 'var(--warning)',
|
warning: 'var(--warning)',
|
||||||
danger: 'var(--danger)',
|
danger: 'var(--danger)',
|
||||||
info: 'var(--info)',
|
info: 'var(--info)',
|
||||||
ring: 'var(--ring)'
|
ring: 'var(--ring)',
|
||||||
|
nimbus: {
|
||||||
|
50: '#f0f9ff',
|
||||||
|
500: '#0ea5e9',
|
||||||
|
900: '#0c4a6e'
|
||||||
|
}
|
||||||
},
|
},
|
||||||
ringColor: {
|
ringColor: {
|
||||||
DEFAULT: 'var(--ring)'
|
DEFAULT: 'var(--ring)'
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user