TL;DR
- Un Strategy pattern : un registre
domaine (regex) → classe Channel. Le reste de l'app ne connaît qu'une interface, jamais une plateforme. - Shadow DOM pour isoler notre UI du CSS hostile de la page hôte, et le nôtre n'en fuit pas non plus.
- Astuce
pointer-events: conteneur transparent au clic, logo cliquable, l'overlay est visible mais ne vole pas le focus du champ. - Heartbeat ~500 ms pour découvrir le composer (qui charge à des moments différents), puis MutationObserver pour réagir aux frappes.
- Manifest V3 : le service worker meurt après ~5 min. L'état vit dans le content script, pas de socket persistant.
- Analyse idempotente via un
instanceId(nanoid) généré côté client : reclics sans doublons.
Le vrai problème : dix apps qui ne se ressemblent en rien
Sur le papier, l'extension fait une chose simple : repérer le champ où l'utilisateur compose un message, lire son contenu, et afficher à côté un petit bouton qui déclenche une analyse. Sauf que « le champ où l'on compose » n'a aucune définition commune d'une plateforme à l'autre.
Gmail utilise une div contenteditable dans une fenêtre de composition qui apparaît à la demande. Slack a son propre éditeur Quill. LinkedIn lazy-load la zone de saisie bien après le reste de la page. Outlook web change de DOM selon que vous lisez ou répondez. Zendesk, Teams, HubSpot, Notion… chacun a sa structure, ses classes CSS générées, son modèle d'événements. Écrire un if par plateforme dans un seul fichier, c'était la garantie d'un cauchemar de 4000 lignes ingérable au troisième ajout.
La bonne abstraction transforme « 10 intégrations bordéliques » en « 1 pipeline + 10 adaptateurs fins ». Tout le travail d'architecture consiste à trouver cette frontière.
Pilier 1, Le Strategy pattern, agnostique au canal
La pièce maîtresse, c'est un registre qui associe chaque domaine (via une regex sur l'URL) à une classe Channel. Le service worker en arrière-plan détecte l'URL de l'onglet actif, identifie le canal correspondant, et envoie un message INIT_CHANNEL au content script. Celui-ci instancie alors la bonne classe, et seulement celle-là.
Chaque Channel hérite d'une interface abstraite commune (AbstractChannel / AbstractChannelInstance) qui force chaque plateforme à implémenter le même contrat : getText, getSubject, getImages, setButton, startObserver. Une fois ce contrat respecté, tout le reste de l'application est rigoureusement identique, quelle que soit la plateforme. Le pipeline d'analyse, l'UI, la télémétrie : ils ne parlent qu'à l'interface, jamais à Gmail ou à Slack directement.
// registre : la seule chose qui connaît les plateformes
const CHANNELS = [
{ match: /mail\.google\.com/, Channel: GmailChannel },
{ match: /linkedin\.com/, Channel: LinkedInChannel },
{ match: /app\.slack\.com/, Channel: SlackChannel },
{ match: /outlook\.(live|office)/, Channel: OutlookChannel },
{ match: /\.zendesk\.com/, Channel: ZendeskChannel },
// … teams, hubspot, notion …
];
// contrat que CHAQUE plateforme doit honorer
class AbstractChannelInstance {
getText() { throw new Error('not implemented'); }
getSubject() { throw new Error('not implemented'); }
getImages() { throw new Error('not implemented'); }
setButton(el) { throw new Error('not implemented'); }
startObserver(onChange) { throw new Error('not implemented'); }
}
// background : détecte l'URL → demande l'init du bon canal
const hit = CHANNELS.find(c => c.match.test(tab.url));
if (hit) chrome.tabs.sendMessage(tab.id, { type: 'INIT_CHANNEL', name: hit.Channel.name });
// content script : instancie, le reste de l'app ignore TOUT du domaine
const channel = new (resolve(msg.name))();
pipeline.run(channel); // pipeline.run() est identique pour les 10 plateformes
Pilier 2, Shadow DOM, la seule isolation viable en territoire hostile
Injecter un bouton dans Gmail, c'est entrer chez quelqu'un qui n'attend pas de visite. Leur CSS est agressif : des sélecteurs globaux, des !important, des resets qui écrasent tout. Si on injecte un simple <div> dans leur arbre, notre UI est défigurée en quelques secondes, et inversement, notre CSS risque de casser leur mise en page.
La parade : tout ce qu'on injecte (boutons, surlignages, panneaux) vit dans un Shadow root attaché via attachShadow. Le CSS de la page hôte ne franchit pas la frontière du shadow, et le nôtre non plus. C'est une bulle étanche, par design du navigateur. Aucune guerre de spécificité, aucun !important défensif, aucune surprise quand la plateforme refait son thème.
// une bulle CSS étanche, dans les deux sens
const host = document.createElement('div');
host.style.cssText = 'position:absolute; pointer-events:none;'; // l'enveloppe laisse passer les clics
const shadow = host.attachShadow({ mode: 'open' });
shadow.innerHTML = `
<style>/* notre CSS, totalement isolé du site */</style>
<div class="overlay">
<button class="logo" /* cliquable */ ></button>
</div>`;
// le logo, lui, redevient cliquable
shadow.querySelector('.logo').style.pointerEvents = 'all';
document.body.appendChild(host);
Le détail qui change tout, c'est l'astuce pointer-events. Notre overlay flotte au-dessus de la zone de composition. Si on le laisse intercepter les clics, l'utilisateur ne peut plus écrire : chaque clic atterrit sur notre couche au lieu du champ éditable. La solution : le conteneur est en pointer-events:none (les clics le traversent comme s'il n'existait pas), et seul le logo cliquable repasse en pointer-events:all. Résultat : l'UI est parfaitement visible, mais elle ne vole jamais le focus du champ de saisie.
Pilier 2 bis : surligner du texte dans le champ de l'autre, le vrai cauchemar
C'est la partie de l'extension dont je suis le plus fier, et de loin la plus pénible à construire. Le produit doit surligner des mots et des phrases (une formulation à risque, une faute, un passage à reformuler) directement à l'endroit où l'utilisateur les a écrits, dans le composer de Gmail, de Slack ou de LinkedIn. L'idée naïve consiste à envelopper le texte fautif dans un <mark> ou un <span style="background">. C'est un piège, et il se referme vite.
Un champ contenteditable n'est pas un bout de HTML neutre : c'est la vue d'un modèle interne que l'éditeur tient à jour. Si vous injectez un nœud au milieu, vous corrompez ce modèle. Concrètement : vous coupez un nœud texte en deux, ce qui décale la position du curseur de l'utilisateur (il se met à taper au mauvais endroit) ; vous déclenchez un input ou un beforeinput que l'éditeur interprète comme une frappe (donc une entrée dans l'historique d'annulation, parfois un appel réseau de sauvegarde) ; et sur un éditeur comme le Quill de Slack, votre <span> étranger est purement et simplement supprimé au prochain cycle de rendu, parce qu'il ne fait pas partie du modèle que Quill connaît. On ne touche jamais au DOM du champ éditable. Jamais.
La solution est un overlay : on ne colore pas le texte, on dessine des rectangles translucides par-dessus les mots, dans le Shadow root, sans toucher d'un cheveu au texte de l'utilisateur. Le texte réel reste intact dans le champ ; nos rectangles flottent à la même position, comme un calque. Ce calque est produit par deux classes : un Ranger qui calcule où se trouve chaque occurrence, et un Highlighter qui transforme ces positions en <div> absolus dans le shadow.
Du caractère au pixel : l'abstraction Ranger
Pour surligner « phrase à risque », il faut d'abord la localiser dans le DOM. Un champ riche n'est pas une chaîne plate : c'est un arbre de nœuds texte, entrecoupés de <span>, de <br>, d'emojis-images. Le Ranger parcourt ce sous-arbre avec un TreeWalker filtré sur SHOW_TEXT, collecte tous les nœuds texte, puis fait un indexOf du mot recherché pour le mapper sur un couple (nœud texte, offset de caractère). À partir de là, un Range natif (setStart / setEnd) matérialise la sélection, et range.getClientRects() me donne sa géométrie réelle à l'écran, telle que le navigateur l'a effectivement rendue.
Le piège le plus retors, c'est le retour à la ligne. Une même occurrence peut s'étaler sur deux lignes : « phrase à » en fin d'une ligne, « risque » au début de la suivante. Un seul rectangle engloberait tout le bloc, en débordant horriblement à droite et à gauche. La parade dans Ranger._createRangesFromIndexes : on avance mot par mot dans la sélection et on compare le bottom du rectangle courant à celui du mot suivant. Dès que les bottom diffèrent, c'est qu'on a changé de ligne : on coupe la plage à cet endroit et on en démarre une nouvelle. Une occurrence sur trois lignes produit donc trois plages, donc trois rectangles bien alignés sur chaque ligne.
// Ranger : un mot → une (ou plusieurs) plages, en suivant le wrapping réel
function rangesForWord(node, start, end, word) {
const ranges = [];
const range = new Range();
range.setStart(node, start);
range.setEnd(node, end);
let rect = range.getClientRects().item(0);
let lineStart = start, cursor = start;
for (const piece of word.split(' ')) {
const probe = new Range();
probe.setStart(node, cursor);
cursor += piece.length;
probe.setEnd(node, cursor);
const probeRect = probe.getClientRects().item(0);
// un bottom différent = changement de ligne = on coupe ici
if (rect && probeRect && rect.bottom !== probeRect.bottom) {
ranges.push({ node, startOffset: lineStart, endOffset: cursor - piece.length });
lineStart = cursor - piece.length;
range.setStart(node, lineStart);
rect = range.getClientRects().item(0);
}
cursor++; // l'espace
}
ranges.push({ node, startOffset: lineStart, endOffset: cursor });
return ranges; // une plage par ligne visuelle
}
Du Range au calque : les rectangles dans le Shadow DOM
Une fois les plages connues, le Highlighter les peint. Pour chacune, il reconstruit un Range, lit son rectangle via getClientRects().item(0), puis crée un <div class="highlightedItem"> en position:absolute dans le content du Shadow root. Ces div portent un fond translucide (opacity:0.3), un border-radius et un mix-blend-mode:darken pour se fondre proprement sur n'importe quelle couleur de texte. Et c'est là que l'isolation du Shadow DOM, si confortable pour le style, complique la géométrie.
getClientRects() renvoie des coordonnées dans le repère du viewport, alors que mes div sont positionnés dans le repère du conteneur Shadow. Il faut donc convertir : pour chaque rectangle de plage, je soustrais le getBoundingClientRect() de l'élément éditable, ce qui me donne un top et un left relatifs au calque. Oublier cette soustraction (ou la faire par rapport au mauvais ancêtre), et tous les surlignages dérivent de quelques dizaines de pixels, pile assez pour rater le mot. C'est le bug le plus fréquent et le plus pénible à diagnostiquer de toute cette mécanique.
// Highlighter : une plage → un rectangle absolu dans le Shadow root
const hostRect = editable.getBoundingClientRect(); // ancre du repère
for (const r of ranges) {
const range = document.createRange();
range.setStart(r.node, r.startOffset);
range.setEnd(r.node, r.endOffset);
const rect = range.getClientRects().item(0);
if (!rect) continue;
const mark = document.createElement('div');
mark.className = 'highlightedItem';
mark.style.position = 'absolute';
// viewport → repère du calque : on soustrait l'ancre
mark.style.top = (rect.top - hostRect.top) + 'px';
mark.style.left = (rect.left - hostRect.left) + 'px';
mark.style.height = rect.height + 'px';
mark.style.width = rect.width + 'px';
mark.style.opacity = '0.3';
mark.style.borderRadius = '5px';
mark.style.mixBlendMode = 'darken';
mark.style.pointerEvents = 'none'; // jamais voler le clic au champ
shadow.content.appendChild(mark); // dans le Shadow root, pas dans le champ
}
Le pointer-events:none n'est pas un détail cosmétique : c'est ce qui permet à la couche de surlignage de flotter pile au-dessus du texte sans jamais intercepter un clic. L'utilisateur clique « à travers » nos rectangles et atterrit dans le champ, à l'endroit exact qu'il visait. Sans ça, surligner un mot reviendrait à rendre ce mot incliquable, ce qui est exactement l'inverse de ce qu'on veut.
Rester synchronisé pendant que l'utilisateur tape et scrolle
Un calque calculé une fois est faux la milliseconde suivante. L'utilisateur tape, le texte se reflow ; il scrolle dans le composer, et tous nos rectangles, ancrés au viewport, glissent hors position. Comme on ne vit pas dans le DOM de l'éditeur, aucun reflow ne déplace nos div automatiquement : il faut les recalculer nous-mêmes. La synchronisation combine donc trois signaux. Un heartbeat (un setInterval léger, autour de 250 ms sur les éditeurs nerveux comme Notion) qui relance le rendu ; un MutationObserver sur le champ pour réagir aux frappes, en prenant soin d'ignorer ses propres mutations (les nœuds <ext-overlay> qu'il ajoute, sinon il s'auto-déclenche en boucle) ; et un écouteur de scroll sur le composer qui efface puis redessine la couche.
À chaque cycle, la séquence est la même : on vide le content du Shadow root de tous ses anciens rectangles, on relance le Ranger sur le texte courant, on repeint. Effacer et redessiner intégralement est plus simple et plus robuste que de tenter de déplacer chaque rectangle individuellement, et comme tout se passe dans un Shadow root détaché du flux de la page, ce thrash n'invalide jamais la mise en page de l'hôte. Un debounce évite de tout recalculer à chaque caractère.
// Garder le calque synchronisé : effacer + redessiner sur 3 signaux
function repaint() {
shadow.content.replaceChildren(); // on jette les anciens rectangles
const ranges = ranger.getRanges(currentText());
paintHighlights(ranges); // on recalcule depuis zéro
}
const beat = setInterval(repaint, 250); // heartbeat : reflow implicite
const obs = new MutationObserver((muts) => { // frappes de l'utilisateur
// ignorer NOS propres nœuds, sinon boucle infinie
if (muts[0]?.addedNodes[0]?.tagName === 'EXT-OVERLAY') return;
debouncedRepaint();
});
obs.observe(editable, { childList: true, characterData: true, subtree: true });
editable.addEventListener('scroll', debouncedRepaint); // le scroll déplace tout
<img data-emoji> et non du texte ; un emoji composé (drapeau, famille) est plusieurs images sœurs qu'il faut recoller en mesurant chaque clientWidth pour étendre le rectangle. Sur Slack, même problème avec des <img data-id=":nom:">, plus la couche Quill qui éclate le texte dans son modèle interne. Sur LinkedIn, les mentions sont des span.ql-mention à traiter comme des blocs insécables plutôt que comme du texte parcourable. Chaque plateforme a donc son propre Ranger dérivé (GmailRanger, SlackRanger, LinkedInRanger) qui surcharge la localisation, pendant que le Highlighter, lui, reste rigoureusement identique partout.Pilier 3, Heartbeat pour découvrir, MutationObserver pour réagir
Vient ensuite la question du quand. Sur Gmail, la zone de composition peut apparaître à tout moment quand l'utilisateur clique sur « Nouveau message ». Sur LinkedIn, elle est lazy-loadée plusieurs secondes après le chargement. Sur d'autres, elle est déjà là au démarrage. Il n'existe aucun événement fiable et commun pour dire « le composer est prêt ».
On utilise donc deux mécanismes distincts, pour deux problèmes distincts. D'abord un heartbeat : un timer qui, toutes les ~500 ms, appelle checkIfAnalyzable(), une méthode propre à chaque Channel qui tente de localiser le composer dans le DOM courant. C'est un polling assumé, parce qu'on cherche quelque chose qui peut apparaître à tout moment et qu'aucun événement ne nous préviendra.
Une fois, et seulement une fois, le composer trouvé, on arrête de poller dessus et on attache un MutationObserver ciblé sur ce nœud précis. Là, on n'est plus dans la découverte mais dans la réaction : à chaque frappe, l'observer se déclenche, et un debounce évite de relancer une analyse à chaque caractère. La règle que j'en retire est devenue un réflexe : on poll pour découvrir, on observe pour réagir.
// 1) DÉCOUVRIR : heartbeat, car aucun event ne signale l'apparition
const beat = setInterval(() => {
const node = channel.checkIfAnalyzable(); // propre à chaque plateforme
if (node) {
clearInterval(beat); // trouvé → on arrête de poller
attach(node);
}
}, 500);
// 2) RÉAGIR : observer ciblé + debounce sur les frappes
function attach(node) {
const obs = new MutationObserver(debounce(() => {
pipeline.onTextChanged(channel.getText());
}, 300));
obs.observe(node, { childList: true, characterData: true, subtree: true });
}
Pilier 4, Le piège Manifest V3 : un service worker qui meurt
Manifest V3 a remplacé la page d'arrière-plan persistante par un service worker éphémère. Conséquence brutale : Chrome le tue après environ 5 minutes d'inactivité. Tout état qu'on y stocke, y compris une connexion socket ouverte, disparaît avec lui. Pour une extension qui veut suivre l'utilisateur en continu sur une plateforme, c'est un changement d'architecture imposé.
La conséquence concrète : pas de socket persistant côté background, et surtout, l'état du canal ne vit pas dans le service worker. Il vit dans le content script, qui lui reste en vie tant que l'onglet est ouvert. Le service worker redevient ce qu'il devrait être : un routeur stateless qui détecte l'URL et déclenche des messages, jamais une mémoire de session.
Le corollaire, c'est qu'il faut gérer proprement les changements d'onglet. Quand l'utilisateur passe de Gmail à Slack, le service worker peut très bien avoir été réveillé de zéro entre-temps. On ré-initialise donc le canal à chaque changement d'onglet pertinent, plutôt que de supposer qu'un état précédent a survécu. Idempotent par construction.
Pilier 5, Idempotence : reclics sans doublons
Dernier piège, plus subtil : l'utilisateur clique sur le bouton d'analyse, ne voit rien arriver dans la demi-seconde, et reclique. Sans protection, on lance deux analyses identiques, on double les appels API, et on risque d'afficher deux fois le résultat.
La solution est minimale : à chaque déclenchement, le client génère un instanceId (un nanoid) qui identifie cette analyse précise. Tant que la même instance est en vol, un reclic est ignoré ; le résultat est rattaché à son instanceId d'origine. C'est une idempotence côté client, pas côté serveur, suffisante ici, parce que la source du doublon, c'est le doigt de l'utilisateur, pas une retransmission réseau.
Les quirks par plateforme : là où le diable se cache
L'architecture rend le problème gérable, mais chaque Channel reste un petit chantier d'adaptation aux bizarreries locales. Quelques exemples vécus :
- Gmail : il faut distinguer l'analyse d'un message en cours de composition (la fenêtre de compose, en
contenteditable) de l'analyse inverse d'un message reçu que l'utilisateur lit, deux structures DOM totalement différentes pour ce qui, métier, est « le texte courant ». - Slack : l'éditeur Quill ne stocke pas un simple
textContent. Le texte est éclaté dans une structure interne de l'éditeur ; lire naïvement le DOM donne du contenu tronqué ou réordonné. Il faut comprendre le modèle de Quill pour reconstituer le texte fidèlement. - Les contenteditable en général : un champ riche est un arbre, pas une chaîne. Récupérer « le texte » impose souvent un tree-walking manuel, parcourir les nœuds, gérer les sauts de ligne implicites, ignorer les éléments décoratifs, pour produire ce que l'humain considère comme « ce que j'ai écrit ».
C'est exactement pour ça que le Strategy pattern paie : toute cette saleté est encapsulée dans getText() de chaque Channel. Le reste de l'application reçoit une chaîne propre et n'a pas la moindre idée du calvaire qui a permis de l'obtenir.
Ce que je retiens
- Une interface Strategy bien posée est un multiplicateur. Elle transforme « 10 intégrations qui se battent » en « 1 pipeline + 10 adaptateurs fins ». Le coût d'une plateforme supplémentaire devient quasi constant.
- Le Shadow DOM est la seule isolation saine en page hostile. Trop faible (div nue) et la page vous écrase ; trop forte (iframe) et vous perdez l'accès au DOM. Le shadow isole le style sans couper l'accès.
- Poll pour découvrir, observe pour réagir. Deux problèmes différents méritent deux mécanismes différents. Mélanger les deux, c'est soit rater le composer, soit brûler du CPU pour rien.
- Connaissez les contraintes de votre plateforme. Le service worker qui meurt en MV3 n'est pas un bug, c'est une règle du jeu. L'architecture doit l'épouser, pas lutter contre.
Ce raisonnement, isoler la complexité derrière une interface stable, choisir le bon mécanisme pour le bon problème, épouser les contraintes plutôt que les combattre, c'est la même mentalité que j'applique aux systèmes temps réel. D'ailleurs, l'analyse déclenchée par ces composers devait remonter un résultat en moins de 200 ms pour rester fluide ; j'en parle en détail dans cet article sur le sentiment en temps réel sous 200 ms.
Un produit complexe à architecturer ? Parlons-en.
Extension cross-plateforme, agent IA en production, système temps réel : si vous avez un produit où la complexité doit rester gérable sur la durée, c'est exactement le genre de problème que j'aime prendre en main.
Discutons-en →