Explorer
KNOW-PAT-136

Web — UX éditeur avancé : autosave, keyboard shortcuts, stale closure, dynamic import

Domaine
web-uiux
Type
pattern
Priorité
P1

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])