Nettoyage post-revue : factorisation, code mort et fiabilité (reorder/lore/foundry/PDF)
All checks were successful
All checks were successful
Corrections fonctionnelles - order initialisé à la création des pages et dossiers de lore (nextOrderFor) : un nouvel élément se place désormais en dernier de sa fratrie au lieu de 0 - storePortrait : type MIME canonique dérivé de l'extension du fichier (un data URL "image/jpg" n'est plus rejeté silencieusement) - takeUntilDestroyed ajouté sur les abonnements paramMap de arc-view, folder-view et campaign-detail (fuites mémoire) Factorisation (suppression de duplication) - shared/folder-grouping.util.ts : groupByFolder + byOrder + byFolderName mutualisés entre npc-list, enemy-list, campaign-detail et la sidebar (folderChildren). Le tri des dossiers est désormais cohérent entre la sidebar et les vues cartes (insensible casse/accents partout) - DataSyncService.onChange()/persist() remplacent le câblage changed$ + reorder recopié dans 5 vues - campaign-detail : loadCampaignBundle()/applyCampaignBundle() éliminent le forkJoin dupliqué entre ngOnInit et reload - ReorderSupport (domain/shared) : squelette générique remplaçant les 8 boucles de réordonnancement copiées dans les services - FoundryExportService : GameSystem résolu une seule fois dans buildBundle - module Foundry : walkScalars mutualise la récursion de flattenStats et flattenStructure (comportements préservés) ; esc() de l'importer aligné sur foundry.utils.escapeHTML Suppression de code mort - characterService/characters retirés de loadCampaignTreeData et de ses 16 appelants (arguments, injections, imports et littéraux CampaignTreeData) - CSS orphelin : .tree-row.cdk-drop-list-receiving et .btn-back - BottomPanel.initiallyOpen (jamais lu) retiré de l'interface et de l'appelant - commentaire trompeur du PDF corrigé Tests - mise à jour de 3 tests obsolètes de campaign-tree.helper.spec qui vérifiaient encore l'ancien comportement (tri alphabétique, non-classés en enfants directs) → alignés sur le modèle actuel (tri par order + pseudo-dossier "Sans dossier")
This commit is contained in:
46
web/src/app/shared/folder-grouping.util.ts
Normal file
46
web/src/app/shared/folder-grouping.util.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Regroupement par dossier + tri par ordre manuel, mutualisé entre toutes les
|
||||
* vues ordonnables (listes PNJ/ennemis, grille de la page campagne, sidebar).
|
||||
* Une seule définition du tri des dossiers évite que la sidebar et les cartes
|
||||
* divergent (cf. revue de propreté).
|
||||
*/
|
||||
|
||||
/** Comparateur d'ordre manuel (`order` croissant, défaut 0). */
|
||||
export const byOrder = (a: { order?: number }, b: { order?: number }): number =>
|
||||
(a.order ?? 0) - (b.order ?? 0);
|
||||
|
||||
/** Comparateur de noms de dossier : alphabétique, insensible casse/accents (comme la sidebar). */
|
||||
export const byFolderName = (a: string, b: string): number =>
|
||||
a.localeCompare(b, 'fr', { sensitivity: 'base' });
|
||||
|
||||
/** Un dossier (`folder`, `''` = non-classé) et ses éléments, déjà triés par ordre. */
|
||||
export interface FolderGroup<T> {
|
||||
folder: string;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Groupe `items` par dossier : dossiers triés alphabétiquement, éléments
|
||||
* non-classés (folder vide) regroupés en dernier. Chaque groupe est trié par
|
||||
* `order`. Renvoie une structure neutre (`{ folder, items }`) que chaque vue
|
||||
* adapte à son besoin (cartes, arbre…).
|
||||
*/
|
||||
export function groupByFolder<T extends { order?: number; folder?: string | null }>(items: T[]): FolderGroup<T>[] {
|
||||
const sorted = [...items].sort(byOrder);
|
||||
const byFolder = new Map<string, T[]>();
|
||||
const ungrouped: T[] = [];
|
||||
for (const it of sorted) {
|
||||
const f = (it.folder ?? '').trim();
|
||||
if (f) {
|
||||
if (!byFolder.has(f)) byFolder.set(f, []);
|
||||
byFolder.get(f)!.push(it);
|
||||
} else {
|
||||
ungrouped.push(it);
|
||||
}
|
||||
}
|
||||
const groups: FolderGroup<T>[] = [...byFolder.keys()]
|
||||
.sort(byFolderName)
|
||||
.map(folder => ({ folder, items: byFolder.get(folder)! }));
|
||||
if (ungrouped.length) groups.push({ folder: '', items: ungrouped });
|
||||
return groups;
|
||||
}
|
||||
@@ -33,75 +33,33 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- cdkDropListGroup : connecte toutes les drop lists de l'arbre (déplacement d'un
|
||||
nœud d'une liste à l'autre). Chaque ligne est un <app-sidebar-tree-node> :
|
||||
comme c'est un VRAI composant déclaré statiquement dans la drop list, son
|
||||
cdkDrag résout sa drop list par DI (impossible via un template outlet-é). -->
|
||||
<div class="tree">
|
||||
@for (item of items; track item) {
|
||||
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: item, level: 0 }"></ng-container>
|
||||
}
|
||||
</div>
|
||||
<!-- Template récursif : un noeud d'arbre rend son bouton, puis ses enfants via ce même template -->
|
||||
<ng-template #treeNode let-item let-level="level">
|
||||
@if (level === 0 && item.sectionHeaderBefore) {
|
||||
<div class="tree-section-header">
|
||||
{{ item.sectionHeaderBefore }}
|
||||
</div>
|
||||
}
|
||||
<div class="tree-item" [style.padding-left.px]="level * 12">
|
||||
<div class="tree-row">
|
||||
@if (!item.isAction && isExpandable(item)) {
|
||||
<button
|
||||
type="button"
|
||||
class="chevron-btn"
|
||||
(click)="clickChevron($event, item)">
|
||||
<lucide-icon
|
||||
[img]="isExpanded(item.id) ? ChevronDown : ChevronRight"
|
||||
[size]="12">
|
||||
</lucide-icon>
|
||||
</button>
|
||||
<div class="tree-list" cdkDropList
|
||||
id="sidebar-root"
|
||||
[cdkDropListData]="rootDragData"
|
||||
[cdkDropListConnectedTo]="dropListIds"
|
||||
[cdkDropListDisabled]="!dndEnabled()"
|
||||
[cdkDropListEnterPredicate]="rootEnterPredicate"
|
||||
(cdkDropListDropped)="onDrop(null, $event)">
|
||||
@for (item of items; track item.id; let first = $first) {
|
||||
<!-- En-tête de section RENDU HORS du nœud (sinon il serait emporté par le
|
||||
drag du premier item). C'est un élément non-déplaçable de la drop list. -->
|
||||
@if (item.sectionHeaderBefore) {
|
||||
<div class="tree-section-header" [class.first]="first">{{ item.sectionHeaderBefore }}</div>
|
||||
}
|
||||
@if (item.isAction || !isExpandable(item)) {
|
||||
<span class="chevron-spacer"></span>
|
||||
@if (dndEnabled() && item.dragKind) {
|
||||
<app-sidebar-tree-node cdkDrag [cdkDragData]="item" cdkDragLockAxis="y"
|
||||
[item]="item" [level]="0"></app-sidebar-tree-node>
|
||||
} @else {
|
||||
<app-sidebar-tree-node [item]="item" [level]="0"></app-sidebar-tree-node>
|
||||
}
|
||||
<button type="button" class="tree-btn"
|
||||
[class.action]="item.isAction"
|
||||
[class.active]="isActive(item)"
|
||||
(click)="clickItem(item)">
|
||||
@if (iconFor(item); as icon) {
|
||||
<lucide-icon
|
||||
[img]="icon"
|
||||
[size]="14"
|
||||
class="item-icon">
|
||||
</lucide-icon>
|
||||
}
|
||||
{{ item.label }}
|
||||
@if (!item.isAction && item.meta) {
|
||||
<span class="tree-item-meta">{{ item.meta }}</span>
|
||||
}
|
||||
</button>
|
||||
<!-- Actions de creation contextuelles, revelees au survol de la ligne -->
|
||||
@if (item.createActions?.length) {
|
||||
<span class="node-actions">
|
||||
@for (a of item.createActions; track a) {
|
||||
<button
|
||||
type="button"
|
||||
class="node-action-btn"
|
||||
[title]="a.label"
|
||||
[attr.aria-label]="a.label"
|
||||
(click)="runCreateAction($event, a)">
|
||||
<lucide-icon [img]="iconForAction(a)" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
@if (isExpanded(item.id) && hasChildren(item)) {
|
||||
<div class="tree-children">
|
||||
@for (child of item.children; track child) {
|
||||
<ng-container *ngTemplateOutlet="treeNode; context: { $implicit: child, level: level + 1 }"></ng-container>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
<!-- Panneau bas (ex: Templates) ------------------------------------ -->
|
||||
@if (bottomPanel) {
|
||||
<section class="bottom-panel">
|
||||
|
||||
@@ -50,23 +50,6 @@
|
||||
&:hover { color: white; background: #1f2937; }
|
||||
}
|
||||
|
||||
.btn-back {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #6b7280;
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
transition: color 0.2s;
|
||||
width: fit-content;
|
||||
|
||||
&:hover { color: white; }
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
@@ -132,153 +115,31 @@
|
||||
|
||||
.tree {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
overflow-y: auto;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.tree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
// NB : les styles d'une LIGNE d'arbre (tree-row, tree-btn, chevron, dossier, poignée…)
|
||||
// vivent désormais dans sidebar-tree-node.component.scss (composant récursif).
|
||||
|
||||
// En-tête de section — groupe visuellement les nœuds racines (ex: Personnages / Narration).
|
||||
// Un filet au-dessus crée la séparation ; pas de filet pour la première section
|
||||
// (le titre suffit) — on cible ça via :not(:first-child).
|
||||
// En-tête de section (NARRATION / PERSONNAGES / OUTILS) — rendu ici, hors des nœuds.
|
||||
.tree-section-header {
|
||||
font-size: 0.68rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: #9ca3af;
|
||||
padding: 0.6rem 0.5rem 0.3rem;
|
||||
}
|
||||
.tree > .tree-section-header:not(:first-child) {
|
||||
border-top: 1px solid #374151;
|
||||
margin-top: 0.35rem;
|
||||
padding-top: 0.6rem;
|
||||
padding: 0.6rem 0.5rem 0.3rem;
|
||||
|
||||
&.first { border-top: none; margin-top: 0; padding-top: 0.3rem; }
|
||||
}
|
||||
|
||||
.tree-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #d1d5db;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #1f2937; }
|
||||
&.action { color: #6b7280; font-style: italic; }
|
||||
&.action:hover { color: #a5b4fc; background: #1f2937; }
|
||||
|
||||
// Dossier / page / scène actuellement affichée : surligné avec un accent
|
||||
// violet et une barre gauche pour repérer instantanément où on se trouve,
|
||||
// utile quand plusieurs entrées partagent le même label.
|
||||
&.active {
|
||||
background: #1e1b4b;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: inset 3px 0 0 #6c63ff;
|
||||
}
|
||||
&.active:hover { background: #2a2558; }
|
||||
|
||||
.tree-item-meta {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #a5b4fc;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
// Hover-reveal : les boutons d'action de creation n'apparaissent qu'au
|
||||
// survol de la ligne. Pattern Notion/VS Code.
|
||||
&:hover .node-actions { opacity: 1; pointer-events: auto; }
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
// Superpose les boutons d'action sur le .tree-item-meta eventuel
|
||||
// (compteur de pages) : au hover on affiche les actions, au repos le meta.
|
||||
position: absolute;
|
||||
right: 0.25rem;
|
||||
background: linear-gradient(to right, transparent, #1f2937 30%);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.node-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(55, 65, 81, 0.6);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #d1d5db;
|
||||
padding: 0;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
|
||||
&:hover { background: #4338ca; color: #ffffff; }
|
||||
}
|
||||
|
||||
.chevron-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
padding: 0;
|
||||
|
||||
&:hover { background: #374151; color: white; }
|
||||
}
|
||||
|
||||
.chevron-spacer {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
|
||||
|
||||
/* --- Bottom panel (ex: Templates) ------------------------------------- */
|
||||
.bottom-panel {
|
||||
border-top: 1px solid #1e1e3a;
|
||||
@@ -372,3 +233,7 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// NB : aperçu/placeholder = styles GLOBAUX (src/styles.scss), car l'aperçu CDK est
|
||||
// déplacé hors du composant. Ici, juste le retour visuel de la drop list RACINE.
|
||||
.tree-list.cdk-drop-list-receiving { background: rgba(138, 123, 200, 0.08); border-radius: 4px; }
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef } from '@angular/core';
|
||||
import { Component, Input, Output, EventEmitter, HostListener, OnDestroy, ElementRef, forwardRef } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Router } from '@angular/router';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { CdkDropList, CdkDrag, CdkDragDrop, moveItemInArray, transferArrayItem } from '@angular/cdk/drag-drop';
|
||||
import { LucideAngularModule, ChevronRight, ChevronDown, PanelLeftClose, PanelLeftOpen, Plus, FolderPlus, FilePlus, Home, LucideIconData } from 'lucide-angular';
|
||||
import { TreeItem, TreeCreateAction, SidebarAction, BottomPanel, BottomPanelItem, LayoutService } from '../../services/layout.service';
|
||||
import { TreeItem, TreeCreateAction, SidebarAction, BottomPanel, BottomPanelItem, LayoutService, ReorderKind, SidebarReorderContext } from '../../services/layout.service';
|
||||
import { SidebarReorderService } from '../../services/sidebar-reorder.service';
|
||||
import { SidebarTreeNodeComponent } from './sidebar-tree-node.component';
|
||||
import { resolveIcon } from '../../lore/lore-icons';
|
||||
|
||||
@Component({
|
||||
selector: 'app-secondary-sidebar',
|
||||
imports: [CommonModule, LucideAngularModule, TranslatePipe],
|
||||
imports: [CommonModule, LucideAngularModule, TranslatePipe, CdkDropList, CdkDrag, forwardRef(() => SidebarTreeNodeComponent)],
|
||||
templateUrl: './secondary-sidebar.component.html',
|
||||
styleUrls: ['./secondary-sidebar.component.scss']
|
||||
})
|
||||
@@ -18,6 +21,10 @@ export class SecondarySidebarComponent implements OnDestroy {
|
||||
@Input() titleRoute: string | null = null;
|
||||
@Input() createActions: SidebarAction[] = [];
|
||||
@Input() bottomPanel: BottomPanel | null = null;
|
||||
/** DnD : types déplaçables à la racine, parent racine, et contexte de rechargement. */
|
||||
@Input() rootDropKinds: ReorderKind[] | null = null;
|
||||
@Input() rootDropParentId: string | null = null;
|
||||
@Input() reorderContext: SidebarReorderContext | null = null;
|
||||
@Output() collapsedChange = new EventEmitter<boolean>();
|
||||
|
||||
/** true = ouvert (on affiche les items) ; false = replié (titre seul). */
|
||||
@@ -47,16 +54,35 @@ export class SecondarySidebarComponent implements OnDestroy {
|
||||
|
||||
private _items: TreeItem[] = [];
|
||||
|
||||
// Données de drag STABLES (références constantes entre détections de changement).
|
||||
// Indispensable : recalculer le tableau à chaque CD réinitialiserait le tri CDK en
|
||||
// plein drag (l'élément flotterait sans s'insérer). Reconstruites au set d'items.
|
||||
private _dragData = new Map<string, TreeItem[]>();
|
||||
private _predicates = new Map<string, (drag: CdkDrag) => boolean>();
|
||||
private _nodesById = new Map<string, TreeItem>();
|
||||
rootDragData: TreeItem[] = [];
|
||||
/** Ids de TOUTES les drop lists, pour les connecter explicitement (registre global
|
||||
* CDK → fonctionne à travers les composants, contrairement à cdkDropListGroup). */
|
||||
dropListIds: string[] = ['sidebar-root'];
|
||||
|
||||
@Input() set items(value: TreeItem[]) {
|
||||
this._items = value ?? [];
|
||||
this._buildDragMaps();
|
||||
this.autoExpandActiveAncestors();
|
||||
}
|
||||
get items(): TreeItem[] { return this._items; }
|
||||
|
||||
/** Prédicat racine (stable) : lit `rootDropKinds` au moment de l'appel. */
|
||||
rootEnterPredicate = (drag: CdkDrag): boolean => {
|
||||
const k = (drag.data as TreeItem | undefined)?.dragKind;
|
||||
return !!k && !!this.rootDropKinds && this.rootDropKinds.includes(k);
|
||||
};
|
||||
|
||||
constructor(
|
||||
private router: Router,
|
||||
private layoutService: LayoutService,
|
||||
private elementRef: ElementRef<HTMLElement>
|
||||
private elementRef: ElementRef<HTMLElement>,
|
||||
private reorderService: SidebarReorderService
|
||||
) {
|
||||
try {
|
||||
const stored = localStorage.getItem(SecondarySidebarComponent.WIDTH_STORAGE_KEY);
|
||||
@@ -199,6 +225,109 @@ export class SecondarySidebarComponent implements OnDestroy {
|
||||
return !!item.children && item.children.length > 0;
|
||||
}
|
||||
|
||||
// --- Glisser-déposer (réordonnancement dans l'arbre) --------------------
|
||||
|
||||
/** DnD actif uniquement si un contexte de rechargement est fourni (campagne/lore). */
|
||||
dndEnabled(): boolean {
|
||||
return !!this.reorderContext;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconstruit, pour chaque nœud, son tableau d'enfants déplaçables et son prédicat
|
||||
* de dépôt — une seule fois (au set d'items) pour fournir des références STABLES.
|
||||
* Filtrer les non-déplaçables garde les indices CDK alignés sur la donnée.
|
||||
*/
|
||||
private _buildDragMaps(): void {
|
||||
this._dragData.clear();
|
||||
this._predicates.clear();
|
||||
this._nodesById.clear();
|
||||
this.dropListIds = ['sidebar-root'];
|
||||
this.rootDragData = this._items.filter(i => !!i.dragKind);
|
||||
const walk = (nodes: TreeItem[]): void => {
|
||||
for (const n of nodes) {
|
||||
this._nodesById.set(n.id, n);
|
||||
if (n.dropKinds) {
|
||||
this._predicates.set(n.id, this._makePredicate(n.dropKinds));
|
||||
this.dropListIds.push('lst-' + n.id);
|
||||
}
|
||||
if (n.children?.length) {
|
||||
this._dragData.set(n.id, n.children.filter(c => !!c.dragKind));
|
||||
walk(n.children);
|
||||
}
|
||||
}
|
||||
};
|
||||
walk(this._items);
|
||||
}
|
||||
|
||||
private _makePredicate(kinds: ReorderKind[] | null): (drag: CdkDrag) => boolean {
|
||||
return (drag: CdkDrag): boolean => {
|
||||
const k = (drag.data as TreeItem | undefined)?.dragKind;
|
||||
return !!k && !!kinds && kinds.includes(k);
|
||||
};
|
||||
}
|
||||
|
||||
/** Données (stables) de la drop list des enfants d'un nœud. */
|
||||
dragDataFor(item: TreeItem): TreeItem[] {
|
||||
return this._dragData.get(item.id) ?? [];
|
||||
}
|
||||
|
||||
/** Prédicat (stable) de la drop list des enfants d'un nœud. */
|
||||
enterPredicateFor(item: TreeItem): (drag: CdkDrag) => boolean {
|
||||
return this._predicates.get(item.id) ?? (() => false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Dépôt d'un élément. Le POINT DE LÂCHER fait autorité (override fiable de la
|
||||
* détection de conteneur de CDK, qui est ambiguë avec des listes imbriquées) :
|
||||
* - lâché sur un AUTRE dossier (compatible) → déplacement DANS ce dossier ;
|
||||
* - sinon → réordonnancement dans la liste courante.
|
||||
* Puis on persiste et on recharge depuis le backend (source de vérité).
|
||||
*/
|
||||
onDrop(parent: TreeItem | null, event: CdkDragDrop<TreeItem[]>): void {
|
||||
if (!this.reorderContext) return;
|
||||
const dragged = event.item.data as TreeItem;
|
||||
const kind = dragged?.dragKind;
|
||||
if (!kind || !dragged.dragId) return;
|
||||
|
||||
// 1) Le curseur est-il sur un dossier compatible, DIFFÉRENT du dossier d'origine ?
|
||||
const folder = this.folderAtPoint(event.dropPoint, kind);
|
||||
const currentParent = parent ? (parent.dropParentId ?? null) : (this.rootDropParentId ?? null);
|
||||
if (folder && (folder.dropParentId ?? null) !== currentParent) {
|
||||
this.moveIntoFolder(dragged, kind, folder);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2) Sinon : réordonnancement (même liste) ou déplacement géré par CDK (autre liste).
|
||||
const target = event.container.data;
|
||||
if (event.previousContainer === event.container) {
|
||||
if (event.previousIndex === event.currentIndex) return;
|
||||
moveItemInArray(target, event.previousIndex, event.currentIndex);
|
||||
} else {
|
||||
transferArrayItem(event.previousContainer.data, target, event.previousIndex, event.currentIndex);
|
||||
}
|
||||
const orderedIds = target.filter(i => i.dragKind === kind).map(i => i.dragId!).filter(Boolean);
|
||||
this.reorderService.reorder(this.reorderContext, kind, currentParent, orderedIds);
|
||||
}
|
||||
|
||||
/** Dossier compatible sous le point de lâcher (via le DOM réel), ou null. */
|
||||
private folderAtPoint(pt: { x: number; y: number } | undefined, kind: ReorderKind): TreeItem | null {
|
||||
if (!pt) return null;
|
||||
const el = document.elementFromPoint(pt.x, pt.y) as HTMLElement | null;
|
||||
const nodeEl = el?.closest('[data-drop-node]') as HTMLElement | null;
|
||||
const id = nodeEl?.getAttribute('data-drop-node');
|
||||
const node = id ? this._nodesById.get(id) : undefined;
|
||||
return node && node.dropKinds?.includes(kind) ? node : null;
|
||||
}
|
||||
|
||||
/** Déplace l'élément à la FIN d'un dossier cible (déplacement inter-dossiers). */
|
||||
private moveIntoFolder(dragged: TreeItem, kind: ReorderKind, folder: TreeItem): void {
|
||||
const existing = (this._dragData.get(folder.id) ?? [])
|
||||
.filter(i => i.dragKind === kind)
|
||||
.map(i => i.dragId!)
|
||||
.filter(id => id && id !== dragged.dragId);
|
||||
this.reorderService.reorder(this.reorderContext!, kind, folder.dropParentId ?? null, [...existing, dragged.dragId!]);
|
||||
}
|
||||
|
||||
/** True si le chevron doit s'afficher — seulement quand le noeud a de vrais enfants. */
|
||||
isExpandable(item: TreeItem): boolean {
|
||||
return this.hasChildren(item);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
<div class="tree-row" [style.padding-left.px]="level * 12">
|
||||
@if (sidebar.dndEnabled() && item.dragKind) {
|
||||
<span class="drag-handle" cdkDragHandle [title]="'secondarySidebar.dragHandle' | translate">
|
||||
<lucide-icon [img]="GripVertical" [size]="12"></lucide-icon>
|
||||
</span>
|
||||
}
|
||||
@if (!item.isAction && sidebar.isExpandable(item)) {
|
||||
<button type="button" class="chevron-btn" (click)="sidebar.clickChevron($event, item)">
|
||||
<lucide-icon [img]="sidebar.isExpanded(item.id) ? ChevronDown : ChevronRight" [size]="12"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
@if (item.isAction || !sidebar.isExpandable(item)) {
|
||||
<span class="chevron-spacer"></span>
|
||||
}
|
||||
<button type="button" class="tree-btn"
|
||||
[class.action]="item.isAction"
|
||||
[class.active]="sidebar.isActive(item)"
|
||||
(click)="sidebar.clickItem(item)">
|
||||
@if (sidebar.iconFor(item); as icon) {
|
||||
<lucide-icon [img]="icon" [size]="14" class="item-icon"></lucide-icon>
|
||||
}
|
||||
{{ item.label }}
|
||||
@if (!item.isAction && item.meta) {
|
||||
<span class="tree-item-meta">{{ item.meta }}</span>
|
||||
}
|
||||
</button>
|
||||
@if (item.createActions?.length) {
|
||||
<span class="node-actions">
|
||||
@for (a of item.createActions; track a) {
|
||||
<button type="button" class="node-action-btn"
|
||||
[title]="a.label" [attr.aria-label]="a.label"
|
||||
(click)="sidebar.runCreateAction($event, a)">
|
||||
<lucide-icon [img]="sidebar.iconForAction(a)" [size]="16"></lucide-icon>
|
||||
</button>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (sidebar.isExpanded(item.id) && sidebar.hasChildren(item)) {
|
||||
@if (sidebar.dndEnabled() && item.dropKinds) {
|
||||
<div class="tree-children" cdkDropList
|
||||
[id]="'lst-' + item.id"
|
||||
[cdkDropListData]="sidebar.dragDataFor(item)"
|
||||
[cdkDropListConnectedTo]="sidebar.dropListIds"
|
||||
[cdkDropListEnterPredicate]="sidebar.enterPredicateFor(item)"
|
||||
(cdkDropListDropped)="sidebar.onDrop(item, $event)">
|
||||
@for (child of item.children; track child.id) {
|
||||
@if (sidebar.dndEnabled() && child.dragKind) {
|
||||
<app-sidebar-tree-node cdkDrag [cdkDragData]="child" cdkDragLockAxis="y"
|
||||
[item]="child" [level]="level + 1"></app-sidebar-tree-node>
|
||||
} @else {
|
||||
<app-sidebar-tree-node [item]="child" [level]="level + 1"></app-sidebar-tree-node>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<div class="tree-children">
|
||||
@for (child of item.children; track child.id) {
|
||||
<app-sidebar-tree-node [item]="child" [level]="level + 1"></app-sidebar-tree-node>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
// Le nœud est l'élément déplaçable (cdkDrag) → il doit être un bloc dans la colonne.
|
||||
// (Les en-têtes de section sont rendus HORS du nœud, dans la sidebar, pour qu'ils ne
|
||||
// soient pas emportés quand on déplace le premier item d'une section.)
|
||||
:host { display: block; }
|
||||
|
||||
.tree-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.15rem;
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
// Hover-reveal : les actions de création n'apparaissent qu'au survol (Notion/VS Code).
|
||||
&:hover .node-actions { opacity: 1; pointer-events: auto; }
|
||||
}
|
||||
|
||||
.tree-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: #d1d5db;
|
||||
font-size: 0.82rem;
|
||||
padding: 0.4rem 0.5rem;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:hover { background: #1f2937; }
|
||||
&.action { color: #6b7280; font-style: italic; }
|
||||
&.action:hover { color: #a5b4fc; background: #1f2937; }
|
||||
|
||||
&.active {
|
||||
background: #1e1b4b;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: inset 3px 0 0 #6c63ff;
|
||||
}
|
||||
&.active:hover { background: #2a2558; }
|
||||
|
||||
.tree-item-meta {
|
||||
margin-left: auto;
|
||||
font-size: 0.72rem;
|
||||
color: #6b7280;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.item-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
color: #a5b4fc;
|
||||
flex-shrink: 0;
|
||||
margin-right: 0.1rem;
|
||||
}
|
||||
|
||||
.node-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
margin-left: auto;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.15s ease;
|
||||
position: absolute;
|
||||
right: 0.25rem;
|
||||
background: linear-gradient(to right, transparent, #1f2937 30%);
|
||||
padding-left: 1rem;
|
||||
}
|
||||
|
||||
.node-action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: rgba(55, 65, 81, 0.6);
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
color: #d1d5db;
|
||||
padding: 0;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
|
||||
&:hover { background: #4338ca; color: #ffffff; }
|
||||
}
|
||||
|
||||
.chevron-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
color: #6b7280;
|
||||
padding: 0;
|
||||
|
||||
&:hover { background: #374151; color: white; }
|
||||
}
|
||||
|
||||
.chevron-spacer {
|
||||
display: inline-block;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tree-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
}
|
||||
// Zone de dépôt qui reçoit un élément valide : léger fond pour guider l'œil.
|
||||
.tree-children.cdk-drop-list-receiving { background: rgba(138, 123, 200, 0.08); border-radius: 4px; }
|
||||
|
||||
// Poignée de glisser-déposer : faible au repos, visible au survol de la ligne.
|
||||
.drag-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
flex-shrink: 0;
|
||||
margin-right: 1px;
|
||||
color: #6b7280;
|
||||
opacity: 0.25;
|
||||
cursor: grab;
|
||||
transition: opacity 0.15s, color 0.15s;
|
||||
}
|
||||
.tree-row:hover .drag-handle { opacity: 0.9; color: #8a7bc8; }
|
||||
.drag-handle:active { cursor: grabbing; }
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Component, Input, HostBinding, forwardRef, inject } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { TranslatePipe } from '@ngx-translate/core';
|
||||
import { CdkDropList, CdkDrag, CdkDragHandle } from '@angular/cdk/drag-drop';
|
||||
import { LucideAngularModule, ChevronRight, ChevronDown, GripVertical } from 'lucide-angular';
|
||||
import { TreeItem } from '../../services/layout.service';
|
||||
import { SecondarySidebarComponent } from './secondary-sidebar.component';
|
||||
|
||||
/**
|
||||
* Un nœud de l'arbre de la sidebar (récursif).
|
||||
*
|
||||
* Pourquoi un COMPOSANT récursif et pas un `*ngTemplateOutlet` ? Parce que `cdkDrag`
|
||||
* résout sa drop list par INJECTION au lieu de DÉCLARATION du template : un template
|
||||
* outlet-é se résout au site de DÉCLARATION (hors des drop lists) → le drag n'a aucun
|
||||
* conteneur → aucun réordonnancement (l'élément flotte). En rendant chaque enfant via
|
||||
* `<app-sidebar-tree-node cdkDrag>` STATIQUEMENT dans la drop list du parent, le DI
|
||||
* trouve la bonne liste et le tri/insertion fonctionne.
|
||||
*
|
||||
* Toute la logique (clic, expand, icônes, DnD) est déléguée au composant sidebar parent
|
||||
* (injecté) pour éviter la duplication.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-sidebar-tree-node',
|
||||
imports: [
|
||||
CommonModule, LucideAngularModule, TranslatePipe,
|
||||
CdkDropList, CdkDrag, CdkDragHandle,
|
||||
forwardRef(() => SidebarTreeNodeComponent)
|
||||
],
|
||||
templateUrl: './sidebar-tree-node.component.html',
|
||||
styleUrls: ['./sidebar-tree-node.component.scss']
|
||||
})
|
||||
export class SidebarTreeNodeComponent {
|
||||
@Input({ required: true }) item!: TreeItem;
|
||||
@Input() level = 0;
|
||||
|
||||
/** Marque l'hôte comme dossier (cible de dépôt détectée par position de lâcher).
|
||||
* Posé sur l'HÔTE = ancêtre du header ET des enfants → un drop n'importe où sur le
|
||||
* dossier le résout correctement. */
|
||||
@HostBinding('attr.data-drop-node') get dropNodeAttr(): string | null {
|
||||
return this.item?.dropKinds ? this.item.id : null;
|
||||
}
|
||||
|
||||
readonly ChevronRight = ChevronRight;
|
||||
readonly ChevronDown = ChevronDown;
|
||||
readonly GripVertical = GripVertical;
|
||||
|
||||
/** Sidebar parente (toujours un ancêtre DI) : on lui délègue toute la logique. */
|
||||
readonly sidebar = inject(SecondarySidebarComponent);
|
||||
}
|
||||
Reference in New Issue
Block a user