Flux d'exécution
Le code dans l'ordre — de l'instanciation jusqu'à save()
Briques de base : Dom.makel()
Avant d'entrer dans le flux, un helper apparaît partout dans le code sans être expliqué.
dom.js expose deux méthodes statiques qui remplacent les appels verbeux de l'API DOM native.
// Créer un élément avec des classes
Dom.makel('div', ['rdac-block', 'mt-2'])
// équivaut à :
const el = document.createElement('div')
el.classList.add('rdac-block', 'mt-2')
// Créer une icône Bootstrap Icons
Dom.make_icon(['bi-type-bold'])
// équivaut à :
const i = document.createElement('i')
i.classList.add('bi', 'bi-type-bold') // ← 'bi' ajouté automatiquement
Quand tu vois Dom.makel(...) dans un snippet, lis-le comme
"crée cet élément HTML avec ces classes". C'est tout.
Le détail complet est dans Tune & Inline menu.
Un second helper apparaît dans le constructeur de Block :
Utils.rdm_id() // → "k7x2mq4pnb" (10 caractères alphanumériques aléatoires)
Utils.rdm_id(6) // → "x4m2qp"
Chaque bloc généré reçoit un id unique via Utils.rdm_id() dès sa construction.
Cet id sert à la fois d'attribut HTML (<div id="k7x2mq4pnb">)
et de clé dans le Block_manager pour retrouver le bloc par document.getElementById(block.id).
new Redac({...}) — l'instanciation
const redac = new Redac({
inline_tools: {
bold: { class: It_bold },
italic: { class: It_italic }
}
})
Le constructeur fait trois choses dans l'ordre :
-
Prépare les classes d'outils inline — extrait les
classdepuisinline_toolset les passe àInline_menu:Object.values(settings.inline_tools).map(t => t.class). C'estInline_menuqui fera lenew It_bold(), pas Redac. -
Crée les composants internes :
new Block_manager()(tableau vide) etnew Inline_menu({tools: [...]}). Le wrapper.rdacdans la page est localisé pardocument.querySelector('.rdac'). -
Lance
_init()puis_listen().
Ce que fait new Inline_menu() en détail
constructor({ tools: [] }) {
// 1. Crée le conteneur de la barre flottante
this._wrapper = Dom.makel('div', ['rdac-inline-menu'])
// 2. Instancie chaque outil : [It_bold, It_italic] → [new It_bold(), new It_italic()]
this._tools = settings.tools.map(Tool_class => new Tool_class())
// 3. Un seul listener sur le wrapper — délègue au bon outil via data-redacItName
this._wrapper.addEventListener('click', (event) => {
const btn = event.target.closest('button')
if (btn === null) return // ← garde : clic sur le wrapper mais pas un bouton
const tool = this._tools.find(t => t.name === btn.dataset.redacItName)
if (tool) tool.action(event)
})
// 4. Inséré dans document.body (PAS dans .rdac) — une seule fois, pour toujours
document.body.appendChild(this._wrapper)
this.close() // masqué par défaut (display:none)
}
Pourquoi dans body et pas dans .rdac ?
Pour que le positionnement position:absolute soit relatif à la page entière
et non limité par le container de l'éditeur (qui pourrait avoir overflow:hidden).
_init() — le premier bloc
_init() {
const first = new Paragraph() // crée le bloc
this._wrapper.append(first.render()) // render() → insère le DOM
this._blocks.add(first) // ajoute au tableau
this._mount_tune(first) // câble les callbacks
this._refresh_states() // met à jour les boutons ↑/↓/✕
first.focus() // place le curseur dedans
}
C'est la séquence canonique pour créer n'importe quel bloc — elle est répétée à chaque fois qu'un nouveau bloc est inséré, quelle qu'en soit la raison.
render() doit être appelé avant
_mount_tune(), car render() crée les éléments DOM que
Tune.render() retourne. Et mount_tune() doit être fait
avant _refresh_states(), car update_state() appelle
des méthodes sur le Tune qui n'existeraient pas encore.
_listen() — les événements globaux
Quatre écouteurs sont posés une seule fois sur document (et un sur this._wrapper).
Ils restent actifs pendant toute la vie de l'éditeur.
| Événement | Cible | Ce qui se passe |
|---|---|---|
keydown |
this._wrapper |
Si la touche est Enter et que l'élément actif est dans .rdac-content,
preventDefault() bloque le saut de ligne natif et _split_block() prend le relais.
|
click |
document |
Ferme tous les dropdowns Tune ouverts si le clic est en dehors de
.rdac-tune-dd et .rdac-tools.
Ferme aussi le menu inline s'il est actif.
|
selectstart |
document |
L'utilisateur commence une nouvelle sélection → ferme le menu inline s'il était ouvert. |
mouseup |
document |
L'utilisateur relâche la souris après une sélection → si le texte sélectionné
est dans un .rdac-content, affiche le menu inline.
|
Enter — couper un bloc
_split_block(block_id, content_el) utilise l'API Range du navigateur pour
savoir où est le curseur et séparer le contenu en deux.
// 1. Capturer la position du curseur
const range = sel.getRangeAt(0)
// 2. Définir un Range "du curseur jusqu'à la fin du bloc"
const tail = document.createRange()
tail.setStart(range.endContainer, range.endOffset) // ← là où est le curseur
tail.setEnd(end_bounds.endContainer, end_bounds.endOffset) // ← fin du bloc
// 3. extractContents() : coupe et retourne ce fragment (modifie le DOM existant)
const tail_fragment = tail.extractContents()
// 4. Créer le nouveau bloc avec le contenu extrait
const new_block = new Paragraph()
new_block.content_el.innerHTML = temp.innerHTML // ← le "après curseur"
// Le bloc courant garde le "avant curseur" (le reste après extractContents)
extractContents() est la clé : elle retire physiquement le fragment du DOM
et le retourne. Le bloc courant se retrouve tronqué au niveau du curseur — exactement ce qu'on veut.
Bouton + — ajouter un bloc
Quand l'utilisateur clique +, Tune appelle this.on_add().
Ce callback a été défini par _mount_tune(block) :
t.on_add = () => this._add_block(block.id).
_add_block(after_id) {
const block = new Paragraph() // nouveau bloc vide
const dom = block.render()
const ref_el = document.getElementById(after_id)
// DOM : insérer juste après le bloc de référence
ref_el?.nextSibling
? this._wrapper.insertBefore(dom, ref_el.nextSibling)
: this._wrapper.append(dom)
this._blocks.insert_after(after_id, block) // tableau : même position
this._mount_tune(block)
this._refresh_states()
block.focus()
}
C'est la version simplifiée de _split_block : pas de Range, pas de contenu à déplacer,
juste un bloc vide inséré à la bonne position.
Bouton ⋮ — le dropdown Tune
Tune est un composant passif : il construit son DOM dans render()
et expose des callbacks, mais ne fait rien par lui-même. Redac lui donne son comportement via
_mount_tune().
Quand l'utilisateur ouvre le dropdown
// Dans Tune._toggle() :
const { id, classes } = this.on_open() // ← Redac retourne les valeurs courantes
this._id_input.value = id // ← pré-remplit le champ "id"
this._cls_input.value = classes // ← pré-remplit le champ "class"
this._dropdown.style.display = 'block'
// on_open est défini par _mount_tune() :
t.on_open = () => {
const block_el = document.getElementById(block.id)
return {
id: block_el?.dataset.customId || '',
classes: Array.from(block_el?.classList || [])
.filter(c => c !== 'rdac-block').join(' ')
}
}
Quand l'utilisateur clique "Appliquer"
// Tune lit les inputs et appelle on_apply_attrs :
this.on_apply_attrs?.({ id: this._id_input.value.trim(), classes: this._cls_input.value.trim() })
// Redac applique sur le wrapper DOM :
t.on_apply_attrs = ({ id, classes }) => {
const block_el = document.getElementById(block.id)
if (id) block_el.dataset.customId = id // ← stocké en data-custom-id
else delete block_el.dataset.customId
// Reset les classes custom, garde seulement rdac-block
Array.from(block_el.classList)
.filter(c => c !== 'rdac-block')
.forEach(c => block_el.classList.remove(c))
classes.split(/\s+/).filter(Boolean).forEach(c => block_el.classList.add(c))
}
data-custom-id et les classes custom sont stockés dans le DOM,
pas dans l'objet block JS. save() les lit ensuite depuis le DOM
(el.dataset.customId, el.classList) pour les inclure dans le JSON.
Sélection de texte — le menu inline
Inline_menu est inséré une seule fois dans document.body au constructor,
puis repositionné à chaque sélection.
// mouseup dans Redac._listen() :
const selection = window.getSelection()
const selected_txt = selection.toString()
if (!selected_txt) return
// Vérifier que la sélection est dans un .rdac-content
const anchor_node = selection.anchorNode
const parent_el = anchor_node.nodeType === Node.TEXT_NODE
? anchor_node.parentElement
: anchor_node
if (parent_el?.closest('.rdac-content')) {
this._inline_menu.render(selection, selected_txt)
}
const rect = range.getBoundingClientRect() // coordonnées de la sélection dans le viewport
// scrollX/Y pour compenser le scroll de la page (position absolute dans body)
const x = window.scrollX + rect.left + rect.width / 2 // centré horizontalement
const y = window.scrollY + rect.top // bord haut de la sélection
this._wrapper.style.left = `${x}px`
this._wrapper.style.top = `${y}px`
this._wrapper.style.transform = 'translate(-50%, calc(-100% - 6px))'
// ↑ centrer ↑ monter au-dessus + 6px de marge
// Demander à chaque outil de construire son bouton
this._tools.forEach(tool => {
this._wrapper.appendChild(tool.render(selection, selected_txt))
})
Quand l'utilisateur clique un bouton (ex: Gras)
// Délégation : un seul listener sur le wrapper lit data-redacItName
this._wrapper.addEventListener('click', (event) => {
const btn = event.target.closest('button')
const tool = this._tools.find(t => t.name === btn.dataset.redacItName)
if (tool) tool.action(event) // ← l'outil applique le formatage
})
La sélection est passée à tool.render() et stockée dans l'outil.
tool.action() la relit et applique le formatage via l'API Range.
Zoom : anatomie d'un inline tool (it_bold)
Les outils inline (it_bold.js, it_italic.js) sont de petites classes
indépendantes qui implémentent le même contrat en trois méthodes. Voici le détail de chaque étape
en suivant it_bold.
Le constructor — construction du bouton une seule fois
constructor() {
this._name = 'bold'
this._wrapper = Dom.makel('button', ['btn', 'btn-outline-secondary'])
this._wrapper.append(Dom.make_icon(['bi-type-bold']))
this._wrapper.dataset.redacItName = this._name // ← clé de la délégation
}
Le bouton est construit une seule fois dans le constructor et réutilisé à chaque
ouverture du menu. dataset.redacItName = 'bold' est l'identifiant que
Inline_menu lit pour savoir quel outil appeler quand ce bouton est cliqué.
render() — stocker la sélection courante
render(selection, selected_txt) {
this._selection = selection // ← objet Selection du navigateur (live)
this._selected_txt = selected_txt // ← résultat de selection.toString()
return this._wrapper // ← le même bouton DOM à chaque fois
}
render() est appelé à chaque fois que le menu s'ouvre (mouseup).
Son seul rôle ici : mémoriser la sélection pour que action()
puisse la relire lors du clic. Il n'y a pas de reconstruction du bouton — c'est toujours
le même élément DOM retourné.
Selection est live —
il reflète l'état courant de la sélection dans le navigateur. Entre le mouseup
(render) et le click (action), certains navigateurs effacent la sélection
quand le focus passe au bouton. Si le gras ne s'applique pas, c'est cette raison.Solution future : sauvegarder le
Range et le restaurer explicitement dans action().
action() — le mécanisme de toggle
action() doit répondre à deux cas : le texte est déjà en gras (→ retirer),
ou ne l'est pas (→ ajouter). La détection se fait via commonAncestorContainer.
action(event) {
if (!this._selected_txt || !this._selection.rangeCount) return
const range = this._selection.getRangeAt(0)
// commonAncestorContainer = le nœud DOM qui contient toute la sélection
// .closest('strong') remonte dans les parents jusqu'à trouver un
const bold_parent = range.commonAncestorContainer.closest?.('strong')
?? range.commonAncestorContainer.parentElement?.closest('strong')
// ↑ fallback si commonAncestorContainer est un TextNode (pas d'élément)
let new_node // ← déclaré ici pour être accessible dans les deux branches
if (bold_parent) {
// ── CAS : déjà en gras → retirer le ──────────────────────
const text_node = document.createTextNode(this._selected_txt)
bold_parent.replaceWith(text_node) // ← remplace X par X
new_node = text_node
} else {
// ── CAS : pas en gras → entourer de ──────────────────────
const strong = document.createElement('strong')
strong.textContent = this._selected_txt
range.deleteContents() // ← supprime le texte sélectionné du DOM
range.insertNode(strong) // ← insère exactement à cette position
new_node = strong
}
// Rétablir la sélection sur le nouveau nœud (pour un toggle immédiat éventuel)
this._selection.removeAllRanges()
const new_range = document.createRange()
new_range.selectNodeContents(new_node)
this._selection.addRange(new_range)
this._selected_txt = this._selection.toString()
}
it_italic.js est identique, en remplaçant 'strong' par 'em'.
Les deux cas non gérés : une sélection qui chevauche partiellement une balise existante
(ex : sélectionner "ello" dans "hello monde"). Ce cas est laissé pour
une version future.
Créer un nouvel outil inline
Trois étapes — it_link.js suivrait exactement ce schéma :
-
Créer
it_lien.jsavecget name(),render(),action(). Dans le constructor, poserthis._wrapper.dataset.redacItName = this.name. -
Dans
action(), détecter si la sélection est dans un<a>viaclosest('a')et toggler. -
Passer la classe à
new Redac({ inline_tools: { lien: { class: It_lien } } }).
Rien d'autre à modifier — Inline_menu instancie et câble automatiquement tout outil fourni.
save() — la sérialisation
save() {
return new Promise((resolve, reject) => {
const data = []
this._blocks.blocks.forEach(block => {
const el = document.getElementById(block.id)
if (!el) return
const content_el = el.querySelector('.rdac-content')
if (!content_el || content_el.innerHTML === '') return // ← blocs vides ignorés
const saved = block.save() // ← délègue à Paragraph.save() → { type, data }
// Enrichir avec les attributs custom (stockés dans le DOM)
const custom_id = el.dataset.customId || ''
const custom_classes = Array.from(el.classList)
.filter(c => c !== 'rdac-block').join(' ')
if (custom_id) saved.id_attr = custom_id
if (custom_classes) saved.data.classes = custom_classes
data.push(saved)
})
resolve(JSON.stringify({ time: Date.now(), blocks: data }))
})
}
save() parcourt this._blocks.blocks (qui respecte l'ordre du document)
et appelle block.save() sur chacun. Les attributs custom sont récupérés depuis le DOM
car c'est là qu'ils vivent. Les blocs dont .rdac-content est vide sont exclus.
load() — la désérialisation
load(json_string) {
const doc = JSON.parse(json_string)
// 1. Vider l'état courant (DOM + tableau)
this._wrapper.textContent = ''
this._blocks = new Block_manager()
// 2. Type map : associe le string "paragraph" à la classe Paragraph
const type_map = { paragraph: Paragraph }
// 3. Reconstruire chaque bloc
doc.blocks.forEach(saved => {
const Block_class = type_map[saved.type] // ← lookup par type string
if (!Block_class?.load) return // ← type inconnu → ignoré
const block = Block_class.load(saved) // ← static load() reconstruit le bloc
this._wrapper.append(block.render())
this._blocks.add(block)
this._mount_tune(block)
// Restaurer les attributs custom dans le DOM
const el = document.getElementById(block.id)
if (saved.id_attr) el.dataset.customId = saved.id_attr
if (saved.data?.classes) saved.data.classes.split(/\s+/).forEach(c => el.classList.add(c))
})
this._refresh_states()
}
Le type_map est la clé de l'extensibilité : pour qu'un type soit chargeable,
il suffit de l'ajouter à cet objet. La classe doit implémenter static load(saved).
Cycle de vie d'un bloc
Tous les chemins (création manuelle, split sur Enter, chargement JSON) aboutissent à la même séquence :
new Paragraph() ← naissance : id aléatoire, _wrapper div, _content div, _tune Tune
↓
block.render() ← construction DOM : rdac-block > rdac-tools + rdac-content + rdac-tune-dd
↓
wrapper.append/insertBefore ← insertion dans la page
↓
blocks.add/insert_after ← enregistrement dans le tableau (source de vérité de l'ordre)
↓
_mount_tune(block) ← câblage des 6 callbacks on_* sur le Tune du bloc
↓
_refresh_states() ← update_state(is_first, is_last, is_only) sur TOUS les blocs
↓
block.focus() ← (optionnel) place le curseur dedans
↓
[utilisation : édition, déplacement, formatage inline]
↓
block.save() ← { type: 'paragraph', data: { text: innerHTML } }
↓
[optionnel] static Paragraph.load(saved) ← reconstruction depuis JSON
- Construire sa structure DOM (
render()) - Exposer son contenu éditable (
content_el) - Exposer son Tune (
tune) - Se focaliser avec curseur en fin (
focus()) - Se sérialiser (
save()) - Se reconstruire depuis JSON (
static load())
- Son insertion dans le DOM et dans le tableau
- Le câblage de ses callbacks Tune
- La mise à jour de ses boutons selon sa position
- Son déplacement / suppression
- La récupération de ses attributs custom au
save()