Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions e2e/golden-path/14-plugins.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,38 @@ test.describe('Golden-path 7 — Menu plugins', () => {
// Le plugin manuel (non autoActivate) touchPointerInput doit être proposé.
await expect(page.getByText('touchPointerInput', { exact: false })).toBeVisible();
});

// Régression #41 : le zoom du touch pointer calculait sa translation dans le repère
// de la FENÊTRE alors qu'il transforme l'iframe, qui n'occupe que 3/5 de la largeur
// en vue presenter. Résultat mesuré avant correctif : frame projeté en x=835..2339
// pour une fenêtre de 1280 — slide entièrement hors écran.
test('le zoom du touch pointer garde la slide dans son cadre', async ({ page }) => {
await openView(page, 'presenter.html');

await page.getByText('Plugins', { exact: false }).click();
await page.getByText('touchPointerInput', { exact: false }).click();

const mask = page.locator('tc-touch-pointer-mask');
await expect(mask).toBeVisible();

// Clic près du coin haut-gauche : le cas le plus défavorable, celui qui
// projetait la slide le plus loin hors du cadre.
await mask.click({ position: { x: 30, y: 20 } });

await expect
.poll(async () =>
page.evaluate(() => {
const slide = document.getElementById('currentSlide');
const root = slide.shadowRoot;
const frame = root.getElementById('slideViewFrame').getBoundingClientRect();
const section = root.getElementById('slideViewSection').getBoundingClientRect();
return {
zoomed: root.getElementById('slideViewFrame').style.transform.includes('scale'),
// Le frame agrandi doit recouvrir sa section, sans laisser de vide.
covers: frame.left <= section.left && frame.top <= section.top && frame.right >= section.right && frame.bottom >= section.bottom
};
})
)
.toEqual({ zoomed: true, covers: true });
});
});
8 changes: 8 additions & 0 deletions src/client/web-components/slide-view/slide-view.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ export class SlideViewComponent extends TcStyledElement {
width: 100%;
height: 100%;
}
/* Filet de sécurité du zoom (#41) : le plugin touch-pointer applique un
transform scale() sur l'iframe. Sans clipping, une translation erronée
projette la slide par-dessus le reste de la vue presenter — voire hors
de l'écran. La géométrie est corrigée dans touch-pointer/zoom.ts ; ceci
garantit qu'aucune régression future ne puisse repeindre hors du cadre. */
section {
overflow: hidden;
}
section.fullscreen {
width: 100vw;
height: 100vh;
Expand Down
17 changes: 15 additions & 2 deletions src/plugins/input/keyboard/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,32 @@ import { config } from '@services/config';
import { Plugin } from '@plugins/plugin.js';

class KeyboardInput extends Plugin {
// Références stables : sans elles, removeEventListener n'a aucune prise et
// `unload()` laisserait les listeners actifs derrière lui (#42).
private readonly onKeyUp = (event: KeyboardEvent): void => this._captureKeyboardEvent(event, true);
private readonly onKeyDown = (event: KeyboardEvent): void => this._captureKeyboardEvent(event);

constructor() {
super();
this.type = 'inputEvent';
}

override init(): void {
addEventListener('keyup', e => this._captureKeyboardEvent(e, true), true);
addEventListener('keyup', this.onKeyUp, true);
// keydown sans forward : ne sert qu'à stopPropagation sur les touches bloquées,
// avant que la présentation embarquée ne les voie. Seul keyup déclenche la navigation.
addEventListener('keydown', this._captureKeyboardEvent.bind(this), true);
addEventListener('keydown', this.onKeyDown, true);
this.initialized = true;
}

override unload(): void {
// Le 3e argument doit reproduire le flag de capture utilisé à l'ajout,
// sinon le navigateur ne retrouve pas le listener.
removeEventListener('keyup', this.onKeyUp, true);
removeEventListener('keydown', this.onKeyDown, true);
super.unload();
}

_captureKeyboardEvent(event: KeyboardEvent, forward = false): void {
const keys = config.tcComponent.keysBlocked;
const activeElementIsInput = document.activeElement?.tagName && /input|textarea/i.test(document.activeElement.tagName);
Expand Down
82 changes: 62 additions & 20 deletions src/plugins/input/touch-pointer/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Plugin } from '@plugins/plugin.js';
import { logger } from '@services/logger';
import { ZOOM_SCALE, computeZoomTransform } from './zoom';

interface PointerState {
x: string;
Expand All @@ -25,6 +26,9 @@ class TouchPointerInput extends Plugin {
interval: ReturnType<typeof setInterval> | undefined;
messageEventRegistered = false;

/** Référence stable, sans laquelle `removeEventListener` serait impossible. */
private readonly messageHandler = (message: MessageEvent): void => this._onMessageEvent(message);

constructor() {
super();
this.type = 'touchPointerEvent';
Expand All @@ -36,7 +40,7 @@ class TouchPointerInput extends Plugin {
this._addPointer();

if (!this.messageEventRegistered) {
addEventListener('message', message => this._onMessageEvent(message));
addEventListener('message', this.messageHandler);
this.messageEventRegistered = true;
}
this.initialized = true;
Expand All @@ -46,7 +50,39 @@ class TouchPointerInput extends Plugin {
this._removeSettingsArea();
this._removeMaskArea();
this._removePointer();
this.initialized = false;
this._restoreFrame();

if (this.messageEventRegistered) {
removeEventListener('message', this.messageHandler);
this.messageEventRegistered = false;
}

clearInterval(this.interval);
this.interval = undefined;

super.unload();
}

/** Shadow root de la slide courante, ou `undefined` hors vue presenter/on-stage. */
_slideShadowRoot(): ShadowRoot | undefined {
return document.getElementById('currentSlide')?.shadowRoot ?? undefined;
}

/**
* Rend au frame l'apparence qu'il avait avant `_addPointer` : dimensions,
* transition, curseur de zoom et transform éventuel. Sans cela, désactiver
* le plugin en état zoomé laissait la slide hors cadre, sans retour possible (#42).
*/
_restoreFrame(): void {
const frame = this._slideShadowRoot()?.getElementById('slideViewFrame');
if (frame) {
frame.style.width = '';
frame.style.height = '';
frame.style.transitionDuration = '';
frame.style.cursor = '';
frame.style.transform = '';
}
this.zooming = false;
}

_addPointer(): void {
Expand Down Expand Up @@ -155,10 +191,10 @@ class TouchPointerInput extends Plugin {
}

if (messageData.type === 'pointerClick') {
this._toggleZoom(
this._convertPercentToCoordinates(messageData.payload.x ?? '0', window.innerWidth),
this._convertPercentToCoordinates(messageData.payload.y ?? '0', window.innerHeight)
);
// Les coordonnées arrivent en POURCENTAGE de la zone de masque : elles ne sont
// converties en pixels qu'à l'intérieur de _toggleZoom, dans le repère du frame
// (cf. zoom.ts). Les convertir ici contre la fenêtre était le bug #41.
this._toggleZoom(messageData.payload.x ?? '0', messageData.payload.y ?? '0');
}
}

Expand Down Expand Up @@ -192,7 +228,7 @@ class TouchPointerInput extends Plugin {
}
}

_toggleZoom(mouseX: number, mouseY: number): void {
_toggleZoom(xPercent: string, yPercent: string): void {
const currentSlide = document.getElementById('currentSlide');
if (!currentSlide || !currentSlide.shadowRoot) {
return;
Expand All @@ -204,23 +240,29 @@ class TouchPointerInput extends Plugin {
}

if (this.zooming) {
element.style.transform = 'translate3D(0px, 0px, 0px)';
element.style.cursor = 'zoom-in';
this.zooming = !this.zooming;
this._resetZoom(element);
return;
}

const scaleValue = 2;
const windowCenterX = window.innerWidth / 2;
const windowCenterY = window.innerHeight / 2;
const targetX = Math.round((windowCenterX - mouseX) * scaleValue);
const targetY = Math.round((windowCenterY - mouseY) * scaleValue);
// getBoundingClientRect() plutôt que offsetWidth : on ne passe ici qu'en état
// dézoomé, donc le rect vaut la boîte de mise en page, et il tient compte d'un
// éventuel scale porté par un ancêtre.
const box = element.getBoundingClientRect();
const point = {
x: this._convertPercentToCoordinates(xPercent, box.width),
y: this._convertPercentToCoordinates(yPercent, box.height)
};
const translation = computeZoomTransform(box, point, ZOOM_SCALE);

if (!this.zooming) {
element.style.transform = `translateX(${targetX}px) translateY(${targetY}px) scale(${scaleValue})`;
element.style.cursor = 'zoom-out';
this.zooming = !this.zooming;
}
element.style.transform = `translateX(${Math.round(translation.x)}px) translateY(${Math.round(translation.y)}px) scale(${ZOOM_SCALE})`;
element.style.cursor = 'zoom-out';
this.zooming = true;
}

_resetZoom(element: HTMLElement): void {
element.style.transform = 'translate3D(0px, 0px, 0px)';
element.style.cursor = 'zoom-in';
this.zooming = false;
}

_convertPercentToCoordinates(percentValue: string, size: number): number {
Expand Down
53 changes: 53 additions & 0 deletions src/plugins/input/touch-pointer/zoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Géométrie du zoom du plugin touch-pointer, isolée du DOM pour être testable
* sans layout (jsdom n'en calcule pas).
*
* Le zoom applique `translate(t) scale(s)` sur `#slideViewFrame`, dont le
* `transform-origin` est le centre (valeur par défaut). Un point `p` exprimé
* dans le repère LOCAL du frame (origine = coin haut-gauche du frame) se
* retrouve donc en `c + s·(p − c) + t`, avec `c` le centre du frame.
*
* Pour amener le point cliqué au centre, on résout `c = c + s·(p − c) + t`,
* d'où `t = s·(c − p)`.
*
* ⚠️ Le repère est celui du FRAME, pas celui de la fenêtre : c'était la cause
* du bug #41. En vue presenter, le frame n'occupe que 3/5 de la largeur, et
* calculer `t` à partir de `window.innerWidth` projetait la slide entièrement
* hors de l'écran.
*/

export const ZOOM_SCALE = 2;

export interface Size {
width: number;
height: number;
}

export interface Point {
x: number;
y: number;
}

function clamp(value: number, bound: number): number {
return Math.min(Math.max(value, -bound), bound);
}

/**
* Translation à appliquer pour centrer `point` dans un frame de taille `box`
* agrandi d'un facteur `scale`.
*
* Le résultat est plafonné pour que le frame agrandi recouvre toujours sa boîte
* d'origine : au-delà de `(scale − 1)·taille / 2`, un bord se décollerait du
* conteneur, laissant apparaître du vide (et débordant de l'autre côté).
*
* @param box - dimensions de mise en page du frame, en pixels
* @param point - point à centrer, dans le repère local du frame, en pixels
* @param scale - facteur d'agrandissement (1 = pas de zoom)
* @returns translation en pixels à appliquer avant le `scale`
*/
export function computeZoomTransform(box: Size, point: Point, scale: number): Point {
return {
x: clamp(scale * (box.width / 2 - point.x), ((scale - 1) * box.width) / 2),
y: clamp(scale * (box.height / 2 - point.y), ((scale - 1) * box.height) / 2)
};
}
15 changes: 13 additions & 2 deletions src/plugins/input/touch/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ interface TouchPosition {
class TouchInput extends Plugin {
touchPosition: { touchstart: TouchPosition; touchend: TouchPosition };

// Références stables : sans elles, removeEventListener n'a aucune prise et
// `unload()` laisserait les listeners actifs derrière lui (#42).
private readonly onTouchStart = (event: TouchEvent): void => this._captureTouchEvent(event);
private readonly onTouchEnd = (event: TouchEvent): void => this._captureTouchEvent(event, true);

constructor() {
super();
this.type = 'inputEvent';
Expand All @@ -19,11 +24,17 @@ class TouchInput extends Plugin {
}

override init(): void {
addEventListener('touchstart', this._captureTouchEvent.bind(this), false);
addEventListener('touchend', e => this._captureTouchEvent(e, true), false);
addEventListener('touchstart', this.onTouchStart, false);
addEventListener('touchend', this.onTouchEnd, false);
this.initialized = true;
}

override unload(): void {
removeEventListener('touchstart', this.onTouchStart, false);
removeEventListener('touchend', this.onTouchEnd, false);
super.unload();
}

_captureTouchEvent(event: TouchEvent, forward = false): void {
if (!event.view) return;
if (contextService.isPresentationIframe(event.view.location.href)) {
Expand Down
15 changes: 14 additions & 1 deletion src/plugins/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,18 @@ export class Plugin {
}

init(): void {}
unload(): void {}

/**
* Défait ce que `init()` a mis en place. Les surcharges doivent appeler
* `super.unload()` : la classe de base est responsable de l'état qu'elle
* possède, à savoir les callbacks et le drapeau `initialized`.
*
* Sans cette purge, `pluginService.activateOnComponent` empile un callback
* supplémentaire à chaque réactivation — le plugin réémet alors chacun de
* ses événements en autant d'exemplaires qu'il y a eu de cycles (#42).
*/
unload(): void {
this.callbacks = [];
this.initialized = false;
}
}
24 changes: 24 additions & 0 deletions test/common/services/plugin.spec.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { loadPluginModule } from '@plugins/plugin-loader';
import { logger } from '@services/logger';
import pluginService from '@services/plugin';
import { Plugin } from '@plugins/plugin';

vi.mock('@plugins/plugin-loader');

Expand Down Expand Up @@ -231,5 +232,28 @@ describe('Plugin service', function () {
}
expect(threw).toBe(false);
});

// Régression #42 : c'est ici que la fuite se matérialisait. Mesuré sur l'app
// réelle avant correctif, le tableau `callbacks` du singleton grimpait à 1, 2,
// puis 3 sur trois cycles activer/désactiver, et chaque événement du plugin
// partait en autant d'exemplaires.
it('should not accumulate callbacks across activate/unload cycles', async function () {
// Given un vrai Plugin plutôt qu'un mock : le contrat testé est celui de la classe de base
const pluginName = 'pluginName';
const pluginInstance = new Plugin();
pluginInstance.type = 'type';
vi.mocked(loadPluginModule).mockResolvedValue({ instance: pluginInstance });
const host = { controllerComponentChannel: { broadcast: vi.fn() } };

// When trois cycles complets
for (let cycle = 0; cycle < 3; cycle++) {
await pluginService.activateOnComponent(pluginName, host as any);
pluginInstance.unload();
}
await pluginService.activateOnComponent(pluginName, host as any);

// Then un seul callback, celui du cycle courant
expect(pluginInstance.callbacks).toHaveLength(1);
});
});
});
Loading