ObsiViewer/src/app/editor/components/toc/toc-panel.component.ts
Bruno Charest ee3085ce38 feat: add Nimbus Editor with Unsplash integration
- Integrated Unsplash API for image search functionality with environment configuration
- Added new Nimbus Editor page component with navigation from sidebar and mobile drawer
- Enhanced TOC with highlight animation for editor heading navigation
- Improved CDK overlay z-index hierarchy for proper menu layering
- Removed obsolete logging validation script
2025-11-11 11:38:27 -05:00

288 lines
9.9 KiB
TypeScript

import { Component, inject, Input } from '@angular/core';
import { CommonModule } from '@angular/common';
import { TocService, TocItem } from '../../services/toc.service';
@Component({
selector: 'app-toc-panel',
standalone: true,
imports: [CommonModule],
template: `
<div
id="toc-panel"
role="navigation"
aria-label="Table of Contents"
[class]="panelClass"
class="shrink-0"
tabindex="0"
(keydown)="onKeydown($event)"
(keydown.escape)="tocService.close()"
[style.width.px]="tocService.isOpen() ? 280 : 0"
[attr.aria-hidden]="!tocService.isOpen() ? 'true' : null"
[class.pointer-events-none]="!tocService.isOpen()"
>
<div class="flex flex-col h-full min-h-0 w-[280px]">
<!-- Header -->
<div class="toc-header flex items-center justify-between px-4 py-3">
<h3 class="text-sm font-semibold">Table of Contents</h3>
<button
type="button"
class="toc-close-btn p-1.5 rounded transition"
(click)="tocService.close()"
title="Close Table of Contents"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M6 18L18 6M6 6l12 12"/>
</svg>
</button>
</div>
<!-- TOC Items -->
<div class="flex-1 min-h-0 overflow-y-auto overflow-x-hidden px-4 py-3">
@if (visibleTocItems().length === 0) {
<div class="toc-empty text-sm italic text-text-muted">
Aucun titre trouvé
</div>
} @else {
<div class="space-y-1.5 text-sm">
@for (item of visibleTocItems(); track item.id) {
<button
#tocItem
type="button"
class="toc-item w-full text-left px-3 py-2 rounded-lg transition"
[ngClass]="[getTocItemClass(item), tocService.activeId() === item.blockId ? 'toc-item-active' : '']"
[title]="item.text || 'Untitled'"
[attr.aria-current]="tocService.activeId() === item.blockId ? 'true' : null"
[attr.aria-expanded]="isCollapsible(item) ? (!isCollapsed(item) ? 'true' : 'false') : null"
(click)="onItemClick(item, $event)"
>
<span class="toc-text">
<span class="toc-label">{{ item.text || 'Sans titre' }}</span>
<span class="toc-level">Niveau {{ item.level }}</span>
</span>
</button>
}
</div>
}
</div>
<!-- Footer info -->
<div class="toc-footer px-4 py-2 text-xs">
{{ tocService.tocItems().length }} heading{{ tocService.tocItems().length !== 1 ? 's' : '' }}
</div>
</div>
</div>
`,
styles: [`
.toc-panel {
background: var(--toc-bg, #111827);
color: var(--toc-fg, #e5e7eb);
border-left: 1px solid var(--toc-border, rgba(148, 163, 184, 0.18));
}
.toc-header {
border-bottom: 1px solid var(--toc-border, rgba(148, 163, 184, 0.18));
color: inherit;
}
.toc-close-btn {
color: inherit;
}
.toc-close-btn:hover {
background: color-mix(in srgb, rgba(148, 163, 184, 0.15) 60%, transparent);
}
.toc-empty {
color: var(--toc-muted, rgba(148, 163, 184, 0.75));
}
.toc-footer {
border-top: 1px solid var(--toc-border, rgba(148, 163, 184, 0.18));
color: var(--toc-muted, rgba(148, 163, 184, 0.75));
}
.toc-item {
border: 1px solid transparent;
background: color-mix(in srgb, var(--toc-bg, #111827) 70%, rgba(148, 163, 184, 0.12) 30%);
color: inherit;
box-shadow: inset 0 0 0 0 transparent;
}
.toc-item:hover {
border-color: color-mix(in srgb, var(--toc-active, #6366f1) 35%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--toc-active, #6366f1) 25%, transparent);
background: color-mix(in srgb, rgba(99, 102, 241, 0.18) 40%, var(--toc-bg, #111827));
}
.toc-item:focus-visible {
outline: 2px solid var(--toc-active, #6366f1);
outline-offset: 2px;
}
.toc-item-h1 { padding-left: 0.25rem; font-weight: 600; }
.toc-item-h2 { padding-left: 1.25rem; font-weight: 500; }
.toc-item-h3 { padding-left: 2.25rem; font-weight: 500; font-size: 0.8125rem; color: var(--toc-muted, rgba(148, 163, 184, 0.75)); }
.toc-item-active {
border-color: color-mix(in srgb, var(--toc-active, #6366f1) 60%, transparent);
background: color-mix(in srgb, var(--toc-active, #6366f1) 18%, transparent);
color: var(--toc-active, #6366f1);
}
.toc-text {
display: flex;
justify-content: space-between;
gap: 0.5rem;
color: inherit;
}
.toc-level {
font-size: 0.75rem;
color: var(--toc-muted, rgba(148, 163, 184, 0.75));
}
`]
})
export class TocPanelComponent {
readonly tocService = inject(TocService);
@Input() mode: 'fixed' | 'container' = 'fixed';
private collapsed = new Set<string>();
getTocItemClass(item: TocItem): string {
switch (item.level) {
case 1: return 'toc-item-h1';
case 2: return 'toc-item-h2';
case 3: return 'toc-item-h3';
default: return 'toc-item-h3';
}
}
get panelClass(): string {
const base = 'toc-panel shadow-xl z-40';
if (this.mode === 'container') {
return `${base} h-full overflow-hidden`;
}
return `${base} fixed right-0 top-0 bottom-0 overflow-y-auto`;
}
onItemClick(item: TocItem, ev?: MouseEvent): void {
// Shift+Click on H1/H2 toggles collapse/expand
if ((ev?.shiftKey) && this.isCollapsible(item)) {
this.toggleCollapse(item);
return;
}
this.ensureExpandedFor(item);
this.tocService.scrollToHeading(item.blockId);
}
ngOnChanges(): void { this.maybeFocusFirst(); }
ngAfterViewChecked(): void { this.maybeFocusFirst(); }
private lastFocused = false;
private maybeFocusFirst() {
// When panel opens, focus first item once
if (this.tocService.isOpen() && !this.lastFocused) {
const root = (document.getElementById('toc-panel')) as HTMLElement | null;
const btn = root?.querySelector('button');
(btn as HTMLElement | null)?.focus?.();
this.lastFocused = true;
}
if (!this.tocService.isOpen() && this.lastFocused) this.lastFocused = false;
// Auto-expand ancestors for active item
this.ensureExpandedForActive();
}
onKeydown(ev: KeyboardEvent) {
const root = document.getElementById('toc-panel') as HTMLElement | null;
if (!root) return;
const items = Array.from(root.querySelectorAll('button')) as HTMLElement[];
if (!items.length) return;
const active = document.activeElement as HTMLElement | null;
let idx = Math.max(0, items.findIndex(b => b === active));
const move = (delta: number) => {
idx = (idx + delta + items.length) % items.length;
items[idx]?.focus?.();
};
switch (ev.key) {
case 'ArrowDown': move(1); ev.preventDefault(); break;
case 'ArrowUp': move(-1); ev.preventDefault(); break;
case 'Home': idx = 0; items[idx]?.focus?.(); ev.preventDefault(); break;
case 'End': idx = items.length - 1; items[idx]?.focus?.(); ev.preventDefault(); break;
case 'Enter':
case ' ': (active as HTMLButtonElement | null)?.click?.(); ev.preventDefault(); break;
case 'Tab': {
// Focus trap inside panel
const shift = ev.shiftKey;
if (shift && idx === 0) { items[items.length - 1]?.focus?.(); ev.preventDefault(); }
else if (!shift && idx === items.length - 1) { items[0]?.focus?.(); ev.preventDefault(); }
break;
}
}
}
isCollapsible(item: TocItem): boolean { return item.level === 1 || item.level === 2; }
isCollapsed(item: TocItem): boolean { return this.collapsed.has(item.blockId); }
toggleCollapse(item: TocItem): void {
if (!this.isCollapsible(item)) return;
if (this.isCollapsed(item)) this.collapsed.delete(item.blockId); else this.collapsed.add(item.blockId);
}
visibleTocItems(): TocItem[] {
const items = this.tocService.tocItems();
const out: TocItem[] = [];
let hideLevel1: string | null = null;
let hideLevel2: string | null = null;
for (let i = 0; i < items.length; i++) {
const it = items[i];
if (it.level === 1) {
hideLevel2 = null;
hideLevel1 = this.isCollapsed(it) ? it.blockId : null;
out.push(it);
continue;
}
if (it.level === 2) {
if (hideLevel1) continue; // hidden under collapsed H1
hideLevel2 = this.isCollapsed(it) ? it.blockId : null;
out.push(it);
continue;
}
// level 3
if (hideLevel1 || hideLevel2) continue;
out.push(it);
}
return out;
}
private ensureExpandedForActive() {
const active = this.tocService.activeId();
if (!active) return;
const items = this.tocService.tocItems();
const idx = items.findIndex(it => it.blockId === active);
if (idx < 0) return;
// Expand nearest ancestors (H2 then H1 above)
for (let i = idx - 1; i >= 0; i--) {
const it = items[i];
if (it.level === 3) continue;
if (it.level === 2) { this.collapsed.delete(it.blockId); }
if (it.level === 1) { this.collapsed.delete(it.blockId); break; }
}
}
private ensureExpandedFor(item: TocItem) {
// When navigating to item, expand its ancestors
if (item.level === 3) {
const items = this.tocService.tocItems();
const idx = items.findIndex(it => it.blockId === item.blockId);
for (let i = idx - 1; i >= 0; i--) {
const it = items[i];
if (it.level === 2) this.collapsed.delete(it.blockId);
if (it.level === 1) { this.collapsed.delete(it.blockId); break; }
}
} else if (item.level === 2) {
const items = this.tocService.tocItems();
const idx = items.findIndex(it => it.blockId === item.blockId);
for (let i = idx - 1; i >= 0; i--) { const it = items[i]; if (it.level === 1) { this.collapsed.delete(it.blockId); break; } }
}
}
}