Les groupes sont visuels, les positions absolues

groupSelection(): string crée un groupe parent et renvoie son identifiant. Les enfants utilisent parentId, mais leurs positions restent absolues dans l’espace du graphe : elles ne deviennent pas relatives au rectangle du groupe. Déplacer un groupe déplace tous ses descendants, sans déplacer deux fois un enfant également sélectionné.

ungroup(groupId) retire seulement le groupe, promeut ses enfants directs et préserve toutes les positions mondiales. En revanche, supprimer un groupe avec removeNode() ou deleteSelection() supprime aussi ses descendants et les connexions concernées. Les groupes n’ont aucun port et leur hiérarchie ne peut pas contenir de cycle.

TypeScript · groupes, duplication et suppression
import { Editor } from '@newvexa/flowora';

const editor = new Editor(undefined, { historyLimit: 64 });
try {
  const first = editor.addFromCatalog('response', { x: 40, y: 100 });
  const second = editor.addFromCatalog('response', { x: 340, y: 100 });
  editor.select([first, second]);
  const groupId = editor.groupSelection();

  // Les enfants restent en coordonnées absolues du graphe.
  editor.moveNodes([groupId], { x: 100, y: 80 });
  console.log(editor.getNode(first)?.position);

  // Ne supprime que le groupe ; conserve les enfants et leurs positions.
  editor.ungroup(groupId);
  editor.undo(); // Annule le dégroupement.
  editor.redo(); // Le rétablit.

  editor.select([first, second]);
  const copies = editor.duplicateSelection({ x: 0, y: 240 });
  editor.select(copies);
  editor.deleteSelection();
} finally {
  editor.dispose();
}

duplicateSelection(offset?) copie les nœuds sélectionnés, les descendants des groupes sélectionnés et leurs liens internes, puis renvoie les nouveaux identifiants. Les parents externes sont conservés volontairement. Cette duplication n’appelle aucun service métier.

Un groupe n’est pas un sous-workflow.

La relation parent/enfant n’introduit ni portée de variables, ni session, ni branche exécutée, ni traitement imbriqué. Ces notions restent dans votre modèle et votre moteur applicatifs.

L’historique est borné, pas durable

Les commandes documentaires alimentent annuler/rétablir. Le snapshot fournit canUndo et canRedo pour vos contrôles ; undo() et redo() n’ont aucun effet s’il n’y a plus d’entrée. Terminez ou annulez une transaction ouverte avant de les appeler.

historyLimit vaut 64 par défaut et accepte un entier de 0 à 10 000 ; 0 désactive la conservation d’historique. Il s’agit d’un nombre de snapshots, pas d’un quota d’octets. Leur coût dépend du document. Le JSON exporté ne contient pas l’historique : ce n’est ni un audit durable, ni une sauvegarde de session.

La sélection ne crée pas d’entrée d’historique. setViewport(viewport, record = false) change par défaut le pan/zoom sans enregistrer une action ; passez explicitement true si votre cas requiert un retour sur la vue. moveNodes(ids, delta, record = true) enregistre par défaut le déplacement.

Un geste, une action ; un geste interrompu, aucun commit

beginTransaction() mémorise l’état initial ; endTransaction() regroupe les modifications en une action annulable. Les transactions peuvent être imbriquées : la fermeture de la plus extérieure termine l’action. Les mouvements intermédiaires avec record: false restent coalescés lorsqu’ils appartiennent à une transaction.

cancelTransaction() annule toute la transaction imbriquée et restaure document, sélection et viewport sans nouvelle entrée d’historique. Une transaction émet des snapshots intermédiaires : ce n’est pas une isolation de base de données. L’hôte doit définir séparément le moment où il persiste.

TypeScript · adaptateur de geste annulable pour l’hôte
import type { Editor, Point } from '@newvexa/flowora';

// L'hôte appelle ces méthodes depuis ses événements de geste.
// Cette classe emprunte le store ; elle ne le dispose pas.
export class NodeMoveGesture {
  private open = false;
  private ids: readonly string[] = [];

  constructor(private readonly editor: Editor) {}

  start(ids: readonly string[]): void {
    if (this.open) this.cancel();
    this.ids = [...ids];
    this.editor.beginTransaction();
    this.open = true;
  }

  move(deltaSinceLastMove: Point): void {
    if (!this.open) return;
    try {
      this.editor.moveNodes(this.ids, deltaSinceLastMove, false);
    } catch (error: unknown) {
      this.cancel();
      throw error;
    }
  }

  finish(): void {
    if (!this.open) return;
    this.editor.endTransaction();
    this.open = false;
    this.ids = [];
  }

  cancel(): void {
    if (!this.open) return;
    this.editor.cancelTransaction();
    this.open = false;
    this.ids = [];
  }
}

Branchez start() au début du déplacement, move() avec le delta depuis le dernier mouvement en coordonnées du graphe, et finish() à la fin réussie. Appelez cancel() sur pointercancel, perte de capture, Échap ou démontage avant de libérer le store. La classe suppose qu’elle possède la transaction du geste ; ne la mélangez pas à une autre transaction concurrente.

ELK calcule ; Editor applique

Après installation du peer elkjs ^0.12.1, ElkLayoutAdapter accepte direction: 'RIGHT' | 'DOWN' et spacing (72 par défaut, de 8 à 1 000). Sa méthode layout(document) renvoie une Promise de positions, sans muter l’éditeur. applyLayout(positions) valide et applique ensuite l’ensemble dans une action d’historique.

TypeScript · placement explicite et protection contre un résultat périmé
import { Editor, createDemoDocument } from '@newvexa/flowora';
import { ElkLayoutAdapter } from '@newvexa/flowora/layout';

const editor = new Editor(createDemoDocument());
const layout = new ElkLayoutAdapter({ direction: 'RIGHT', spacing: 72 });

async function arrange(): Promise<void> {
  const before = editor.getSnapshot();
  const positions = await layout.layout(before.document);
  // Une sélection ou un pan ne change pas les données à positionner.
  const current = editor.getSnapshot().document;
  if (current.id !== before.document.id ||
      current.nodes !== before.document.nodes ||
      current.connections !== before.document.connections) {
    throw new Error('Le graphe a changé pendant le calcul : relancez le layout.');
  }
  editor.applyLayout(positions);
}

async function main(): Promise<void> {
  try {
    await arrange();
    console.log(editor.toJSON());
    if (editor.getSnapshot().canUndo) editor.undo();
  } finally {
    editor.dispose();
  }
}
main().catch((error: unknown) => console.error(error));

Les positions renvoyées sont absolues, y compris celles des groupes imbriqués. Si votre propre adaptateur ne positionne qu’un groupe, ses descendants non positionnés explicitement héritent du delta de leur plus proche groupe repositionné. Un nœud positionné explicitement conserve sa position absolue, sans addition d’un second delta.

Dans Angular, passez une instance stable à layoutAdapter pour l’action de rangement du renderer. Dans une application interactive, empêchez les calculs concurrents, ignorez les résultats périmés et ne faites pas dispose() tant qu’un résultat doit encore être appliqué.

Une Promise n’est pas un Web Worker.

L’adaptateur ELK fourni calcule sur le thread principal dans le navigateur, sans worker par défaut. Pour de grands graphes, implémentez un LayoutAdapter avec votre transport worker et mesurez-le dans l’hôte. Aucun benchmark ni garantie de latence n’est fourni.