Parent : [[INDEX-WEB-UIUX]]
Web — UX éditeur avancé : autosave, keyboard shortcuts, stale closure, dynamic import
Pattern 1 — Autosave 30s avec protection contre les race conditions
Problème : Un autosave déclenché pendant une publication ou un autre save corrompt les données.
Solution : Vérifier isPublishing || isSaving avant de planifier le timeout. Utiliser useRef pour le timeout (pas de state) pour éviter les re-renders. Reset le timeout à chaque changement de isDirty. Silent fail sur les erreurs réseau — ne pas interrompre l'utilisateur pour un autosave raté.
const autoSaveTimeoutRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (autoSaveTimeoutRef.current) clearTimeout(autoSaveTimeoutRef.current)
if (isPublishing || isSaving) return // Protection race condition
if (isDirty) {
autoSaveTimeoutRef.current = setTimeout(handleSaveDraft, 30000)
}
return () => { if (autoSaveTimeoutRef.current) clearTimeout(autoSaveTimeoutRef.current) }
}, [isDirty, isPublishing, isSaving, handleSaveDraft])
Pattern 2 — Stale closure dans les callbacks async : utiliser store.getState()
Problème : Un useCallback qui lit des variables du state au moment de sa création les "gèle" — en autosave, blocks peut être vieux de 30 secondes.
Solution : Dans les callbacks async critiques, lire l'état directement depuis store.getState() plutôt que depuis les variables du closure. Garantit toujours la valeur fraîche au moment de l'exécution.
const handleSaveDraft = useCallback(async () => {
// ✅ State frais au moment du save
const { blocks, pageSettings, isDirty, isPublishing, isSaving } = useEditorStore.getState()
if (!isDirty || isPublishing || isSaving) return
// ...
}, [setSaving, markDraftSaved]) // Pas de blocks dans les deps → pas de stale issue
Pattern 3 — beforeunload : sauvegarder et afficher le dialog natif
Problème : Si l'utilisateur ferme l'onglet avec des modifications non sauvegardées, les données sont perdues.
Solution : beforeunload listener avec e.preventDefault() + e.returnValue = "" pour déclencher le dialog natif du navigateur. Lancer handleSaveDraft() en parallèle (best-effort, peut ne pas compléter). Lire isDirty via store.getState() pour éviter le stale closure.
const handleBeforeUnload = (e: BeforeUnloadEvent) => {
const freshIsDirty = useEditorStore.getState().isDirty
if (freshIsDirty) {
e.preventDefault()
e.returnValue = "" // Dialog natif "Quitter sans sauvegarder ?"
handleSaveDraft() // Tentative best-effort
}
}
window.addEventListener("beforeunload", handleBeforeUnload)
Pattern 4 — Keyboard shortcuts globaux : skip si focus dans un input
Problème : Des raccourcis globaux (Delete, Ctrl+Z) interceptés quand l'utilisateur tape dans un input détruisent du contenu.
Solution : En début de handler keydown, vérifier target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable → return early. Détecter Mac vs PC via navigator.platform pour Cmd vs Ctrl.
const handleKeyDown = (e: KeyboardEvent) => {
const target = e.target as HTMLElement
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable) return
const isMac = navigator.platform.toUpperCase().includes("MAC")
const ctrlKey = isMac ? e.metaKey : e.ctrlKey
if (ctrlKey && e.key === "z" && !e.shiftKey && canUndo()) { e.preventDefault(); undo() }
if (ctrlKey && e.key === "z" && e.shiftKey && canRedo()) { e.preventDefault(); redo() }
if ((e.key === "Delete" || e.key === "Backspace") && selectedBlockId) { e.preventDefault(); removeBlock(selectedBlockId) }
}
Pattern 5 — Raccourcis clavier complets pour un éditeur
Liste complète à implémenter dans tout éditeur :
Ctrl/Cmd+Z → Undo
Ctrl/Cmd+Shift+Z → Redo
Ctrl/Cmd+Y → Redo (Windows alternative)
Ctrl/Cmd+C → Copier le bloc sélectionné
Ctrl/Cmd+V → Coller
Ctrl/Cmd+X → Couper
Ctrl/Cmd+D → Dupliquer le bloc sélectionné
Delete/Backspace → Supprimer le bloc sélectionné
Escape → Désélectionner
ArrowUp/Down → Naviguer entre blocs
Ctrl/Cmd+Plus → Zoom in
Ctrl/Cmd+Minus → Zoom out
Ctrl/Cmd+0 → Reset zoom
Pattern 6 — Dynamic import pour les panneaux lourds (code splitting)
Problème : Importer FloatingToolsPanel (89KB) et FloatingPropertiesPanel (100KB) dans le bundle initial ralentit le premier rendu.
Solution : next/dynamic avec ssr: false pour les composants lourds qui ne sont pas nécessaires au premier rendu. Ils sont chargés uniquement quand l'utilisateur ouvre l'éditeur.
const FloatingToolsPanel = dynamic(
() => import("./FloatingToolsPanel").then(mod => ({ default: mod.FloatingToolsPanel })),
{ ssr: false }
)
const FloatingPropertiesPanel = dynamic(
() => import("./FloatingPropertiesPanel").then(mod => ({ default: mod.FloatingPropertiesPanel })),
{ ssr: false }
)
Pattern 7 — Fullscreen via CustomEvent inter-composants
Problème : Un composant enfant (editor) doit communiquer un changement d'état (fullscreen) à un composant parent (dashboard layout) sans prop drilling ni store global.
Solution : Dispatcher un CustomEvent sur window. Le parent écoute avec addEventListener. Propre, découplé, pas de shared state nécessaire. Cleanup dans le return du useEffect pour éviter les doublons.
// Enfant (editor) :
window.dispatchEvent(new CustomEvent('editor-fullscreen', { detail: { fullscreen: true } }))
// Parent (dashboard layout) :
window.addEventListener('editor-fullscreen', (e: CustomEvent) => {
setIsFullscreen(e.detail.fullscreen)
})
Pattern 8 — Panneau flottant : fermeture au clic extérieur + Escape
Problème : Un panneau flottant sans fermeture au clic extérieur piège l'utilisateur.
Solution : Ref sur le panneau. mousedown listener sur document avec !panelRef.current.contains(event.target). Délai de 100ms avant d'ajouter le listener pour éviter la fermeture immédiate à l'ouverture. keydown Escape séparé. Nettoyer les deux dans le return.
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) onClose()
}
if (isOpen) {
setTimeout(() => document.addEventListener("mousedown", handleClickOutside), 100)
}
return () => document.removeEventListener("mousedown", handleClickOutside)
}, [isOpen, onClose])
Pattern 9 — Présence temps réel : throttle viewport à 100ms (10fps)
Problème : Envoyer la position viewport/scroll de chaque utilisateur à 60fps via WebSocket sature le réseau pour rien.
Solution : Throttler les updates de viewport à 100ms (10fps) via useRef timestamp. Les curseurs et présences ne nécessitent pas 60fps — 10fps est imperceptible. Nettoyer les users de la room précédente (setUsers([])) avant de rejoindre une nouvelle room pour éviter le mélange de présences.
const VIEWPORT_THROTTLE_MS = 100 // 10fps suffit pour viewport/scroll
const lastViewportSentRef = useRef(0)
const updateViewport = (viewport) => {
const now = Date.now()
if (now - lastViewportSentRef.current < VIEWPORT_THROTTLE_MS) return
lastViewportSentRef.current = now
socket.emit('viewport-update', viewport)
}
Pattern 10 — Version history : "load more" avec deux états de loading distincts
Problème : Afficher le même spinner pour le premier chargement et pour "charger plus" donne une UX confuse.
Solution : Deux états distincts : initialLoading (premier fetch, affiche un full loading state) et loadingMore (fetches suivants, affiche un petit spinner en bas de liste). Le premier ne remplace jamais les items existants, le second les concatène.
const [initialLoading, setInitialLoading] = useState(false) // Bloc le UI complet
const [loadingMore, setLoadingMore] = useState(false) // Juste un spinner en bas
// Premier fetch : initialLoading = true
// Fetch suivants : loadingMore = true, items existants restent visibles
setVersions(prev => [...prev, ...newVersions])