L’annulation et la rétablissement d’actions (undo/redo) sont des mécanismes incontournables pour tout éditeur. Deux stratégies principales s’offrent à nous : enregistrer un snapshot complet de l’état à chaque modification, ou capturer des opérations atomiques (Op). La première approche est triviale à implémenter mais consomme rapidement beaucoup de mémoire, tandis que la seconde, plus granulaire, impose une conception soignée mais préserve les ressources.
Pour notre éditeur Canvas, nous avons retenu le modèle par opérations. Cela implique d’abord de définir une structure de données adaptée aux éléments graphiques.
Conception des données
Nous avons choisi de modéliser tout objet comme un rectangle, ce qui simplifie le rendu. Chaque élément possède un identifiant unique, une position (x, y), une taille (width, height), une profondeur (z) et un dictionnaire d’attributs libres. La classe de base est abstraite et impose une méthode de dessin :
abstract class GraphicElement {
public readonly id: string;
protected posX: number;
protected posY: number;
protected zIndex: number;
protected boxW: number;
protected boxH: number;
public attributes: Record<string, string>;
public abstract draw(ctx: CanvasRenderingContext2D): void;
}
Cette base permet d’envisager cinq types d’opérations atomiques qui couvrent l’ensemble des manipulations : ajout (INSERT), suppression (DELETE), déplacement (MOVE), redimensionnement (RESIZE) et modification d’attributs (REVISE). Pour que l’historique fonctionne, chaque opération doit être capable de produier son inverse (invert) en s’appuyant sur l’état précédent.
La classe AtomicOperation encapsule le type et les données utiles. La méthode invert reçoit l’ensemble des éléments avant modification et retourne l’opération contraire :
export type OpPayload = {
[OpType.INSERT]: { element: GraphicElement; parentId: string };
[OpType.DELETE]: { id: string; parentId: string };
[OpType.MOVE]: { ids: string[]; dx: number; dy: number };
[OpType.RESIZE]: { id: string; x: number; y: number; w: number; h: number };
[OpType.REVISE]: { id: string; attrs: Record<string, string> };
};
export class AtomicOperation<T extends OpType> {
public readonly kind: T;
public readonly payload: OpPayload[T];
constructor(kind: T, payload: OpPayload[T]) {
this.kind = kind;
this.payload = payload;
}
public invert(prev: ElementSet): AtomicOperation<OpType> | null {
switch (this.kind) {
case OpType.INSERT: {
const { element, parentId } = this.payload as OpPayload[OpType.INSERT];
return new AtomicOperation(OpType.DELETE, { id: element.id, parentId });
}
case OpType.DELETE: {
const { id, parentId } = this.payload as OpPayload[OpType.DELETE];
const element = prev.get(id);
if (!element) return null;
return new AtomicOperation(OpType.INSERT, { element, parentId });
}
case OpType.MOVE: {
const { ids, dx, dy } = this.payload as OpPayload[OpType.MOVE];
return new AtomicOperation(OpType.MOVE, { ids, dx: -dx, dy: -dy });
}
case OpType.RESIZE: {
const { id } = this.payload as OpPayload[OpType.RESIZE];
const element = prev.get(id);
if (!element) return null;
const { posX, posY, boxW, boxH } = element.getRect();
return new AtomicOperation(OpType.RESIZE, { id, x: posX, y: posY, w: boxW, h: boxH });
}
case OpType.REVISE: {
const { id, attrs } = this.payload as OpPayload[OpType.REVISE];
const element = prev.get(id);
if (!element) return null;
const prevAttrs: Record<string, string> = {};
for (const key of Object.keys(attrs)) {
prevAttrs[key] = element.getAttribute(key);
}
return new AtomicOperation(OpType.REVISE, { id, attrs: prevAttrs });
}
default:
return null;
}
}
}
Moteur d’historique
L’application d’une opération se fait via une méthode apply qui prend soin de conserver un clone de l’état précédent (avec une approche de type immer). Un événement CONTENT_CHANGE est alors émis, transportant l’état antérieur, l’état courant, l’opération et des options. Le module History écoute cet événement pour construire les piles d’annulation et de rétablissement.
Pour éviter qu’une micro‑action ne génère une entrée d’historique à chaque pression, un tampon temporaire et un miunteur sont utilisés. Les opérations inverses sont accumulées, puis regroupées en une seule transaction après un délai d’inactivité (BATCH_DELAY).
export class History {
private readonly BATCH_DELAY = 800;
private readonly MAX_HISTORY = 100;
private pendingBatch: AtomicOperation<OpType>[] = [];
private undoStack: AtomicOperation<OpType>[][] = [];
private redoStack: AtomicOperation<OpType>[][] = [];
private timer: ReturnType<typeof setTimeout> | null = null;
constructor(private editor: Editor) {
this.editor.event.on(EDITOR_EVENT.CONTENT_CHANGE, this.onContentChange, 10);
}
destroy() {
this.editor.event.off(EDITOR_EVENT.CONTENT_CHANGE, this.onContentChange);
}
private onContentChange = (e: ContentChangeEvent) => {
if (!e.options.undoable) return;
this.redoStack = [];
const { previous, changes } = e;
const invert = changes.invert(previous);
if (invert) {
this.pendingBatch.push(invert);
if (!this.timer) {
this.timer = setTimeout(this.flushBatch, this.BATCH_DELAY);
}
}
};
private flushBatch = () => {
if (!this.pendingBatch.length) return;
this.undoStack.push(this.pendingBatch);
this.pendingBatch = [];
this.redoStack = [];
if (this.timer) {
clearTimeout(this.timer);
this.timer = null;
}
if (this.undoStack.length > this.MAX_HISTORY) {
this.undoStack.shift();
}
};
Les méthodes undo et redo inversent à nouveau les opérations de la pile concernée pour les placer dans l’autre pile, puis les appliquent une à une sans les enregistrer dans l’historique (option undoable: false).
public undo() {
this.flushBatch();
if (!this.undoStack.length) return;
const ops = this.undoStack.pop()!;
this.editor.canvas.mask.clearWithOp();
this.redoStack.push(
ops.map(op => op.invert(this.editor.elementSet)).filter(Boolean) as AtomicOperation<OpType>[]
);
ops.forEach(op => this.editor.state.apply(op, { source: "undo", undoable: false }));
}
public redo() {
if (!this.redoStack.length) return;
const ops = this.redoStack.pop()!;
this.editor.canvas.mask.clearWithOp();
this.undoStack.push(
ops.map(op => op.invert(this.editor.elementSet)).filter(Boolean) as AtomicOperation<OpType>[]
);
ops.forEach(op => this.editor.state.apply(op, { source: "redo", undoable: false }));
}
}