Pages de lore en blocs (grille 2D libre) + bloc image recadrable, et import dd2vtt sur les scènes
All checks were successful
Build & Push Images / build (brain) (push) Successful in 1m23s
Build & Push Images / build (web) (push) Successful in 1m56s
Build & Push Images / build (core) (push) Successful in 3m24s
Build & Push Images / build-switcher (push) Successful in 20s

Refonte du système de templates de lore en assemblage de blocs (façon Lore
d'Amsel), plus le support des cartes DungeonDraft (.dd2vtt) sur les scènes.

Templates de lore — éditeur de blocs en grille :
- Builder partagé `block-grid-builder` : palette de types de blocs à glisser sur
  une grille 12 colonnes, placement LIBRE (x/y) et redimensionnement en largeur
  ET hauteur par poignées, renommage en place. Remplace la liste + flèches de
  template-create/template-edit (dédupliqués).
- Rendu : page-view (aperçu) honore largeur ET hauteur (grille à lignes fixes) ;
  page-edit garde les colonnes (côte-à-côte) avec des hauteurs naturelles pour
  l'édition. Repli empilé pour les templates sans mise en page.
- Modèle : `TemplateField` gagne `id` (clé stable) et `pos {x,y,w,h}`. Les valeurs
  de page sont ancrées sur l'`id` (repli sur le nom) → renommer un bloc ne perd
  plus son contenu. Sérialisation JSON additive, aucune migration ; correction au
  passage de la non-relecture de `foundryPath` dans le converter.

Bloc image :
- Nouveau composant `image-block` (remplace la galerie pour les pages de lore) :
  plusieurs images plein cadre (carrousel), déplacement (recadrage) et zoom
  persistés PAR image et PAR page. Rendu `object-fit: contain` (image entière,
  bordures noires) avec zoom 0,4×–4×.
- Stockage : `Page.imageFraming` (fieldKey → imageId → {x,y,scale}), colonne
  `image_framing` (migration V8), DTO/mapper + passthrough export/import.

Scènes — source de carte au choix :
- Sélecteur Dungeon Alchemist (image/vidéo + .json, inchangé) / DungeonDraft
  (.dd2vtt). Le .dd2vtt embarque l'image en base64 : extraction à l'upload
  (→ média) + sidecar allégé (→ données) pour que l'export Foundry et le module
  fonctionnent sans changement. Mode déduit à la relecture de l'extension.
  Front-only, sans migration.

i18n fr/en, tests (converters back + helper front) et specs e2e mis à jour.
This commit is contained in:
2026-06-26 17:38:27 +02:00
parent f670735100
commit 11a33b1cf0
55 changed files with 2604 additions and 632 deletions

View File

@@ -0,0 +1,66 @@
@if (currentId) {
<div class="image-block" [class.fill]="fill">
<div class="frame"
[class.grabbable]="editable"
(pointerdown)="startPan($event)"
(wheel)="onWheel($event)">
<img class="img"
[src]="urlFor(currentId)"
alt=""
draggable="false"
[style.object-position]="objectPosition(currentId)"
[style.transform-origin]="objectPosition(currentId)"
[style.transform]="zoomTransform(currentId)" />
</div>
@if (hasMany) {
<button type="button" class="nav prev" (click)="prev()" [attr.aria-label]="'imageBlock.prev' | translate">
<lucide-icon [img]="ChevronLeft" [size]="20"></lucide-icon>
</button>
<button type="button" class="nav next" (click)="next()" [attr.aria-label]="'imageBlock.next' | translate">
<lucide-icon [img]="ChevronRight" [size]="20"></lucide-icon>
</button>
<div class="dots">
@for (id of imageIds; track id; let i = $index) {
<button type="button" class="dot" [class.active]="i === current"
(click)="goTo(i)" [attr.aria-label]="'imageBlock.goTo' | translate:{ n: i + 1 }"></button>
}
</div>
}
@if (editable) {
<div class="edit-bar">
<div class="zoom">
<button type="button" (click)="setZoom(scaleOf(currentId) - 0.2)"
[attr.aria-label]="'imageBlock.zoomOut' | translate" [title]="'imageBlock.zoomOut' | translate">
<lucide-icon [img]="ZoomOut" [size]="14"></lucide-icon>
</button>
<input type="range" min="0.4" max="4" step="0.05"
[ngModel]="scaleOf(currentId)" (ngModelChange)="setZoom($event)"
[attr.aria-label]="'imageBlock.zoom' | translate" />
<button type="button" (click)="setZoom(scaleOf(currentId) + 0.2)"
[attr.aria-label]="'imageBlock.zoomIn' | translate" [title]="'imageBlock.zoomIn' | translate">
<lucide-icon [img]="ZoomIn" [size]="14"></lucide-icon>
</button>
</div>
<span class="move-hint"><lucide-icon [img]="Move" [size]="13"></lucide-icon> {{ 'imageBlock.dragHint' | translate }}</span>
<div class="edit-actions">
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
<button type="button" class="btn-remove" (click)="removeCurrent()"
[attr.aria-label]="'imageBlock.remove' | translate" [title]="'imageBlock.remove' | translate">
<lucide-icon [img]="X" [size]="14"></lucide-icon>
</button>
</div>
</div>
}
</div>
} @else if (editable) {
<div class="image-empty" [class.fill]="fill">
<lucide-icon [img]="ImageIcon" [size]="28"></lucide-icon>
<app-image-uploader [compact]="true" (uploaded)="onUploaded($event)"></app-image-uploader>
</div>
} @else {
<div class="image-empty view" [class.fill]="fill">
<lucide-icon [img]="ImageIcon" [size]="28"></lucide-icon>
</div>
}

View File

@@ -0,0 +1,168 @@
.image-block {
position: relative;
// Mode "remplir le bloc" (page-view sur grille à hauteur fixe).
&.fill {
height: 100%;
display: flex;
flex-direction: column;
.frame { flex: 1 1 auto; aspect-ratio: auto; min-height: 0; }
}
}
.frame {
position: relative;
width: 100%;
aspect-ratio: 16 / 9;
overflow: hidden;
border-radius: 8px;
background: #000; // bordures noires quand l'image entière ne remplit pas le bloc
touch-action: none; // évite le scroll pendant le drag tactile
&.grabbable { cursor: grab; }
&.grabbable:active { cursor: grabbing; }
}
.img {
width: 100%;
height: 100%;
// contain : l'image entière est visible (letterbox/pillarbox) ; le zoom (scale)
// permet ensuite de l'agrandir jusqu'à remplir le bloc, voire rogner.
object-fit: contain;
display: block;
user-select: none;
-webkit-user-drag: none;
will-change: transform;
}
// Flèches de navigation (plusieurs images)
.nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
background: rgba(0, 0, 0, 0.45);
border: none;
border-radius: 50%;
color: white;
cursor: pointer;
transition: background 0.15s;
&:hover { background: rgba(0, 0, 0, 0.7); }
&.prev { left: 8px; }
&.next { right: 8px; }
}
.dots {
position: absolute;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 6px;
padding: 4px 8px;
background: rgba(0, 0, 0, 0.35);
border-radius: 999px;
.dot {
width: 7px;
height: 7px;
padding: 0;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.45);
cursor: pointer;
transition: background 0.15s;
&.active { background: white; }
&:hover { background: rgba(255, 255, 255, 0.8); }
}
}
// Barre d'édition (zoom + déplacer + ajouter/supprimer)
.edit-bar {
display: flex;
align-items: center;
gap: 0.75rem;
flex-wrap: wrap;
margin-top: 0.5rem;
.zoom {
display: flex;
align-items: center;
gap: 0.35rem;
button {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
background: #1a1a2e;
border: 1px solid #2a2a3d;
border-radius: 5px;
color: #d1d5db;
cursor: pointer;
&:hover { border-color: #6c63ff; color: white; }
}
input[type='range'] {
width: 120px;
accent-color: #6c63ff;
cursor: pointer;
}
}
.move-hint {
display: inline-flex;
align-items: center;
gap: 0.3rem;
font-size: 0.74rem;
color: #6b7280;
}
.edit-actions {
display: flex;
align-items: center;
gap: 0.4rem;
margin-left: auto;
}
.btn-remove {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
background: #3f1f1f;
border: none;
border-radius: 6px;
color: #fca5a5;
cursor: pointer;
&:hover { background: #5a2a2a; }
}
}
// État vide
.image-empty {
width: 100%;
aspect-ratio: 16 / 9;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
border: 1px dashed #2a2a3d;
border-radius: 8px;
background: rgba(255, 255, 255, 0.015);
color: #4b5563;
&.fill { height: 100%; aspect-ratio: auto; }
}

View File

@@ -0,0 +1,209 @@
import {
ChangeDetectorRef, Component, EventEmitter, Input, OnDestroy, Output,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
import {
LucideAngularModule, ChevronLeft, ChevronRight, X, ZoomIn, ZoomOut, Move, Image as ImageIcon,
} from 'lucide-angular';
import { TranslatePipe } from '@ngx-translate/core';
import { ImageService } from '../../services/image.service';
import { Image } from '../../services/image.model';
import { ImageFraming } from '../../services/page.model';
import { ImageUploaderComponent } from '../image-uploader/image-uploader.component';
const DEFAULT_FRAMING: ImageFraming = { x: 50, y: 50, scale: 1 };
// scale 1 = image ENTIÈRE visible (object-fit contain, bordures noires si besoin).
// < 1 : encore plus petite (plus de marge) ; > 1 : on zoome -> remplit puis rogne.
const MIN_SCALE = 0.4;
const MAX_SCALE = 4;
function clamp(v: number, min: number, max: number): number {
return Math.min(max, Math.max(min, v));
}
/**
* Bloc image d'une page de lore : une ou plusieurs images, chacune remplissant
* tout le bloc (object-fit cover). En mode édition, on peut DÉPLACER l'image
* (glisser pour recadrer, via object-position) et la ZOOMER (molette / curseur),
* le cadrage étant persisté PAR IMAGE (pan/zoom) côté page.
*
* Remplace `app-image-gallery` pour les blocs IMAGE des pages (les variantes
* grille/mosaïque/héros n'ont plus de sens : l'image occupe le bloc entier).
*
* E/S :
* [imageIds] (string[]) + (imageIdsChange)
* [framing] (imageId → {x,y,scale}) + (framingChange)
* [editable]
*/
@Component({
selector: 'app-image-block',
imports: [FormsModule, LucideAngularModule, ImageUploaderComponent, TranslatePipe],
templateUrl: './image-block.component.html',
styleUrls: ['./image-block.component.scss'],
})
export class ImageBlockComponent implements OnDestroy {
readonly ChevronLeft = ChevronLeft;
readonly ChevronRight = ChevronRight;
readonly X = X;
readonly ZoomIn = ZoomIn;
readonly ZoomOut = ZoomOut;
readonly Move = Move;
readonly ImageIcon = ImageIcon;
@Input() imageIds: string[] = [];
@Input() framing: Record<string, ImageFraming> = {};
@Input() editable = false;
/** Remplit la hauteur du conteneur (bloc à hauteur fixe) au lieu d'un ratio 16/9. */
@Input() fill = false;
@Output() imageIdsChange = new EventEmitter<string[]>();
@Output() framingChange = new EventEmitter<Record<string, ImageFraming>>();
/** Index de l'image affichée. */
current = 0;
private panState:
| { id: string; startX: number; startY: number; fw: number; fh: number; ox: number; oy: number; scale: number }
| null = null;
constructor(private imageService: ImageService, private cdr: ChangeDetectorRef) {}
// --- Accès aux images ---------------------------------------------------
get currentId(): string | null {
const i = clamp(this.current, 0, Math.max(0, this.imageIds.length - 1));
return this.imageIds[i] ?? null;
}
get hasMany(): boolean {
return this.imageIds.length > 1;
}
urlFor(id: string): string {
return this.imageService.contentUrl(id);
}
framingOf(id: string | null): ImageFraming {
return (id && this.framing?.[id]) || DEFAULT_FRAMING;
}
/** object-position CSS du cadrage courant (sert aussi de transform-origin). */
objectPosition(id: string | null): string {
const f = this.framingOf(id);
return `${f.x}% ${f.y}%`;
}
/** transform CSS (zoom) du cadrage courant. */
zoomTransform(id: string | null): string {
return `scale(${this.framingOf(id).scale})`;
}
scaleOf(id: string | null): number {
return this.framingOf(id).scale;
}
// --- Navigation ---------------------------------------------------------
prev(): void {
if (!this.imageIds.length) return;
this.current = (this.current - 1 + this.imageIds.length) % this.imageIds.length;
}
next(): void {
if (!this.imageIds.length) return;
this.current = (this.current + 1) % this.imageIds.length;
}
goTo(i: number): void {
this.current = clamp(i, 0, this.imageIds.length - 1);
}
// --- Ajout / suppression (édition) --------------------------------------
onUploaded(image: Image): void {
const ids = [...this.imageIds, image.id];
this.imageIdsChange.emit(ids);
this.current = ids.length - 1; // affiche la nouvelle image
}
removeCurrent(): void {
const id = this.currentId;
if (!id) return;
// Best-effort côté serveur (pas d'orpheline) ; on n'attend pas la réponse.
this.imageService.delete(id).subscribe({ error: () => {} });
const ids = this.imageIds.filter(i => i !== id);
if (this.framing?.[id]) {
const next = { ...this.framing };
delete next[id];
this.framing = next;
this.framingChange.emit(next);
}
this.imageIdsChange.emit(ids);
this.current = clamp(this.current, 0, Math.max(0, ids.length - 1));
}
// --- Recadrage : déplacement (drag) -------------------------------------
startPan(event: PointerEvent): void {
if (!this.editable) return;
const id = this.currentId;
if (!id) return;
event.preventDefault();
const frame = event.currentTarget as HTMLElement;
const rect = frame.getBoundingClientRect();
const f = this.framingOf(id);
this.panState = {
id, startX: event.clientX, startY: event.clientY,
fw: rect.width, fh: rect.height, ox: f.x, oy: f.y, scale: f.scale,
};
frame.setPointerCapture?.(event.pointerId);
window.addEventListener('pointermove', this.onPanMove);
window.addEventListener('pointerup', this.onPanUp, { once: true });
}
private onPanMove = (event: PointerEvent): void => {
const s = this.panState;
if (!s) return;
// Glisser l'image la fait suivre le pointeur (object-position diminue quand
// on tire vers la droite -> on révèle la partie gauche). Plus on est zoomé,
// plus le déplacement est fin (/ scale).
const dxPct = ((event.clientX - s.startX) / s.fw) * 100 / s.scale;
const dyPct = ((event.clientY - s.startY) / s.fh) * 100 / s.scale;
const x = clamp(s.ox - dxPct, 0, 100);
const y = clamp(s.oy - dyPct, 0, 100);
this.framing[s.id] = { x, y, scale: s.scale }; // mise à jour live (sans émettre)
this.cdr.detectChanges();
};
private onPanUp = (): void => {
window.removeEventListener('pointermove', this.onPanMove);
if (!this.panState) return;
this.panState = null;
this.framing = { ...this.framing };
this.framingChange.emit(this.framing);
};
// --- Recadrage : zoom ---------------------------------------------------
setZoom(scale: number): void {
const id = this.currentId;
if (!id) return;
const f = this.framingOf(id);
this.framing = { ...this.framing, [id]: { ...f, scale: clamp(scale, MIN_SCALE, MAX_SCALE) } };
this.framingChange.emit(this.framing);
}
onWheel(event: WheelEvent): void {
if (!this.editable) return;
const id = this.currentId;
if (!id) return;
event.preventDefault();
const delta = event.deltaY < 0 ? 0.12 : -0.12;
this.setZoom(this.framingOf(id).scale + delta);
}
ngOnDestroy(): void {
window.removeEventListener('pointermove', this.onPanMove);
window.removeEventListener('pointerup', this.onPanUp);
}
}