- 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
288 lines
9.9 KiB
TypeScript
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; } }
|
|
}
|
|
}
|
|
}
|