Flux d'exécution

Le code dans l'ordre — de l'instanciation jusqu'à save()

Cette page suit l'ordre chronologique d'exécution. Elle répond à : "qu'est-ce qui se passe exactement quand X ?" Pour l'architecture en couches, voir Sous le capot.

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 :

  1. Prépare les classes d'outils inline — extrait les class depuis inline_tools et les passe à Inline_menu : Object.values(settings.inline_tools).map(t => t.class). C'est Inline_menu qui fera le new It_bold(), pas Redac.
  2. Crée les composants internes : new Block_manager() (tableau vide) et new Inline_menu({tools: [...]}). Le wrapper .rdac dans la page est localisé par document.querySelector('.rdac').
  3. 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.

L'ordre compte. 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énementCibleCe 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.

Si le curseur est en fin de ligne, le fragment extrait est vide. Le nouveau bloc est donc vide — ce qui est le comportement attendu (appuyer sur Enter en fin de paragraphe crée un nouveau paragraphe vide).

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)
}
Dans Inline_menu.render()
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é.

Limitation connue. L'objet 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 :

  1. Créer it_lien.js avec get name(), render(), action(). Dans le constructor, poser this._wrapper.dataset.redacItName = this.name.
  2. Dans action(), détecter si la sélection est dans un <a> via closest('a') et toggler.
  3. 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
Ce que Paragraph sait faire
  • 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())
Ce que Redac gère pour lui
  • 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()
La règle de responsabilité : un bloc connaît son contenu. Redac connaît la liste et les interactions. Tune connaît les boutons. Aucun ne déborde sur le domaine de l'autre.