// =====================================================
// Odinote — Canvas v3
// • drag-to-create for ALL tools
// • hover anchors → drag to create connectors
// • Line tool: drag between items
// • image picker, link input, doc modal, calendar
// • theme prop, no zoom-out, subtle enter animation
// =====================================================
const {
  useState: useStateCanvas,
  useRef: useRefCanvas,
  useEffect: useEffectCanvas,
  useCallback: useCallbackCanvas,
  useMemo: useMemoCanvas,
} = React;

// Column auto-resize helpers
function colChildHeight(c) {
  if (c.type === 'board') {
    return c.showPreview === false ? 58 : (c.h || 200);
  }
  if (c.h !== undefined && c.h !== null) return c.h;
  return c.type === 'note' ? 90 :
         c.type === 'todo' ? 140 :
         c.type === 'link' ? 180 :
         c.type === 'image' ? 140 :
         c.type === 'doc' ? 90 :
         c.type === 'comment' ? 80 :
         c.type === 'calendar' ? 220 : 90;
}
function colNeededHeight(col, isEmpty) {
  // La franja de arriba lleva el título Y la línea de «3 nodos»: medida en la
  // app, 58 px con los bordes. Con los 40 de antes a toda columna le faltaban
  // 17 px y salía la barra de desplazamiento cortando la última tarjeta. Si el
  // título salta a dos líneas, la columna se mide y lo apunta (altoCabecera,
  // en ColumnItem).
  const headerH = col.altoCabecera || 58;
  const padding = 20; // top 10px + bottom 10px
  const gap = 7;
  if (isEmpty) return 160;
  const childrenH = (col.children || []).reduce((s, c) => s + colChildHeight(c), 0);
  const gaps = ((col.children || []).length - 1) * gap;
  return headerH + padding + childrenH + gaps;
}
window.colNeededHeight = colNeededHeight;
function withResizedColumn(items, colId) {
  return items.map(it => {
    if (it.id !== colId) return it;
    const empty = !(it.children || []).length;
    const h = colNeededHeight(it, empty);
    return { ...it, h };
  });
}

// ───── default templates per tool ─────
// El tamaño con el que nace cada tipo de nodo vive en src/nodos.js, que
// comparte con el servidor.
function defaultDims(type) {
  return window.OdiNodos.dimensiones(type);
}
window.defaultDims = defaultDims;

function ToolGhost({ x, y, tool, lang }) {
  const t = window.TRANSLATIONS[lang];
  const conf = (window.TOOLS || []).find(t => t.id === tool);
  return (
    <div style={{
      position: 'fixed', left: x, top: y,
      transform: 'translate(8px, 8px)',
      pointerEvents: 'none',
      zIndex: 9999,
      background: 'var(--paper)',
      border: '1.5px solid var(--line)',
      borderRadius: 8,
      boxShadow: 'var(--pop)',
      padding: '6px 10px',
      display: 'flex', alignItems: 'center', gap: 7,
      fontSize: 12, fontWeight: 600,
    }}>
      <div style={{
        width: 22, height: 22, borderRadius: 6,
        background: conf?.bg || 'var(--paper)',
        color: conf?.fg || 'var(--ink)',
        border: '1.5px solid var(--line)',
        display: 'grid', placeItems: 'center',
      }}>
        <span className="material-symbols-rounded" style={{fontSize:14}}>{conf?.icon || 'add'}</span>
      </div>
      <span>{t[conf?.label] || tool}</span>
    </div>
  );
}

function makeNewItem(type, x, y, w, h, lang) {
  // Toda creación de nodo pasa por aquí, sea desde la barra, el menú del botón
  // derecho o pegando: es el único sitio donde contarlo una vez y sin repetir.
  window.odiTrack && window.odiTrack('nodo_creado', { tipo: type });
  // Los campos con los que nace cada tipo están en src/nodos.js (los comparte
  // con el servidor). Aquí solo se añade lo del momento.
  const nodo = window.OdiNodos.nuevo(type, { x, y, w, h, nombreComentario: window.t('Tú', 'You') });
  if (!nodo) return null;
  nodo._new = true;
  // El de archivo abre el selector al nacer, y el dibujo entra a dibujar.
  if (type === 'file') nodo._triggerFilePick = true;
  if (type === 'draw') nodo._startDrawing = true;
  return nodo;
}

// Gira un punto alrededor de un centro. Se usa para todo lo que tiene que
// seguir a un nodo girado: sus anclas y su redimensionado.
function giraPunto(x, y, cx, cy, grados) {
  if (!grados) return { x, y };
  const r = grados * Math.PI / 180;
  const dx = x - cx, dy = y - cy;
  return {
    x: cx + dx * Math.cos(r) - dy * Math.sin(r),
    y: cy + dx * Math.sin(r) + dy * Math.cos(r),
  };
}

// ───── anchor helper (must match Connector.jsx) ─────
function anchorPos(item, anchor) {
  const cx = item.x + item.w / 2;
  const cy = item.y + item.h / 2;
  let p;
  switch (anchor) {
    case 'top':    p = { x: cx, y: item.y }; break;
    case 'bottom': p = { x: cx, y: item.y + item.h }; break;
    case 'left':   p = { x: item.x, y: cy }; break;
    case 'right':  p = { x: item.x + item.w, y: cy }; break;
    default:       return { x: cx, y: cy };   // el centro no se mueve al girar
  }
  // Un nodo girado tiene los lados girados con él. Sin esto, una flecha
  // pegada a su borde derecho seguiría apuntando a donde ESTABA ese borde
  // antes de girar el nodo, y se vería despegada.
  return giraPunto(p.x, p.y, cx, cy, item.rot || 0);
}
function closestAnchor(item, x, y) {
  const opts = ['top','right','bottom','left'].map(a => ({ a, p: anchorPos(item, a) }));
  opts.sort((u, v) => {
    const du = (u.p.x - x) ** 2 + (u.p.y - y) ** 2;
    const dv = (v.p.x - x) ** 2 + (v.p.y - y) ** 2;
    return du - dv;
  });
  return opts[0].a;
}


function duplicateCanvasState(state, origId, newId) {
  if (!state[origId]) return;
  const origCanvas = state[origId];
  const canvasIdMap = {};
  const innerCanvasDuplications = [];
  const duplicatedItems = origCanvas.items.map(it => {
    const itemNewId = `it-${Date.now()}-${Math.floor(Math.random()*99999)}-${Math.floor(Math.random()*99999)}`;
    canvasIdMap[it.id] = itemNewId;
    const copy = { ...it, id: itemNewId };
    if (copy.type === 'board' && copy.canvasId) {
      const innerNewCid = `b-${Date.now()}-${Math.floor(Math.random()*99999)}`;
      copy.canvasId = innerNewCid;
      innerCanvasDuplications.push({ orig: it.canvasId, next: innerNewCid });
    }
    return copy;
  });

  const duplicatedConnectors = (origCanvas.connectors || []).map(co => {
    const connNewId = `co-${Date.now()}-${Math.floor(Math.random()*99999)}`;
    const copy = { ...co, id: connNewId };
    if (copy.fromEnd && canvasIdMap[copy.fromEnd.itemId]) {
      copy.fromEnd = { ...copy.fromEnd, itemId: canvasIdMap[copy.fromEnd.itemId] };
    }
    if (copy.toEnd && canvasIdMap[copy.toEnd.itemId]) {
      copy.toEnd = { ...copy.toEnd, itemId: canvasIdMap[copy.toEnd.itemId] };
    }
    return copy;
  });

  state[newId] = {
    ...origCanvas,
    items: duplicatedItems,
    connectors: duplicatedConnectors
  };

  innerCanvasDuplications.forEach(({ orig, next }) => {
    duplicateCanvasState(state, orig, next);
  });
}

// Zoom con el que se abre un lienzo que aún no tiene cámara guardada.
const defaultScale = () => (window.odiIsMobile && window.odiIsMobile()) ? 0.7 : 1;

// Los nodos que pintan tiradores DENTRO de su contenido: la columna (los
// puntos de conexión de sus tarjetas), la imagen (el recorte) y el código (el
// ▶, que en el teléfono mide lo mismo a cualquier zoom). Solo estos llevan las
// variables de los tiradores en su caja (ver varsTiradoresRef).
const TIPOS_CON_TIRADORES = new Set(['column', 'image', 'code']);

function Canvas({ projectId, lang, setLang, theme, setTheme, onHome, canvasesIn, setCanvases: setExtCanvases, updateAvailable, onUpdateClick, volume, onChangeVolume, onSettingsClick, vaultPath, userProfile, esPatrocinador, onAbrirCorona, onUserClick, projects, setProjects, onSharingClick, onManualSync, isSyncingDrive, needsDriveAuth, driveReachable, jumpTarget, onSearchClick, onGoToNode, onGraphClick, initialTrail, onTrailChange }) {
  const currentProject = projects ? projects.find(p => p.id === projectId) : null;
  const [canvases, _setCanvases] = useStateCanvas(() => canvasesIn || JSON.parse(JSON.stringify(window.INITIAL_CANVASES)));
  // Stable ref to App's setter — avoids the infinite loop caused by it being a dep on every render
  const setExtCanvasesRef = useRefCanvas(setExtCanvases);
  setExtCanvasesRef.current = setExtCanvases;

  // Track canvases we sent to App to avoid circular updates and old echoes overwriting local state
  const sentCanvasesRef = useRefCanvas(new Set());

  // Sync canvas state up to the App (for persistence). Skipped during an active drag/resize
  // (body.odi-busy) so we don't trigger a full App+Canvas re-render on every mouse-move frame.
  // The final committed change (odi-busy removed on mouseup) syncs normally, so nothing is lost.
  useEffectCanvas(() => {
    if (document.body.classList.contains('odi-busy')) return;
    if (setExtCanvasesRef.current) {
      // Keep track of the reference we are sending to the parent
      sentCanvasesRef.current.add(canvases);
      // Bounded set size to prevent memory leaks
      if (sentCanvasesRef.current.size > 50) {
        const first = sentCanvasesRef.current.values().next().value;
        sentCanvasesRef.current.delete(first);
      }
      setExtCanvasesRef.current(canvases);
    }
  }, [canvases]);

  // Sync external changes (e.g. from media saving or vault loading) into local canvas state
  // Skip while dragging/resizing (odi-busy) to prevent the debounced App save from resetting
  // node positions mid-drag and causing the "convulsion" jitter bug.
  useEffectCanvas(() => {
    if (document.body.classList.contains('odi-busy')) return;
    if (canvasesIn) {
      // Avoid circular update loops and old echoes overwriting newer local state
      if (sentCanvasesRef.current.has(canvasesIn)) return;
      _setCanvases(canvasesIn);
    }
  }, [canvasesIn]);

  const setCanvases = (updater) => {
    _setCanvases(prev => {
      if (typeof updater !== 'function') return updater;
      // Si el lienzo en el que estás ya no existe (lo quitó un deshacer, o un
      // cambio que llegó de otro equipo), los cambios que lo leen fallarían
      // DENTRO de React y la app entera se quedaría en blanco. Se ignoran: el
      // efecto de abajo recorta la pila hasta un lienzo que siga existiendo.
      if (prev && !prev[currentId]) return prev;
      return updater(prev);
    });
  };

  // Se vuelve al tablero donde estabas, no al lienzo raiz. El rastro guardado
  // se valida antes de usarlo: si borraste uno de esos tableros entre sesiones,
  // se entra hasta el ultimo que siga existiendo en vez de fallar.
  const [stack, setStack] = useStateCanvas(() => {
    const guardado = Array.isArray(initialTrail) ? initialTrail : null;
    if (!guardado || guardado[0] !== projectId) return [projectId];
    const base = canvasesIn || {};
    const valido = [projectId];
    for (let i = 1; i < guardado.length; i++) {
      const cid = guardado[i];
      const padre = base[valido[valido.length - 1]];
      const existe = base[cid] && padre && (padre.items || []).some(it => it.canvasId === cid);
      if (!existe) break;
      valido.push(cid);
    }
    return valido;
  });

  // Se avisa hacia arriba para que el rastro se guarde con el resto del estado.
  // Deshacer la creación de un tablero quita su lienzo, y la pila seguía
  // apuntando a él: el bot de pruebas dejó la app en blanco así (Ctrl+Z dentro
  // de un tablero recién creado y luego un clic en una herramienta). Se vuelve
  // al último lienzo de la pila que siga existiendo.
  useEffectCanvas(() => {
    const i = stack.findIndex(id => !canvases[id]);
    if (i > 0) setStack(stack.slice(0, i));
  }, [canvases, stack]);

  const onTrailChangeRef = useRefCanvas(onTrailChange);
  onTrailChangeRef.current = onTrailChange;
  useEffectCanvas(() => {
    if (onTrailChangeRef.current) onTrailChangeRef.current(stack);
  }, [stack]);
  const [transition, setTransition] = useStateCanvas(null);

  const currentId = stack[stack.length - 1];
  const current = canvases[currentId] || {
    title: { es: 'Sin titulo', en: 'Untitled' },
    items: [],
    connectors: [],
  };

  // ───── Sesión en vivo ─────
  //
  // Todo el trabajo del lienzo pasa por el estado de arriba, así que aquí está
  // el único sitio donde hay que mirar para saber qué mandar: se compara con
  // lo último que se envió y solo viaja la diferencia. Lo que llega del otro
  // lado entra por _setCanvases, sin pasar por el historial y marcado para que
  // no rebote de vuelta.
  const sesionRef = useRefCanvas(null);
  const ultimoEnviadoRef = useRefCanvas(null);
  const aplicandoRemotoRef = useRefCanvas(false);
  const [cursoresAjenos, setCursoresAjenos] = useStateCanvas({});   // uid -> {x, y, lienzo, color, nombre}
  const [participantes, setParticipantes] = useStateCanvas([]);
  // Qué puedo hacer YO en la sala en la que estoy: 'editor' o 'lector'. Lo
  // decide quien la abrió y puede cambiarlo en cualquier momento.
  const [miRol, setMiRol] = useStateCanvas('editor');
  const miRolRef = useRefCanvas('editor');
  miRolRef.current = miRol;
  const avisoLecturaRef = useRefCanvas(0);
  // Dónde estoy: null (en lo mío), 'anfitrion' (abrí yo la sala) o 'invitado'
  // (estoy en la de otro). Es estado y no una lectura de sesionRef porque de
  // esto cuelga cómo se pinta el botón de compartir, y un ref no repinta nada.
  const [enSala, setEnSala] = useStateCanvas(null);
  // Qué lienzos son de otra persona. Al terminar la sesión se devuelven: no se
  // guardan en el disco de quien fue de visita ni se quedan en su menú.
  const prestadosRef = useRefCanvas(null);   // { raiz, ids:Set }
  const avisoCorteRef = useRefCanvas(0);
  // 'reconectando' mientras se intenta volver, null el resto del tiempo. Se
  // pinta arriba: durante un corte hay que saber que lo que escribes no está
  // llegando a nadie, o lo escribes igual y se pierde.
  const [corte, setCorte] = useStateCanvas(null);

  const aplicaRemoto = useCallbackCanvas((ops) => {
    if (!ops || !ops.length) return;
    // Un tablero que crea el anfitrión mientras estás dentro también es suyo:
    // se apunta igual que los del volcado inicial, o al salir se quedaría aquí.
    if (prestadosRef.current) {
      ops.forEach(op => { if (op.o === 'lienzo+' && op.c) prestadosRef.current.ids.add(op.c); });
    }
    aplicandoRemotoRef.current = true;
    skipHistory.current = true;
    _setCanvases(prev => {
      const next = window.OdiSync.aplica(prev, ops);
      // Lo que llega del otro no debe volver a salir hacia él.
      ultimoEnviadoRef.current = next;
      return next;
    });
  }, []);

  // Envío: cada cambio local se compara con lo último enviado.
  useEffectCanvas(() => {
    const s = sesionRef.current;
    if (!s) { ultimoEnviadoRef.current = canvases; return; }
    if (aplicandoRemotoRef.current) { aplicandoRemotoRef.current = false; return; }
    if (ultimoEnviadoRef.current === canvases) return;

    // Solo lectura: el cambio se deshace en el sitio.
    //
    // El anfitrión ya tira lo que le llegue de un lector, así que dejarlo
    // editar en su pantalla sería peor que impedírselo: vería su trabajo
    // durante horas y no existiría para nadie más. Aquí vuelve atrás al
    // instante, con un aviso que explica por qué.
    if (miRolRef.current === 'lector') {
      const previo = ultimoEnviadoRef.current;
      if (previo && previo !== canvases) {
        aplicandoRemotoRef.current = true;
        skipHistory.current = true;
        _setCanvases(previo);
        const ahora = Date.now();
        if (ahora - avisoLecturaRef.current > 4000) {
          avisoLecturaRef.current = ahora;
          showToastCanvas(window.t(
            'Estás en esta sesión como lector: puedes verlo todo, pero no cambiarlo.',
            'You are in this session as a reader: you can see everything, but not change it.'
          ), 'error');
        }
      }
      return;
    }

    // Durante un arrastre se dejan pasar los cambios igual: son 50 bytes por
    // fotograma y es justo lo que hace que el otro vea el nodo moverse.
    const ops = window.OdiSync.diff(ultimoEnviadoRef.current || {}, canvases);
    if (!ops.length) { ultimoEnviadoRef.current = canvases; return; }

    // Lo que no llegó a nadie NO se da por enviado.
    //
    // Antes se marcaba como enviado pasara lo que pasara, así que un cambio
    // hecho en el medio segundo en que el canal aún no está abierto —o justo
    // mientras se recupera— desaparecía para siempre: quedaba en la pantalla
    // de quien lo hizo y en ninguna otra, y ya nada volvía a mencionarlo.
    // Dejando la marca donde está, el siguiente cambio lo arrastra consigo.
    const llegaron = s.envia({ t: 'ops', ops }, null);
    if (llegaron > 0 || s.cuantos() === 0) ultimoEnviadoRef.current = canvases;
  }, [canvases]);

  // El puntero se manda aparte y con cuentagotas: 20 veces por segundo basta
  // para que se vea fluido, y así no se mezcla con los cambios de verdad.
  const ultimoCursorRef = useRefCanvas(0);
  const mandaCursor = useCallbackCanvas((clientX, clientY) => {
    const s = sesionRef.current;
    if (!s) return;
    const ahora = Date.now();
    if (ahora - ultimoCursorRef.current < 50) return;
    ultimoCursorRef.current = ahora;
    const p = screenToCanvas(clientX, clientY);
    s.envia({ t: 'cursor', x: Math.round(p.x), y: Math.round(p.y), lienzo: currentId }, null);
  }, [currentId]);

  const colorDe = (uid) => {
    const p = participantes.find(x => x.uid === uid);
    return (p && p.color) || '#595459';
  };
  const nombreDe = (uid) => {
    const p = participantes.find(x => x.uid === uid);
    return (p && p.nombre) || 'Invitado';
  };

  const recibeMensaje = useCallbackCanvas((m, de) => {
    if (m.t === 'ops') { aplicaRemoto(m.ops); return; }

    if (m.t === 'cursor') {
      setCursoresAjenos(prev => ({ ...prev, [de]: { x: m.x, y: m.y, lienzo: m.lienzo, visto: Date.now() } }));
      return;
    }

    if (m.t === 'quienes') {
      const lista = m.lista || [];
      setParticipantes(lista);
      // Y de paso, mi propio papel: si el anfitrión acaba de pasarme a lector
      // (o al revés), hay que enterarse en el momento, no al intentar mover
      // algo y ver que no se mueve.
      const s = sesionRef.current;
      const yo = s ? lista.find(p => p.uid === s.miUid) : null;
      if (yo && yo.rol && yo.rol !== miRolRef.current) {
        miRolRef.current = yo.rol;
        setMiRol(yo.rol);
        showToastCanvas(yo.rol === 'lector'
          ? window.t('El anfitrión te ha puesto como lector: puedes ver el lienzo, pero no cambiarlo.', 'The host made you a reader: you can see the canvas, but not change it.')
          : window.t('El anfitrión te ha dado permiso para editar.', 'The host gave you permission to edit.'));
      }
      return;
    }

    if (m.t === 'expulsado') {
      // Se avisa ANTES de cortar, para que no parezca que se ha caído la red.
      showToastCanvas(window.t('El anfitrión te ha sacado de la sesión.', 'The host removed you from the session.'), 'error');
      desconectaSesion();
      return;
    }

    if (m.t === 'proyecto') {
      // Quien acaba de entrar recibe el proyecto entero una sola vez y a
      // partir de ahí solo los cambios.
      //
      // Se MEZCLA, no se sustituye: los lienzos que trae el anfitrión se
      // añaden a los que ya tenía esta persona. Sustituir el estado entero le
      // borraría del disco sus propios proyectos, porque este mismo estado es
      // el que se guarda.
      aplicandoRemotoRef.current = true;
      skipHistory.current = true;
      // Se apunta QUÉ es prestado, para poder devolverlo entero al terminar.
      // Sin esta lista no había forma de distinguir sus lienzos de los míos, y
      // por eso se quedaban aquí para siempre.
      const prestados = prestadosRef.current || { raiz: null, ids: new Set() };
      prestados.raiz = m.raiz || prestados.raiz;
      Object.keys(m.canvases || {}).forEach(id => prestados.ids.add(id));
      prestadosRef.current = prestados;
      _setCanvases(prev => {
        const mezcla = { ...prev, ...(m.canvases || {}) };
        ultimoEnviadoRef.current = mezcla;
        return mezcla;
      });
      // El proyecto del anfitrión entra en la lista de quien se une, marcado
      // como prestado. Sin esto, sus lienzos quedaban sueltos en la memoria:
      // al recargar no había forma de volver a ellos y la persona aterrizaba
      // en su propio proyecto vacío sin entender qué había pasado.
      if (m.raiz && setProjects) {
        setProjects(prev => {
          if (prev.some(p => p.id === m.raiz)) {
            return prev.map(p => p.id === m.raiz ? { ...p, invitado: true, deleted: false } : p);
          }
          const meta = m.proyecto || {};
          return [{
            id: m.raiz,
            name: meta.name || { es: 'Proyecto compartido', en: 'Shared project' },
            emoji: meta.emoji || '🤝',
            cover: meta.cover || 'linear-gradient(135deg, #F7DA84 0%, #F0C24B 100%)',
            starred: false,
            invitado: true,
          }, ...prev];
        });
      }
      // Y hay que ir a SU tablero: el invitado seguía mirando el suyo, que no
      // existe en el proyecto del anfitrión, así que veía un lienzo vacío.
      if (m.raiz) setStack([m.raiz]);
      showToastCanvas(window.t('Proyecto recibido del anfitrión.', 'Project received from the host.'));
      return;
    }

    if (m.t === 'adios') {
      setCursoresAjenos(prev => { const n = { ...prev }; delete n[de]; return n; });
    }
  }, [aplicaRemoto]);

  const showToastCanvas = (msg, tipo) => { window.showToast && window.showToast(msg, tipo); };

  // ── Devolver lo prestado ──
  //
  // Al terminar la sesión, el proyecto de la otra persona se va de aquí: de la
  // pantalla, del menú y del disco. Antes se quedaba todo —sus notas, sus
  // imágenes— y encima seguías DENTRO de él, mirando un lienzo que ya no se
  // actualizaba y que no había forma de volver a conectar. Parecía tuyo y no
  // lo era; al día siguiente seguía en el menú, muerto.
  //
  // Se te saca al menú, que es lo honesto: la visita se ha terminado.
  const sueltaPrestado = useCallbackCanvas((motivo) => {
    const prestado = prestadosRef.current;
    prestadosRef.current = null;
    if (!prestado || !prestado.ids.size) return;

    skipHistory.current = true;
    aplicandoRemotoRef.current = true;
    _setCanvases(prev => {
      const limpio = {};
      Object.keys(prev).forEach(id => { if (!prestado.ids.has(id)) limpio[id] = prev[id]; });
      ultimoEnviadoRef.current = limpio;
      return limpio;
    });
    if (prestado.raiz && setProjects) {
      setProjects(prev => prev.filter(p => p.id !== prestado.raiz));
    }
    showToastCanvas(motivo || window.t(
      'La sesión ha terminado. El proyecto era de la otra persona, así que no se queda aquí.',
      'The session ended. The project belonged to the other person, so it does not stay here.'
    ), 'error');
    // Al menú: el lienzo que estabas viendo acaba de dejar de existir.
    setTimeout(() => { onHome && onHome(); }, 60);
  }, [onHome, setProjects]);

  const desconectaSesion = useCallbackCanvas(async () => {
    const s = sesionRef.current;
    sesionRef.current = null;
    setParticipantes([]);
    setCursoresAjenos({});
    // Fuera de la sala se vuelve a mandar en lo propio: quedarse en "lector"
    // dejaría el lienzo de uno mismo bloqueado después de colgar.
    miRolRef.current = 'editor';
    setMiRol('editor');
    setEnSala(null);
    setCorte(null);
    // Salir por la puerta también devuelve lo prestado. Si no, bastaba con
    // pulsar "Salir de la sesión" para quedarse el proyecto de otro.
    sueltaPrestado();
    if (s) await s.cierra();
  }, [sueltaPrestado]);

  const conectaSesion = useCallbackCanvas(async ({ modo, codigo, nombre, rol, onProgreso }) => {
    if (sesionRef.current) await desconectaSesion();
    const callbacks = {
      onMensaje: recibeMensaje,
      onProgreso,
      onParticipantes: (lista) => setParticipantes(lista),
      onEstado: (estado, uid) => {
        if (estado === 'conectado') {
          // "Alguien se ha unido" solo lo dice quien tiene la sala. Al invitado
          // le salía al entrar ÉL, avisándole de su propia llegada.
          setCorte(null);
          if (modo === 'abrir') showToastCanvas(window.t('Alguien se ha unido a la sesión.', 'Someone joined the session.'));
          return;
        }

        if (estado === 'reconectando') {
          // Se avisa, pero sin gritar cada dos segundos: el aviso es para que
          // no parezca que el programa se ha colgado, no para castigar.
          setCorte('reconectando');
          const ahora = Date.now();
          if (ahora - avisoCorteRef.current > 8000) {
            avisoCorteRef.current = ahora;
            showToastCanvas(window.t('Se cortó la conexión. Reintentando…', 'Connection dropped. Retrying…'), 'error');
          }
          return;
        }

        // Fin del camino: o el anfitrión terminó la sesión, o después de varios
        // intentos no se ha podido volver. En los dos casos la visita acabó.
        if (estado === 'terminada' || estado === 'perdida') {
          setCorte(null);
          const razon = estado === 'terminada'
            ? window.t('El anfitrión ha terminado la sesión.', 'The host ended the session.')
            : window.t('No se pudo recuperar la conexión con el anfitrión.', 'Could not get the connection back to the host.');
          // Primero se devuelve lo prestado (con el motivo de verdad) y luego
          // se cuelga: al revés, colgar ya lo devuelve y el aviso saldría con
          // el texto genérico, sin decir qué ha pasado.
          sueltaPrestado(razon);
          desconectaSesion();
          return;
        }

        if (estado === 'desconectado' || estado === 'failed') {
          setCursoresAjenos(prev => { const n = { ...prev }; delete n[uid]; return n; });
        }
      },
      onError: (err) => {
        console.error('[SALA]', err);
        showToastCanvas(window.t('Error en la sesión en vivo: ' + err.message, 'Live session error: ' + err.message), 'error');
      },
      // Lo que se le manda a quien entra: SOLO el proyecto compartido.
      //
      // Antes se enviaba el estado entero, que incluye todos los demás
      // proyectos de quien invita. Compartir un tablero no puede significar
      // entregar el resto de tus cosas a quien tenga el código.
      pideProyecto: () => {
        const todo = canvasesLiveRef.current;
        const solo = {};
        const visita = (id) => {
          const c = todo[id];
          if (!c || solo[id]) return;
          solo[id] = c;
          (c.items || []).forEach(it => { if (it.canvasId) visita(it.canvasId); });
        };
        visita(projectId);
        const proy = projects ? projects.find(p => p.id === projectId) : null;
        // Este volcado es el punto de partida: a partir de aquí solo viajan
        // las diferencias contra ÉL.
        //
        // Sin esta línea, la marca de "lo último enviado" seguía siendo la de
        // antes de que entrara nadie, y el primer cambio que hiciera el
        // anfitrión —mover una nota— salía acompañado del proyecto entero
        // otra vez, imágenes incluidas. Megas por cada empujón de ratón.
        ultimoEnviadoRef.current = todo;
        return {
          canvases: solo,
          raiz: projectId,
          proyecto: proy ? { id: proy.id, name: proy.name, emoji: proy.emoji, cover: proy.cover } : null,
        };
      },
    };
    const s = modo === 'abrir'
      ? await window.OdiRealtime.abreSala({ nombre, rol, callbacks })
      : await window.OdiRealtime.entraSala({ codigo, nombre, callbacks });
    sesionRef.current = s;
    // Quien abre la sala manda en ella; quien entra empieza pudiendo editar y
    // el anfitrión le dirá enseguida si en realidad viene de lector.
    miRolRef.current = 'editor';
    setMiRol('editor');
    setEnSala(s.esAnfitrion ? 'anfitrion' : 'invitado');
    setParticipantes(s.lista());
    ultimoEnviadoRef.current = canvasesLiveRef.current;
    return { codigo: s.codigo };
  }, [recibeMensaje, desconectaSesion, projectId]);

  // El modal de compartir vive en app.jsx; se le deja aquí el mando, como ya
  // se hace con otras piezas que necesitan hablar entre archivos.
  useEffectCanvas(() => {
    window.__odiVivo = {
      conecta: conectaSesion,
      desconecta: desconectaSesion,
      activa: () => !!sesionRef.current,
      codigo: () => sesionRef.current && sesionRef.current.codigo,
      participantes: () => participantes,
      diagnostico: () => sesionRef.current && sesionRef.current.diagnostico(),
      // Mando de la sala. Todo esto solo hace algo si esta es la máquina que
      // la abrió; en la del invitado devuelve `false` y no pasa nada más.
      soyAnfitrion: () => !!(sesionRef.current && sesionRef.current.esAnfitrion),
      miUid: () => sesionRef.current && sesionRef.current.miUid,
      miRol: () => miRolRef.current,
      ponRol: (uid, r) => sesionRef.current && sesionRef.current.ponRol(uid, r),
      expulsa: (uid) => sesionRef.current && sesionRef.current.expulsa(uid),
      // Papel que recibirá el PRÓXIMO que entre. Se decide antes de dar el
      // código, que es el momento en que uno sabe a quién se lo está dando.
      rolNuevos: () => (sesionRef.current ? sesionRef.current.rolNuevos() : 'editor'),
      ponRolNuevos: (r) => sesionRef.current && sesionRef.current.ponRolNuevos(r),
    };
    return () => { delete window.__odiVivo; };
  }, [conectaSesion, desconectaSesion, participantes]);

  // Al cerrar el lienzo, se cuelga.
  //
  // Antes no: volver al menú desmontaba esta pantalla pero dejaba la sesión
  // viva y sin dueño, escuchando en Firebase y contestando ofertas. Quien
  // intentara entrar con ese código se conectaba a un fantasma que jamás le
  // iba a mandar el proyecto — y lo veía como "me uní pero no pasa nada".
  //
  // Efecto aparte y con lista de dependencias vacía a propósito: el de arriba
  // se rehace cada vez que cambia la lista de participantes, y colgar ahí
  // cortaría la sesión cada vez que entra alguien.
  useEffectCanvas(() => () => {
    const s = sesionRef.current;
    sesionRef.current = null;
    if (s) { try { s.cierra(); } catch (e) {} }
  }, []);

  // Un cursor que lleva diez segundos sin moverse es alguien que se fue sin
  // avisar (cerró el portátil, se cayó el wifi): se retira solo.
  useEffectCanvas(() => {
    if (!Object.keys(cursoresAjenos).length) return;
    const t = setInterval(() => {
      const corte = Date.now() - 10000;
      setCursoresAjenos(prev => {
        let cambio = false;
        const n = {};
        Object.keys(prev).forEach(k => {
          if (prev[k].visto > corte) n[k] = prev[k]; else cambio = true;
        });
        return cambio ? n : prev;
      });
    }, 4000);
    return () => clearInterval(t);
  }, [cursoresAjenos]);


  useEffectCanvas(() => {
    if (canvases[currentId]) return;
    setCanvases(prev => ({
      ...prev,
      [currentId]: {
        title: { es: 'Sin titulo', en: 'Untitled' },
        items: [],
        connectors: [],
      },
    }));
  // eslint-disable-next-line
  }, [currentId]);

  const [pan, setPan] = useStateCanvas({ x: 40, y: 20 });
  // Barra espaciadora: mientras está pulsada, arrastrar mueve el lienzo, como
  // el botón central. En una ref además del estado porque quien la consulta son
  // controladores de ratón, que no se vuelven a crear en cada render.
  const espacioRef = useRefCanvas(false);
  const [espacio, setEspacio] = useStateCanvas(false);
  const [paneando, setPaneando] = useStateCanvas(false);
  // En un móvil, al 100% una nota corriente (300px) ocupa el 80% del ancho y
  // no ves nada alrededor. Los lienzos sin cámara guardada se abren más
  // alejados para tener contexto; acercar es un pellizco.
  const [scale, setScale] = useStateCanvas(defaultScale);
  const [showBgSelector, setShowBgSelector] = useStateCanvas(false);
  // Nodo sobre el que caería la flecha que se está arrastrando ahora mismo.
  const [linkTargetId, setLinkTargetId] = useStateCanvas(null);
  // Al crear una nota, el escritorio abre el editor de una. En el móvil eso
  // encadenaba tres cosas de golpe (nodo nuevo + salto de zoom + teclado) y
  // era justo lo que se sentía descontrolado: ahora se crea seleccionada y se
  // entra a escribir con un toque doble, cuando tú quieras.
  const skipAutoEdit = () => !!(window.odiIsMobile && window.odiIsMobile());
  const [windowSize, setWindowSize] = useStateCanvas({ w: window.innerWidth, h: window.innerHeight });
  useEffectCanvas(() => {
    const handleResize = () => {
      setWindowSize({ w: window.innerWidth, h: window.innerHeight });
    };
    window.addEventListener('resize', handleResize);
    return () => window.removeEventListener('resize', handleResize);
  }, []);

  // Refs to always have the latest pan, scale, and currentId in cleanup effects
  const panRef = useRefCanvas(pan);
  panRef.current = pan;
  const scaleRef = useRefCanvas(scale);
  scaleRef.current = scale;
  const currentIdRef = useRefCanvas(currentId);
  currentIdRef.current = currentId;

  // Load saved camera when currentId changes
  useEffectCanvas(() => {
    setCroppingId(null);
    const saved = canvases[currentId];
    if (saved && saved.pan && saved.scale !== undefined) {
      // Si el lienzo ya tiene cámara guardada se respeta tal cual, también en
      // móvil: es el zoom que el usuario dejó puesto.
      setPan(saved.pan);
      setScale(saved.scale);
    } else {
      setPan({ x: 40, y: 20 });
      setScale(defaultScale());
    }
  // eslint-disable-next-line
  }, [currentId]);

  // Clear selection and editing states on canvas level change to prevent crashes/inconsistencies
  useEffectCanvas(() => {
    setSelected(null);
    setSelectedIds([]);
    setSelectedConn(null);
    setEditing(null);
    setEditingChildState(null);
    setContextMenu(null);
    setShowBgSelector(false);
  }, [currentId]);

  // Save the camera when navigating away from currentId or when unmounting Canvas
  useEffectCanvas(() => {
    const cid = currentId;
    return () => {
      const p = panRef.current;
      const s = scaleRef.current;
      if (cid) {
        // Save to local copy
        _setCanvases(prev => {
          const existing = prev[cid];
          if (!existing) return prev;
          return {
            ...prev,
            [cid]: {
              ...existing,
              pan: p,
              scale: s
            }
          };
        });
        // Save to parent copy directly so it's not lost on unmount!
        if (setExtCanvasesRef.current) {
          setExtCanvasesRef.current(prev => {
            const existing = prev[cid];
            if (!existing) return prev;
            return {
              ...prev,
              [cid]: {
                ...existing,
                pan: p,
                scale: s
              }
            };
          });
        }
      }
    };
  }, [currentId]);

  const [selected, setSelected] = useStateCanvas(null);
  const [selectedIds, setSelectedIds] = useStateCanvas([]); // multi-select (rect drag)
  // El desplegable de alinear de la barrita de la selección.
  const [alineaAbierto, setAlineaAbierto] = useStateCanvas(false);
  useEffectCanvas(() => { if (selectedIds.length < 2) setAlineaAbierto(false); }, [selectedIds.length]);
  const [marquee, setMarquee] = useStateCanvas(null); // {x, y, w, h} in canvas coords while drag-selecting
  const [selectedConn, setSelectedConn] = useStateCanvas(null);
  const [connLabelOpen, setConnLabelOpen] = useStateCanvas(false); // connector label input open
  const [editing, setEditing] = useStateCanvas(null);
  // Qué nodo está abierto para escribir, consultable desde un controlador de
  // teclado (que no se vuelve a crear en cada render).
  const editingRef = useRefCanvas(null);
  editingRef.current = editing;
  const [captionFocusId, setCaptionFocusId] = useStateCanvas(null); // node whose rich caption ("leyenda") is focused
  const [search, setSearch] = useStateCanvas('');

  // ── El buscador del lienzo, plegado en el móvil ──
  //
  // En un teléfono la barra ocupaba todo el ancho de arriba a todas horas, para
  // algo que se usa de vez en cuando. Plegada es solo una lupa; se toca y se
  // abre, se toca fuera (o la lupa otra vez) y se cierra, como en Windows.
  //
  // En el escritorio no cambia nada: allí sobra sitio y tenerla siempre a la
  // vista es lo que hace que se use.
  const [buscadorAbierto, setBuscadorAbierto] = useStateCanvas(false);
  const buscadorRef = useRefCanvas(null);
  const buscadorInputRef = useRefCanvas(null);

  useEffectCanvas(() => {
    if (!buscadorAbierto) return;
    if (buscadorInputRef.current) buscadorInputRef.current.focus();
    // Tocar fuera lo cierra. En captura, para enterarse aunque quien reciba el
    // toque lo detenga (el lienzo para de propagar casi todo).
    const fuera = (e) => {
      if (buscadorRef.current && !buscadorRef.current.contains(e.target)) setBuscadorAbierto(false);
    };
    document.addEventListener('pointerdown', fuera, true);
    return () => document.removeEventListener('pointerdown', fuera, true);
  }, [buscadorAbierto]);
  const [activeTool, setActiveTool] = useStateCanvas(null);
  const [contextMenu, setContextMenu] = useStateCanvas(null);
  const [docOpen, setDocOpen] = useStateCanvas(null); // { id, colId? }
  const [fileOpen, setFileOpen] = useStateCanvas(null); // { id } — file viewer modal

  // connector being drawn (preview)
  const [pendingConn, setPendingConn] = useStateCanvas(null); // {fromId, fromAnchor, mx, my} or {fromX,fromY,...}
  // drop target column for current item drag
  const [dropTargetCol, setDropTargetCol] = useStateCanvas(null);
  const [dropTargetTodo, setDropTargetTodo] = useStateCanvas(null);

  // drag-to-create preview
  const [dragCreate, setDragCreate] = useStateCanvas(null); // { x,y,w,h, sx,sy }
  // tool ghost (drag-from-toolbar)
  const [toolGhost, setToolGhost] = useStateCanvas(null);

  // undo stack
  const [history, setHistory] = useStateCanvas([]);
  const [historyIdx, setHistoryIdx] = useStateCanvas(-1);
  const [croppingId, setCroppingId] = useStateCanvas(null);

  // ── Modo dibujo ──
  //
  // Funciona como el recorte de una imagen: un nodo entra en un modo propio,
  // la barra de la izquierda cambia a sus herramientas y el resto del lienzo
  // se aparta. La diferencia es que aquí se trabaja sobre una COPIA (la
  // sesión): así "Descartar" es gratis y todo lo dibujado entra en el
  // historial general como un solo paso al guardar.
  const [drawingId, setDrawingId] = useStateCanvas(null);
  const [fogonazo, setFogonazo] = useStateCanvas(false);
  const [drawSession, setDrawSession] = useStateCanvas(null); // { strokes, past, future }
  const [drawTool, setDrawTool] = useStateCanvas('pen');      // pen | move | eraser
  const [drawColor, setDrawColor] = useStateCanvas('#E6544F');
  const [drawWidth, setDrawWidth] = useStateCanvas(4);
  const [drawPressureMode, setDrawPressureMode] = useStateCanvas('auto');
  // El estabilizador (0 apagado … 3 fuerte): la «cuerda» de draw.js. Es una
  // costumbre de quien dibuja (con ratón o con lápiz), así que se recuerda en
  // el equipo y no va en el proyecto.
  const [drawEstabilizador, setDrawEstabilizadorEstado] = useStateCanvas(() => {
    try { const n = parseInt(localStorage.getItem('odinote.dibujo_estabilizador') || '0', 10); return n >= 0 && n <= 3 ? n : 0; } catch (e) { return 0; }
  });
  const setDrawEstabilizador = (n) => {
    setDrawEstabilizadorEstado(n);
    try { localStorage.setItem('odinote.dibujo_estabilizador', String(n)); } catch (e) {}
  };
  const [selectedStrokeId, setSelectedStrokeId] = useStateCanvas(null);

  // alignment guides and dragged task ghost
  const [guides, setGuides] = useStateCanvas(null);
  const [draggedTask, setDraggedTask] = useStateCanvas(null);
  const skipHistory = useRefCanvas(false);

  const surfaceRef = useRefCanvas(null);
  // Lo que mide lo mismo en pantalla a cualquier zoom (los tiradores, los
  // puntos de conexión, el candado, el recorte, el ▶ del código) lee
  // --handle-scale (crece hasta el triple al alejarse) y --handle-libre (la
  // misma cuenta sin tope, para el tirador de girar y los del recorte; el zoom
  // no baja del 20 %, así que nunca pasa de 5). Van EN LÍNEA en los
  // contenedores de esos tiradores y no en la superficie: ahí las heredaban
  // todos los elementos de todos los nodos, y cada paso del zoom recalculaba
  // el estilo de la página entera (con 500 nodos, 38.000 elementos). Los
  // nodos que llevan tiradores DENTRO de su contenido (columna, imagen,
  // código) las llevan en su caja: ver TIPOS_CON_TIRADORES.
  const varsTiradoresRef = useRefCanvas({});
  const ctxMenuRef = useRefCanvas(null); // right-click menu element (for edge-flip positioning)
  const pasteIntRef = useRefCanvas(null);
  // Last known mouse position (screen coords) — used to paste copied nodes where the cursor is
  const lastMouseRef = useRefCanvas({ x: 0, y: 0 });


  // Rich captions ("leyendas") report focus/blur via these globals so the caption
  // format sidebar can appear next to them without threading props into every node.
  useEffectCanvas(() => {
    window.__odiCaptionFocus = (id) => setCaptionFocusId(id);
    window.__odiCaptionBlur  = () => setCaptionFocusId(null);
    return () => { delete window.__odiCaptionFocus; delete window.__odiCaptionBlur; };
  }, []);

  // ───── History ─────
  // Última instantánea registrada: evita guardar estados idénticos (los
  // micro-ajustes automáticos, como recalcular la altura de una nota, producían
  // entradas invisibles y hacían que Ctrl+Z "no hiciera nada" visible).
  const lastSnapRef = useRefCanvas(null);
  const historyTimerRef = useRefCanvas(null);

  // Espejos en refs para poder leer/actualizar el historial de forma síncrona
  const historyRef = useRefCanvas([]);
  const historyIdxRef = useRefCanvas(-1);
  historyRef.current = history;
  historyIdxRef.current = historyIdx;
  // Estado vivo del lienzo (evita leer datos obsoletos desde un closure viejo)
  const canvasesLiveRef = useRefCanvas(canvases);
  canvasesLiveRef.current = canvases;

  // ── El deshacer ──
  //
  // Quien probó la 1.2.0 lo dijo claro: Ctrl+Z deshacía «lo más mínimo», y a
  // veces parecía no hacer nada. Dos causas:
  //
  //  · Pasos invisibles. La instantánea llevaba los campos del momento
  //    (_new, _dragging…) y los ajustes automáticos (la altura que se pone sola
  //    una nota al escribir) entraban como pasos propios: el primer Ctrl+Z
  //    deshacía eso, que no se ve, y parecía que no funcionaba. Ahora la
  //    instantánea va sin los campos del momento (sinMomento), y lo que toca
  //    los mismos nodos va al mismo paso (abajo).
  //  · Pasos diminutos. Cada pausa de medio segundo era un paso: una frase eran
  //    seis Ctrl+Z. Ahora los cambios a LOS MISMOS NODOS sin una pausa de más de
  //    1,5 s se juntan en un paso: lo que escribes de corrido se deshace de una
  //    vez. Tocar otro nodo siempre empieza un paso nuevo.
  const sinMomento = (k, v) => (k && k.charCodeAt(0) === 95 /* _ */ ? undefined : v);
  const instantanea = (obj) => JSON.stringify(obj, sinMomento);
  // Cada paso guarda solo los lienzos de ESTE proyecto (su raíz y lo que se
  // alcanza desde ella, también los tableros dentro de una columna). Antes
  // guardaba todos los de todos los proyectos: con una bóveda normal, 1,3 MB y
  // 17 ms por paso, y un Ctrl+Z podía devolver a su estado de antes otro
  // proyecto que hubiera cambiado entre tanto (Drive, una sala en vivo).
  const lienzosDelProyecto = (todos) => {
    const out = {};
    const visita = (id) => {
      const c = todos && todos[id];
      if (!c || out[id]) return;
      out[id] = c;
      (c.items || []).forEach(it => {
        if (it.canvasId) visita(it.canvasId);
        (it.children || []).forEach(h => { if (h && h.canvasId) visita(h.canvasId); });
      });
    };
    visita(projectId);
    return out;
  };
  // Qué nodos (o flechas, o lienzos) cambiaron entre dos estados. Los nodos que
  // no se tocan conservan su objeto, así que basta con comparar referencias.
  // Las altas y bajas (nodos que aparecen o desaparecen) se marcan aparte: un
  // paso que crea o borra nunca se junta con otro (crear una nota y escribir
  // en ella son dos Ctrl+Z, no uno que se la lleve entera).
  const queCambio = (antes, ahora) => {
    const ids = new Set();
    ids.altas = false;
    if (!antes || !ahora) return ids;
    for (const cid of new Set([...Object.keys(antes), ...Object.keys(ahora)])) {
      const a = antes[cid], b = ahora[cid];
      if (a === b) continue;
      if (!a || !b) { ids.add('lienzo:' + cid); ids.altas = true; continue; }
      if (a.connectors !== b.connectors) ids.add('flechas:' + cid);
      const previos = new Map((a.items || []).map(i => [i.id, i]));
      for (const it of (b.items || [])) {
        if (!previos.has(it.id)) ids.altas = true;
        if (previos.get(it.id) !== it) ids.add(it.id);
        previos.delete(it.id);
      }
      for (const id of previos.keys()) { ids.add(id); ids.altas = true; }
    }
    return ids;
  };
  const JUNTAR_MS = 1500;
  // El estado del último paso guardado (para saber qué cambió desde él) y de
  // qué trataba y cuándo cambió por última vez (para saber si juntar).
  const ultimoObjRef = useRefCanvas(null);
  const ultimoPasoRef = useRefCanvas(null);   // { ids, ultimoCambio } o null
  const primerCambioRef = useRefCanvas(0);
  const ultimoCambioRef = useRefCanvas(0);
  const pendienteRef = useRefCanvas(null);    // lo que espera su pausa: { snap, obj }
  // Los ajustes que la app hace sola justo después de deshacer, rehacer, abrir
  // el lienzo o CREAR nodos (una nota que mide su altura al aparecer) NO son
  // pasos: antes entraban como uno nuevo y además borraban el rehacer, así que
  // Ctrl+Y no devolvía lo deshecho y el siguiente Ctrl+Z solo deshacía esa
  // altura. Con una plantilla se veía clarísimo: el primer Ctrl+Z no hacía
  // nada a la vista. Si no ha habido ni tecla ni clic desde entonces, se meten
  // en el paso actual.
  const restauradoEnRef = useRefCanvas(Date.now());
  const ultimaEntradaRef = useRefCanvas(0);
  useEffectCanvas(() => {
    const apunta = () => { ultimaEntradaRef.current = Date.now(); };
    // beforeinput también: el dictado, el teclado de la pantalla o pegar desde
    // el menú escriben sin keydown, y lo escrito se colaba en el paso de crear
    // la nota (Ctrl+Z dejaba la nota con lo primero que se escribió).
    const EVENTOS = ['pointerdown', 'keydown', 'beforeinput'];
    EVENTOS.forEach(ev => document.addEventListener(ev, apunta, true));
    return () => EVENTOS.forEach(ev => document.removeEventListener(ev, apunta, true));
  }, []);

  const pushHistory = useCallbackCanvas((snap, obj) => {
    if (lastSnapRef.current === snap) return; // sin cambios reales
    // Ni un paso igual al que ya está en el historial: dos Ctrl+Z seguidos que
    // no hacían nada (lo vio el bot tras crear un to-do y tras el candado).
    if (historyRef.current[historyIdxRef.current] === snap) { lastSnapRef.current = snap; if (obj) ultimoObjRef.current = obj; return; }
    const cambio = obj ? queCambio(ultimoObjRef.current, obj) : new Set();
    const ids = [...cambio].sort().join('|');
    const soloModifica = !cambio.altas;
    const paso = ultimoPasoRef.current;
    const juntar = !!(paso && ids && paso.ids === ids && paso.soloModifica && soloModifica &&
      primerCambioRef.current - paso.ultimoCambio < JUNTAR_MS &&
      historyIdxRef.current >= 1 && historyIdxRef.current === historyRef.current.length - 1);
    lastSnapRef.current = snap;
    if (obj) ultimoObjRef.current = obj;
    let cut;
    if (juntar) {
      // El mismo paso, con lo último: Ctrl+Z vuelve a como estaba antes de
      // empezar, no a medio escribir.
      cut = historyRef.current.slice();
      cut[cut.length - 1] = snap;
    } else {
      cut = historyRef.current.slice(0, historyIdxRef.current + 1);
      cut.push(snap);
      if (cut.length > 50) cut.shift();
    }
    // El primer estado (el de partida) nunca se junta con nada.
    ultimoPasoRef.current = cut.length > 1 ? { ids, soloModifica, ultimoCambio: ultimoCambioRef.current || Date.now() } : null;
    primerCambioRef.current = 0;
    historyRef.current = cut;
    historyIdxRef.current = cut.length - 1;
    setHistory(cut);
    setHistoryIdx(cut.length - 1);
  }, []);

  // Los cambios se apuntan tras una pausa de medio segundo (y luego, si tocan
  // lo mismo que el paso anterior sin pausa larga, se juntan con él).
  useEffectCanvas(() => {
    if (skipHistory.current) { skipHistory.current = false; return; }
    // Mientras se arrastra, se estira o se gira, cada fotograma cambia el
    // estado, y la instantánea es un JSON de TODOS los lienzos (el bot midió
    // 1,3 MB y 17 ms cada una): el arrastre de varios nodos iba a tirones solo
    // por esto. Al soltar, el arrastre hace su cambio final y ese sí se apunta.
    if (document.body.classList.contains('odi-busy')) return;
    const obj = lienzosDelProyecto(canvases);
    const snap = instantanea(obj);
    // La primera instantánea se guarda de inmediato (estado base del lienzo)
    if (lastSnapRef.current === null) { pushHistory(snap, obj); return; }
    if (snap === lastSnapRef.current) {
      // Ha vuelto a como estaba en el último paso (una altura que crece al
      // elegir un nodo y vuelve al soltarlo): lo que esperaba su pausa ya no
      // existe, y si se apuntaba dejaba un paso fantasma.
      if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; pendienteRef.current = null; }
      return;
    }
    const ahora = Date.now();
    // Un ajuste solo de la app tras deshacer/rehacer/abrir: al paso actual.
    if (ahora - restauradoEnRef.current < 1500 && ultimaEntradaRef.current < restauradoEnRef.current &&
        historyIdxRef.current >= 0 && !historyTimerRef.current) {
      const cut = historyRef.current.slice();
      cut[historyIdxRef.current] = snap;
      historyRef.current = cut;
      lastSnapRef.current = snap;
      ultimoObjRef.current = obj;
      setHistory(cut);
      return;
    }
    // Crear o borrar nodos se apunta YA, como paso propio: con la pausa, una
    // nota recién puesta y lo primero que se escribe en ella acababan en el
    // mismo paso, y Ctrl+Z se llevaba la nota entera.
    if (queCambio(ultimoObjRef.current, obj).altas) {
      if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; }
      // Lo que estaba esperando su pausa va antes, en su propio paso.
      const pend = pendienteRef.current;
      pendienteRef.current = null;
      if (pend && pend.snap !== lastSnapRef.current) pushHistory(pend.snap, pend.obj);
      if (!primerCambioRef.current) primerCambioRef.current = ahora;
      ultimoCambioRef.current = ahora;
      pushHistory(snap, obj);
      // Lo que se mida solo en el próximo segundo y medio, al mismo paso.
      restauradoEnRef.current = ahora;
      return;
    }
    if (!primerCambioRef.current) primerCambioRef.current = ahora;
    ultimoCambioRef.current = ahora;
    if (historyTimerRef.current) clearTimeout(historyTimerRef.current);
    pendienteRef.current = { snap, obj };
    historyTimerRef.current = setTimeout(() => {
      historyTimerRef.current = null;
      pendienteRef.current = null;
      pushHistory(snap, obj);
    }, 450);
  // eslint-disable-next-line
  }, [canvases]);

  const applySnapshot = (idx) => {
    const snapStr = historyRef.current[idx];
    if (snapStr == null) return;
    skipHistory.current = true;
    lastSnapRef.current = snapStr;
    historyIdxRef.current = idx;
    setHistoryIdx(idx);
    const obj = JSON.parse(snapStr);
    // Tras deshacer o rehacer no se junta nada con ese paso: lo siguiente que
    // se haga es un paso nuevo.
    ultimoObjRef.current = obj;
    ultimoPasoRef.current = null;
    primerCambioRef.current = 0;
    restauradoEnRef.current = Date.now();
    // Dónde cambia algo. Si no es en el lienzo que se ve, se va allí: el bot
    // vio que, dentro de un tablero, Ctrl+Z deshacía cosas del lienzo de fuera
    // sin que se viera nada, y parecía que no hacía nada (o que borraba algo
    // al azar).
    const antes = lienzosDelProyecto(canvasesLiveRef.current);
    const cambiados = Object.keys(Object.assign({}, antes, obj)).filter(id =>
      !antes[id] || !obj[id] || instantanea(antes[id]) !== instantanea(obj[id]));
    _setCanvases(prev => {
      const next = { ...prev };
      const delProyecto = lienzosDelProyecto(prev);
      Object.keys(delProyecto).forEach(id => { if (!obj[id]) delete next[id]; });
      return Object.assign(next, obj);
    });
    if (cambiados.length && !cambiados.includes(currentId)) {
      const destino = cambiados.find(id => obj[id]);
      const camino = destino ? caminoHasta(obj, destino) : null;
      if (camino) setStack(camino);
    }
  };
  // El camino de tableros desde la raíz del proyecto hasta un lienzo.
  const caminoHasta = (lienzos, destino) => {
    const busca = (id, camino) => {
      if (id === destino) return camino;
      const c = lienzos[id];
      if (!c) return null;
      for (const it of (c.items || [])) {
        const hijos = [it, ...(it.children || [])];
        for (const h of hijos) {
          if (h && h.canvasId && !camino.includes(h.canvasId)) {
            const r = busca(h.canvasId, [...camino, h.canvasId]);
            if (r) return r;
          }
        }
      }
      return null;
    };
    return busca(projectId, [projectId]);
  };

  const undo = () => {
    if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; }
    pendienteRef.current = null;
    // Volcar los cambios aún no registrados (p. ej. lo que se acaba de escribir)
    // para que este Ctrl+Z los pueda deshacer en lugar de perderse. También los
    // que llegaron en silencio (una altura automática): sin esto, el Ctrl+Z
    // gastaba su pulsación en deshacerlos, que no se ven.
    const vivo = lienzosDelProyecto(canvasesLiveRef.current);
    const live = instantanea(vivo);
    if (live !== lastSnapRef.current) {
      if (!primerCambioRef.current) primerCambioRef.current = Date.now();
      ultimoCambioRef.current = Date.now();
      pushHistory(live, vivo);
    }
    if (historyIdxRef.current <= 0) return;
    applySnapshot(historyIdxRef.current - 1);
  };
  const redo = () => {
    if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; }
    pendienteRef.current = null;
    if (historyIdxRef.current >= historyRef.current.length - 1) return;
    applySnapshot(historyIdxRef.current + 1);
  };

  // ───── En el teléfono: dos dedos deshacen, tres rehacen ─────
  // Un toque corto con dos dedos que no se mueven es «deshacer»; con tres,
  // «rehacer», como en Procreate y las apps de dibujo del iPad. Si los dedos
  // se mueven es un pellizco o un arrastre y no cuenta. Deshacer sigue arriba
  // para quien no conozca el gesto.
  const gestosRef = useRefCanvas({});
  gestosRef.current = { undo, redo };
  useEffectCanvas(() => {
    const el = surfaceRef.current;
    if (!el) return;
    let toque = null;
    const empieza = (e) => {
      if (e.touches.length < 2) { toque = null; return; }
      const pts = [...e.touches].map(t => ({ id: t.identifier, x: t.clientX, y: t.clientY }));
      if (!toque) toque = { t0: Date.now(), dedos: e.touches.length, pts, movido: false };
      else { toque.dedos = Math.max(toque.dedos, e.touches.length); toque.pts = toque.pts.concat(pts.filter(p => !toque.pts.some(q => q.id === p.id))); }
    };
    const mueve = (e) => {
      if (!toque) return;
      for (const t of e.touches) {
        const p = toque.pts.find(q => q.id === t.identifier);
        if (p && Math.hypot(p.x - t.clientX, p.y - t.clientY) > 14) toque.movido = true;
      }
    };
    const acaba = (e) => {
      if (!toque || e.touches.length > 0) return;
      const corto = Date.now() - toque.t0 < 320;
      if (corto && !toque.movido) {
        if (toque.dedos === 2) gestosRef.current.undo();
        else if (toque.dedos >= 3) gestosRef.current.redo();
        if (navigator.vibrate) { try { navigator.vibrate(8); } catch (err) {} }
      }
      toque = null;
    };
    el.addEventListener('touchstart', empieza, { passive: true });
    el.addEventListener('touchmove', mueve, { passive: true });
    el.addEventListener('touchend', acaba, { passive: true });
    el.addEventListener('touchcancel', acaba, { passive: true });
    return () => {
      el.removeEventListener('touchstart', empieza);
      el.removeEventListener('touchmove', mueve);
      el.removeEventListener('touchend', acaba);
      el.removeEventListener('touchcancel', acaba);
    };
  }, []);

  // ───── Reset state on canvas change ─────
  useEffectCanvas(() => {
    setSelected(null);
    setSelectedIds([]);
    setSelectedConn(null);
    setConnLabelOpen(false);
    setEditing(null);
    setContextMenu(null);
    setPendingConn(null);
    setDropTargetCol(null);
  }, [currentId]);

  // ───── Auto-clear _new flags so dragging doesn't replay animation ─────
  useEffectCanvas(() => {
    const hasNew = current?.items?.some(i => i._new);
    if (!hasNew) return;
    const t = setTimeout(() => {
      skipHistory.current = true;
      _setCanvases(prev => {
        const c = prev[currentId];
        if (!c) return prev;
        return { ...prev, [currentId]: { ...c, items: c.items.map(it => it._new ? { ...it, _new: false } : it) } };
      });
    }, 300);
    return () => clearTimeout(t);
  }, [current?.items, currentId]);

  // Los nodos van memorizados y no se pintan otra vez con cada paso del zoom:
  // el que necesite saberlo (la galería, para pedir sus miniaturas al tamaño
  // de la pantalla) escucha este aviso.
  useEffectCanvas(() => {
    window.odiZoomActual = scale;
    window.dispatchEvent(new CustomEvent('odi-zoom', { detail: scale }));
  }, [scale]);

  // ───── Wheel pan/zoom ─────
  useEffectCanvas(() => {
    const el = surfaceRef.current;
    if (!el) return;
    let zoomCrudo = null;
    const onWheel = (e) => {
      const target = e.target;
      const isScrollable = (element) => {
        if (!element || element === el) return false;
        if (element.tagName === 'IFRAME') return true;
        const style = window.getComputedStyle(element);
        const overflowY = style.overflowY || '';
        const overflowX = style.overflowX || '';
        const isScrollableY = (overflowY === 'auto' || overflowY === 'scroll') && element.scrollHeight > element.clientHeight;
        const isScrollableX = (overflowX === 'auto' || overflowX === 'scroll') && element.scrollWidth > element.clientWidth;
        if (isScrollableY || isScrollableX) return true;
        return isScrollable(element.parentElement);
      };
      if (isScrollable(target)) {
        return; // Allow native scroll / pan / zoom behavior inside the iframe/scrollable area
      }

      if (e.ctrlKey || e.metaKey) {
        e.preventDefault();
        const rect = el.getBoundingClientRect();
        const mx = e.clientX - rect.left;
        const my = e.clientY - rect.top;
        const delta = -e.deltaY * 0.0015;
        setScale(s => {
          // El zoom se redondea a pasos de 0,05, pero lo que pide la rueda se
          // va sumando sin redondear: el pellizco de un panel táctil manda
          // pasos tan pequeños (deltaY de 4 o 10) que, redondeado cada uno por
          // su cuenta, a 100 % se quedaba siempre en 100 % (lo vio el bot).
          const base = (zoomCrudo != null && Math.abs(Math.round(zoomCrudo * 20) / 20 - s) < 1e-6) ? zoomCrudo : s;
          const raw = Math.min(2.5, Math.max(0.2, base * (1 + delta)));
          zoomCrudo = raw;
          const ns = Math.round(raw * 20) / 20;
          if (ns === s) return s;
          setPan(p => ({ x: mx - (mx - p.x) * (ns / s), y: my - (my - p.y) * (ns / s) }));
          return ns;
        });
      } else {
        e.preventDefault();
        setPan(p => ({ x: p.x - e.deltaX, y: p.y - e.deltaY }));
      }
    };
    el.addEventListener('wheel', onWheel, { passive: false });

    // Red de seguridad: si algo (foco por teclado, scrollIntoView, etc.) desplaza
    // el canvas o sus ancestros, lo devolvemos a 0 para que nada "suba" la interfaz
    // ni descuadre las coordenadas del zoom/paneo.
    const resetScroll = () => {
      let node = el;
      while (node) {
        if (node.scrollTop || node.scrollLeft) { node.scrollTop = 0; node.scrollLeft = 0; }
        node = node.parentElement;
      }
    };
    const scrollTargets = [];
    let n = el;
    while (n) { scrollTargets.push(n); n = n.parentElement; }
    scrollTargets.forEach(t => t.addEventListener('scroll', resetScroll, { passive: true }));

    return () => {
      el.removeEventListener('wheel', onWheel);
      scrollTargets.forEach(t => t.removeEventListener('scroll', resetScroll));
    };
  }, []);

  // Quién enlaza al nodo seleccionado. Se recalcula solo al cambiar la
  // selección o el contenido, no en cada render: recorre todos los proyectos.
  const backlinksForSelected = useMemoCanvas(() => {
    if (!selected || !window.OdiLinks) return [];
    try {
      return window.OdiLinks.backlinksFor({
        projects: projects || [], canvases, targetItemId: selected, lang,
      });
    } catch (e) { return []; }
  }, [selected, canvases, projects, lang]);

  // ───── Salto desde el buscador global ─────
  // Baja por la cadena de tableros hasta el lienzo del resultado, centra el
  // nodo y lo resalta un momento. Sin esto, encontrar algo enterrado a cuatro
  // niveles no serviría de nada: sabrías dónde está pero no cómo llegar.
  const [jumpHighlight, setJumpHighlight] = useStateCanvas(null);
  useEffectCanvas(() => {
    if (!jumpTarget || jumpTarget.projectId !== projectId) return;
    const trail = (jumpTarget.trailIds && jumpTarget.trailIds.length)
      ? jumpTarget.trailIds
      : [projectId];
    // Si el salto salió de un enlace dentro de un documento, hay que cerrarlo:
    // si no, la vista viaja por detrás y el documento sigue tapándola, así que
    // parece que no ha pasado nada.
    setDocOpen(null);
    setFileOpen(null);
    setStack(trail);

    // Un respiro para que el lienzo destino ya esté montado y medido antes de
    // calcular dónde está el nodo.
    const t = setTimeout(() => {
      const canvas = canvases[jumpTarget.canvasId];
      const item = canvas && (canvas.items || []).find(i => i.id === jumpTarget.itemId);
      const el = surfaceRef.current;
      if (item && el) {
        const rect = el.getBoundingClientRect();
        const def = defaultDims(item.type);
        const w = item.w !== undefined ? item.w : def.w;
        const h = item.h !== undefined ? item.h : def.h;
        const s = scaleRef.current || 1;
        setPan({
          x: rect.width / 2 - (item.x + w / 2) * s,
          y: rect.height / 2 - (item.y + h / 2) * s,
        });
        setSelected(item.id);
      }
      setJumpHighlight(jumpTarget.itemId);

      // Tras mover la cámara de golpe, el navegador reutiliza la imagen que ya
      // tenía rasterizada de la capa transformada y se ve borrosa hasta que
      // algo la obliga a repintar (por eso se arreglaba al mover el lienzo a
      // mano). Este empujón de una milésima la fuerza a redibujarse nítida.
      if (el) {
        el.style.willChange = 'transform';
        requestAnimationFrame(() => {
          requestAnimationFrame(() => { if (el) el.style.willChange = ''; });
        });
      }
    }, 60);

    const clear = setTimeout(() => setJumpHighlight(null), 3400);
    return () => { clearTimeout(t); clearTimeout(clear); };
  }, [jumpTarget && jumpTarget.nonce]);

  // ───── Gestos táctiles del lienzo ─────
  // touch.js traduce un dedo a eventos de ratón para todo lo demás (arrastrar
  // nodos, tiradores, conectores…), pero se aparta del lienzo vacío: aquí un
  // dedo significa PANEAR. Dibujar una marquesina con el dedo sería inútil y
  // dejaría el lienzo inmóvil, que es justo lo que hace difícil usarlo en el
  // móvil hoy. Dos dedos = zoom de pellizco, como en cualquier mapa.
  useEffectCanvas(() => {
    const el = surfaceRef.current;
    if (!el) return;

    let mode = null;      // 'pan' | 'pinch' | null
    let start = null;
    let moved = false;
    let longPress = null;

    const clearLong = () => { if (longPress) { clearTimeout(longPress); longPress = null; } };
    const gap = (a, b) => Math.hypot(a.clientX - b.clientX, a.clientY - b.clientY);

    const isBareSurface = (target) => {
      if (!target) return false;
      if (el.classList.contains('placing') || el.classList.contains('linking')) return false;
      if (target === el) return true;
      const cl = target.classList;
      return !!cl && (cl.contains('canvas-surface') || cl.contains('canvas-content') ||
                      cl.contains('connectors') || cl.contains('board-cover-grid'));
    };

    const beginPinch = (e) => {
      clearLong();
      const rect = el.getBoundingClientRect();
      const a = e.touches[0], b = e.touches[1];
      mode = 'pinch';
      moved = true;
      start = {
        gap: gap(a, b) || 1,
        mx: (a.clientX + b.clientX) / 2 - rect.left,
        my: (a.clientY + b.clientY) / 2 - rect.top,
        scale: scaleRef.current,
        pan: { ...panRef.current },
      };
    };

    const onTouchStart = (e) => {
      if (e.touches.length >= 2) {
        beginPinch(e);
        if (e.cancelable) e.preventDefault();
        return;
      }
      const t = e.touches[0];
      if (!t) return;
      const target = document.elementFromPoint(t.clientX, t.clientY) || e.target;
      if (!isBareSurface(target)) { mode = null; return; }

      mode = 'pan';
      moved = false;
      start = { x: t.clientX, y: t.clientY, pan: { ...panRef.current }, target };
      if (e.cancelable) e.preventDefault();

      // Mantener pulsado sobre el lienzo vacío abre el menú de crear nodo, que
      // en escritorio es el clic derecho.
      const px = t.clientX, py = t.clientY;
      clearLong();
      longPress = setTimeout(() => {
        longPress = null;
        if (mode !== 'pan' || moved) return;
        mode = null;
        target.dispatchEvent(new MouseEvent('contextmenu', {
          bubbles: true, cancelable: true, view: window,
          clientX: px, clientY: py, button: 2,
        }));
        if (navigator.vibrate) { try { navigator.vibrate(12); } catch (err) {} }
      }, 520);
    };

    const onTouchMove = (e) => {
      if (mode === 'pinch') {
        if (e.touches.length < 2) return;
        if (e.cancelable) e.preventDefault();
        const a = e.touches[0], b = e.touches[1];
        const rect = el.getBoundingClientRect();
        const factor = (gap(a, b) || 1) / start.gap;
        const ns = Math.min(2.5, Math.max(0.2, start.scale * factor));
        // El punto del lienzo bajo el centro del pellizco debe quedarse quieto,
        // y además el centro puede desplazarse: eso panea mientras se hace zoom.
        const mx = (a.clientX + b.clientX) / 2 - rect.left;
        const my = (a.clientY + b.clientY) / 2 - rect.top;
        setScale(ns);
        setPan({
          x: mx - (start.mx - start.pan.x) * (ns / start.scale),
          y: my - (start.my - start.pan.y) * (ns / start.scale),
        });
        return;
      }
      if (mode !== 'pan') return;
      const t = e.touches[0];
      if (!t) return;
      const dx = t.clientX - start.x;
      const dy = t.clientY - start.y;
      if (!moved && (Math.abs(dx) > 4 || Math.abs(dy) > 4)) { moved = true; clearLong(); }
      if (!moved) return;
      if (e.cancelable) e.preventDefault();
      setPan({ x: start.pan.x + dx, y: start.pan.y + dy });
    };

    const onTouchEnd = (e) => {
      clearLong();
      if (mode === 'pinch') {
        // Al soltar un dedo, el zoom termina; el que queda no debe dar un salto.
        if (e.touches.length === 1) {
          mode = 'pan';
          moved = true;
          start = { x: e.touches[0].clientX, y: e.touches[0].clientY, pan: { ...panRef.current } };
        } else {
          mode = null;
          setScale(s => Math.round(s * 20) / 20);
        }
        return;
      }
      if (e.touches.length === 0) {
        // Toque limpio sobre el lienzo vacío: en escritorio esto es un clic que
        // deselecciona y cierra el editor abierto. Como aquí bloqueamos los
        // eventos de compatibilidad para poder panear, lo emitimos a mano.
        if (mode === 'pan' && !moved && start && start.target) {
          const t = e.changedTouches[0];
          const init = {
            bubbles: true, cancelable: true, view: window,
            clientX: t ? t.clientX : start.x,
            clientY: t ? t.clientY : start.y,
            button: 0,
          };
          start.target.dispatchEvent(new MouseEvent('mousedown', { ...init, buttons: 1, detail: 1 }));
          start.target.dispatchEvent(new MouseEvent('mouseup', { ...init, buttons: 0 }));
        }
        mode = null;
      }
    };

    el.addEventListener('touchstart', onTouchStart, { passive: false });
    el.addEventListener('touchmove', onTouchMove, { passive: false });
    el.addEventListener('touchend', onTouchEnd, { passive: false });
    el.addEventListener('touchcancel', onTouchEnd, { passive: false });
    return () => {
      clearLong();
      el.removeEventListener('touchstart', onTouchStart);
      el.removeEventListener('touchmove', onTouchMove);
      el.removeEventListener('touchend', onTouchEnd);
      el.removeEventListener('touchcancel', onTouchEnd);
    };
  }, []);

  // ───── Editar texto en móvil sin que el navegador secuestre el zoom ─────
  // Al enfocar un texto diminuto (el lienzo suele estar al 50%, o sea letra de
  // ~6px), el navegador móvil hace SU propio zoom para que se vea el cursor.
  // Ese zoom es del navegador, no del lienzo: los botones − / + no lo
  // deshacen y el usuario se queda encerrado. Adelantándonos y acercando
  // nosotros a 100%, el texto ya es legible y el navegador no interviene; y si
  // aun así lo hiciera, se puede salir con un pellizco (por eso el viewport ya
  // no lleva user-scalable=no).
  useEffectCanvas(() => {
    if (!editing) return;
    if (!(window.odiIsMobile && window.odiIsMobile())) return;
    const el = surfaceRef.current;
    const item = current.items.find(i => i.id === editing);
    if (!el || !item) return;
    const rect = el.getBoundingClientRect();
    const def = defaultDims(item.type);
    const w = item.w !== undefined ? item.w : def.w;
    const h = item.h !== undefined ? item.h : def.h;
    // El raíl de herramientas flota sobre el borde derecho, así que el ancho
    // realmente visible es menor: si no lo descontamos, el nodo que estás
    // escribiendo se mete por debajo del raíl.
    const RAIL = 76;
    const visibleW = rect.width - RAIL;
    // Acercamos lo justo para que el nodo quepa en ese ancho, nunca más del
    // 100%: saltar siempre al 100% dejaba una nota pequeña comiéndose toda la
    // pantalla. Y nunca alejamos, para no deshacer un zoom hecho a mano.
    const fit = Math.min(1, (visibleW - 16) / w);
    const ns = Math.max(scaleRef.current, fit);
    if (ns <= scaleRef.current + 0.01) return; // ya se lee bien: no tocar la vista
    setScale(ns);
    // Un tercio de alto en vez de la mitad: el teclado se come la parte de
    // abajo, así el nodo que se está editando queda por encima de él.
    setPan({
      x: visibleW / 2 - (item.x + w / 2) * ns,
      y: rect.height * 0.32 - (item.y + h / 2) * ns,
    });
  }, [editing]);

  // ───── Keyboard ─────
  useEffectCanvas(() => {
    const onKey = (e) => {
      const matchShortcut = (s) => {
        if (!s || !window.shortcuts) return false;
        const eKey = e.key.toLowerCase();
        const configKey = s.key.toLowerCase();
        const hasCtrl = e.ctrlKey || e.metaKey;
        return eKey === configKey &&
               hasCtrl === !!s.ctrl &&
               e.shiftKey === !!s.shift &&
               e.altKey === !!s.alt;
      };
      // Con el visor de archivos o el editor de documentos abiertos, el lienzo
      // no ejecuta NINGUNO de sus atajos: cada uno tiene los suyos y su propio
      // Esc. Antes solo se comprobaba el visor, y por eso pulsar Suprimir en el
      // documento mientras el foco no estaba dentro del texto —por ejemplo tras
      // usar un botón de la barra— borraba el nodo entero del lienzo.
      if (fileOpen || docOpen) return;
      // Mientras se dibuja, el lienzo cede el teclado al modo dibujo: aquí
      // Ctrl+Z deshace trazos y Suprimir borra el trazo elegido, no el nodo.
      if (drawingId) {
        if (e.key === 'Escape') { e.preventDefault(); saveDrawing(); return; }
        if (matchShortcut(window.shortcuts.undo)) { e.preventDefault(); drawUndo(); return; }
        if (matchShortcut(window.shortcuts.redo)) { e.preventDefault(); drawRedo(); return; }
        if (e.key === 'Backspace' || e.key === 'Delete') { e.preventDefault(); deleteSelectedStroke(); return; }
        return;
      }
      const tag = (e.target.tagName || '').toLowerCase();
      const isPasteInt = e.target === pasteIntRef.current;
      const inField = !isPasteInt && (tag === 'input' || tag === 'textarea' || e.target.isContentEditable);
      if (inField) {
        if (e.key === 'Escape') e.target.blur();
        // Deshacer/Rehacer TAMBIÉN mientras se escribe dentro de un nodo: antes se
        // salía aquí y Ctrl+Z solo llegaba al undo nativo del navegador, así que
        // parecía que no funcionaba en las notas. Se desenfoca primero para que el
        // contenido del editor se sincronice con el estado restaurado.
        if (matchShortcut(window.shortcuts.undo) || matchShortcut(window.shortcuts.redo)) {
          e.preventDefault();
          const isUndo = matchShortcut(window.shortcuts.undo);
          try { e.target.blur(); } catch (err) {}
          setEditing(null);
          // Dos cuadros y no un setTimeout(0): al desenfocar, el nodo guarda lo
          // que quedaba y React lo pinta después; deshacer antes de eso no
          // veía lo último escrito.
          requestAnimationFrame(() => requestAnimationFrame(() => { isUndo ? undo() : redo(); }));
        }
        return;
      }
      // Tab fuera de un campo movía el foco a un nodo editable lejano y el navegador
      // desplazaba el canvas para revelarlo (aunque overflow sea hidden, el foco sí
      // desplaza). Eso subía la interfaz y descuadraba el zoom al cursor. Lo anulamos.
      if (e.key === 'Tab') { e.preventDefault(); return; }
      if (e.key === 'Escape') {
        setActiveTool(null); setSelected(null); setSelectedIds([]); setSelectedConn(null);
        setEditing(null); setPendingConn(null); setContextMenu(null); setDropTargetCol(null);
      }
      // Table cell Excel-style keyboard. (Only reached when the cell input is NOT focused,
      // since the inField check above returns early when typing inside the input.)
      {
        const ftc = window._focusedTableCell;
        if (ftc && ftc.itemId === selected) {
          const editingNow = ftc.isEditingCell?.();
          if (!editingNow) {
            if (e.key === 'ArrowUp')    { e.preventDefault(); ftc.moveSelection?.(-1, 0); return; }
            if (e.key === 'ArrowDown')  { e.preventDefault(); ftc.moveSelection?.(1, 0); return; }
            if (e.key === 'ArrowLeft')  { e.preventDefault(); ftc.moveSelection?.(0, -1); return; }
            if (e.key === 'ArrowRight') { e.preventDefault(); ftc.moveSelection?.(0, 1); return; }
            if (e.key === 'Tab')        { e.preventDefault(); ftc.moveSelection?.(0, e.shiftKey ? -1 : 1); return; }
            if (e.key === 'F2' || e.key === 'Enter') { e.preventDefault(); ftc.editCell?.(); return; }
          }
          // Printable key: type-to-edit. typeChar appends if already editing (handles the autofocus race).
          if (!e.metaKey && !e.ctrlKey && !e.altKey && e.key.length === 1) {
            e.preventDefault(); ftc.typeChar?.(e.key); return;
          }
        }
      }
      if ((e.key === 'Backspace' || e.key === 'Delete') && (selected || selectedIds.length)) {
        // If a table cell is focused, clear its content instead of deleting the whole node
        const ftc = window._focusedTableCell;
        if (ftc && ftc.itemId === selected && !ftc.isEditingCell?.()) {
          e.preventDefault();
          ftc.clearContent && ftc.clearContent();
          return;
        }
        e.preventDefault();
        if (selectedIds.length > 1) {
          // Delete all multi-selected items in a single state update.
          // Los bloqueados se quedan (y sus flechas con ellos).
          const fuera = current.items.filter(it => selectedIds.includes(it.id) && !it.bloqueado).map(it => it.id);
          if (fuera.length < selectedIds.length) avisaBloqueado();
          if (!fuera.length) return;
          window.playAudioTone && window.playAudioTone('delete');
          setCanvases(prev => {
            const c = prev[currentId];
            return { ...prev, [currentId]: {
              ...c,
              items: c.items.filter(it => !fuera.includes(it.id)),
              connectors: (c.connectors || []).filter(co => !fuera.includes(co.id) && !fuera.includes(co.fromEnd?.itemId) && !fuera.includes(co.toEnd?.itemId)),
            }};
          });
          setSelectedIds([]); setSelected(null);
        } else if (selected) {
          const el = current.items.find(it => it.id === selected);
          if (el && el.bloqueado) { avisaBloqueado(); return; }
          deleteItem(selected);
        }
      }
      if ((e.key === 'Backspace' || e.key === 'Delete') && selectedConn) {
        e.preventDefault();
        const co = (current.connectors || []).find(x => x.id === selectedConn);
        if (flechaBloqueada(co)) { avisaFlechaBloqueada(); return; }
        deleteConnector(selectedConn);
      }
      if (matchShortcut(window.shortcuts.duplicate) && selected) {
        e.preventDefault(); duplicateItem(selected);
      }
      if (matchShortcut(window.shortcuts.selectAll)) {
        e.preventDefault(); selectAllItems();
      }
      if (matchShortcut(window.shortcuts.undo)) { e.preventDefault(); undo(); }
      if (matchShortcut(window.shortcuts.redo)) { e.preventDefault(); redo(); }
      // Agrupar y desagrupar (Ctrl+G, Ctrl+Shift+G) y el candado
      // (Ctrl+Shift+L), como en los demás editores.
      if ((e.ctrlKey || e.metaKey) && !e.altKey && (e.key === 'g' || e.key === 'G')) {
        e.preventDefault();
        if (e.shiftKey) desagrupaSeleccion(); else agrupaSeleccion();
      }
      if ((e.ctrlKey || e.metaKey) && e.shiftKey && !e.altKey && (e.key === 'l' || e.key === 'L')) {
        e.preventDefault();
        if (selectedConn && !selected && !selectedIds.length) alternaBloqueoFlecha(selectedConn);
        else alternaBloqueo();
      }
      // Cortar/Copiar nodos desde el teclado. Los eventos nativos 'cut'/'copy' solo se
      // disparan cuando el foco está en un elemento editable (el interceptor oculto);
      // si un nodo roba el foco (botones, celdas, etc.) nunca llegan, por lo que Ctrl+X
      // solo funcionaba con algunos nodos. preventDefault() cancela el evento nativo de
      // portapapeles, así que esta ruta nunca se ejecuta dos veces.
      if ((e.ctrlKey || e.metaKey) && !e.shiftKey && !e.altKey &&
          (e.key === 'x' || e.key === 'X' || e.key === 'c' || e.key === 'C') && !docOpen) {
        const isCutKey = e.key === 'x' || e.key === 'X';
        const selectedItems = current.items.filter(it => selectedIds.includes(it.id) || (selectedIds.length === 0 && it.id === selected));
        if (selectedItems.length > 0) {
          e.preventDefault();
          const selectedItemIds = selectedItems.map(it => it.id);
          const selectedConnectors = (current.connectors || []).filter(co =>
            selectedIds.includes(co.id) ||
            (selectedItemIds.includes(co.fromEnd?.itemId) && selectedItemIds.includes(co.toEnd?.itemId))
          );
          const copiedData = {
            odinote: true,
            items: JSON.parse(JSON.stringify(selectedItems)),
            connectors: JSON.parse(JSON.stringify(selectedConnectors)),
          };
          window._odiCopiedData = copiedData;
          window._odiCopiedItem = copiedData.items[0];
          const jsonStr = JSON.stringify(copiedData);
          if (navigator.clipboard && navigator.clipboard.writeText) {
            navigator.clipboard.writeText(jsonStr).catch(() => {});
          }
          if (isCutKey) {
            window.playAudioTone && window.playAudioTone('delete');
            setCanvases(prev => {
              const c = prev[currentId];
              return { ...prev, [currentId]: {
                ...c,
                // Cortar copia también los bloqueados, pero no se los lleva.
                items: c.items.filter(it => !selectedItemIds.includes(it.id) || it.bloqueado),
                connectors: (c.connectors || []).filter(co => !selectedItemIds.includes(co.id) && !selectedItemIds.includes(co.fromEnd?.itemId) && !selectedItemIds.includes(co.toEnd?.itemId)),
              }};
            });
            setSelectedIds([]); setSelected(null);
          }
          return;
        }
      }
      if (matchShortcut(window.shortcuts.search) && !contextMenu) {
        e.preventDefault();
        document.querySelector('.mini-search input')?.focus();
      }
    };
    // El botón del medio SOLO mueve la vista, se pulse donde se pulse dentro del
    // lienzo: antes, encima de un nodo arrastraba el nodo (lo pidió el usuario
    // así). Se intercepta en captura, antes que nadie, y se paran también el
    // pointerdown (de él tiran la galería, los tiradores y las flechas) y el
    // auxclick (que abriría los enlaces en otra ventana). Fuera del lienzo
    // (Ajustes, el documento abierto…) el botón hace lo de siempre.
    // También con el modo dibujo abierto: su capa va fuera del lienzo, encima.
    const enElLienzo = (e) => !!(e.target && ((surfaceRef.current && surfaceRef.current.contains(e.target)) || (e.target.closest && e.target.closest('.draw-layer'))) &&
      !(e.target.closest && e.target.closest('.modal, .doc-modal-overlay, .doc-modal-backdrop, .panel-color')));
    const onMouseDownCapture = (e) => {
      if (e.button !== 1 || !enElLienzo(e)) return;
      e.preventDefault();
      e.stopPropagation();
      const startX = e.clientX, startY = e.clientY, startPan = { ...panRef.current };
      setPaneando(true);
      const onMove = (ev) => {
        setPan({ x: startPan.x + ev.clientX - startX, y: startPan.y + ev.clientY - startY });
      };
      const onUp = () => {
        window.removeEventListener('mousemove', onMove);
        window.removeEventListener('mouseup', onUp);
        setPaneando(false);
      };
      window.addEventListener('mousemove', onMove);
      window.addEventListener('mouseup', onUp);
    };
    // Al pointerdown NO se le cancela lo de por defecto: entonces Chromium ya
    // no manda el mousedown, que es de donde arranca el arrastre de la vista.
    const paraBotonCentral = (e) => {
      if (e.button !== 1 || !enElLienzo(e)) return;
      e.stopPropagation();
      if (e.type === 'auxclick') e.preventDefault();
    };
    window.addEventListener('keydown', onKey);
    window.addEventListener('mousedown', onMouseDownCapture, { capture: true });
    window.addEventListener('pointerdown', paraBotonCentral, { capture: true });
    window.addEventListener('auxclick', paraBotonCentral, { capture: true });
    return () => {
      window.removeEventListener('keydown', onKey);
      window.removeEventListener('mousedown', onMouseDownCapture, { capture: true });
      window.removeEventListener('pointerdown', paraBotonCentral, { capture: true });
      window.removeEventListener('auxclick', paraBotonCentral, { capture: true });
    };
  });

  // ───── Focus the paste interceptor whenever any node or connector is selected ─────
  useEffectCanvas(() => {
    if (!selected && selectedIds.length === 0 && !selectedConn) return;
    
    // Check if user is actively editing a text field or contentEditable
    const activeEl = document.activeElement;
    const isEditing = activeEl && (
      activeEl.tagName === 'INPUT' || 
      activeEl.tagName === 'TEXTAREA' || 
      activeEl.isContentEditable
    );
    if (isEditing) return;

    const t = setTimeout(() => {
      // Re-verify after timeout to avoid race conditions
      const currActive = document.activeElement;
      const currEditing = currActive && (
        currActive.tagName === 'INPUT' || 
        currActive.tagName === 'TEXTAREA' || 
        currActive.isContentEditable
      );
      // Con el dedo NO. Este div invisible existe solo para que Ctrl+V pegue
      // una imagen en el nodo seleccionado; en un teléfono no hay Ctrl+V, y
      // en cambio enfocar un contentEditable ABRE EL TECLADO. Como esto se
      // ejecuta tras CADA cambio de selección, el teclado saltaba al tocar un
      // nodo, al pulsar un botón de la barra o al borrar, y de paso el cambio
      // de foco y el teclado moviendo la pantalla se comían el arrastre.
      if (!currEditing && !window.odiLastInputWasTouch) {
        pasteIntRef.current?.focus();
      }
    }, 50);
    return () => clearTimeout(t);
  }, [selected, selectedIds, selectedConn]);

  // ───── Reset the connector label input when the selected connector changes ─────
  useEffectCanvas(() => { setConnLabelOpen(false); }, [selectedConn]);

  // ───── Track the mouse position so Ctrl+V pastes a copied node where the cursor is ─────
  useEffectCanvas(() => {
    const onMove = (e) => { lastMouseRef.current = { x: e.clientX, y: e.clientY }; };
    window.addEventListener('mousemove', onMove);
    return () => window.removeEventListener('mousemove', onMove);
  }, []);

  // ───── Keep the right-click menu on-screen: flip above/left when it would overflow the edges ─────
  React.useLayoutEffect(() => {
    if (!contextMenu || !ctxMenuRef.current || !surfaceRef.current) return;
    const menu = ctxMenuRef.current;
    const ww = surfaceRef.current.clientWidth;
    const wh = surfaceRef.current.clientHeight;
    const mw = menu.offsetWidth, mh = menu.offsetHeight;
    let left = contextMenu.x, top = contextMenu.y;
    if (left + mw > ww - 6) left = contextMenu.x - mw; // flip to the left of the cursor
    if (top + mh > wh - 6)  top = contextMenu.y - mh;  // flip above the cursor
    left = Math.max(6, Math.min(left, ww - mw - 6));
    top = Math.max(6, Math.min(top, wh - mh - 6));
    menu.style.left = left + 'px';
    menu.style.top = top + 'px';
  }, [contextMenu]);

  // ───── Close the right-click context menu on any outside click (incl. nodes that stopPropagation) ─────
  useEffectCanvas(() => {
    if (!contextMenu) return;
    const onDown = (e) => {
      if (e.target.closest && e.target.closest('.context-menu')) return; // clicking the menu itself
      setContextMenu(null);
    };
    // Capture phase so it fires even when children call stopPropagation
    document.addEventListener('mousedown', onDown, true);
    return () => document.removeEventListener('mousedown', onDown, true);
  }, [contextMenu]);

  // ───── Document-level drag-drop: replace image/audio OR create a new image/audio node ─────
  useEffectCanvas(() => {
    const onDragOver = (e) => {
      if (e.dataTransfer) {
        const types = Array.from(e.dataTransfer.types || []);
        if (types.some(t => t === 'Files' || t === 'files' || t === 'text/uri-list' || t === 'text/html' || t === 'text/plain')) {
          e.preventDefault();
        }
      }
    };
    const onDrop = (e) => {
      // Don't intercept drops while a fullscreen modal (doc editor / file viewer) is open
      if (docOpen || fileOpen) return;
      let file = e.dataTransfer?.files?.[0];
      if (!file) {
        const urlList = e.dataTransfer?.getData('text/uri-list');
        const html = e.dataTransfer?.getData('text/html');
        let imgUrl = urlList ? urlList.split('\n')[0].trim() : '';
        if (!imgUrl && html) {
          const match = html.match(/<img[^>]+src=["']([^"']+)["']/i);
          if (match && match[1]) imgUrl = match[1];
        }
        if (imgUrl && (imgUrl.startsWith('http://') || imgUrl.startsWith('https://') || imgUrl.startsWith('data:'))) {
          e.preventDefault();
          const targetEl = e.target?.closest?.('[data-item-id]');
          const targetId = targetEl?.dataset?.itemId;
          const targetItem = targetId ? current.items.find(i => i.id === targetId) : null;
          const pt = screenToCanvas(e.clientX, e.clientY);
          const applyDroppedImageSrc = (rawSrc) => {
            const src = rawSrc;
            const defW = 300;
            const defH = 220;

            const insertDroppedNode = (wVal, hVal) => {
              if (targetItem && targetItem.type === 'image') {
                updateItem(targetId, { src, name: 'image.png', w: wVal, h: hVal });
              } else {
                const newItem = makeNewItem('image', pt.x - wVal / 2, pt.y - hVal / 2, wVal, hVal, lang);
                newItem.src = src;
                newItem.name = 'image.png';
                newItem.w = wVal;
                newItem.h = hVal;
                setCanvases(prev => {
                  const c = prev[currentId];
                  return { ...prev, [currentId]: { ...c, items: [...c.items, newItem] } };
                });
                setSelected(newItem.id);
              }
            };

            if (src.startsWith('file:///')) {
              insertDroppedNode(defW, defH);
              return;
            }

            const img = new Image();
            img.onload = () => {
              const ratio = img.naturalWidth / img.naturalHeight;
              if (targetItem && targetItem.type === 'image') {
                const w = targetItem.w || 260;
                insertDroppedNode(w, Math.max(60, Math.round(w / ratio)));
              } else {
                insertDroppedNode(defW, Math.max(60, Math.round(defW / ratio)));
              }
            };
            img.onerror = () => {
              insertDroppedNode(defW, defH);
            };
            img.src = src;
          };
          if (imgUrl.startsWith('data:')) {
            applyDroppedImageSrc(imgUrl);
          } else {
            if (window.electronAPI && window.electronAPI.downloadMediaToVault && vaultPath) {
              let tentativeName = 'web_image.png';
              try {
                const parsedUrl = new URL(imgUrl);
                const base = parsedUrl.pathname.split('/').pop();
                if (base && base.includes('.')) tentativeName = base;
              } catch(e) {}
              window.electronAPI.downloadMediaToVault(vaultPath, imgUrl, tentativeName)
                .then(relativePath => {
                  const normalizedRelative = relativePath.replace(/\\/g, '/');
                  const absolutePath = `file:///${vaultPath.replace(/\\/g, '/')}/${normalizedRelative}`;
                  applyDroppedImageSrc(absolutePath);
                })
                .catch(err => {
                  console.warn('Failed to download dropped image via IPC, falling back to base64 fetch:', err);
                  if (window.electronAPI.fetchImageBase64) {
                    window.electronAPI.fetchImageBase64(imgUrl)
                      .then(applyDroppedImageSrc)
                      .catch(() => applyDroppedImageSrc(imgUrl));
                  } else {
                    applyDroppedImageSrc(imgUrl);
                  }
                });
            } else if (window.electronAPI && window.electronAPI.fetchImageBase64) {
              window.electronAPI.fetchImageBase64(imgUrl)
                .then(applyDroppedImageSrc)
                .catch(err => {
                  console.warn('Failed to fetch dropped image via IPC:', err);
                  applyDroppedImageSrc(imgUrl);
                });
            } else {
              fetch(imgUrl)
                .then(r => r.blob())
                .then(blob => {
                  const fr = new FileReader();
                  fr.onload = () => applyDroppedImageSrc(fr.result);
                  fr.readAsDataURL(blob);
                })
                .catch(err => {
                  console.warn('Failed to fetch dropped image, fallback to URL:', err);
                  applyDroppedImageSrc(imgUrl);
                });
            }
          }
          return;
        }
        return;
      }

      const isImg = file.type.startsWith('image/');
      const isAud = file.type.startsWith('audio/');

      e.preventDefault();

      const targetEl = e.target?.closest?.('[data-item-id]');
      const targetId = targetEl?.dataset?.itemId;
      const targetItem = targetId ? current.items.find(i => i.id === targetId) : null;

      // Read drop coordinates in canvas space for creating a new node
      const pt = screenToCanvas(e.clientX, e.clientY);

      // De dónde salió, para poder abrir su carpeta después. Ver
      // window.rutaDeArchivo en items.jsx: en el navegador esto es null.
      const rutaOrigen = window.rutaDeArchivo ? window.rutaDeArchivo(file) : null;

      if (isImg) {
        const fr = new FileReader();
        fr.onload = () => {
          const src = fr.result;
          const img = new Image();
          img.onload = () => {
            const ratio = img.naturalWidth / img.naturalHeight;
            // Soltar una imagen sobre un nodo de ARCHIVO lo convierte en nodo
            // de imagen, igual que al elegirla desde el propio nodo: una
            // imagen se mira, no se queda como una tarjeta con su nombre.
            if (targetItem && (targetItem.type === 'image' || targetItem.type === 'file')) {
              // Replace existing image
              const w = targetItem.w || 260;
              const ext = (file.name.split('.').pop() || '').toLowerCase();
              updateItem(targetId, { type: 'image', src, name: file.name, size: file.size, fileType: ext,
                                     naturalRatio: ratio, w, h: Math.max(60, Math.round(w / ratio)), rutaOrigen });
            } else {
              // Create a new image node at drop location
              const w = 300;
              const h = Math.max(60, Math.round(w / ratio));
              const newItem = makeNewItem('image', pt.x - w / 2, pt.y - h / 2, w, h, lang);
              newItem.src = src;
              newItem.name = file.name;
              newItem.w = w;
              newItem.h = h;
              newItem.rutaOrigen = rutaOrigen;
              setCanvases(prev => {
                const c = prev[currentId];
                return { ...prev, [currentId]: { ...c, items: [...c.items, newItem] } };
              });
              setSelected(newItem.id);
            }
          };
          img.onerror = () => {
            if (targetItem && targetItem.type === 'image') updateItem(targetId, { src, name: file.name, rutaOrigen });
          };
          img.src = src;
        };
        fr.readAsDataURL(file);
      } else if (isAud) {
        const fr = new FileReader();
        fr.onload = () => {
          const src = fr.result;
          // Igual que con las imágenes: un audio soltado sobre un nodo de
          // archivo lo convierte en nodo de audio, que es donde se escucha.
          if (targetItem && (targetItem.type === 'audio' || targetItem.type === 'file')) {
            // Replace existing audio
            const ext = (file.name.split('.').pop() || '').toLowerCase();
            updateItem(targetId, { type: 'audio', src, name: file.name, size: file.size, fileType: ext,
                                   h: targetItem.type === 'file' ? 140 : targetItem.h, rutaOrigen });
          } else {
            // Create a new audio node at drop location
            const w = 320;
            const h = 140;
            const newItem = makeNewItem('audio', pt.x - w / 2, pt.y - h / 2, w, h, lang);
            newItem.src = src;
            newItem.name = file.name;
            newItem.size = file.size;
            newItem.rutaOrigen = rutaOrigen;
            setCanvases(prev => {
              const c = prev[currentId];
              return { ...prev, [currentId]: { ...c, items: [...c.items, newItem] } };
            });
            setSelected(newItem.id);
          }
        };
        fr.readAsDataURL(file);
      } else {
        // Any other file type → create (or replace) a File node
        const fr = new FileReader();
        fr.onload = () => {
          const src = fr.result;
          const ext = (file.name.split('.').pop() || '').toLowerCase();
          if (targetItem && targetItem.type === 'file') {
            updateItem(targetId, { src, name: file.name, size: file.size, fileType: ext, rutaOrigen });
          } else {
            // El nodo nace con la forma de lo que se ha soltado: un vídeo
            // apaisado, un PDF de página. Ver formaDeArchivo en items.jsx.
            const forma = (window.formaDeArchivo && window.formaDeArchivo(ext)) || { w: 200, h: 190 };
            const w = forma.w, h = forma.h;
            const newItem = makeNewItem('file', pt.x - w / 2, pt.y - h / 2, w, h, lang);
            newItem.src = src; newItem.name = file.name; newItem.size = file.size; newItem.fileType = ext;
            newItem.rutaOrigen = rutaOrigen;
            newItem._triggerFilePick = false;
            setCanvases(prev => {
              const c = prev[currentId];
              return { ...prev, [currentId]: { ...c, items: [...c.items, newItem] } };
            });
            setSelected(newItem.id);
          }
        };
        fr.readAsDataURL(file);
      }
    };
    document.addEventListener('dragover', onDragOver);
    document.addEventListener('drop', onDrop);
    return () => {
      document.removeEventListener('dragover', onDragOver);
      document.removeEventListener('drop', onDrop);
    };
  });

  // ───── Document-level paste: replace selected image OR create a new image node ─────
  useEffectCanvas(() => {
    const onDocPaste = (e) => {
      // Don't intercept paste while a fullscreen modal (doc editor / file viewer) is open
      if (docOpen || fileOpen) return;
      // If user is typing into a real input/textarea (not our hidden interceptor), ignore
      const af = document.activeElement;
      if (af && af !== pasteIntRef.current &&
          ((af.tagName || '').toLowerCase() === 'input' ||
           (af.tagName || '').toLowerCase() === 'textarea' ||
           (af.isContentEditable && af !== pasteIntRef.current))) {
        return;
      }
      const cdata = e.clipboardData;
      const items = cdata?.items;
      const types = cdata ? Array.from(cdata.types || []) : [];

      // Pegar dibujando (una imagen de referencia para calcarla, sobre todo):
      // lo pegado entra en el lienzo pero NO se selecciona. Antes se
      // seleccionaba, la barra lateral cambiaba a la de la imagen y el modo
      // dibujo se quedaba abierto sin sus botones, sin manera de guardar. Y al
      // no tocarlo queda por debajo del dibujo (el orden de apilado sigue a lo
      // último seleccionado), que es donde tiene que estar para pintar encima.
      const enDibujo = !!drawingId;
      const seleccionaPegado = (id) => { if (!enDibujo) setSelected(id); };

      // Paste structured items and connectors
      const pasteData = (data) => {
        if (!data || !data.items || !data.items.length) return;
        
        let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
        data.items.forEach(it => {
          const w = it.w || 200;
          const h = it.h || 120;
          if (it.x < minX) minX = it.x;
          if (it.y < minY) minY = it.y;
          if (it.x + w > maxX) maxX = it.x + w;
          if (it.y + h > maxY) maxY = it.y + h;
        });

        const centerX = (minX + maxX) / 2;
        const centerY = (minY + maxY) / 2;
        const m = lastMouseRef.current || { x: window.innerWidth / 2, y: window.innerHeight / 2 };
        const pt = screenToCanvas(m.x, m.y);
        const dx = pt.x - centerX;
        const dy = pt.y - centerY;

        const idMap = {};
        const canvasDuplications = [];
        // Lo pegado forma su propio grupo (si lo copiado lo era), no se mete en
        // el del original. Y llega sin candado: un candado pegado se quedaría
        // donde cae sin poder moverlo.
        const gruposNuevos = {};
        const pastedItems = data.items.map(it => {
          const newId = `it-${Date.now()}-${Math.floor(Math.random()*99999)}-${Math.floor(Math.random()*99999)}`;
          idMap[it.id] = newId;
          const copy = {
            ...it,
            id: newId,
            x: Math.round(it.x + dx),
            y: Math.round(it.y + dy),
            _new: true,
          };
          if (copy.grupo) copy.grupo = gruposNuevos[copy.grupo] || (gruposNuevos[copy.grupo] = `g-${Date.now()}-${Math.floor(Math.random() * 99999)}`);
          delete copy.bloqueado;
          if (copy.type === 'board' && copy.canvasId) {
            const origCanvasId = copy.canvasId;
            const newCid = `b-${Date.now()}-${Math.floor(Math.random()*99999)}`;
            copy.canvasId = newCid;
            canvasDuplications.push({ orig: origCanvasId, next: newCid });
          }
          return copy;
        });

        const pastedConnectors = (data.connectors || []).map(co => {
          const newConnId = `co-${Date.now()}-${Math.floor(Math.random()*99999)}`;
          const copy = {
            ...co,
            id: newConnId,
          };
          delete copy.bloqueado;
          if (copy.fromEnd && idMap[copy.fromEnd.itemId]) {
            copy.fromEnd = { ...copy.fromEnd, itemId: idMap[copy.fromEnd.itemId] };
          }
          if (copy.toEnd && idMap[copy.toEnd.itemId]) {
            copy.toEnd = { ...copy.toEnd, itemId: idMap[copy.toEnd.itemId] };
          }
          return copy;
        });

        setCanvases(prev => {
          const c = prev[currentId];
          const next = {
            ...prev,
            [currentId]: {
              ...c,
              items: [...c.items, ...pastedItems],
              connectors: [...(c.connectors || []), ...pastedConnectors],
            }
          };
          canvasDuplications.forEach(({ orig, next: nextCid }) => {
            duplicateCanvasState(next, orig, nextCid);
          });
          return next;
        });

        if (enDibujo) {
          // Ver seleccionaPegado: dibujando, la selección es del dibujo.
        } else if (pastedItems.length === 1) {
          setSelected(pastedItems[0].id);
          setSelectedIds([]);
        } else if (pastedItems.length > 1) {
          setSelected(null);
          setSelectedIds(pastedItems.map(it => it.id));
        }
      };

      // Resolve to a usable image source (data URL from blob, or http URL string)
      const applyImageSrc = (rawSrc) => {
        const src = rawSrc;
        e.preventDefault();
        const selItem = selected ? current.items.find(i => i.id === selected) : null;
        
        const insertImageNode = (wVal, hVal) => {
          if (selItem && selItem.type === 'image') {
            updateItem(selected, { src, w: wVal, h: hVal });
          } else {
            const w = wVal;
            const h = hVal;
            const m = lastMouseRef.current || { x: 0, y: 0 };
            const pt = screenToCanvas(m.x, m.y);
            const newItem = makeNewItem('image', pt.x - w / 2, pt.y - h / 2, w, h, lang);
            newItem.src = src; newItem.w = w; newItem.h = h;
            setCanvases(prev => {
              const c = prev[currentId];
              return { ...prev, [currentId]: { ...c, items: [...c.items, newItem] } };
            });
            seleccionaPegado(newItem.id);
          }
        };

        const defW = 300;
        const defH = 220;

        if (src.startsWith('file:///')) {
          insertImageNode(defW, defH);
          return;
        }

        const img = new Image();
        img.onload = () => {
          const ratio = img.naturalWidth / img.naturalHeight;
          if (selItem && selItem.type === 'image') {
            const w = selItem.w || 260;
            insertImageNode(w, Math.max(60, Math.round(w / ratio)));
          } else {
            insertImageNode(defW, Math.max(60, Math.round(defW / ratio)));
          }
        };
        img.onerror = () => {
          insertImageNode(defW, defH);
        };
        img.src = src;
      };

      const fetchAndApplyImage = (url) => {
        if (url.startsWith('data:')) {
          applyImageSrc(url);
          return;
        }
        e.preventDefault();
        if (window.electronAPI && window.electronAPI.downloadMediaToVault && vaultPath) {
          let tentativeName = 'web_image.png';
          try {
            const parsedUrl = new URL(url);
            const base = parsedUrl.pathname.split('/').pop();
            if (base && base.includes('.')) tentativeName = base;
          } catch(e) {}
          window.electronAPI.downloadMediaToVault(vaultPath, url, tentativeName)
            .then(relativePath => {
              const normalizedRelative = relativePath.replace(/\\/g, '/');
              const absolutePath = `file:///${vaultPath.replace(/\\/g, '/')}/${normalizedRelative}`;
              applyImageSrc(absolutePath);
            })
            .catch(err => {
              console.warn('Failed to download image to vault directly via IPC, falling back to base64 fetch:', err);
              if (window.electronAPI.fetchImageBase64) {
                window.electronAPI.fetchImageBase64(url)
                  .then(applyImageSrc)
                  .catch(err2 => {
                    console.warn('Failed base64 fetch too, fallback to URL:', err2);
                    applyImageSrc(url);
                  });
              } else {
                applyImageSrc(url);
              }
            });
        } else if (window.electronAPI && window.electronAPI.fetchImageBase64) {
          window.electronAPI.fetchImageBase64(url)
            .then(base64 => {
              applyImageSrc(base64);
            })
            .catch(err => {
              console.warn('Failed to fetch image via IPC, falling back to URL string:', err);
              applyImageSrc(url);
            });
        } else {
          fetch(url)
            .then(res => res.blob())
            .then(blob => {
              const fr = new FileReader();
              fr.onload = () => applyImageSrc(fr.result);
              fr.readAsDataURL(blob);
            })
            .catch(err => {
              console.warn('Failed to fetch image, falling back to URL string:', err);
              applyImageSrc(url);
            });
        }
      };

      // 1) Intentar leer JSON estructurado de Odinote síncronamente desde el portapapeles
      let parsedOdiData = null;
      try {
        const text = cdata?.getData('text/plain');
        if (text) {
          const parsed = JSON.parse(text);
          if (parsed && parsed.odinote && parsed.items) {
            parsedOdiData = parsed;
          }
        }
      } catch (err) {}

      // OJO: aquí NO se usa window._odiCopiedData como respaldo. Antes se hacía y,
      // al copiar algo de fuera (texto o imagen), se pegaba el último nodo copiado
      // dentro de la app en lugar del contenido externo. El respaldo interno solo
      // se usa al final, si el portapapeles no trae nada aprovechable.
      if (parsedOdiData) {
        e.preventDefault();
        pasteData(parsedOdiData);
        return;
      }

      // 2) Real image blob (screenshots, "copy image") — highest priority
      if (items) {
        for (const it of Array.from(items)) {
          if (it.type.startsWith('image/')) {
            const file = it.getAsFile();
            if (file) {
              const fr = new FileReader();
              fr.onload = () => applyImageSrc(fr.result);
              fr.readAsDataURL(file);
              return;
            }
          }
        }
      }
      // 3) HTML with <img src="..."> (copying an image from a web page)
      if (types.includes('text/html')) {
        const html = cdata.getData('text/html');
        const match = html && html.match(/<img[^>]+src=["']([^"']+)["']/i);
        if (match && match[1]) { fetchAndApplyImage(match[1]); return; }
      }
      // 4) A plain URL pointing to an image
      const urlText = (cdata.getData('text/uri-list') || cdata.getData('text/plain') || '').split('\n')[0].trim();
      if (urlText && /^https?:\/\//i.test(urlText) && /\.(png|jpe?g|gif|webp|svg|bmp)(\?|$)/i.test(urlText)) {
        fetchAndApplyImage(urlText);
        return;
      }

      // 5) Texto plano externo → crear una nota (o un enlace si es una URL)
      const plainText = (cdata.getData('text/plain') || '').trim();
      if (plainText) {
        e.preventDefault();
        const m = lastMouseRef.current || { x: 0, y: 0 };
        const pt = screenToCanvas(m.x, m.y);
        const isUrl = /^https?:\/\/\S+$/i.test(plainText);
        const type = isUrl ? 'link' : 'note';
        const def = defaultDims(type);
        const newItem = makeNewItem(type, pt.x - def.w / 2, pt.y - def.h / 2, def.w, def.h, lang);
        if (isUrl) {
          newItem.url = plainText;
        } else {
          // Respetar los saltos de línea del texto copiado
          const html = plainText
            .split(/\r?\n/)
            .map(line => `<p>${line.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') || '<br>'}</p>`)
            .join('');
          newItem.content = { es: html, en: html };
        }
        setCanvases(prev => {
          const c = prev[currentId];
          return { ...prev, [currentId]: { ...c, items: [...c.items, newItem] } };
        });
        seleccionaPegado(newItem.id);
        window.playAudioTone && window.playAudioTone('drop');
        return;
      }

      // 6) El portapapeles no trae nada aprovechable → repetir la última copia interna
      if (window._odiCopiedData) {
        e.preventDefault();
        pasteData(window._odiCopiedData);
      }
    };
    const onDocCopy = (e) => {
      console.log('[CLIPBOARD DEBUG] onDocCopy event fired');
      if (docOpen || fileOpen) {
        console.log('[CLIPBOARD DEBUG] Ignored: docOpen or fileOpen is true');
        return;
      }
      const af = document.activeElement;
      if (af &&
          ((af.tagName || '').toLowerCase() === 'input' ||
           (af.tagName || '').toLowerCase() === 'textarea' ||
           (af.isContentEditable && af !== pasteIntRef.current))) {
        console.log('[CLIPBOARD DEBUG] Ignored: Focus is on editable element:', af);
        return; // Let native copy handle it
      }

      const selectedItems = current.items.filter(it => selectedIds.includes(it.id) || (selectedIds.length === 0 && it.id === selected));
      console.log('[CLIPBOARD DEBUG] Selected items for copy:', selectedItems);
      if (selectedItems.length > 0) {
        e.preventDefault();
        const selectedItemIds = selectedItems.map(it => it.id);
        const selectedConnectors = (current.connectors || []).filter(co =>
          selectedIds.includes(co.id) ||
          (selectedItemIds.includes(co.fromEnd?.itemId) && selectedItemIds.includes(co.toEnd?.itemId))
        );
        
        const copiedData = {
          odinote: true,
          items: JSON.parse(JSON.stringify(selectedItems)),
          connectors: JSON.parse(JSON.stringify(selectedConnectors)),
        };
        window._odiCopiedData = copiedData;
        window._odiCopiedItem = copiedData.items[0];
        
        const jsonStr = JSON.stringify(copiedData);
        console.log('[CLIPBOARD DEBUG] Serialized JSON string size:', jsonStr.length);
        
        if (e.clipboardData) {
          e.clipboardData.setData('text/plain', jsonStr);
          console.log('[CLIPBOARD DEBUG] Synchronous e.clipboardData.setData executed');
        } else {
          console.warn('[CLIPBOARD DEBUG] e.clipboardData is not available');
        }

        // Async fallback write
        setTimeout(() => {
          navigator.clipboard.writeText(jsonStr)
            .then(() => console.log('[CLIPBOARD DEBUG] Copy Async navigator.clipboard.writeText successful'))
            .catch(err => console.error('[CLIPBOARD DEBUG] Copy Async navigator.clipboard.writeText failed:', err));
        }, 0);
      }
    };

    const onDocCut = (e) => {
      console.log('[CLIPBOARD DEBUG] onDocCut event fired');
      if (docOpen || fileOpen) {
        console.log('[CLIPBOARD DEBUG] Ignored: docOpen or fileOpen is true');
        return;
      }
      const af = document.activeElement;
      if (af &&
          ((af.tagName || '').toLowerCase() === 'input' ||
           (af.tagName || '').toLowerCase() === 'textarea' ||
           (af.isContentEditable && af !== pasteIntRef.current))) {
        console.log('[CLIPBOARD DEBUG] Ignored: Focus is on editable element:', af);
        return; // Let native cut handle it
      }

      const selectedItems = current.items.filter(it => selectedIds.includes(it.id) || (selectedIds.length === 0 && it.id === selected));
      console.log('[CLIPBOARD DEBUG] Selected items for cut:', selectedItems);
      if (selectedItems.length > 0) {
        e.preventDefault();
        const selectedItemIds = selectedItems.map(it => it.id);
        const selectedConnectors = (current.connectors || []).filter(co =>
          selectedIds.includes(co.id) ||
          (selectedItemIds.includes(co.fromEnd?.itemId) && selectedItemIds.includes(co.toEnd?.itemId))
        );
        
        const copiedData = {
          odinote: true,
          items: JSON.parse(JSON.stringify(selectedItems)),
          connectors: JSON.parse(JSON.stringify(selectedConnectors)),
        };
        window._odiCopiedData = copiedData;
        window._odiCopiedItem = copiedData.items[0];
        
        const jsonStr = JSON.stringify(copiedData);
        console.log('[CLIPBOARD DEBUG] Serialized JSON string size:', jsonStr.length);
        
        if (e.clipboardData) {
          e.clipboardData.setData('text/plain', jsonStr);
          console.log('[CLIPBOARD DEBUG] Synchronous e.clipboardData.setData executed');
        } else {
          console.warn('[CLIPBOARD DEBUG] e.clipboardData is not available');
        }

        // Async fallback write
        setTimeout(() => {
          navigator.clipboard.writeText(jsonStr)
            .then(() => console.log('[CLIPBOARD DEBUG] Cut Async navigator.clipboard.writeText successful'))
            .catch(err => console.error('[CLIPBOARD DEBUG] Cut Async navigator.clipboard.writeText failed:', err));
        }, 0);

        // Delete the cut items
        setCanvases(prev => {
          const c = prev[currentId];
          return { ...prev, [currentId]: {
            ...c,
            items: c.items.filter(it => !selectedItemIds.includes(it.id)),
            connectors: (c.connectors || []).filter(co => !selectedItemIds.includes(co.id) && !selectedItemIds.includes(co.fromEnd?.itemId) && !selectedItemIds.includes(co.toEnd?.itemId)),
          }};
        });
        setSelectedIds([]); setSelected(null);
      }
    };

    document.addEventListener('paste', onDocPaste);
    document.addEventListener('copy', onDocCopy);
    document.addEventListener('cut', onDocCut);
    return () => {
      document.removeEventListener('paste', onDocPaste);
      document.removeEventListener('copy', onDocCopy);
      document.removeEventListener('cut', onDocCut);
    };
  });

  // ───── Coordinates ─────
  const screenToCanvas = (clientX, clientY) => {
    const rect = surfaceRef.current.getBoundingClientRect();
    const p = panRef.current || pan;
    const s = scaleRef.current || scale;
    return {
      x: (clientX - rect.left - p.x) / s,
      y: (clientY - rect.top - p.y) / s,
    };
  };

  // ───── Item updates ─────
  const updateItem = useCallbackCanvas((itemId, patch) => {
    setCanvases(prev => {
      const c = prev[currentId];
      const it = c.items.find(x => x.id === itemId);
      let nextCanvases = { ...prev };
      if (it && it.type === 'board' && patch.content && it.canvasId) {
        if (nextCanvases[it.canvasId]) {
          nextCanvases[it.canvasId] = {
            ...nextCanvases[it.canvasId],
            title: { ...(nextCanvases[it.canvasId].title || {}), ...patch.content }
          };
        }
      }
      nextCanvases[currentId] = {
        ...c,
        items: c.items.map(item => item.id === itemId ? { ...item, ...patch } : item)
      };
      return nextCanvases;
    });
  // eslint-disable-next-line
  }, [currentId]);

  const updateItemSilent = (itemId, patch) => {
    skipHistory.current = true;
    _setCanvases(prev => {
      const c = prev[currentId];
      const next = { ...prev, [currentId]: { ...c, items: c.items.map(it => it.id === itemId ? { ...it, ...patch } : it) } };
      return next;
    });
  };

  // Move several items together (used when dragging a connector to translate its attached nodes)
  const dragItemsSilent = (updates) => {
    document.body.classList.add('odi-busy');
    skipHistory.current = true;
    _setCanvases(prev => {
      const c = prev[currentId];
      return { ...prev, [currentId]: { ...c, items: c.items.map(it => {
        const u = updates.find(x => x.id === it.id);
        return u ? { ...it, x: u.x, y: u.y, _dragging: true } : it;
      }) } };
    });
  };
  const commitItemsDrag = (ids) => {
    document.body.classList.remove('odi-busy');
    setCanvases(prev => {
      const c = prev[currentId];
      return { ...prev, [currentId]: {
        ...c,
        // Los codos de las flechas rectas ya se movieron con los nodos; al
        // soltar no se "limpian": eso los recolocaba solos.
        items: c.items.map(it => ids.includes(it.id) ? { ...it, _dragging: false } : it),
      }};
    });
  };

  // ───── Modo dibujo ─────
  //
  // Dentro del modo se trabaja en coordenadas del LIENZO, no del nodo: así se
  // puede seguir dibujando fuera de la caja actual y la caja se ajusta sola al
  // guardar. El nodo guarda los trazos en un espacio propio (vw × vh) para
  // poder estirarse sin deformar los datos.
  const drawMovingRef = useRefCanvas(false);

  const strokesToCanvasSpace = (item) => {
    const D = window.OdiDraw;
    const vw = item.vw || item.w || 1;
    const vh = item.vh || item.h || 1;
    const scaled = D.scaleStrokes(item.strokes || [], (item.w || vw) / vw, (item.h || vh) / vh);
    return D.translateStrokes(scaled, item.x, item.y);
  };

  const enterDrawMode = (itemId) => {
    const it = (canvasesLiveRef.current[currentId].items || []).find(i => i.id === itemId);
    if (!it || it.type !== 'draw') return;
    // La marca de "ábreme al nacer" se gasta AQUÍ, no al guardar. Se guarda en
    // disco con el resto del nodo, así que si la sesión terminaba por un
    // reinicio en vez de por Guardar, la marca sobrevivía y ese nodo volvía a
    // robarle el modo dibujo al siguiente que se creara.
    if (it._startDrawing) updateItemSilent(itemId, { _startDrawing: false });
    setSelected(itemId);
    setEditing(null);
    setSelectedStrokeId(null);
    setDrawTool('pen');
    setDrawSession({ strokes: strokesToCanvasSpace(it), past: [], future: [] });
    setDrawingId(itemId);
  };

  // Cada cambio de la sesión guarda el estado anterior para el deshacer PROPIO
  // del modo (los botones de la barra), que no toca el historial del lienzo.
  const applyDrawChange = (fn) => {
    setDrawSession(s => {
      if (!s) return s;
      const next = fn(s.strokes);
      if (next === s.strokes) return s;
      const past = [...s.past, s.strokes].slice(-60);
      return { strokes: next, past, future: [] };
    });
  };

  const commitStroke = (stroke) => applyDrawChange(list => [...list, stroke]);

  const eraseStroke = (id) => applyDrawChange(list => {
    if (!list.some(s => s.id === id)) return list;
    return list.filter(s => s.id !== id);
  });

  // La goma: borra un círculo de los trazos por cada punto por donde pasa
  // (ver OdiDraw.eraseArea). Una pasada entera, de pulsar a soltar, es UN
  // paso de deshacer; si no, cada movimiento del ratón sería uno.
  const drawErasingRef = useRefCanvas(false);
  const eraseArea = (puntos, r, fase) => {
    if (fase === 'fin') { drawErasingRef.current = false; return; }
    if (fase === 'inicio') drawErasingRef.current = false;
    const D = window.OdiDraw;
    const corta = (lista) => puntos.reduce((l, p) => D.eraseArea(l, p.x, p.y, r), lista);
    setDrawSession(s => {
      if (!s) return s;
      const next = corta(s.strokes);
      if (next === s.strokes) return s;
      if (drawErasingRef.current) return { ...s, strokes: next };
      drawErasingRef.current = true;
      return { strokes: next, past: [...s.past, s.strokes].slice(-60), future: [] };
    });
  };

  const moveStroke = (id, dx, dy, isEnd) => {
    if (isEnd) { drawMovingRef.current = false; return; }
    const D = window.OdiDraw;
    // Un arrastre entero es UN paso de deshacer, no uno por píxel recorrido.
    if (!drawMovingRef.current) {
      drawMovingRef.current = true;
      applyDrawChange(list => list.map(s => s.id === id ? { ...s, pts: D.translateStrokes([s], dx, dy)[0].pts } : s));
      return;
    }
    setDrawSession(s => s && ({
      ...s,
      strokes: s.strokes.map(st => st.id === id ? { ...st, pts: D.translateStrokes([st], dx, dy)[0].pts } : st),
    }));
  };

  const recolorStroke = (hex) => {
    setDrawColor(hex);
    // Elegir un color es querer pintar con él: si estabas con Mover o con la
    // goma, se vuelve al lápiz. Si no, el trazo siguiente no salía y parecía
    // que el color no había cambiado («le daba a cambiar de color y no
    // pasaba nada»). Con un trazo elegido no: ahí el color es para ese trazo.
    if (!selectedStrokeId) {
      if (drawTool !== 'pen') setDrawTool('pen');
      return;
    }
    applyDrawChange(list => list.map(s => s.id === selectedStrokeId ? { ...s, color: hex } : s));
  };

  const deleteSelectedStroke = () => {
    if (!selectedStrokeId) return;
    eraseStroke(selectedStrokeId);
    setSelectedStrokeId(null);
  };

  const drawUndo = () => setDrawSession(s => {
    if (!s || !s.past.length) return s;
    const prev = s.past[s.past.length - 1];
    return { strokes: prev, past: s.past.slice(0, -1), future: [s.strokes, ...s.future].slice(0, 60) };
  });

  const drawRedo = () => setDrawSession(s => {
    if (!s || !s.future.length) return s;
    return { strokes: s.future[0], past: [...s.past, s.strokes], future: s.future.slice(1) };
  });

  const exitDrawMode = () => {
    setDrawingId(null);
    setDrawSession(null);
    setSelectedStrokeId(null);
    drawMovingRef.current = false;
  };

  // Guardar: la caja del nodo se ajusta a lo que hay dibujado (si no, quedaría
  // un rectángulo enorme medio vacío) y los trazos pasan al espacio del nodo.
  const saveDrawing = () => {
    const D = window.OdiDraw;
    const id = drawingId;
    const session = drawSession;
    if (!id || !session) { exitDrawMode(); return; }
    const strokes = session.strokes;
    if (!strokes.length) {
      // Se borró todo: el nodo vacío no pinta nada en el lienzo.
      deleteItem(id);
      exitDrawMode();
      return;
    }
    const b = D.strokesBounds(strokes);
    const pad = 6;
    const x = Math.round(b.x - pad);
    const y = Math.round(b.y - pad);
    const w = Math.round(b.w + pad * 2);
    const h = Math.round(b.h + pad * 2);
    window.odiTrack && window.odiTrack('dibujo_guardado', { trazos: strokes.length });
    updateItem(id, {
      strokes: D.translateStrokes(strokes, -x, -y),
      x, y, w, h, vw: w, vh: h,
      _startDrawing: false,
    });
    exitDrawMode();
    window.playAudioTone && window.playAudioTone('click');
  };

  const discardDrawing = () => {
    const id = drawingId;
    const it = id ? (canvasesLiveRef.current[currentId].items || []).find(i => i.id === id) : null;
    // Un nodo recién creado que se descarta sin un solo trazo no debe quedarse
    // como una caja invisible en medio del lienzo.
    if (it && it.type === 'draw' && !(it.strokes || []).length) deleteItem(id);
    exitDrawMode();
  };

  // Un nodo de dibujo recién soltado abre el modo por su cuenta.
  //
  // Se exige también `_new`, que el lienzo apaga a los 300 ms de crear un nodo
  // y por tanto solo vale dentro de esta sesión. Sin eso, un nodo que se quedó
  // a medias (se recargó la página antes de guardar) conservaba la marca en
  // disco y le robaba el modo dibujo al siguiente nodo que se creara.
  useEffectCanvas(() => {
    if (drawingId) return;
    const pend = (current.items || []).find(i => i.type === 'draw' && i._startDrawing && i._new);
    if (pend) enterDrawMode(pend.id);
    // eslint-disable-next-line
  }, [current.items, drawingId]);

  // Si el nodo que se estaba dibujando desaparece (deshacer, borrado desde
  // otro sitio), el modo se cierra solo en vez de quedarse pintando en el aire.
  useEffectCanvas(() => {
    if (!drawingId) return;
    if (!(current.items || []).some(i => i.id === drawingId)) exitDrawMode();
    // eslint-disable-next-line
  }, [current.items, drawingId]);

  const updateConnector = useCallbackCanvas((connId, patch) => {
    skipHistory.current = true;
    setCanvases(prev => {
      const c = prev[currentId];
      return { ...prev, [currentId]: { ...c, connectors: (c.connectors || []).map(co => co.id === connId ? { ...co, ...patch } : co) } };
    });
  // eslint-disable-next-line
  }, [currentId]);

  const addConnector = (fromEnd, toEnd) => {
    window.playAudioTone && window.playAudioTone('connect');
    setCanvases(prev => {
      const c = prev[currentId];
      const conns = c.connectors || [];
      const newConn = {
        id: `cn-${Date.now()}-${Math.floor(Math.random()*9999)}`,
        fromEnd, toEnd,
        bend: { x: 0, y: 0 },
        isColorExplicit: false,
      };
      return { ...prev, [currentId]: { ...c, connectors: [...conns, newConn] } };
    });
  };

  // El candado de una flecha. Si está bloqueada por sus nodos, quitarle el
  // suyo no la suelta: se explica en vez de no hacer nada.
  const alternaBloqueoFlecha = (connId) => {
    const co = (current.connectors || []).find(x => x.id === connId);
    if (!co) return;
    if (!co.bloqueado && flechaPorNodos(co)) {
      window.showToast && window.showToast(window.t('Está bloqueada porque uno de sus nodos tiene el candado.', 'It is locked because one of its nodes is locked.'));
      return;
    }
    setCanvases(prev => {
      const c = prev[currentId];
      if (!c) return prev;
      return { ...prev, [currentId]: { ...c, connectors: (c.connectors || []).map(x => x.id === connId ? { ...x, bloqueado: !co.bloqueado } : x) } };
    });
    window.playAudioTone && window.playAudioTone('click');
  };

  const deleteConnector = (connId) => {
    window.playAudioTone && window.playAudioTone('delete');
    setCanvases(prev => {
      const c = prev[currentId];
      return { ...prev, [currentId]: { ...c, connectors: (c.connectors || []).filter(co => co.id !== connId) } };
    });
    setSelectedConn(null);
  };

  // ───── Surface mousedown ─────
  // ───── La barra espaciadora mueve el lienzo ─────
  //
  // Es el gesto que traen aprendido los que vienen de Photoshop, Figma o
  // Blender, y saca del apuro a los ratones sin botón central.
  //
  // Lo delicado es no robarle el espacio a quien está escribiendo: dentro de
  // una nota, de un título o de cualquier campo, el espacio es un espacio y
  // punto. Por eso se mira antes dónde está el foco. También se suelta al
  // perder la ventana, o al volver el espacio se habría quedado "pulsado".
  useEffectCanvas(() => {
    // ¿Se está escribiendo DE VERDAD?
    //
    // No basta con mirar si el foco está en algo editable. Al seleccionar un
    // nodo, el foco se queda dentro de él —en el div de su texto— aunque no
    // se esté escribiendo nada, así que con la comprobación ingenua la barra
    // espaciadora dejaba de funcionar en cuanto tocabas un nodo: era el caso
    // normal, no el raro.
    //
    // Un campo de formulario (el buscador, un título) siempre manda: ahí el
    // espacio es un espacio. Y cualquier texto editable también, MENOS el
    // campo invisible de pegar imágenes, que es el que se queda con el foco al
    // elegir un nodo. Antes un texto solo contaba si había un nodo ABIERTO
    // para editar, y el editor de documentos y las leyendas de las imágenes
    // (que se escriben sin abrir el nodo) se quedaban sin espacios: lo avisó
    // alguien que usaba la 1.2.0.
    const escribiendo = () => {
      const ae = document.activeElement;
      if (!ae) return false;
      if (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA') return true;
      if (ae.isContentEditable) {
        if (ae === pasteIntRef.current || (ae.closest && ae.closest('[data-paste-interceptor]'))) return false;
        return true;
      }
      return false;
    };
    const bajar = (e) => {
      if (e.code !== 'Space' || e.repeat || escribiendo()) return;
      if (e.ctrlKey || e.metaKey || e.altKey) return;
      espacioRef.current = true;
      setEspacio(true);
      // Sin esto la página se desplaza y, si hay un botón con el foco, el
      // espacio lo pulsa.
      e.preventDefault();
    };
    const soltar = () => { espacioRef.current = false; setEspacio(false); };
    const subir = (e) => { if (e.code === 'Space') soltar(); };
    window.addEventListener('keydown', bajar);
    window.addEventListener('keyup', subir);
    window.addEventListener('blur', soltar);
    return () => {
      window.removeEventListener('keydown', bajar);
      window.removeEventListener('keyup', subir);
      window.removeEventListener('blur', soltar);
    };
  }, []);

  // Arrastrar el lienzo. Lo comparten el botón central, Alt y la barra
  // espaciadora, así que vive aquí y no dentro de un solo controlador.
  const empiezaPan = (e) => {
    e.preventDefault();
    const startX = e.clientX, startY = e.clientY, startPan = { ...panRef.current };
    setPaneando(true);
    const onMove = (ev) => setPan({ x: startPan.x + ev.clientX - startX, y: startPan.y + ev.clientY - startY });
    const onUp = () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
      setPaneando(false);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  // Con el espacio pulsado, TODO arrastra el lienzo: da igual que el ratón
  // esté sobre un nodo, sobre un tirador o sobre una flecha. Por eso esta
  // comprobación se llama la primera en cada gesto y corta el resto.
  const panearSiEspacio = (e) => {
    if (!espacioRef.current || e.button !== 0) return false;
    e.stopPropagation();
    empiezaPan(e);
    return true;
  };

  const onSurfaceMouseDown = (e) => {
    if (panearSiEspacio(e)) return;
    if (e.button === 2) return; // right-click is handled by the create menu, not the marquee
    if (!surfaceRef.current.contains(e.target)) return;
    const isSurface =
      e.target === surfaceRef.current ||
      e.target.classList.contains('canvas-surface') ||
      e.target.classList.contains('canvas-content') ||
      e.target.classList.contains('connectors') ||
      e.target.classList.contains('board-cover-grid');
    // Con una herramienta elegida, un clic ENCIMA de un nodo también pone el
    // nodo nuevo (como en Miro o FigJam). Antes no pasaba nada: la herramienta
    // seguía elegida y lo que se tecleaba después se perdía. Los campos de
    // texto se quedan fuera: ahí el clic es para escribir.
    const sobreNodo = activeTool && activeTool !== 'line' && !!e.target.closest('[data-item-id]') &&
      !e.target.closest('input, textarea, select, [contenteditable="true"]');

    if (!isSurface && !sobreNodo) return;

    // Commit any open inline editor (cell/event/etc.) — the marquee preventDefault below would
    // otherwise keep the focused input from blurring, leaving it stuck in edit mode.
    const ae = document.activeElement;
    if (ae && ae !== pasteIntRef.current && (ae.tagName === 'INPUT' || ae.tagName === 'TEXTAREA' || ae.isContentEditable)) {
      ae.blur();
    }

    setContextMenu(null);
    setShowBgSelector(false);

    // panning
    if (e.button === 1 || e.altKey) {
      empiezaPan(e);
      return;
    }

    // line tool from empty canvas → start a free arrow
    if (activeTool === 'line') {
      e.preventDefault();
      startLineDrag(e, null);
      return;
    }

    // tool placement (drag-to-create)
    if (activeTool && activeTool !== 'line') {
      e.preventDefault();
      const startPt = screenToCanvas(e.clientX, e.clientY);
      setDragCreate({ sx: startPt.x, sy: startPt.y, x: startPt.x, y: startPt.y, w: 0, h: 0 });

      const onMove = (ev) => {
        const p = screenToCanvas(ev.clientX, ev.clientY);
        setDragCreate(d => d && ({
          sx: d.sx, sy: d.sy,
          x: Math.min(d.sx, p.x),
          y: Math.min(d.sy, p.y),
          w: Math.abs(p.x - d.sx),
          h: Math.abs(p.y - d.sy),
        }));
      };
      const onUp = (ev) => {
        window.removeEventListener('mousemove', onMove);
        window.removeEventListener('mouseup', onUp);
        const p = screenToCanvas(ev.clientX, ev.clientY);
        const sx = startPt.x, sy = startPt.y;
        const dx = Math.abs(p.x - sx);
        const dy = Math.abs(p.y - sy);
        const small = dx < 14 && dy < 14;
        const w = small ? 0 : dx;
        const h = small ? 0 : dy;
        const def = defaultDims(activeTool);
        const x = small ? sx - def.w / 2 : Math.min(sx, p.x);
        const y = small ? sy - def.h / 2 : Math.min(sy, p.y);
        const item = makeNewItem(activeTool, x, y, w, h, lang);
        if (item) {
          window.playAudioTone && window.playAudioTone('create');
          setCanvases(prev => {
            const c = prev[currentId];
            const next = { ...prev, [currentId]: { ...c, items: [...c.items, item] } };
            if (item.type === 'board') {
              next[item.canvasId] = {
                title: item.content,
                parent: currentId,
                parentLabel: c.title,
                items: [], connectors: [],
              };
            }
            return next;
          });
          setSelected(item.id);
          // auto-enter edit mode for text types
          if (['note','comment','bigtitle','shape','separator'].includes(item.type) && !skipAutoEdit()) {
            setTimeout(() => setEditing(item.id), 40);
          }
          if (item.type === 'doc') {
            setTimeout(() => setDocOpen({ id: item.id }), 40);
          }
          if (['link','todo','board','column','map','frame'].includes(item.type)) {
            // for these, edit mode just allows inline rename / URL input
            setTimeout(() => setEditing(item.id), 40);
          }
        }
        setDragCreate(null);
        setActiveTool(null);
      };
      window.addEventListener('mousemove', onMove);
      window.addEventListener('mouseup', onUp);
      return;
    }

    // Drag-rectangle selection (marquee) — empty canvas, no tool
    e.preventDefault();
    const startPt = screenToCanvas(e.clientX, e.clientY);
    let didDrag = false;
    setMarquee({ sx: startPt.x, sy: startPt.y, x: startPt.x, y: startPt.y, w: 0, h: 0 });

    const onMove = (ev) => {
      const p = screenToCanvas(ev.clientX, ev.clientY);
      const dx = ev.clientX - e.clientX;
      const dy = ev.clientY - e.clientY;
      if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didDrag = true;
      setMarquee({
        sx: startPt.x, sy: startPt.y,
        x: Math.min(startPt.x, p.x),
        y: Math.min(startPt.y, p.y),
        w: Math.abs(p.x - startPt.x),
        h: Math.abs(p.y - startPt.y),
      });
    };
    const onUp = (ev) => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
      const p = screenToCanvas(ev.clientX, ev.clientY);
      setMarquee(null);

      if (!didDrag) {
        if (e.shiftKey || ev.shiftKey) {
          // Si hace Shift+Clic en el lienzo vacio, no deseleccionamos nada
          return;
        }
        // Click on empty canvas → just deselect
        if (croppingId) setCroppingId(null);
        setSelected(null);
        setSelectedIds([]);
        setSelectedConn(null);
        setEditing(null);
        setEditingChildState(null);
        return;
      }

      // Compute selection rect in canvas coords
      const rx = Math.min(startPt.x, p.x);
      const ry = Math.min(startPt.y, p.y);
      const rw = Math.abs(p.x - startPt.x);
      const rh = Math.abs(p.y - startPt.y);
      // Tocar un nodo de un grupo con el recuadro coge el grupo entero.
      const hits = conGrupos(current.items.filter(it => {
        const { x: ix, y: iy, w: iw, h: ih } = cajaVisible(it);
        // intersection test (any overlap counts)
        return !(ix + iw < rx || ix > rx + rw || iy + ih < ry || iy > ry + rh);
      }).map(it => it.id));

      if (e.shiftKey || ev.shiftKey) {
        // Seleccion aditiva con Shift
        let baseSelected = [...selectedIds];
        if (selected && !baseSelected.includes(selected)) {
          baseSelected.push(selected);
        }
        const union = Array.from(new Set([...baseSelected, ...hits]));
        setSelectedIds(union);
        setSelected(union.length === 1 ? union[0] : null);
      } else {
        setSelectedIds(hits);
        setSelected(hits.length === 1 ? hits[0] : null);
      }
      setSelectedConn(null);
      setEditing(null);
      setEditingChildState(null);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  // Double-click on empty canvas → drop a note where the cursor is
  const onSurfaceDoubleClick = (e) => {
    if (activeTool) return;
    if (!surfaceRef.current || !surfaceRef.current.contains(e.target)) return;
    const isEmpty =
      e.target === surfaceRef.current ||
      e.target.classList.contains('canvas-surface') ||
      e.target.classList.contains('canvas-content') ||
      e.target.classList.contains('connectors');
    if (!isEmpty) return;
    const p = screenToCanvas(e.clientX, e.clientY);
    const def = defaultDims('note');
    const item = makeNewItem('note', p.x - def.w / 2, p.y - def.h / 2, 0, 0, lang);
    if (!item) return;
    window.playAudioTone && window.playAudioTone('create');
    setCanvases(prev => {
      const c = prev[currentId];
      return { ...prev, [currentId]: { ...c, items: [...c.items, item] } };
    });
    setSelected(item.id);
    setSelectedIds([]);
    setTimeout(() => setEditing(item.id), 40);
  };

  // Create a node centered at the given canvas coords (used by the right-click menu)
  const createNodeAt = (type, cx, cy) => {
    if (type === 'line') { setActiveTool('line'); return; }
    const def = defaultDims(type);
    const item = makeNewItem(type, cx - def.w / 2, cy - def.h / 2, 0, 0, lang);
    if (!item) return;
    window.playAudioTone && window.playAudioTone('create');
    setCanvases(prev => {
      const c = prev[currentId];
      const next = { ...prev, [currentId]: { ...c, items: [...c.items, item] } };
      if (item.type === 'board') {
        next[item.canvasId] = { title: item.content, parent: currentId, parentLabel: c.title, items: [], connectors: [] };
      }
      return next;
    });
    setSelected(item.id);
    setSelectedIds([]);
    if (item.type === 'doc') setTimeout(() => setDocOpen({ id: item.id }), 40);
    else if (['note','comment','link','todo','board','column','shape','separator'].includes(item.type)) setTimeout(() => setEditing(item.id), 40);
  };

  const selectAllItems = () => {
    const itemIds = current.items.map(i => i.id);
    const connIds = (current.connectors || []).map(c => c.id);
    setSelectedIds([...itemIds, ...connIds]);
    setSelected(null);
    setSelectedConn(null);
  };

  // Right-click on empty canvas → quick-create menu
  const onSurfaceContextMenu = (e) => {
    if (activeTool) return;
    if (e.target.closest('.item') || e.target.closest('.connector-hit')) return;
    e.preventDefault();
    const rect = surfaceRef.current.getBoundingClientRect();
    const cpt = screenToCanvas(e.clientX, e.clientY);
    setContextMenu({ x: e.clientX - rect.left, y: e.clientY - rect.top, canvas: true, cx: cpt.x, cy: cpt.y });
  };

  // ───── Item drag (with column drop detection) ─────
  // ── Grupos y candados ──
  //
  // Un grupo es una marca en cada nodo (grupo: 'g-…'), no un objeto aparte:
  // así viaja, se copia y se deshace con el nodo sin tocar nada más. Pulsar
  // cualquiera coge el grupo entero (ver startDragItem). Un nodo bloqueado
  // (bloqueado: true) se puede elegir —para desbloquearlo— pero no se mueve,
  // no se estira, no se gira ni se borra: el candado que pidió quien probó.
  // La caja que ocupa un nodo en pantalla, contando su giro: el recuadro de
  // selección medía la caja sin girar, y un nodo torcido no se cogía por la
  // esquina que asoma.
  const cajaVisible = (it) => {
    const w = it.w !== undefined ? it.w : defaultDims(it.type).w;
    const h = it.type === 'frame' ? 36 : (it.h !== undefined ? it.h : defaultDims(it.type).h);
    if (!it.rot) return { x: it.x, y: it.y, w, h };
    const a = it.rot * Math.PI / 180, cs = Math.abs(Math.cos(a)), sn = Math.abs(Math.sin(a));
    const W = w * cs + h * sn, H = w * sn + h * cs;
    const cx = it.x + w / 2, cy = it.y + h / 2;
    return { x: cx - W / 2, y: cy - H / 2, w: W, h: H };
  };
  const conGrupos = (ids) => {
    const items = current.items || [];
    const grupos = new Set(items.filter(it => ids.includes(it.id) && it.grupo).map(it => it.grupo));
    if (!grupos.size) return ids;
    return Array.from(new Set([...ids, ...items.filter(it => it.grupo && grupos.has(it.grupo)).map(it => it.id)]));
  };
  const idsElegidos = () => (selectedIds.length ? selectedIds : (selected ? [selected] : []));
  // Alinear, repartir o igualar el tamaño de los elegidos (src/alinea.js).
  const alineaElegidos = (modo) => {
    const ids = idsElegidos();
    setCanvases(prev => {
      const c = prev[currentId];
      const items = window.OdiAlinea.aplica(c.items, ids, modo, defaultDims);
      return items === c.items ? prev : { ...prev, [currentId]: { ...c, items } };
    });
    window.playAudioTone && window.playAudioTone('click');
  };
  const agrupaSeleccion = () => {
    const ids = conGrupos(selectedIds);
    if (ids.length < 2) return;
    const g = `g-${Date.now()}-${Math.floor(Math.random() * 99999)}`;
    setCanvases(prev => {
      const c = prev[currentId];
      return { ...prev, [currentId]: { ...c, items: c.items.map(it => ids.includes(it.id) ? { ...it, grupo: g } : it) } };
    });
    setSelected(null); setSelectedIds(ids);
    window.playAudioTone && window.playAudioTone('create');
  };
  const desagrupaSeleccion = () => {
    const ids = idsElegidos();
    const grupos = new Set(current.items.filter(it => ids.includes(it.id) && it.grupo).map(it => it.grupo));
    if (!grupos.size) return;
    setCanvases(prev => {
      const c = prev[currentId];
      return { ...prev, [currentId]: { ...c, items: c.items.map(it => (it.grupo && grupos.has(it.grupo)) ? { ...it, grupo: null } : it) } };
    });
    window.playAudioTone && window.playAudioTone('click');
  };
  const alternaBloqueo = () => {
    const ids = idsElegidos();
    if (!ids.length) return;
    const bloquear = current.items.some(it => ids.includes(it.id) && !it.bloqueado);
    setCanvases(prev => {
      const c = prev[currentId];
      return { ...prev, [currentId]: { ...c, items: c.items.map(it => ids.includes(it.id) ? { ...it, bloqueado: bloquear } : it) } };
    });
    window.playAudioTone && window.playAudioTone('click');
  };
  const avisaBloqueado = () => window.showToast && window.showToast(window.t(
    'Está bloqueado: quítale el candado para moverlo o borrarlo.',
    'It is locked: unlock it to move or delete it.'));
  // Una flecha está bloqueada si tiene su candado o si está pegada a un nodo
  // que lo tiene (Connector.jsx hace la misma cuenta para no dejarla mover).
  // Con la misma caja que pinta la flecha (window.getNodeRect): así cuenta
  // también una tarjeta dentro de una columna bloqueada.
  const nodoDeExtremo = (end) => (end && end.itemId && window.getNodeRect) ? window.getNodeRect(end.itemId, current.items || []) : null;
  const flechaPorNodos = (conn) => {
    const a = nodoDeExtremo(conn.fromEnd || (conn.from ? { itemId: conn.from } : null));
    const b = nodoDeExtremo(conn.toEnd || (conn.to ? { itemId: conn.to } : null));
    return !!((a && a.bloqueado) || (b && b.bloqueado));
  };
  const flechaBloqueada = (conn) => !!(conn && (conn.bloqueado || flechaPorNodos(conn)));
  const avisaFlechaBloqueada = () => window.showToast && window.showToast(window.t(
    'Está bloqueada: quítale el candado (a ella o a sus nodos) para moverla o borrarla.',
    'It is locked: unlock it (or its nodes) to move or delete it.'));

  // ── Las plantillas (window.OdiPlantillas) ──
  // Se ponen en el centro de lo que se está viendo, de una vez (un solo paso de
  // deshacer) y quedan elegidas, para poder moverlas juntas a otro sitio.
  const [plantillasAbiertas, setPlantillasAbiertas] = useStateCanvas(false);
  // Como cualquier menú: Escape o un clic fuera lo cierran. Sin esto se quedaba
  // abierto tapando el lienzo hasta volver a pulsar el botón.
  useEffectCanvas(() => {
    if (!plantillasAbiertas) return;
    const tecla = (e) => { if (e.key === 'Escape') { e.stopPropagation(); setPlantillasAbiertas(false); } };
    const fuera = (e) => { if (!e.target.closest || !e.target.closest('.plantillas-esquina')) setPlantillasAbiertas(false); };
    document.addEventListener('keydown', tecla, true);
    document.addEventListener('pointerdown', fuera, true);
    return () => {
      document.removeEventListener('keydown', tecla, true);
      document.removeEventListener('pointerdown', fuera, true);
    };
  }, [plantillasAbiertas]);
  const ponPlantilla = (id) => {
    const P = window.OdiPlantillas;
    const pl = P && P.PLANTILLAS.find(x => x.id === id);
    if (!pl) return;
    const { w, h } = P.caja(pl);
    const rect = surfaceRef.current ? surfaceRef.current.getBoundingClientRect() : { width: window.innerWidth, height: window.innerHeight };
    let ox = Math.round((rect.width / 2 - pan.x) / scale - w / 2);
    const oy = Math.round((rect.height / 2 - pan.y) / scale - h / 2);
    // Que no caiga encima de lo que ya hay: si su caja pisa algún nodo, se
    // corre a la derecha de lo que pisa (hasta veinte veces) y la vista va con
    // ella. Antes se ponía en el centro aunque hubiera un tablero lleno debajo.
    const existentes = (current && current.items) || [];
    const margen = 40;
    const pisa = (x0) => existentes.filter(it => it.x < x0 + w + margen && it.x + (it.w || 0) > x0 - margen
      && it.y < oy + h + margen && it.y + (it.h || 0) > oy - margen);
    let corrida = false;
    for (let i = 0; i < 20; i++) {
      const p = pisa(ox);
      if (!p.length) break;
      ox = Math.round(Math.max(...p.map(it => it.x + (it.w || 0))) + 60);
      corrida = true;
    }
    if (corrida) setPan({ x: Math.round(rect.width / 2 - (ox + w / 2) * scale), y: pan.y });
    const sello = Date.now();
    const porRef = {};
    // Sin «const { a, ...resto } = n»: el Babel del navegador lo convierte en
    // una variable global _excluded, y otro archivo ya tiene la suya; al
    // cargarlos juntos la página se quedaba en blanco.
    const sinCampos = (o, fuera) => { const r = {}; Object.keys(o).forEach(k => { if (fuera.indexOf(k) === -1) r[k] = o[k]; }); return r; };
    // En los otros nueve idiomas, cada texto { es, en } de la plantilla se
    // traduce al ponerla (y se guarda en su idioma dentro del propio texto,
    // que es lo primero que mira pickLang). Las notas son «título + ayuda» en
    // HTML: se traduce cada trozo. Lo que no está en el catálogo se queda en
    // inglés, como antes.
    const traduceTextos = (o) => {
      if (lang === 'es' || lang === 'en' || !window.odiTraduce || !window.odiTraduce.fija) return o;
      const fija = (es, en) => window.odiTraduce.fija(lang, es, en);
      const visita = (v) => {
        if (Array.isArray(v)) { v.forEach(visita); return; }
        if (!v || typeof v !== 'object') return;
        if (typeof v.es === 'string' && typeof v.en === 'string' && !v[lang]) {
          let t = fija(v.es, v.en);
          if (!t) {
            const re = /^<p><b>([\s\S]*?)<\/b><\/p><p>([\s\S]*?)<\/p>$/;
            const me = v.es.match(re), mn = v.en.match(re);
            if (me && mn) {
              const ti = fija(me[1], mn[1]), ay = fija(me[2], mn[2]);
              if (ti || ay) t = '<p><b>' + (ti || mn[1]) + '</b></p><p>' + (ay || mn[2]) + '</p>';
            }
          }
          if (t) v[lang] = t;
        }
        Object.keys(v).forEach(k => visita(v[k]));
      };
      visita(o);
      return o;
    };
    const nuevos = pl.nodos.map((n, k) => {
      const { ref, tipo, x, y, w: nw, h: nh, hijos } = n;
      const resto = sinCampos(n, ['ref', 'tipo', 'x', 'y', 'w', 'h', 'hijos']);
      const it = makeNewItem(tipo, ox + x, oy + y, nw, nh, lang);
      // makeNewItem pone el id con la hora en milisegundos: doce nodos en el
      // mismo milisegundo podrían repetirlo.
      it.id = `it-${sello}-pl${k}-${Math.floor(Math.random() * 99999)}`;
      Object.assign(it, traduceTextos(JSON.parse(JSON.stringify(resto))));
      if (hijos) {
        it.children = hijos.map((hj, j) => {
          const tipoHijo = hj.tipo;
          const restoHijo = sinCampos(hj, ['tipo']);
          const hijo = makeNewItem(tipoHijo, 0, 0, undefined, hj.h, lang);
          return { ...hijo, ...traduceTextos(JSON.parse(JSON.stringify(restoHijo))), id: `${it.id}-h${j}` };
        });
      }
      porRef[ref] = it.id;
      return it;
    });
    const flechas = (pl.flechas || []).map(([a, b], k) => ({
      id: `cn-${sello}-pl${k}-${Math.floor(Math.random() * 9999)}`,
      fromEnd: { itemId: porRef[a] }, toEnd: { itemId: porRef[b] },
      bend: { x: 0, y: 0 }, isColorExplicit: false,
    }));
    setCanvases(prev => {
      const c = prev[currentId];
      return { ...prev, [currentId]: { ...c, items: [...c.items, ...nuevos], connectors: [...(c.connectors || []), ...flechas] } };
    });
    setSelected(null);
    setSelectedIds(nuevos.map(n => n.id));
    window.playAudioTone && window.playAudioTone('create');
    window.showToast && window.showToast(window.t(
      `Plantilla puesta: ${pl.nombre.es}. Está elegida entera: arrástrala para moverla.`,
      `Template added: ${pl.nombre.en}. It is all selected: drag it to move it.`));
  };

  const startDragItem = (e, itemId) => {
    if (panearSiEspacio(e)) return;
    if (e.target.closest('input, textarea, button, .todo-check, .swatch-btn, .anchor, .todo-add, .cal-mb-input, .cal-mb-cell, .cal-mb-nav')) return;

    if (e.shiftKey) {
      e.stopPropagation();
      e.preventDefault();
      // Iniciar el marquee de selección desde esta posición
      const startPt = screenToCanvas(e.clientX, e.clientY);
      let didDrag = false;
      setMarquee({ sx: startPt.x, sy: startPt.y, x: startPt.x, y: startPt.y, w: 0, h: 0 });

      const onMove = (ev) => {
        const p = screenToCanvas(ev.clientX, ev.clientY);
        const dx = ev.clientX - e.clientX;
        const dy = ev.clientY - e.clientY;
        if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didDrag = true;
        setMarquee({
          sx: startPt.x, sy: startPt.y,
          x: Math.min(startPt.x, p.x),
          y: Math.min(startPt.y, p.y),
          w: Math.abs(p.x - startPt.x),
          h: Math.abs(p.y - startPt.y),
        });
      };

      const onUp = (ev) => {
        window.removeEventListener('mousemove', onMove);
        window.removeEventListener('mouseup', onUp);
        setMarquee(null);

        if (!didDrag) {
          // Fue un Shift+Clic individual -> Hacer toggle del item en la selección
          let nextSelectedIds = [...selectedIds];
          if (selected && !nextSelectedIds.includes(selected)) {
            nextSelectedIds.push(selected);
          }
          if (nextSelectedIds.includes(itemId)) {
            nextSelectedIds = nextSelectedIds.filter(id => id !== itemId);
          } else {
            nextSelectedIds.push(itemId);
          }
          
          if (nextSelectedIds.length === 1) {
            setSelected(nextSelectedIds[0]);
            setSelectedIds([]);
          } else if (nextSelectedIds.length > 1) {
            setSelected(null);
            setSelectedIds(nextSelectedIds);
          } else {
            setSelected(null);
            setSelectedIds([]);
          }
          setSelectedConn(null);
          setEditing(null);
          setEditingChildState(null);
          return;
        }

        // Fue un Shift+Arrastrar -> Seleccionar los elementos dentro del recuadro
        // y agregarlos/combinarlos con la selección existente
        const p = screenToCanvas(ev.clientX, ev.clientY);
        const rx = Math.min(startPt.x, p.x);
        const ry = Math.min(startPt.y, p.y);
        const rw = Math.abs(p.x - startPt.x);
        const rh = Math.abs(p.y - startPt.y);
        const hits = conGrupos(current.items.filter(it => {
          const { x: ix, y: iy, w: iw, h: ih } = cajaVisible(it);
          return !(ix + iw < rx || ix > rx + rw || iy + ih < ry || iy > ry + rh);
        }).map(it => it.id));

        // Agregamos los nuevos elementos seleccionados a los que ya estaban
        let baseSelected = [...selectedIds];
        if (selected && !baseSelected.includes(selected)) {
          baseSelected.push(selected);
        }
        
        // Unir ambas listas de forma única
        const union = Array.from(new Set([...baseSelected, ...hits]));
        if (union.length === 1) {
          setSelected(union[0]);
          setSelectedIds([]);
        } else if (union.length > 1) {
          setSelected(null);
          setSelectedIds(union);
        } else {
          setSelected(null);
          setSelectedIds([]);
        }
        setSelectedConn(null);
        setEditing(null);
        setEditingChildState(null);
      };

      window.addEventListener('mousemove', onMove);
      window.addEventListener('mouseup', onUp);
      return;
    }
    // A column may only be grabbed by its top strip — clicking the gray body just selects it
    // (so its children stay interactive and the body isn't a drag handle).
    {
      const draggedItem = current.items.find(i => i.id === itemId);
      if (draggedItem && draggedItem.type === 'column' && e.target.closest('.column-body')) {
        // Con una herramienta elegida el clic sigue hasta el lienzo, que pone
        // ahí el nodo nuevo.
        if (!e.target.closest('.col-child-wrap') && !(activeTool && activeTool !== 'line')) {
          e.stopPropagation();
          setSelected(itemId); setSelectedIds([]); setSelectedConn(null); setContextMenu(null);
          setEditingChildState(null);
        }
      return;
    }
    }
    {
      const draggedItem = current.items.find(i => i.id === itemId);
      if (draggedItem && draggedItem.type === 'frame' && !e.target.closest('.frame-header')) {
        return;
      }
    }
    if (editing === itemId) return;
    if (editing && editing !== itemId) {
      setEditing(null);
      setEditingChildState(null);
    }
    if (activeTool === 'line') return; // handled by startLineDrag
    if (activeTool) return;
    e.stopPropagation();
    // Multi-drag: if the clicked item is part of multi-selection, move all together.
    // Y un grupo se coge entero: pulsar cualquiera de sus nodos los coge a
    // todos. Si el grupo ya estaba cogido, pulsar uno sin moverlo lo elige a
    // él solo (para escribir en él o cambiarle el color), como en cualquier
    // editor.
    const pulsado = current.items.find(i => i.id === itemId);
    const delGrupo = pulsado && pulsado.grupo ? current.items.filter(it => it.grupo === pulsado.grupo).map(it => it.id) : [];
    const grupoYaCogido = delGrupo.length > 1 && delGrupo.every(id => selectedIds.includes(id));
    const enSeleccion = selectedIds.length > 1 && selectedIds.includes(itemId);
    const multiIds = enSeleccion ? conGrupos(selectedIds) : (delGrupo.length > 1 ? delGrupo : null);
    const isMultiDrag = !!multiIds;
    if (!isMultiDrag) setSelectedIds([]); else setSelectedIds(multiIds);
    setSelected(isMultiDrag ? null : itemId);
    // Bloqueado: se elige (para poder desbloquearlo) pero no se mueve.
    if (pulsado && pulsado.bloqueado && !isMultiDrag) {
      setEditingChildState(null); setSelectedConn(null); setContextMenu(null);
      return;
    }
    setEditingChildState(null);
    setSelectedConn(null);
    setContextMenu(null);
    document.body.classList.add('odi-busy');
    const startX = e.clientX, startY = e.clientY;
    const item = current.items.find(i => i.id === itemId);
    if (!item) { document.body.classList.remove('odi-busy'); return; }
    const startItemX = item.x, startItemY = item.y;
    // Snapshot original positions for multi-drag
    // Los bloqueados de la selección se quedan quietos.
    const multiStart = isMultiDrag
      ? current.items.filter(it => multiIds.includes(it.id) && !it.bloqueado).map(it => ({ id: it.id, x: it.x, y: it.y }))
      : null;
    
    // Frame drag: snapshot geometrically-contained items, incluyendo MARCOS ANIDADOS
    // y su contenido (de forma recursiva).
    let frameChildrenStart = null;
    if (item.type === 'frame' && !isMultiDrag) {
      const sizeOf = (it) => ({
        w: it.w !== undefined ? it.w : (defaultDims ? defaultDims(it.type).w : 200),
        h: it.h !== undefined ? it.h : (defaultDims ? defaultDims(it.type).h : 120),
      });

      // Hijos directos de un marco.
      //  · Nodos normales: basta con que su CENTRO caiga dentro (regla tolerante).
      //  · Marcos: deben estar CONTENIDOS POR COMPLETO y ser de menor área. Esto
      //    hace la relación de un solo sentido por geometría — un marco grande
      //    nunca cabe entero dentro de uno pequeño, así que el hijo jamás
      //    arrastra al padre.
      const directChildren = (frame) => {
        const fw = frame.w || 400, fh = frame.h || 400;
        return current.items.filter(it => {
          if (it.id === frame.id || it.type === 'line') return false;
          const { w, h } = sizeOf(it);
          if (it.type === 'frame') {
            return it.x >= frame.x && it.y >= frame.y &&
                   it.x + w <= frame.x + fw && it.y + h <= frame.y + fh &&
                   (w * h) < (fw * fh);
          }
          const cx = it.x + w / 2, cy = it.y + h / 2;
          return cx >= frame.x && cx <= frame.x + fw &&
                 cy >= frame.y && cy <= frame.y + fh;
        });
      };

      // Recorrido en profundidad: al mover un marco se lleva sus marcos hijos y
      // todo lo que haya dentro de ellos. El Set evita duplicados y bucles.
      const collected = new Map();
      const walk = (frame) => {
        directChildren(frame).forEach(child => {
          if (collected.has(child.id) || child.id === itemId) return;
          collected.set(child.id, child);
          if (child.type === 'frame') walk(child);
        });
      };
      walk(item);

      frameChildrenStart = Array.from(collected.values()).map(it => ({ id: it.id, x: it.x, y: it.y }));
    }

    const startConnectors = (current.connectors || []).map(co => ({
      id: co.id,
      ortho: (co.ortho || []).map(p => ({ x: p.x, y: p.y }))
    }));
    let moved = false;
    let wasSnappedX = false;
    let wasSnappedY = false;
    let currentDropCol = null;
    let currentDropTodo = null;

    const onMove = (ev) => {
      // Si el mouseup se perdió (se soltó encima de la barra de título, que es
      // otra página, o fuera de la ventana), el nodo seguía al ratón sin botón
      // pulsado hasta el siguiente clic. Un movimiento sin botones lo termina.
      if (ev.buttons === 0) { onUp(); return; }
      const dx = (ev.clientX - startX) / scale;
      const dy = (ev.clientY - startY) / scale;
      if (!moved && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) {
        moved = true;
        window.playAudioTone && window.playAudioTone('drag_start');
      }
      if (!moved) return;

      let targetX = startItemX + dx;
      let targetY = startItemY + dy;

      if (ev.shiftKey) {
        // Lock to horizontal or vertical axis based on major movement delta
        if (Math.abs(dx) > Math.abs(dy)) {
          targetY = startItemY;
        } else {
          targetX = startItemX;
        }
        // Snap to 20px grid
        targetX = Math.round(targetX / 20) * 20;
        targetY = Math.round(targetY / 20) * 20;
      }

      let activeGuidesX = [];
      let activeGuidesY = [];

      // Smart alignment guides relative to other items (ALWAYS active)
      const snapThreshold = 12;
      const w = item.w || 200;
      const h = item.h || 120;
      let bestDiffX = snapThreshold;
      let bestDiffY = snapThreshold;

      const currentItems = current.items || [];
      const MAX_ALIGN_DIST = 99999; // Constante de proximidad física perpendicular

      for (const other of currentItems) {
        if (other.id === itemId || other.type === 'line') continue;
        const ow = other.w || 200;
        const oh = other.h || 120;

        // Solo alinear en X (guías verticales) si la distancia vertical entre centros es menor al umbral
        const centerY = targetY + h / 2;
        const otherCenterY = other.y + oh / 2;
        const isNearY = Math.abs(centerY - otherCenterY) < MAX_ALIGN_DIST;

        if (isNearY) {
          // X alignments: left, center, right, left-to-right, right-to-left
          const xOpts = [
            { dragVal: targetX,       otherVal: other.x,        guideVal: other.x,        offset: 0 },
            { dragVal: targetX + w,   otherVal: other.x + ow,   guideVal: other.x + ow,   offset: -w },
            { dragVal: targetX,       otherVal: other.x + ow,   guideVal: other.x + ow,   offset: 0 },
            { dragVal: targetX + w,   otherVal: other.x,        guideVal: other.x,        offset: -w }
          ];
          if (other.type !== 'frame') {
            xOpts.push({ dragVal: targetX + w/2, otherVal: other.x + ow/2, guideVal: other.x + ow/2, offset: -w/2 });
          }
          for (const opt of xOpts) {
            const diff = Math.abs(opt.dragVal - opt.otherVal);
            if (diff < bestDiffX) {
              bestDiffX = diff;
              targetX = opt.otherVal + opt.offset;
              activeGuidesX = [{
                x: opt.guideVal,
                y1: Math.min(targetY, other.y),
                y2: Math.max(targetY + h, other.y + oh)
              }];
            }
          }
        }

        // Solo alinear en Y (guías horizontales) si la distancia horizontal entre centros es menor al umbral
        const centerX = targetX + w / 2;
        const otherCenterX = other.x + ow / 2;
        const isNearX = Math.abs(centerX - otherCenterX) < MAX_ALIGN_DIST;

        if (isNearX) {
          // Y alignments: top, middle, bottom, top-to-bottom, bottom-to-top
          const yOpts = [
            { dragVal: targetY,       otherVal: other.y,        guideVal: other.y,        offset: 0 },
            { dragVal: targetY + h,   otherVal: other.y + oh,   guideVal: other.y + oh,   offset: -h },
            { dragVal: targetY,       otherVal: other.y + oh,   guideVal: other.y + oh,   offset: 0 },
            { dragVal: targetY + h,   otherVal: other.y,        guideVal: other.y,        offset: -h }
          ];
          if (other.type !== 'frame') {
            yOpts.push({ dragVal: targetY + h/2, otherVal: other.y + oh/2, guideVal: other.y + oh/2, offset: -h/2 });
          }
          for (const opt of yOpts) {
            const diff = Math.abs(opt.dragVal - opt.otherVal);
            if (diff < bestDiffY) {
              bestDiffY = diff;
              targetY = opt.otherVal + opt.offset;
              activeGuidesY = [{
                y: opt.guideVal,
                x1: Math.min(targetX, other.x),
                x2: Math.max(targetX + w, other.x + ow)
              }];
            }
          }
        }
      }

      // ── Equal-spacing guides (Canva/Miro-style): snap so the gaps to the nearest
      //    same-row / same-column neighbours are equal, and draw bracket markers. ──
      const spacing = [];
      const SPACE_T = 8;
      const rowMates = currentItems.filter(o => o.id !== itemId && o.type !== 'line'
        && (targetY + h) > o.y + 4 && targetY < (o.y + (o.h || 120)) - 4);    // vertical overlap
      const colMates = currentItems.filter(o => o.id !== itemId && o.type !== 'line'
        && (targetX + w) > o.x + 4 && targetX < (o.x + (o.w || 200)) - 4);    // horizontal overlap

      // Horizontal equal spacing
      let hSnapped = false;
      {
        const Ls = rowMates.filter(o => (o.x + (o.w||200)) <= targetX + SPACE_T).sort((a,b)=>(b.x+(b.w||200))-(a.x+(a.w||200)));
        const Rs = rowMates.filter(o => o.x >= targetX + w - SPACE_T).sort((a,b)=>a.x-b.x);
        
        // Scenario 1: Center (L <- Target -> R)
        if (!hSnapped && Ls.length > 0 && Rs.length > 0) {
          const L = Ls[0], R = Rs[0];
          const lr = L.x + (L.w||200), rl = R.x;
          const gapL = targetX - lr, gapR = rl - (targetX + w);
          if (gapL > -SPACE_T && gapR > -SPACE_T && Math.abs(gapL - gapR) < SPACE_T * 2) {
            const free = rl - lr - w;
            targetX = lr + free / 2;
            const gy = targetY + h / 2;
            spacing.push({ x: lr, y: gy, w: targetX - lr, horizontal: true });
            spacing.push({ x: targetX + w, y: gy, w: rl - (targetX + w), horizontal: true });
            hSnapped = true;
          }
        }
        // Scenario 2: Right of two (A -> B -> Target)
        if (!hSnapped && Ls.length > 1) {
          const B = Ls[0], A = Ls[1];
          const gapAB = B.x - (A.x + (A.w||200));
          const gapBTarget = targetX - (B.x + (B.w||200));
          if (gapAB > 0 && gapBTarget > -SPACE_T && Math.abs(gapAB - gapBTarget) < SPACE_T * 2) {
            targetX = B.x + (B.w||200) + gapAB;
            const gy = targetY + h / 2;
            spacing.push({ x: A.x + (A.w||200), y: gy, w: gapAB, horizontal: true });
            spacing.push({ x: B.x + (B.w||200), y: gy, w: targetX - (B.x + (B.w||200)), horizontal: true });
            hSnapped = true;
          }
        }
        // Scenario 3: Left of two (Target -> A -> B)
        if (!hSnapped && Rs.length > 1) {
          const A = Rs[0], B = Rs[1];
          const gapAB = B.x - (A.x + (A.w||200));
          const gapTargetA = A.x - (targetX + w);
          if (gapAB > 0 && gapTargetA > -SPACE_T && Math.abs(gapAB - gapTargetA) < SPACE_T * 2) {
            targetX = A.x - gapAB - w;
            const gy = targetY + h / 2;
            spacing.push({ x: targetX + w, y: gy, w: A.x - (targetX + w), horizontal: true });
            spacing.push({ x: A.x + (A.w||200), y: gy, w: gapAB, horizontal: true });
            hSnapped = true;
          }
        }
      }

      // Vertical equal spacing
      let vSnapped = false;
      {
        const Ts = colMates.filter(o => (o.y + (o.h||120)) <= targetY + SPACE_T).sort((a,b)=>(b.y+(b.h||120))-(a.y+(a.h||120)));
        const Bns = colMates.filter(o => o.y >= targetY + h - SPACE_T).sort((a,b)=>a.y-b.y);
        
        // Scenario 1: Center (T <- Target -> B)
        if (!vSnapped && Ts.length > 0 && Bns.length > 0) {
          const T = Ts[0], Bn = Bns[0];
          const tb = T.y + (T.h||120), bt = Bn.y;
          const gapT = targetY - tb, gapB = bt - (targetY + h);
          if (gapT > -SPACE_T && gapB > -SPACE_T && Math.abs(gapT - gapB) < SPACE_T * 2) {
            const free = bt - tb - h;
            targetY = tb + free / 2;
            const gx = targetX + w / 2;
            spacing.push({ x: gx, y: tb, h: targetY - tb, horizontal: false });
            spacing.push({ x: gx, y: targetY + h, h: bt - (targetY + h), horizontal: false });
            vSnapped = true;
          }
        }
        // Scenario 2: Below two (A -> B -> Target)
        if (!vSnapped && Ts.length > 1) {
          const B = Ts[0], A = Ts[1];
          const gapAB = B.y - (A.y + (A.h||120));
          const gapBTarget = targetY - (B.y + (B.h||120));
          if (gapAB > 0 && gapBTarget > -SPACE_T && Math.abs(gapAB - gapBTarget) < SPACE_T * 2) {
            targetY = B.y + (B.h||120) + gapAB;
            const gx = targetX + w / 2;
            spacing.push({ x: gx, y: A.y + (A.h||120), h: gapAB, horizontal: false });
            spacing.push({ x: gx, y: B.y + (B.h||120), h: targetY - (B.y + (B.h||120)), horizontal: false });
            vSnapped = true;
          }
        }
        // Scenario 3: Above two (Target -> A -> B)
        if (!vSnapped && Bns.length > 1) {
          const A = Bns[0], B = Bns[1];
          const gapAB = B.y - (A.y + (A.h||120));
          const gapTargetA = A.y - (targetY + h);
          if (gapAB > 0 && gapTargetA > -SPACE_T && Math.abs(gapAB - gapTargetA) < SPACE_T * 2) {
            targetY = A.y - gapAB - h;
            const gx = targetX + w / 2;
            spacing.push({ x: gx, y: targetY + h, h: A.y - (targetY + h), horizontal: false });
            spacing.push({ x: gx, y: A.y + (A.h||120), h: gapAB, horizontal: false });
            vSnapped = true;
          }
        }
      }

      setGuides({ x: activeGuidesX, y: activeGuidesY, spacing });

      if (isMultiDrag && multiStart) {
        // Move all selected items by the same delta
        let finalDx = dx;
        let finalDy = dy;
        if (ev.shiftKey) {
          if (Math.abs(dx) > Math.abs(dy)) {
            finalDy = 0;
          } else {
            finalDx = 0;
          }
        }
        _setCanvases(prev => {
          const c = prev[currentId];
          return { ...prev, [currentId]: {
            ...c,
            items: c.items.map(it => {
              const ms = multiStart.find(m => m.id === it.id);
              if (ms) {
                let nx = ms.x + finalDx;
                let ny = ms.y + finalDy;
                if (ev.shiftKey) {
                  nx = Math.round(nx / 20) * 20;
                  ny = Math.round(ny / 20) * 20;
                }
                return { ...it, x: nx, y: ny, _dragging: true };
              }
              return it;
            }),
            connectors: (c.connectors || []).map(co => {
              const sc = startConnectors.find(x => x.id === co.id);
              if (sc && sc.ortho.length && co.shape === 'orthogonal' && co.fromEnd && multiIds.includes(co.fromEnd.itemId) && co.toEnd && multiIds.includes(co.toEnd.itemId)) {
                return {
                  ...co,
                  ortho: sc.ortho.map(p => ({ x: p.x + finalDx, y: p.y + finalDy }))
                };
              }
              return co;
            })
          }};
        });
        return;
      }

      if (frameChildrenStart && frameChildrenStart.length > 0) {
        const finalDx = targetX - startItemX;
        const finalDy = targetY - startItemY;
        _setCanvases(prev => {
          const c = prev[currentId];
          return {
            ...prev,
            [currentId]: {
              ...c,
              items: c.items.map(it => {
                if (it.id === itemId) {
                  return { ...it, x: targetX, y: targetY, _dragging: true };
                }
                const fc = frameChildrenStart.find(x => x.id === it.id);
                if (fc) {
                  return { ...it, x: fc.x + finalDx, y: fc.y + finalDy, _dragging: true };
                }
                return it;
              })
            }
          };
        });
        return;
      }

      updateItemSilent(itemId, { x: targetX, y: targetY, _dragging: true });

      // detect column drop target (item being dragged is NOT itself a column)
      if (item.type !== 'column') {
        const cx = targetX + item.w / 2;
        const cy = targetY + item.h / 2;
        const overCol = current.items.find(it =>
          it.id !== itemId && it.type === 'column' &&
          cx >= it.x && cx <= it.x + it.w &&
          cy >= it.y && cy <= it.y + it.h
        );
        const newDropId = overCol?.id || null;
        if (newDropId !== currentDropCol) {
          currentDropCol = newDropId;
          setDropTargetCol(newDropId);
        }
      }

      // Detect To-do card drop target (only if item being dragged is a To-do card itself)
      if (item.type === 'todo') {
        const cx = targetX + item.w / 2;
        const cy = targetY + item.h / 2;
        const overTodo = current.items.find(it =>
          it.id !== itemId && it.type === 'todo' &&
          cx >= it.x && cx <= it.x + it.w &&
          cy >= it.y && cy <= it.y + it.h
        );
        const newDropTodoId = overTodo?.id || null;
        if (newDropTodoId !== currentDropTodo) {
          currentDropTodo = newDropTodoId;
          setDropTargetTodo(newDropTodoId);
        }
      }

      // Snap sound feedback
      const currentlySnappedX = (bestDiffX < snapThreshold);
      const currentlySnappedY = (bestDiffY < snapThreshold);
      if ((currentlySnappedX && !wasSnappedX) || (currentlySnappedY && !wasSnappedY)) {
        window.playAudioTone && window.playAudioTone('snap');
      }
      wasSnappedX = currentlySnappedX;
      wasSnappedY = currentlySnappedY;
    };
    // Escape a mitad de arrastre lo deshace: todo vuelve a donde estaba y no
    // queda ningún paso en el historial. Antes solo quitaba la selección y el
    // nodo seguía pegado al ratón.
    const onKey = (ev) => {
      if (ev.key !== 'Escape') return;
      ev.preventDefault(); ev.stopPropagation();
      quitaEscuchas();
      if (!moved) return;
      const origen = new Map();
      origen.set(itemId, { x: startItemX, y: startItemY });
      (multiStart || []).forEach(m => origen.set(m.id, m));
      (frameChildrenStart || []).forEach(m => origen.set(m.id, m));
      skipHistory.current = true;
      _setCanvases(prev => {
        const c = prev[currentId];
        if (!c) return prev;
        return { ...prev, [currentId]: { ...c,
          items: c.items.map(it => { const o = origen.get(it.id); return o ? { ...it, x: o.x, y: o.y, _dragging: false } : it; }),
          connectors: (c.connectors || []).map(co => { const sc = startConnectors.find(x => x.id === co.id); return (sc && sc.ortho.length) ? { ...co, ortho: sc.ortho } : co; }),
        } };
      });
    };
    const quitaEscuchas = () => {
      document.body.classList.remove('odi-busy');
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
      window.removeEventListener('keydown', onKey, true);
      setDropTargetCol(null);
      setDropTargetTodo(null);
      setGuides(null);
    };
    const onUp = () => {
      quitaEscuchas();

      if (moved) {
        window.playAudioTone && window.playAudioTone('drag_end');
      }

      if (isMultiDrag) {
        // Un clic sin mover sobre un grupo que ya estaba cogido: ese nodo solo.
        if (!moved && grupoYaCogido) {
          setSelectedIds([]); setSelected(itemId);
          return;
        }
        // Commit a non-silent update so history snapshots the new positions, and clear _dragging
        setCanvases(prev => {
          const c = prev[currentId];
          return { ...prev, [currentId]: {
            ...c,
            items: c.items.map(it => multiIds.includes(it.id) ? { ...it, _dragging: false } : it),
          }};
        });
        return;
      }

      if (frameChildrenStart && frameChildrenStart.length > 0) {
        setCanvases(prev => {
          const c = prev[currentId];
          return {
            ...prev,
            [currentId]: {
              ...c,
              items: c.items.map(it => (it.id === itemId || frameChildrenStart.some(x => x.id === it.id)) ? { ...it, _dragging: false } : it)
            }
          };
        });
        return;
      }

      if (currentDropCol) {
        // ABSORB item into column
        setCanvases(prev => {
          const c = prev[currentId];
          const movedItem = c.items.find(i => i.id === itemId);
          if (!movedItem) return prev;
          const childCopy = { ...movedItem };
          delete childCopy._dragging;
          delete childCopy._new;
          return {
            ...prev,
            [currentId]: {
              ...c,
              items: withResizedColumn(
                c.items
                  .filter(i => i.id !== itemId)
                  .map(i => i.id === currentDropCol
                    ? { ...i, children: [...(i.children || []), childCopy] }
                    : i),
                currentDropCol
              ),
              connectors: c.connectors || [],
            },
          };
        });
        setSelected(null);
      } else if (currentDropTodo && item.type === 'todo') {
        // ABSORB todo tasks into another todo card
        setCanvases(prev => {
          const c = prev[currentId];
          const draggedTodo = c.items.find(i => i.id === itemId);
          const destTodo = c.items.find(i => i.id === currentDropTodo);
          if (!draggedTodo || !destTodo) return prev;

          const mergedTasks = [...(destTodo.items || []), ...(draggedTodo.items || [])];

          return {
            ...prev,
            [currentId]: {
              ...c,
              items: c.items
                .filter(i => i.id !== itemId)
                .map(i => i.id === currentDropTodo ? { ...i, items: mergedTasks } : i)
            }
          };
        });
        setSelected(null);
      } else {
        if (moved) updateItem(itemId, { _dragging: false });
        else updateItemSilent(itemId, { _dragging: false });
      }
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    window.addEventListener('keydown', onKey, true);
  };

  // ───── Drag a child OUT of a column (extract on movement) ─────
  const startColChildDrag = (e, columnId, childId) => {
    if (e.target.closest('input, textarea, button, .todo-check, .swatch-btn, .anchor, .todo-add, .cal-mb-input, .cal-mb-cell, .cal-mb-nav, [contenteditable="true"]')) return;
    if (activeTool) return;
    e.stopPropagation();
    document.body.classList.add('odi-busy');
    const startX = e.clientX, startY = e.clientY;
    let extracted = false;
    let wasSnappedX = false;
    let wasSnappedY = false;
    let extractedId = null;
    let extractedW = 200, extractedH = 100;
    let currentDropCol = null;
    let startItemX = 0, startItemY = 0;
    let extractStartX = 0, extractStartY = 0;

    const onMove = (ev) => {
      if (!extracted) {
        const dx = ev.clientX - startX;
        const dy = ev.clientY - startY;
        if (Math.abs(dx) < 6 && Math.abs(dy) < 6) return;
        // EXTRACT child from column
        const col = current.items.find(i => i.id === columnId);
        if (!col) return;
        const child = (col.children || []).find(c => c.id === childId);
        if (!child) return;
        extracted = true;
        window.playAudioTone && window.playAudioTone('drag_start');
        extractedW = child.w || 200;
        extractedH = child.h || 100;
        const screenPt = screenToCanvas(ev.clientX, ev.clientY);
        const newItem = {
          ...child,
          x: screenPt.x - extractedW / 2,
          y: screenPt.y - extractedH / 2,
          _dragging: true,
        };
        extractedId = newItem.id;
        extractStartX = ev.clientX;
        extractStartY = ev.clientY;
        startItemX = newItem.x;
        startItemY = newItem.y;
        setCanvases(prev => {
          const c = prev[currentId];
          return {
            ...prev,
            [currentId]: {
              ...c,
              items: withResizedColumn(
                c.items
                  .map(it => it.id === columnId
                    ? { ...it, children: (it.children || []).filter(ch => ch.id !== childId) }
                    : it)
                  .concat([newItem]),
                columnId
              ),
            },
          };
        });
        setSelected(extractedId);
        return;
      }
      // continue drag
      const screenPt = screenToCanvas(ev.clientX, ev.clientY);
      let nx = screenPt.x - extractedW / 2;
      let ny = screenPt.y - extractedH / 2;

      // Snapping/constraining
      if (ev.shiftKey) {
        const dx = (ev.clientX - extractStartX) / scale;
        const dy = (ev.clientY - extractStartY) / scale;
        if (Math.abs(dx) > Math.abs(dy)) {
          ny = startItemY;
        } else {
          nx = startItemX;
        }
        nx = Math.round(nx / 20) * 20;
        ny = Math.round(ny / 20) * 20;
      }

      let activeGuidesX = [];
      let activeGuidesY = [];
      const snapThreshold = 12;
      const w = extractedW;
      const h = extractedH;
      let bestDiffX = snapThreshold;
      let bestDiffY = snapThreshold;

      const currentItems = current.items || [];
      const MAX_ALIGN_DIST = 99999;

      for (const other of currentItems) {
        if (other.id === extractedId || other.type === 'line') continue;
        const ow = other.w || 200;
        const oh = other.h || 120;

        // Solo alinear en X (guías verticales) si la distancia vertical entre centros es menor al umbral
        const centerY = ny + h / 2;
        const otherCenterY = other.y + oh / 2;
        const isNearY = Math.abs(centerY - otherCenterY) < MAX_ALIGN_DIST;

        if (isNearY) {
          // X alignments
          const xOpts = [
            { dragVal: nx,       otherVal: other.x,        guideVal: other.x,        offset: 0 },
            { dragVal: nx + w/2, otherVal: other.x + ow/2, guideVal: other.x + ow/2, offset: -w/2 },
            { dragVal: nx + w,   otherVal: other.x + ow,   guideVal: other.x + ow,   offset: -w },
            { dragVal: nx,       otherVal: other.x + ow,   guideVal: other.x + ow,   offset: 0 },
            { dragVal: nx + w,   otherVal: other.x,        guideVal: other.x,        offset: -w }
          ];
          for (const opt of xOpts) {
            const diff = Math.abs(opt.dragVal - opt.otherVal);
            if (diff < bestDiffX) {
              bestDiffX = diff;
              nx = opt.otherVal + opt.offset;
              activeGuidesX = [{
                x: opt.guideVal,
                y1: Math.min(ny, other.y),
                y2: Math.max(ny + h, other.y + oh)
              }];
            }
          }
        }

        // Solo alinear en Y (guías horizontales) si la distancia horizontal entre centros es menor al umbral
        const centerX = nx + w / 2;
        const otherCenterX = other.x + ow / 2;
        const isNearX = Math.abs(centerX - otherCenterX) < MAX_ALIGN_DIST;

        if (isNearX) {
          // Y alignments
          const yOpts = [
            { dragVal: ny,       otherVal: other.y,        guideVal: other.y,        offset: 0 },
            { dragVal: ny + h/2, otherVal: other.y + oh/2, guideVal: other.y + oh/2, offset: -h/2 },
            { dragVal: ny + h,   otherVal: other.y + oh,   guideVal: other.y + oh,   offset: -h },
            { dragVal: ny,       otherVal: other.y + oh,   guideVal: other.y + oh,   offset: 0 },
            { dragVal: ny + h,   otherVal: other.y,        guideVal: other.y,        offset: -h }
          ];
          for (const opt of yOpts) {
            const diff = Math.abs(opt.dragVal - opt.otherVal);
            if (diff < bestDiffY) {
              bestDiffY = diff;
              ny = opt.otherVal + opt.offset;
              activeGuidesY = [{
                y: opt.guideVal,
                x1: Math.min(nx, other.x),
                x2: Math.max(nx + w, other.x + ow)
              }];
            }
          }
        }
      }

      setGuides({ x: activeGuidesX, y: activeGuidesY });
      updateItemSilent(extractedId, { x: nx, y: ny, _dragging: true });

      const cx = nx + extractedW / 2;
      const cy = ny + extractedH / 2;
      const overCol = current.items.find(it =>
        it.id !== extractedId && it.type === 'column' &&
        cx >= it.x && cx <= it.x + it.w &&
        cy >= it.y && cy <= it.y + it.h
      );
      const newDropId = overCol?.id || null;
      if (newDropId !== currentDropCol) {
        currentDropCol = newDropId;
        setDropTargetCol(newDropId);
      }

      // Snap sound feedback
      const currentlySnappedX = (bestDiffX < snapThreshold);
      const currentlySnappedY = (bestDiffY < snapThreshold);
      if ((currentlySnappedX && !wasSnappedX) || (currentlySnappedY && !wasSnappedY)) {
        window.playAudioTone && window.playAudioTone('snap');
      }
      wasSnappedX = currentlySnappedX;
      wasSnappedY = currentlySnappedY;
    };
    const onUp = () => {
      document.body.classList.remove('odi-busy');
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
      setDropTargetCol(null);
      setGuides(null);

      if (extracted) {
        window.playAudioTone && window.playAudioTone('drag_end');
      }

      if (extracted && extractedId) {
        if (currentDropCol) {
          setCanvases(prev => {
            const c = prev[currentId];
            const movedItem = c.items.find(i => i.id === extractedId);
            if (!movedItem) return prev;
            const childCopy = { ...movedItem };
            delete childCopy._dragging;
            return {
              ...prev,
              [currentId]: {
                ...c,
                items: withResizedColumn(
                  c.items
                    .filter(i => i.id !== extractedId)
                    .map(i => i.id === currentDropCol
                      ? { ...i, children: [...(i.children || []), childCopy] }
                      : i),
                  currentDropCol
                ),
              },
            };
          });
          setSelected(null);
        } else {
          updateItem(extractedId, { _dragging: false });
        }
      } else {
        setSelected(childId);
        setSelectedIds([]);
        setSelectedConn(null);
        setEditingChildState({ colId: columnId, childId });
      }
    };

    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  // ───── Drag a task row OUT of a todo card (create/absorb task on drop) ─────
  // Remove a to-do node entirely if it is a titleless (pulled-out) card that has just become
  // empty — so dragging its only task into another to-do doesn't leave a dangling empty box.
  const pruneEmptyTitlelessTodos = (items, srcTodoId, newSrcItems) => {
    if (newSrcItems && newSrcItems.length > 0) return items; // still has tasks → keep
    const src = items.find(i => i.id === srcTodoId);
    if (src) {
      if (src.type === 'todo' && src.showTitle === false) {
        return items.filter(i => i.id !== srcTodoId);
      }
      return items;
    }
    // Source might be a column child
    return items.map(it => {
      if (it.type === 'column' && it.children) {
        const ch = it.children.find(c => c.id === srcTodoId);
        if (ch && ch.type === 'todo' && ch.showTitle === false) {
          return { ...it, children: it.children.filter(c => c.id !== srcTodoId) };
        }
      }
      return it;
    });
  };

  const startDragTaskRow = (e, todoId, rowIdx) => {
    e.preventDefault();
    e.stopPropagation();

    // Helper to find a todo item in canvas or columns
    const findTodo = (items, itemId) => {
      const top = items.find(it => it.id === itemId);
      if (top) return { item: top, colId: null };
      for (const it of items) {
        if (it.type === 'column' && it.children) {
          const ch = it.children.find(c => c.id === itemId);
          if (ch) return { item: ch, colId: it.id };
        }
      }
      return { item: null, colId: null };
    };

    const { item: todoItem } = findTodo(current.items, todoId);
    if (!todoItem || !todoItem.items) return;
    const task = todoItem.items[rowIdx];
    if (!task) return;

    document.body.classList.add('odi-busy', 'dragging-task');
    setDraggedTask({
      todoId,
      rowIdx,
      x: e.clientX,
      y: e.clientY,
      text: window.pickLang(task.text, lang)
    });

    let currentDropTodoId = null;

    // Posición en la que caería la tarea. La misma cuenta se hacía solo al
    // soltar, así que durante el arrastre no había forma de saber si la tarea
    // iba a quedar por encima o por debajo de la de al lado.
    const insertIndexAt = (destId, clientY) => {
      const destEl = destId && document.querySelector(`[data-item-id="${destId}"]`);
      if (!destEl) return 0;
      const rows = Array.from(destEl.querySelectorAll('.todo-row'));
      for (let i = 0; i < rows.length; i++) {
        const rect = rows[i].getBoundingClientRect();
        if (clientY < rect.top + rect.height / 2) return i;
      }
      return rows.length;
    };

    // La línea se pinta tocando el DOM en vez de con estado de React: durante
    // un arrastre esto se ejecuta en cada movimiento del dedo y re-renderizar
    // el nodo entero cada vez lo volvería lento.
    const paintDropLine = (destId, clientY) => {
      document.querySelectorAll('.todo-row.drop-before, .todo-row.drop-after')
        .forEach(r => r.classList.remove('drop-before', 'drop-after'));
      if (!destId) return;
      const destEl = document.querySelector(`[data-item-id="${destId}"]`);
      if (!destEl) return;
      const rows = Array.from(destEl.querySelectorAll('.todo-row'));
      if (!rows.length) return;
      const idx = insertIndexAt(destId, clientY);
      if (idx >= rows.length) rows[rows.length - 1].classList.add('drop-after');
      else rows[idx].classList.add('drop-before');
    };

    const onMove = (ev) => {
      setDraggedTask(prev => prev ? { ...prev, x: ev.clientX, y: ev.clientY } : null);

      const el = document.elementFromPoint(ev.clientX, ev.clientY);
      const todoCardEl = el?.closest('.todo-card');
      const itemEl = todoCardEl?.closest('[data-item-id]');
      const newDropId = itemEl?.getAttribute('data-item-id') || null;

      if (newDropId !== currentDropTodoId) {
        currentDropTodoId = newDropId;
        setDropTargetTodo(newDropId);
      }
      paintDropLine(newDropId, ev.clientY);
    };

    const onUp = (ev) => {
      document.body.classList.remove('odi-busy', 'dragging-task');
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
      setDraggedTask(null);
      setDropTargetTodo(null);
      paintDropLine(null);

      const canvasPt = screenToCanvas(ev.clientX, ev.clientY);

      // Helper to apply todo changes to items array (handling both canvas & columns)
      const updateTodo = (items, targetId, fields) => {
        return items.map(it => {
          if (it.id === targetId) {
            return { ...it, ...fields };
          }
          if (it.type === 'column' && it.children) {
            return {
              ...it,
              children: it.children.map(ch => ch.id === targetId ? { ...ch, ...fields } : ch)
            };
          }
          return it;
        });
      };

      if (currentDropTodoId) {
        // Drop inside a To-do (can be the source todoId or a destTodoId!)
        setCanvases(prev => {
          const c = prev[currentId];
          const { item: srcTodo, colId: srcColId } = findTodo(c.items, todoId);
          const { item: destTodo, colId: destColId } = findTodo(c.items, currentDropTodoId);
          if (!srcTodo || !destTodo) return prev;

          const taskToMove = srcTodo.items[rowIdx];
          if (!taskToMove) return prev;

          let newSrcItems = [...srcTodo.items];
          let insertIdx = (destTodo.items || []).length; // default to end

          // Misma cuenta que pinta la línea guía, para que caiga exactamente
          // donde el usuario ha visto que iba a caer.
          if (document.querySelector(`[data-item-id="${currentDropTodoId}"]`)) {
            insertIdx = insertIndexAt(currentDropTodoId, ev.clientY);
          }

          let updatedItems = c.items;

          if (todoId === currentDropTodoId) {
            // Reordering within the same todo card!
            newSrcItems.splice(rowIdx, 1);
            let adjustedInsertIdx = insertIdx;
            if (adjustedInsertIdx > rowIdx) {
              adjustedInsertIdx--;
            }
            newSrcItems.splice(adjustedInsertIdx, 0, taskToMove);

            updatedItems = updateTodo(updatedItems, todoId, { items: newSrcItems });
          } else {
            // Moving to a different todo card!
            newSrcItems.splice(rowIdx, 1);
            const newDestItems = [...(destTodo.items || [])];
            newDestItems.splice(insertIdx, 0, { ...taskToMove, indent: 0 });

            updatedItems = updateTodo(updatedItems, todoId, { items: newSrcItems });
            updatedItems = updateTodo(updatedItems, currentDropTodoId, { items: newDestItems });
            // If the SOURCE was a titleless (pulled-out) to-do and is now empty, remove it
            updatedItems = pruneEmptyTitlelessTodos(updatedItems, todoId, newSrcItems);
          }

          // If source or destination was in a column, trigger column resize
          if (srcColId) {
            updatedItems = withResizedColumn(updatedItems, srcColId);
          }
          if (destColId && destColId !== srcColId) {
            updatedItems = withResizedColumn(updatedItems, destColId);
          }

          return {
            ...prev,
            [currentId]: {
              ...c,
              items: updatedItems
            }
          };
        });
      } else {
        // Drop on canvas -> Create a new To-do card!
        setCanvases(prev => {
          const c = prev[currentId];
          const { item: srcTodo, colId: srcColId } = findTodo(c.items, todoId);
          if (!srcTodo) return prev;

          const taskToMove = srcTodo.items[rowIdx];
          if (!taskToMove) return prev;

          const newSrcItems = srcTodo.items.filter((_, idx) => idx !== rowIdx);

          const newTodoId = `todo-${Date.now()}`;
          const newTodoNode = {
            id: newTodoId,
            type: 'todo',
            x: canvasPt.x - 150,
            y: canvasPt.y - 60,
            w: 300,
            h: 120,
            color: srcTodo.color || 'white',
            showTitle: false,                 // pulled-out task → no title until the user adds one
            title: { es: '', en: '' },
            items: [{ ...taskToMove, indent: 0 }]
          };

          let updatedItems = updateTodo(c.items, todoId, { items: newSrcItems });
          updatedItems = updatedItems.concat([newTodoNode]);
          // If the SOURCE was a titleless (pulled-out) to-do and is now empty, remove it
          updatedItems = pruneEmptyTitlelessTodos(updatedItems, todoId, newSrcItems);

          if (srcColId) {
            updatedItems = withResizedColumn(updatedItems, srcColId);
          }

          return {
            ...prev,
            [currentId]: {
              ...c,
              items: updatedItems
            }
          };
        });
      }
    };

    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  const startAnchorDrag = (e, fromId, fromAnchor) => {
    e.stopPropagation(); e.preventDefault();
    const fromRect = window.getNodeRect?.(fromId, current.items);
    if (!fromRect) return;
    const startPos = anchorPos(fromRect, fromAnchor);
    setPendingConn({ fromId, fromAnchor, fromX: startPos.x, fromY: startPos.y, mx: startPos.x, my: startPos.y });

    // Nodo que hay bajo el puntero mientras se arrastra la flecha. En escritorio
    // esto se ve solo, porque al pasar por encima de un nodo se encienden sus
    // puntos de anclaje; con el dedo no existe "pasar por encima", así que sin
    // esto no hay forma de saber si la flecha va a engancharse o a quedar suelta.
    const highlightUnder = (ev) => {
      const el = document.elementFromPoint(ev.clientX, ev.clientY);
      const itemEl = el && el.closest ? el.closest('.item, .col-child-wrap') : null;
      const id = itemEl && itemEl.getAttribute('data-item-id');
      setLinkTargetId(id && id !== fromId ? id : null);
    };

    const onMove = (ev) => {
      const p = screenToCanvas(ev.clientX, ev.clientY);
      setPendingConn(pc => pc && { ...pc, mx: p.x, my: p.y });
      highlightUnder(ev);
    };
    const onUp = (ev) => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
      setLinkTargetId(null);
      const el = document.elementFromPoint(ev.clientX, ev.clientY);
      const itemEl = el?.closest('.item, .col-child-wrap');
      const targetId = itemEl?.getAttribute('data-item-id');
      if (targetId && targetId !== fromId) {
        const targetRect = window.getNodeRect?.(targetId, current.items);
        if (targetRect) {
          const p = screenToCanvas(ev.clientX, ev.clientY);
          const toAnchor = closestAnchor(targetRect, p.x, p.y);
          addConnector({ itemId: fromId, anchor: fromAnchor }, { itemId: targetId, anchor: toAnchor });
        }
      }
      // No free arrow from anchor drag — only Line tool creates floating arrows
      setPendingConn(null);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  // ───── Line tool: draw an arrow (from item or free) ─────
  const startLineDrag = (e, fromId) => {
    e.stopPropagation(); e.preventDefault();
    const startPt = screenToCanvas(e.clientX, e.clientY);
    let fromEnd;
    if (fromId) {
      const fromRect = window.getNodeRect?.(fromId, current.items);
      if (fromRect) {
        const anchor = 'center';
        fromEnd = { itemId: fromId, anchor };
        const ap = { x: fromRect.x + fromRect.w / 2, y: fromRect.y + fromRect.h / 2 };
        setPendingConn({ fromX: ap.x, fromY: ap.y, mx: ap.x, my: ap.y });
      }
    } else {
      fromEnd = { x: startPt.x, y: startPt.y };
      setPendingConn({ fromX: startPt.x, fromY: startPt.y, mx: startPt.x, my: startPt.y });
    }
    if (!fromEnd) return;

    // Nodo que hay bajo el puntero mientras se arrastra la flecha. En escritorio
    // esto se ve solo, porque al pasar por encima de un nodo se encienden sus
    // puntos de anclaje; con el dedo no existe "pasar por encima", así que sin
    // esto no hay forma de saber si la flecha va a engancharse o a quedar suelta.
    const highlightUnder = (ev) => {
      const el = document.elementFromPoint(ev.clientX, ev.clientY);
      const itemEl = el && el.closest ? el.closest('.item, .col-child-wrap') : null;
      const id = itemEl && itemEl.getAttribute('data-item-id');
      setLinkTargetId(id && id !== fromId ? id : null);
    };

    const onMove = (ev) => {
      const p = screenToCanvas(ev.clientX, ev.clientY);
      setPendingConn(pc => pc && { ...pc, mx: p.x, my: p.y });
      highlightUnder(ev);
    };
    const onUp = (ev) => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
      setLinkTargetId(null);
      const el = document.elementFromPoint(ev.clientX, ev.clientY);
      const itemEl = el?.closest('.item, .col-child-wrap');
      const targetId = itemEl?.getAttribute('data-item-id');
      let toEnd;
      const dropPt = screenToCanvas(ev.clientX, ev.clientY);
      if (targetId && targetId !== fromId) {
        const targetRect = window.getNodeRect?.(targetId, current.items);
        if (targetRect) {
          const anchor = 'center';
          toEnd = { itemId: targetId, anchor };
        }
      }
      if (!toEnd) {
        // ensure minimum length for a 'free' arrow
        const startX = startPt.x, startY = startPt.y;
        const dist = Math.hypot(dropPt.x - startX, dropPt.y - startY);
        if (dist < 30) {
          // make a default-length arrow to the right
          toEnd = { x: dropPt.x + 120, y: dropPt.y };
        } else {
          toEnd = { x: dropPt.x, y: dropPt.y };
        }
      }
      addConnector(fromEnd, toEnd);
      setPendingConn(null);
      setActiveTool(null);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  // ───── Item ops ─────
  const deleteItem = (itemId) => {
    window.playAudioTone && window.playAudioTone('delete');
    setCanvases(prev => {
      const c = prev[currentId];
      let item = c.items.find(i => i.id === itemId);
      let nextItems;
      if (item) {
        nextItems = c.items.filter(i => i.id !== itemId);
      } else {
        // La tarjeta puede vivir DENTRO de una columna: antes estos nodos no se
        // podían eliminar porque solo se buscaba en el primer nivel del lienzo
        nextItems = c.items.map(it => {
          if (it.type === 'column' && it.children) {
            const found = it.children.find(ch => ch.id === itemId);
            if (found) {
              item = found;
              return { ...it, children: it.children.filter(ch => ch.id !== itemId) };
            }
          }
          return it;
        });
        if (!item) return prev;
      }
      const nextCanvases = {
        ...prev,
        [currentId]: {
          ...c,
          items: nextItems,
          connectors: (c.connectors || []).filter(co => {
            const fromId = co.fromEnd?.itemId || co.from;
            const toId   = co.toEnd?.itemId   || co.to;
            return fromId !== itemId && toId !== itemId;
          }),
        },
      };
      if (item && item.type === 'board' && item.canvasId) {
        const toRemove = new Set([item.canvasId]);
        let added = true;
        while (added) {
          added = false;
          Object.keys(nextCanvases).forEach(k => {
            if (toRemove.has(k) && nextCanvases[k]) {
              (nextCanvases[k].items || []).forEach(it => {
                if (it.type === 'board' && it.canvasId && !toRemove.has(it.canvasId)) {
                  toRemove.add(it.canvasId); added = true;
                }
              });
            }
          });
        }
        toRemove.forEach(id => delete nextCanvases[id]);
      }
      return nextCanvases;
    });
    setSelected(null); setEditing(null); setContextMenu(null);
  };

  // Orden de capas. El orden de la lista es el de pintado: el último queda
  // encima. "Adelante" y "atrás" saltan solo por encima o por debajo del
  // siguiente nodo que de verdad se solapa con este; saltar uno que está en
  // otra punta del tablero no cambiaría nada a la vista.
  const cambiaCapa = (itemId, hacia) => {
    setCanvases(prev => {
      const c = prev[currentId];
      const items = c.items.slice();
      const i = items.findIndex(x => x.id === itemId);
      if (i < 0) return prev;
      const [it] = items.splice(i, 1);
      const caja = (o) => {
        const d = defaultDims(o.type);
        return { x: o.x, y: o.y, w: o.w !== undefined ? o.w : d.w, h: o.h !== undefined ? o.h : d.h };
      };
      const a = caja(it);
      const solapa = (o) => {
        const b = caja(o);
        return b.x < a.x + a.w && a.x < b.x + b.w && b.y < a.y + a.h && a.y < b.y + b.h;
      };
      let destino;
      if (hacia === 'frente') destino = items.length;
      else if (hacia === 'fondo') destino = 0;
      else if (hacia === 'delante') {
        const j = items.findIndex((o, k) => k >= i && solapa(o));
        destino = j < 0 ? items.length : j + 1;
      } else {
        let j = -1;
        for (let k = i - 1; k >= 0; k--) if (solapa(items[k])) { j = k; break; }
        destino = j < 0 ? 0 : j;
      }
      if (destino === i) return prev;
      items.splice(destino, 0, it);
      return { ...prev, [currentId]: { ...c, items } };
    });
  };

  const duplicateItem = (itemId) => {
    window.playAudioTone && window.playAudioTone('create');
    setCanvases(prev => {
      const c = prev[currentId];
      const it = c.items.find(i => i.id === itemId);
      if (!it) return prev;
      const copy = JSON.parse(JSON.stringify(it));
      copy.id = `it-${Date.now()}-${Math.floor(Math.random()*9999)}`;
      copy.x += 28; copy.y += 28;
      copy._new = true;
      // La copia de uno solo no entra en el grupo del original ni lleva candado.
      delete copy.grupo;
      delete copy.bloqueado;
      const next = { ...prev, [currentId]: { ...c, items: [...c.items, copy] } };
      if (copy.type === 'board' && copy.canvasId) {
        const newCid = `b-${Date.now()}-${Math.floor(Math.random()*9999)}`;
        const orig = prev[copy.canvasId];
        if (orig) {
          next[newCid] = JSON.parse(JSON.stringify(orig));
          next[newCid].parent = currentId;
          next[newCid].parentLabel = c.title;
        }
        copy.canvasId = newCid;
      }
      return next;
    });
  };

  const setItemColor = (itemId, color) => updateItem(itemId, { color });

  // ───── Resize ─────
  // ───── Girar un nodo ─────
  //
  // Se gira arrastrando el tirador de abajo a la izquierda. El ángulo sale de
  // la línea que va del centro del nodo al puntero, así que el nodo "sigue" al
  // ratón sin saltos, empiece el gesto donde empiece.
  //
  // Enclaje: al pasar cerca de un múltiplo de 90° se pega a él (hay más
  // tolerancia PARA entrar que para salir, si no el nodo se despega solo con
  // el pulso de la mano). Con Shift se gira fino, sin enclajes.
  const ANCLA_GIRO = 90;
  const TOLERANCIA_GIRO = 7;      // grados para pegarse a un múltiplo de 90
  const startRotate = (e, itemId) => {
    { const it0 = current.items.find(i => i.id === itemId); if (it0 && it0.bloqueado) return; }
    if (panearSiEspacio(e)) return;
    e.stopPropagation(); e.preventDefault();
    const item = current.items.find(i => i.id === itemId);
    if (!item) return;
    document.body.classList.add('odi-busy');

    const w = item.w !== undefined ? item.w : defaultDims(item.type).w;
    const h = item.h !== undefined ? item.h : defaultDims(item.type).h;
    const cx = item.x + w / 2;
    const cy = item.y + h / 2;
    const rotInicial = item.rot || 0;

    const anguloHacia = (clientX, clientY) => {
      const p = screenToCanvas(clientX, clientY);
      return Math.atan2(p.y - cy, p.x - cx) * 180 / Math.PI;
    };
    const anguloInicial = anguloHacia(e.clientX, e.clientY);
    let ultimoRot = rotInicial;

    const onMove = (ev) => {
      let rot = rotInicial + (anguloHacia(ev.clientX, ev.clientY) - anguloInicial);
      if (!ev.shiftKey) {
        const cercano = Math.round(rot / ANCLA_GIRO) * ANCLA_GIRO;
        if (Math.abs(rot - cercano) <= TOLERANCIA_GIRO) rot = cercano;
      }
      // Se normaliza a [0, 360) para que no se acumulen vueltas y "0" siga
      // siendo 0 después de dar tres giros completos.
      ultimoRot = ((Math.round(rot * 10) / 10) % 360 + 360) % 360;
      updateItemSilent(itemId, { rot: ultimoRot });
    };
    const onUp = () => {
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
      document.body.classList.remove('odi-busy');
      // El giro entra en el historial como UN paso, no como cien: durante el
      // arrastre se escribe en silencio y solo al soltar se apunta.
      updateItem(itemId, { rot: ultimoRot });
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  const startResize = (e, itemId, corner) => {
    if (panearSiEspacio(e)) return;
    e.stopPropagation(); e.preventDefault();
    const item = current.items.find(i => i.id === itemId);
    if (!item) return;
    if (item.bloqueado) return;
    document.body.classList.add('odi-busy');
    const startX = e.clientX, startY = e.clientY;
    const sx = item.x, sy = item.y, sw = item.w, sh = item.h;
    // Una imagen se encoge todo lo que quieras (lo pidió quien probó): el tope
    // de 100×50 es para los nodos con texto, que por debajo ya no se leen.
    // Hacia arriba no hay tope: el lienzo crece con lo que tiene dentro.
    const libre = item.type === 'image';
    const minW = libre ? 8 : 100, minH = libre ? 8 : 50;
    const aspectRatio = sw / sh;
    // Nodos en modo compacto (sin vista previa): el redimensionado mantiene la
    // proporción (no se puede poner "más gordo o más flaco"), igual que con Shift.
    const aspectLocked = item.showPreview === false && ['board','doc','link'].includes(item.type);
    
    // Multi-resize logic setup
    const isMulti = selectedIds.length > 1 && selectedIds.includes(itemId);
    let groupStarts = null;
    let groupMinX = Infinity, groupMinY = Infinity, groupMaxX = -Infinity, groupMaxY = -Infinity;
    let groupW = 0, groupH = 0;
    
    if (isMulti) {
      const selectedItems = current.items.filter(it => selectedIds.includes(it.id));
      for (const it of selectedItems) {
        const w = it.w !== undefined ? it.w : (it.type === 'frame' ? 400 : 200);
        const h = it.h !== undefined ? it.h : (it.type === 'frame' ? 400 : 120);
        if (it.x < groupMinX) groupMinX = it.x;
        if (it.y < groupMinY) groupMinY = it.y;
        if (it.x + w > groupMaxX) groupMaxX = it.x + w;
        if (it.y + h > groupMaxY) groupMaxY = it.y + h;
      }
      groupW = groupMaxX - groupMinX;
      groupH = groupMaxY - groupMinY;
      
      groupStarts = selectedItems.map(it => {
        const w = it.w !== undefined ? it.w : (it.type === 'frame' ? 400 : 200);
        const h = it.h !== undefined ? it.h : (it.type === 'frame' ? 400 : 120);
        return {
          id: it.id,
          relX: groupW > 0 ? (it.x - groupMinX) / groupW : 0,
          relY: groupH > 0 ? (it.y - groupMinY) / groupH : 0,
          relW: groupW > 0 ? w / groupW : 0,
          relH: groupH > 0 ? h / groupH : 0,
          startX: it.x,
          startY: it.y,
          startW: w,
          startH: h
        };
      });
    }
    
    // Desde el primer momento marcamos la altura como manual en los nodos que se
    // auto-ajustan al texto, para que dejen de imponer su propia altura.
    if (['note', 'comment', 'todo'].includes(item.type) && !item.manualH) {
      updateItemSilent(itemId, { manualH: true });
    }

    let resizeMaster = null; // Locked to 'x' or 'y' on first move to prevent jumps
    let wasSnappedX = false;
    let wasSnappedY = false;

    // Un nodo girado se redimensiona por su propio eje, no por el de la
    // pantalla: tirando de la esquina de un nodo puesto de lado, "más ancho"
    // va en la dirección en la que APUNTA el nodo. Se lleva el gesto al eje
    // del nodo, se calcula el tamaño ahí, y luego se recoloca el centro para
    // que la esquina de enfrente se quede clavada donde estaba. Las guías de
    // alineación se saltan mientras está girado: comparan bordes rectos, y
    // aquí ya no hay bordes rectos que comparar.
    const rotGrados = item.rot || 0;
    const girado = !!rotGrados;
    const signoX = corner === 'tr' || corner === 'br' ? 1 : -1;
    const signoY = corner === 'bl' || corner === 'br' ? 1 : -1;

    const onMoveGirado = (ev) => {
      const dxPantalla = (ev.clientX - startX) / scale;
      const dyPantalla = (ev.clientY - startY) / scale;
      const r = -rotGrados * Math.PI / 180;
      const dxL = dxPantalla * Math.cos(r) - dyPantalla * Math.sin(r);
      const dyL = dxPantalla * Math.sin(r) + dyPantalla * Math.cos(r);

      let nw = Math.max(minW, sw + signoX * dxL);
      let nh = Math.max(minH, sh + signoY * dyL);
      if (ev.shiftKey || aspectLocked) {
        if (Math.abs(dxL) >= Math.abs(dyL)) nh = Math.max(minH, nw / aspectRatio);
        else nw = Math.max(minW, nh * aspectRatio);
      }

      // El centro se desplaza la mitad de lo que crece el nodo, en el eje del
      // propio nodo; llevado a coordenadas del lienzo con el mismo giro.
      const cxIni = sx + sw / 2, cyIni = sy + sh / 2;
      const despl = giraPunto(signoX * (nw - sw) / 2, signoY * (nh - sh) / 2, 0, 0, rotGrados);
      const cx = cxIni + despl.x, cy = cyIni + despl.y;

      updateItemSilent(itemId, {
        x: Math.round(cx - nw / 2), y: Math.round(cy - nh / 2),
        w: Math.round(nw), h: Math.round(nh),
      });
    };

    const onMove = (ev) => {
      if (girado && !isMulti) return onMoveGirado(ev);
      const dx = (ev.clientX - startX) / scale;
      const dy = (ev.clientY - startY) / scale;
      
      // Determine master axis once past a tiny deadzone
      if (resizeMaster === null && (Math.abs(dx) > 2 || Math.abs(dy) > 2)) {
        resizeMaster = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';
      }

      let nx = sx, ny = sy, nw = sw, nh = sh;
      
      let activeGuidesX = [];
      let activeGuidesY = [];
      const snapThreshold = 10;
      const MAX_ALIGN_DIST = 99999;
      const currentItems = current.items || [];

      if (ev.shiftKey || item.type === 'file' || aspectLocked) {
        const master = resizeMaster || 'x'; // default to x
        if (master === 'x') {
          if (corner.includes('r')) nw = Math.max(minW, sw + dx);
          if (corner.includes('l')) nw = Math.max(minW, sw - dx);

          // Snap X
          let bestDiffX = snapThreshold;
          let snapXVal = null;
          let guideY1 = null, guideY2 = null;
          const myVal = corner.includes('r') ? (nx + nw) : (sx + sw - nw);

          for (const other of currentItems) {
            if (other.id === itemId || other.type === 'line') continue;
            const ow = other.w || 200;
            const oh = other.h || 120;
            const centerY = ny + nh / 2;
            const otherCenterY = other.y + oh / 2;
            if (Math.abs(centerY - otherCenterY) < MAX_ALIGN_DIST) {
              const otherXOpts = other.type === 'frame' ? [other.x, other.x + ow] : [other.x, other.x + ow/2, other.x + ow];
              for (const otherVal of otherXOpts) {
                const diff = Math.abs(myVal - otherVal);
                if (diff < bestDiffX) {
                  bestDiffX = diff;
                  snapXVal = otherVal;
                  guideY1 = Math.min(sy, other.y);
                  guideY2 = Math.max(sy + sh, other.y + oh);
                }
              }
            }
          }
          if (snapXVal !== null) {
            if (corner.includes('r')) {
              nw = snapXVal - nx;
            } else {
              nw = sx + sw - snapXVal;
            }
            activeGuidesX = [{ x: snapXVal, y1: guideY1, y2: guideY2 }];
          }

          nh = Math.max(minH, Math.round(nw / aspectRatio));
        } else {
          if (corner.includes('b')) nh = Math.max(minH, sh + dy);
          if (corner.includes('t')) nh = Math.max(minH, sh - dy);

          // Snap Y
          let bestDiffY = snapThreshold;
          let snapYVal = null;
          let guideX1 = null, guideX2 = null;
          const myVal = corner.includes('b') ? (ny + nh) : (sy + sh - nh);

          for (const other of currentItems) {
            if (other.id === itemId || other.type === 'line') continue;
            const ow = other.w || 200;
            const oh = other.h || 120;
            const centerX = nx + nw / 2;
            const otherCenterX = other.x + ow / 2;
            if (Math.abs(centerX - otherCenterX) < MAX_ALIGN_DIST) {
              const otherYOpts = other.type === 'frame' ? [other.y, other.y + oh] : [other.y, other.y + oh/2, other.y + oh];
              for (const otherVal of otherYOpts) {
                const diff = Math.abs(myVal - otherVal);
                if (diff < bestDiffY) {
                  bestDiffY = diff;
                  snapYVal = otherVal;
                  guideX1 = Math.min(sx, other.x);
                  guideX2 = Math.max(sx + sw, other.x + ow);
                }
              }
            }
          }
          if (snapYVal !== null) {
            if (corner.includes('b')) {
              nh = snapYVal - ny;
            } else {
              nh = sy + sh - snapYVal;
            }
            activeGuidesY = [{ y: snapYVal, x1: guideX1, x2: guideX2 }];
          }

          nw = Math.max(minW, Math.round(nh * aspectRatio));
        }
        
        // Re-adjust top-left position if resizing left or top sides
        if (corner.includes('l')) nx = sx + (sw - nw);
        if (corner.includes('t')) ny = sy + (sh - nh);
      } else {
        // Free resize
        if (corner.includes('r')) nw = Math.max(minW, sw + dx);
        if (corner.includes('b')) nh = Math.max(minH, sh + dy);
        if (corner.includes('l')) { nw = Math.max(minW, sw - dx); nx = sx + (sw - nw); }
        if (corner.includes('t')) { nh = Math.max(minH, sh - dy); ny = sy + (sh - nh); }

        // Snap X
        let bestDiffX = snapThreshold;
        let snapXVal = null;
        let guideY1 = null, guideY2 = null;
        const myXVal = corner.includes('r') ? (nx + nw) : (corner.includes('l') ? nx : null);

        if (myXVal !== null) {
          for (const other of currentItems) {
            if (other.id === itemId || other.type === 'line') continue;
            const ow = other.w || 200;
            const oh = other.h || 120;
            const centerY = ny + nh / 2;
            const otherCenterY = other.y + oh / 2;
            if (Math.abs(centerY - otherCenterY) < MAX_ALIGN_DIST) {
              const otherXOpts = other.type === 'frame' ? [other.x, other.x + ow] : [other.x, other.x + ow/2, other.x + ow];
              for (const otherVal of otherXOpts) {
                const diff = Math.abs(myXVal - otherVal);
                if (diff < bestDiffX) {
                  bestDiffX = diff;
                  snapXVal = otherVal;
                  guideY1 = Math.min(ny, other.y);
                  guideY2 = Math.max(ny + nh, other.y + oh);
                }
              }
            }
          }
          if (snapXVal !== null) {
            if (corner.includes('r')) {
              nw = snapXVal - nx;
            } else if (corner.includes('l')) {
              nx = snapXVal;
              nw = (sx + sw) - nx;
            }
            activeGuidesX = [{ x: snapXVal, y1: guideY1, y2: guideY2 }];
          }
        }

        // Snap Y
        let bestDiffY = snapThreshold;
        let snapYVal = null;
        let guideX1 = null, guideX2 = null;
        const myYVal = corner.includes('b') ? (ny + nh) : (corner.includes('t') ? ny : null);

        if (myYVal !== null) {
          for (const other of currentItems) {
            if (other.id === itemId || other.type === 'line') continue;
            const ow = other.w || 200;
            const oh = other.h || 120;
            const centerX = nx + nw / 2;
            const otherCenterX = other.x + ow / 2;
            if (Math.abs(centerX - otherCenterX) < MAX_ALIGN_DIST) {
              const otherYOpts = other.type === 'frame' ? [other.y, other.y + oh] : [other.y, other.y + oh/2, other.y + oh];
              for (const otherVal of otherYOpts) {
                const diff = Math.abs(myYVal - otherVal);
                if (diff < bestDiffY) {
                  bestDiffY = diff;
                  snapYVal = otherVal;
                  guideX1 = Math.min(nx, other.x);
                  guideX2 = Math.max(nx + nw, other.x + ow);
                }
              }
            }
          }
          if (snapYVal !== null) {
            if (corner.includes('b')) {
              nh = snapYVal - ny;
            } else if (corner.includes('t')) {
              ny = snapYVal;
              nh = (sy + sh) - ny;
            }
            activeGuidesY = [{ y: snapYVal, x1: guideX1, x2: guideX2 }];
          }
        }
      }
      
      // Snap sound feedback
      const currentlySnappedX = (activeGuidesX.length > 0);
      const currentlySnappedY = (activeGuidesY.length > 0);
      if ((currentlySnappedX && !wasSnappedX) || (currentlySnappedY && !wasSnappedY)) {
        window.playAudioTone && window.playAudioTone('snap');
      }
      wasSnappedX = currentlySnappedX;
      wasSnappedY = currentlySnappedY;

      setGuides({ x: activeGuidesX, y: activeGuidesY });
      if (isMulti && groupStarts) {
        const scaleX = sw > 0 ? nw / sw : 1;
        const scaleY = sh > 0 ? nh / sh : 1;
        _setCanvases(prev => {
          const c = prev[currentId];
          return {
            ...prev,
            [currentId]: {
              ...c,
              items: c.items.map(it => {
                const gs = groupStarts.find(x => x.id === it.id);
                if (gs) {
                  const newW = Math.max(minW, gs.startW * scaleX);
                  const newH = Math.max(minH, gs.startH * scaleY);
                  let newX = gs.startX;
                  let newY = gs.startY;
                  if (corner.includes('r')) {
                    newX = groupMinX + gs.relX * (groupW * scaleX);
                  } else if (corner.includes('l')) {
                    newX = groupMaxX - (1 - gs.relX) * (groupW * scaleX);
                  }
                  if (corner.includes('b')) {
                    newY = groupMinY + gs.relY * (groupH * scaleY);
                  } else if (corner.includes('t')) {
                    newY = groupMaxY - (1 - gs.relY) * (groupH * scaleY);
                  }
                  return { ...it, x: newX, y: newY, w: newW, h: newH };
                }
                return it;
              })
            }
          };
        });
      } else {
        updateItemSilent(itemId, { x: nx, y: ny, w: nw, h: nh });
      }
    };
    const onUp = () => {
      document.body.classList.remove('odi-busy');
      setGuides(null);
      // Marcar que la altura la fijó el usuario, para que el auto-ajuste al
      // contenido (notas/comentarios) deje de pisar el tamaño elegido.
      const manualPatch = ['note', 'comment', 'todo'].includes(item.type) ? { manualH: true } : {};
      updateItem(itemId, manualPatch);
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
    };
    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
  };

  // ───── Nested board open ─────
  const openBoard = (canvasId, fromItemId) => {
    // La profundidad dice si la gente llega a usar los tableros dentro de
    // tableros, que es lo que diferencia a Odinote de lo demás.
    window.odiTrack && window.odiTrack('tablero_abierto', { profundidad: stack.length });
    if (!canvases[canvasId]) {
      let fromItem = current.items.find(i => i.id === fromItemId);
      if (!fromItem) {
        for (const it of current.items) {
          if (it.type === 'column' && it.children) {
            const child = it.children.find(c => c.id === fromItemId);
            if (child) { fromItem = child; break; }
          }
        }
      }
      setCanvases(prev => ({
        ...prev,
        [canvasId]: {
          title: fromItem?.content || { es: 'Tablero', en: 'Board' },
          parent: currentId,
          parentLabel: current.title,
          items: [], connectors: [],
        },
      }));
    }
    setStack(s => [...s, canvasId]);
    setTransition('entering');
    setTimeout(() => setTransition(null), 350);
    window.playAudioTone && window.playAudioTone('board_open');
  };

  const goBackTo = (idx) => {
    if (idx >= stack.length - 1) return;
    setStack(s => s.slice(0, idx + 1));
    setTransition('entering');
    setTimeout(() => setTransition(null), 350);
  };

  const renameCanvas = (canvasId, newName) => {
    setCanvases(prev => {
      const c = prev[canvasId];
      if (!c) return prev;
      const next = { ...prev, [canvasId]: { ...c, title: { ...(c.title || {}), [lang]: newName } } };
      // also update parent board item's content
      const parent = c.parent;
      if (parent && next[parent]) {
        next[parent] = {
          ...next[parent],
          items: next[parent].items.map(it => it.canvasId === canvasId
            ? { ...it, content: { ...(it.content || {}), [lang]: newName } } : it),
        };
      }
      return next;
    });
  };

  // ───── Helper: get items inside a board for preview ─────
  const getNestedItems = useCallbackCanvas((canvasId) => {
    return (canvases[canvasId]?.items) || [];
  }, [canvases]);

  const [editingChild, setEditingChildState] = useStateCanvas(null); // {colId, childId} | null

  // ── Un tablero que pasa a ser su propio proyecto ──
  //
  // Pedido por el usuario: un botón en el tablero para que ese tablero, con
  // todo lo que tiene dentro, se convierta en un proyecto aparte, "con una
  // animación bacana, como una teletransportación, o que se vaya para la
  // izquierda". Se va volando a la flecha de volver, que es donde viven los
  // proyectos, así que la animación dice a dónde ha ido.
  //
  // Lo que pasa con los datos: el lienzo del tablero pasa a ser el lienzo raíz
  // del proyecto nuevo (misma información, con la clave del proyecto), y los
  // tableros que tuviera dentro se van con él solos, porque un proyecto es todo
  // lo que se alcanza desde su raíz (collectProjectCanvases). Del lienzo de
  // aquí se quita el nodo y sus flechas. El historial de deshacer se empieza de
  // nuevo: un Ctrl+Z que devolviera el tablero dejaría el proyecto nuevo sin su
  // lienzo. Las imágenes siguen en la carpeta del proyecto de antes hasta que,
  // al guardar la bóveda, se copian a la del nuevo (Boveda.reparaMedios); entre
  // tanto el servidor las encuentra allí (Boveda.buscaMedio).
  const projectsRef = useRefCanvas(projects);
  projectsRef.current = projects;
  const reiniciaHistorial = () => {
    if (historyTimerRef.current) { clearTimeout(historyTimerRef.current); historyTimerRef.current = null; }
    historyRef.current = [];
    historyIdxRef.current = -1;
    lastSnapRef.current = null;
    ultimoObjRef.current = null;
    ultimoPasoRef.current = null;
    primerCambioRef.current = 0;
    setHistory([]);
    setHistoryIdx(-1);
  };
  const animaTeletransporte = (itemId) => new Promise((resolve) => {
    const el = document.querySelector(`.item[data-item-id="${itemId}"]`);
    const destino = document.querySelector('.crumb-back');
    if (!el || !destino || !el.animate) { resolve(); return; }
    const r = el.getBoundingClientRect();
    const d = destino.getBoundingClientRect();
    const ancho = el.offsetWidth || r.width, alto = el.offsetHeight || r.height;
    const escala = r.width / ancho;
    // Una copia del nodo, fuera del lienzo, es la que viaja: así no la corta el
    // borde del lienzo ni la mueve el zoom.
    const copia = el.cloneNode(true);
    copia.querySelectorAll('.handle, .anchors, .connect-handle, .crop-handle').forEach(n => n.remove());
    copia.classList.remove('selected');
    copia.classList.add('teleporte-copia');
    Object.assign(copia.style, {
      position: 'fixed', margin: '0', zIndex: '9999', pointerEvents: 'none',
      width: ancho + 'px', height: alto + 'px',
      left: (r.left + r.width / 2 - ancho / 2) + 'px', top: (r.top + r.height / 2 - alto / 2) + 'px',
      transformOrigin: '50% 50%', transform: `scale(${escala})`,
    });
    document.body.appendChild(copia);
    el.style.visibility = 'hidden';
    // El anillo del portal, donde estaba el tablero.
    const anillo = document.createElement('div');
    anillo.className = 'teleporte-anillo';
    const lado = Math.max(r.width, r.height) * 1.1;
    Object.assign(anillo.style, { left: (r.left + r.width / 2) + 'px', top: (r.top + r.height / 2) + 'px', width: lado + 'px', height: lado + 'px' });
    document.body.appendChild(anillo);
    window.playAudioTone && window.playAudioTone('teletransporte');
    const dx = d.left + d.width / 2 - (r.left + r.width / 2);
    const dy = d.top + d.height / 2 - (r.top + r.height / 2);
    const vuelo = copia.animate([
      { transform: `translate(0, 0) scale(${escala})`, opacity: 1, offset: 0 },
      // Se carga un instante, como quien coge impulso.
      { transform: `translate(0, 0) scale(${escala * 1.06})`, opacity: 1, offset: 0.16 },
      // Y sale en arco, hacia arriba y a la izquierda, encogiéndose.
      { transform: `translate(${dx * 0.42}px, ${dy * 0.3 - 60}px) scale(${escala * 0.42}) rotate(-8deg)`, opacity: 0.95, offset: 0.58 },
      { transform: `translate(${dx}px, ${dy}px) scale(0.03) rotate(-18deg)`, opacity: 0.15, offset: 1 },
    ], { duration: 950, easing: 'cubic-bezier(0.55, 0, 0.3, 1)', fill: 'forwards' });
    const termina = () => {
      copia.remove();
      anillo.remove();
      // La flecha de volver "recibe" el proyecto con un saltito.
      destino.classList.add('recibe');
      setTimeout(() => destino.classList.remove('recibe'), 650);
      window.playAudioTone && window.playAudioTone('board_open');
      resolve();
    };
    vuelo.onfinish = termina;
    vuelo.oncancel = termina;
  });
  const convierteEnProyecto = async (itemId) => {
    const c = canvases[currentId];
    const tablero = c && (c.items || []).find(i => i.id === itemId && i.type === 'board');
    if (!tablero || !tablero.canvasId || !canvases[tablero.canvasId]) return;
    const texto = (tablero.content && (tablero.content[lang] || tablero.content.es || tablero.content.en)) || '';
    const nombre = String(texto).replace(/<[^>]*>/g, '').replace(/&nbsp;/g, ' ').trim() || window.t('Tablero', 'Board');
    const vale = window.customConfirm
      ? await window.customConfirm(window.t(
          `¿Convertir «${nombre}» en un proyecto? Sale de este lienzo con todo lo que tiene dentro y aparece en tu pantalla de inicio.`,
          `Turn "${nombre}" into a project? It leaves this canvas with everything inside it and shows up on your home screen.`))
      : true;
    if (!vale) return;
    setSelected(null);
    setSelectedIds([]);
    setEditing(null);
    await animaTeletransporte(itemId);

    const nuevoId = `proj-${Date.now()}-${Math.floor(Math.random() * 9999)}`;
    const hex = tablero.color && window.resolveStickyColor ? window.resolveStickyColor(tablero.color) : null;
    // La portada sale del color del tablero, para que se reconozca; uno blanco
    // se queda con la amarilla de siempre.
    const cover = hex && String(hex).toUpperCase() !== '#FFFFFF' && String(hex).toUpperCase() !== '#FAF9F6'
      ? `linear-gradient(135deg, ${hex} 0%, color-mix(in srgb, ${hex} 68%, #000) 100%)`
      : 'linear-gradient(135deg, #F7DA84 0%, #F0C24B 100%)';
    const actual = (projectsRef.current || []).find(p => p.id === projectId);
    const proyecto = {
      id: nuevoId,
      name: { es: nombre, en: nombre },
      emoji: tablero.icon && /^[a-z0-9_]+$/.test(tablero.icon) ? tablero.icon : 'dashboard',
      cover,
      updated: { es: 'ahora', en: 'just now' },
      items: 0,
      // En la misma carpeta de la pantalla de inicio que el proyecto de donde sale.
      ...(actual && actual.carpeta ? { carpeta: actual.carpeta } : null),
    };

    // Las imágenes viajan solas: el lienzo se lleva sus rutas ('media/…') y el
    // servidor las busca también en la carpeta de otro proyecto hasta que, al
    // guardar la bóveda, se copian a la del nuevo (Boveda.reparaMedios). Aquí
    // antes se copiaban en el acto a la carpeta que tendría el proyecto, pero
    // si otro proyecto ya se llamaba igual, esa carpeta era la del otro:
    // se renombraba al guardar y se llevaba las imágenes consigo.
    setCanvases(prev => {
      const cc = prev[currentId];
      if (!cc || !prev[tablero.canvasId]) return prev;
      const next = { ...prev };
      next[nuevoId] = { ...prev[tablero.canvasId], title: { es: nombre, en: nombre } };
      delete next[tablero.canvasId];
      next[currentId] = {
        ...cc,
        items: (cc.items || []).filter(i => i.id !== itemId),
        connectors: (cc.connectors || []).filter(co => {
          const de = co.fromEnd?.itemId || co.from;
          const a = co.toEnd?.itemId || co.to;
          return de !== itemId && a !== itemId;
        }),
      };
      return next;
    });
    setProjects(prev => [proyecto, ...(prev || [])]);
    reiniciaHistorial();
    window.showToast && window.showToast(window.t(
      `«${nombre}» ya es un proyecto: lo tienes en la pantalla de inicio.`,
      `"${nombre}" is now a project: it's on your home screen.`));
  };

  // Lo que los nodos reciben para hablar con el lienzo. Las FUNCIONES van
  // por una ref y llaman siempre a la versión más nueva, así que no hace falta
  // rehacer el objeto cuando cambia `canvases`: antes se rehacía en cada
  // fotograma de un arrastre y, con él, se volvían a pintar TODOS los nodos
  // (5 fotogramas por segundo arrastrando uno entre 500, medido). El objeto
  // solo cambia con los valores que los nodos pintan (lo de abajo, en las
  // dependencias). El progreso y el tablero, que enseñan cosas de otros
  // lienzos, reciben aparte lo que miran (ver «lienzos» en ItemRenderer).
  const callbacksVivos = ({
    convertirEnProyecto: convierteEnProyecto,
    openBoard,
    openDoc: (id, colId) => setDocOpen({ id, colId }),
    openFile: (id) => setFileOpen({ id }),
    updateItem,
    // Un nodo nuevo a la derecha de otro, a su misma altura (la paleta que se
    // saca de una imagen). El que estaba elegido sigue elegido.
    ponNodoAlLado: (itemId, nodo) => {
      const c = canvases[currentId];
      const junto = c && c.items.find(x => x.id === itemId);
      if (!junto) return;
      const d = defaultDims(junto.type);
      const nuevo = Object.assign({
        id: `it-${Date.now()}-${Math.floor(Math.random() * 99999)}`,
        x: Math.round((junto.x || 0) + (junto.w || d.w) + 30),
        y: Math.round(junto.y || 0),
        _new: true,
      }, nodo);
      setCanvases(prev => {
        const cv = prev[currentId];
        return { ...prev, [currentId]: { ...cv, items: [...cv.items, nuevo] } };
      });
    },
    getNestedItems,
    // Lo que cuenta el nodo de progreso: el proyecto entero o este tablero
    // con lo que tenga dentro (window.OdiProgreso).
    cuentaProgreso: (alcance) => window.OdiProgreso
      ? window.OdiProgreso.cuenta(canvases, alcance === 'tablero' ? currentId : projectId)
      : { hechas: 0, total: 0, porcentaje: 0 },
    croppingId,
    setCroppingId,
    startEdit: (id) => setEditing(id),
    endEdit: () => setEditing(null),
    selectItem: (id) => {
      setSelected(id);
      let isColChild = false;
      const c = canvases[currentId];
      if (c && c.items) {
        for (const it of c.items) {
          if (it.type === 'column' && it.children && it.children.some(ch => ch.id === id)) {
            isColChild = true;
            break;
          }
        }
      }
      if (!isColChild) {
        setEditingChildState(null);
      }
    },
    isSelectedItem: (id) => selected === id,
    resizeItemSilent: (id, patch) => updateItemSilent(id, patch),
    startColChildDrag,
    startAnchorDrag,
    startDragTaskRow,
    editingChild,
    setEditingChild: (colId, childId) => setEditingChildState({ colId, childId }),
    updateColChild: (columnId, childId, patch) => {
      setCanvases(prev => {
        const c = prev[currentId];
        const col = c.items.find(x => x.id === columnId);
        const child = col?.children?.find(x => x.id === childId);
        let nextCanvases = { ...prev };
        if (child && child.type === 'board' && patch.content && child.canvasId) {
          if (nextCanvases[child.canvasId]) {
            nextCanvases[child.canvasId] = {
              ...nextCanvases[child.canvasId],
              title: { ...(nextCanvases[child.canvasId].title || {}), ...patch.content }
            };
          }
        }
        nextCanvases[currentId] = {
          ...c,
          items: withResizedColumn(
            c.items.map(it => {
              if (it.id !== columnId) return it;
              return { ...it, children: (it.children || []).map(ch => ch.id === childId ? { ...ch, ...patch } : ch) };
            }),
            columnId
          )
        };
        return nextCanvases;
      });
    },
    // Modo dibujo (lo consume la barra contextual)
    drawingId,
    enterDrawMode,
    saveDrawing,
    discardDrawing,
    drawTool, setDrawTool,
    drawColor, setDrawColor: recolorStroke,
    drawWidth, setDrawWidth,
    drawPressureMode, setDrawPressureMode,
    drawEstabilizador, setDrawEstabilizador,
    drawUndo, drawRedo,
    canDrawUndo: !!(drawSession && drawSession.past.length),
    canDrawRedo: !!(drawSession && drawSession.future.length),
    selectedStrokeId,
    deleteSelectedStroke,
  });
  const callbacksVivosRef = useRefCanvas(callbacksVivos);
  callbacksVivosRef.current = callbacksVivos;
  const callbacks = useMemoCanvas(() => {
    const r = {};
    Object.keys(callbacksVivosRef.current).forEach(k => {
      const v = callbacksVivosRef.current[k];
      r[k] = typeof v === 'function' ? (...args) => callbacksVivosRef.current[k](...args) : v;
    });
    return r;
  // eslint-disable-next-line
  }, [currentId, editingChild, selected, croppingId,
       drawingId, drawSession, drawTool, drawColor, drawWidth, drawPressureMode, drawEstabilizador, selectedStrokeId]);

  // ───── Breadcrumbs ─────
  const crumbs = useMemoCanvas(() => {
    const out = [{ id: '__home', label: window.TRANSLATIONS[lang].home }];
    stack.forEach((cid, idx) => {
      const c = canvases[cid];
      if (!c) return;
      const proj = window.SAMPLE_PROJECTS.find(p => p.id === cid);
      const fromParent = idx > 0 ? (canvases[stack[idx-1]]?.items || []).find(i => i.canvasId === cid) : null;
      const colorKey = window.colorClass(fromParent?.color || 'olive');
      out.push({
        id: cid,
        label: proj ? window.pickLang(proj.name, lang) : window.pickLang(c.title, lang),
        chipColor: idx === 0 ? 'var(--ink)' : window.COLOR_HEX_RESOLVED[colorKey],
      });
    });
    return out;
  }, [stack, canvases, lang]);

  const onCrumb = (idx) => {
    if (idx === 0) onHome();
    else if (idx >= 1 && idx < stack.length) goBackTo(idx - 1);
  };
  const onCrumbRename = (idx, newName) => {
    if (idx === 0) return;
    const cid = stack[idx - 1];
    renameCanvas(cid, newName);
  };

  if (!current) return <div style={{padding:40}}>Canvas not found</div>;

  const matchesSearch = (item) => {
    if (!search.trim()) return true;
    const q = search.toLowerCase();
    return JSON.stringify(item).toLowerCase().includes(q);
  };

  const bounds = useMemoCanvas(() => {
    const items = current.items;
    if (!items.length) return { w: 2400, h: 1800 };
    let mx = 0, my = 0;
    items.forEach(i => { mx = Math.max(mx, i.x + i.w); my = Math.max(my, i.y + i.h); });
    return { w: Math.max(2400, mx + 400), h: Math.max(1800, my + 400) };
  }, [current.items]);

  const wrapClass = ['canvas-wrap', `canvas-bg-${current.bgColor || 'default'}`];
  // La cuadrícula de fondo se puede quitar por tablero (menú del clic derecho).
  if (current.sinCuadricula) wrapClass.push('canvas-wrap--sin-cuadricula');
  // Flecha en curso: enciende los puntos de anclaje de todos los nodos
  if (pendingConn) wrapClass.push('conn-drag');
  if (transition === 'entering') wrapClass.push('entering');
  if (activeTool && activeTool !== 'line') wrapClass.push('placing');
  if (activeTool === 'line') wrapClass.push('linking');
  // La mano abierta avisa de que el espacio está pulsado antes de arrastrar.
  if (espacio) wrapClass.push(paneando ? 'paneando' : 'con-espacio');

  const selectedItem = (() => {
    if (editingChild) {
      const col = current.items.find(it => it.id === editingChild.colId);
      const child = col?.children?.find(c => c.id === editingChild.childId);
      if (child) return child;
    }
    if (!selected) return null;
    for (const it of current.items) {
      if (it.id === selected) return it;
      if (it.type === 'column' && it.children) {
        const child = it.children.find(c => c.id === selected);
        if (child) return child;
      }
    }
    return null;
  })();
  // Resolve doc item: either a top-level item or a child of a column
  let docItem = null;
  let docUpdater = null;
  if (docOpen) {
    if (docOpen.colId) {
      const col = current.items.find(i => i.id === docOpen.colId);
      const child = (col?.children || []).find(c => c.id === docOpen.id);
      if (child) {
        docItem = child;
        docUpdater = (patch) => callbacks.updateColChild(docOpen.colId, docOpen.id, patch);
      }
    } else {
      docItem = current.items.find(i => i.id === docOpen.id);
      if (docItem) docUpdater = (patch) => updateItem(docOpen.id, patch);
    }
  }

  // ───── Tool drag-from-toolbar ─────
  const onToolDragStart = (e, toolId) => {
    e.preventDefault();

    // Con el modo dibujo abierto no puede haber ademas una herramienta armada.
    // La capa de dibujo se come los clics del lienzo, asi que la herramienta se
    // queda puesta y sin forma de usarla; y al salir del dibujo el lienzo se
    // queda "colocando" algo que nunca se coloco. Desde fuera se ve como que la
    // aplicacion se ha muerto: ni pone nodos ni deja mover los que hay.
    //
    // La barra de arriba sigue clicable mientras se dibuja a proposito —desde
    // ahi se sale, se deshace y se cambia de lienzo—, asi que la solucion no es
    // bloquearla: es cerrar el dibujo, que es lo que esa persona esta pidiendo
    // al coger otra herramienta.
    if (drawingId) saveDrawing();

    const startX = e.clientX, startY = e.clientY;
    let dragging = false;
    setActiveTool(toolId);
    setToolGhost({ x: e.clientX, y: e.clientY, tool: toolId });

    const onMove = (ev) => {
      const dx = ev.clientX - startX;
      const dy = ev.clientY - startY;
      if (!dragging && Math.hypot(dx, dy) > 5) dragging = true;
      if (dragging) setToolGhost({ x: ev.clientX, y: ev.clientY, tool: toolId });
    };
    // Red de seguridad por si el "soltar" nunca llega (ver más abajo): si se
    // levanta el dedo y nadie lo ha recogido, se cierra igual. El cerrojo evita
    // que se cree el nodo dos veces cuando llegan los dos avisos.
    let terminado = false;

    const onUp = (ev) => {
      if (terminado) return;
      terminado = true;
      window.removeEventListener('mousemove', onMove);
      window.removeEventListener('mouseup', onUp);
      window.removeEventListener('touchend', onDedoLevantado);
      window.removeEventListener('touchcancel', onDedoLevantado);
      setToolGhost(null);
      if (!dragging) {
        // Dibujar no se "coloca": se empieza a dibujar. Pulsar el botón y
        // tener que ir a dar otro clic al lienzo para que pase algo se sentía
        // raro — con el resto de nodos tiene sentido elegir dónde va, pero
        // aquí el sitio lo decide el propio trazo.
        if (toolId === 'draw') {
          setActiveTool(null);
          const wrap = surfaceRef.current;
          const rect = wrap ? wrap.getBoundingClientRect() : { width: 900, height: 600 };
          // En el centro de lo que se está viendo ahora mismo.
          const centro = screenToCanvas(rect.left + rect.width / 2, rect.top + rect.height / 2);
          const def = defaultDims('draw');
          const item = makeNewItem('draw', centro.x - def.w / 2, centro.y - def.h / 2, def.w, def.h, lang);
          if (item) {
            setCanvases(prev => {
              const c = prev[currentId];
              return { ...prev, [currentId]: { ...c, items: [...c.items, item] } };
            });
            window.playAudioTone && window.playAudioTone('create');
          }
          return;
        }
        // click without drag → keep tool active for click-create
        return;
      }
      // dropped over canvas?
      const wrap = surfaceRef.current;
      if (!wrap) { setActiveTool(null); return; }
      const rect = wrap.getBoundingClientRect();
      if (ev.clientX < rect.left || ev.clientX > rect.right || ev.clientY < rect.top || ev.clientY > rect.bottom) {
        setActiveTool(null);
        return;
      }
      // create at drop position
      const pt = screenToCanvas(ev.clientX, ev.clientY);
      if (toolId === 'line') {
        // create a default-length floating arrow
        addConnector({ x: pt.x - 60, y: pt.y }, { x: pt.x + 60, y: pt.y });
        setActiveTool(null);
        return;
      }
      const def = defaultDims(toolId);
      const item = makeNewItem(toolId, pt.x - def.w / 2, pt.y - def.h / 2, def.w, def.h, lang);
      if (item) {
        setCanvases(prev => {
          const c = prev[currentId];
          const next = { ...prev, [currentId]: { ...c, items: [...c.items, item] } };
          if (item.type === 'board') {
            next[item.canvasId] = {
              title: item.content,
              parent: currentId,
              parentLabel: c.title,
              items: [], connectors: [],
            };
          }
          return next;
        });
        setSelected(item.id);
        window.playAudioTone && window.playAudioTone('drop');
        if (['note','comment','bigtitle','shape','separator'].includes(item.type) && !skipAutoEdit()) setTimeout(() => setEditing(item.id), 40);
        if (item.type === 'doc') setTimeout(() => setDocOpen({ id: item.id }), 40);
        if (['link','todo','board','column','map','frame'].includes(item.type)) setTimeout(() => setEditing(item.id), 40);
      }
      setActiveTool(null);
    };

    // El dedo levantado, directo del navegador y sin pasar por la traducción a
    // ratón. Este aviso llega siempre, exista todavía o no el botón del que
    // estabas arrastrando.
    const onDedoLevantado = (ev) => {
      const t = (ev.changedTouches && ev.changedTouches[0]) || null;
      onUp({ clientX: t ? t.clientX : startX, clientY: t ? t.clientY : startY });
    };

    window.addEventListener('mousemove', onMove);
    window.addEventListener('mouseup', onUp);
    window.addEventListener('touchend', onDedoLevantado);
    window.addEventListener('touchcancel', onDedoLevantado);
  };

  // ── Paste interceptor: hidden contentEditable that receives paste events when an image node is selected ──
  const onPasteIntercept = (e) => {
    if (!selected) return;
    const selItem = current.items.find(i => i.id === selected);
    if (!selItem || selItem.type !== 'image') return;
    e.preventDefault();
    const items = e.clipboardData?.items;
    if (!items) return;
    for (const it of Array.from(items)) {
      if (it.type.startsWith('image/')) {
        const file = it.getAsFile();
        if (!file) continue;
        const fr = new FileReader();
        fr.onload = () => {
          const src = fr.result;
          const img = new Image();
          img.onload = () => {
            const ratio = img.naturalWidth / img.naturalHeight;
            const w = selItem.w || 260;
            updateItem(selected, { src, w, h: Math.max(60, Math.round(w / ratio)) });
          };
          img.onerror = () => updateItem(selected, { src });
          img.src = src;
        };
        fr.readAsDataURL(file);
        return;
      }
    }
  };

  // ── Foto del lienzo ──
  //
  // Se esconden los mandos que viven DENTRO del lienzo —la busqueda, la barra de
  // estado, el zoom y el selector de fondo— y se captura la ventana de verdad.
  // La barra de herramientas de arriba no hace falta esconderla: esta fuera de
  // esta zona y el recorte ya la deja fuera.
  //
  // `visibility` y no `display`: quitar un elemento del flujo reordena lo que
  // queda y la foto saldria con el contenido movido respecto a lo que se veia.
  // Invisible sigue ocupando su sitio.
  const capturaLienzo = async () => {
    const api = window.electronAPI;
    const zona = document.querySelector('.canvas-wrap');
    if (!api || !api.capturarLienzo || !zona || fogonazo) return;

    const mandos = Array.from(zona.children).filter(c => !c.classList.contains('canvas-surface'));
    mandos.forEach(m => { m.style.visibility = 'hidden'; });

    try {
      // El fogonazo primero: los bordes se encienden en blanco un instante, como
      // el obturador de una camara. No es adorno — entre pulsar y ver la ventana
      // de guardar pasa casi un segundo, y sin nada en medio parece que el boton
      // no ha hecho nada y se pulsa otra vez.
      setFogonazo(true);
      await new Promise(r => setTimeout(r, 420));
      setFogonazo(false);

      // Dos fotogramas antes de disparar. Con uno solo, a veces la captura sale
      // con los mandos todavia puestos: el navegador aun no ha repintado. Y aqui
      // ademas hace falta que el fogonazo se haya ido, o saldria en la foto.
      await new Promise(r => requestAnimationFrame(() => requestAnimationFrame(r)));

      const r = zona.getBoundingClientRect();
      const res = await api.capturarLienzo({ x: r.left, y: r.top, width: r.width, height: r.height,
        titulo: window.t('Guardar la captura', 'Save the screenshot'), tipo: window.t('Imagen PNG', 'PNG image') });

      if (res && res.ok) {
        window.showToast && window.showToast(window.t('Captura guardada.', 'Screenshot saved.'));
      } else if (res && res.motivo !== 'cancelado') {
        window.showToast && window.showToast(window.t('No se pudo guardar la captura.', 'Could not save the screenshot.'), 'error');
      }
    } finally {
      setFogonazo(false);
      // En un `finally` a proposito: si algo falla a medias, lo que no puede
      // pasar es que la persona se quede con el lienzo sin mandos.
      mandos.forEach(m => { m.style.visibility = ''; });
    }
  };

  return (
    <div className="app" data-screen-label={`Canvas · ${window.pickLang(current.title, lang)}`}>
      {/* Invisible paste interceptor — focused when an image node is selected, so Ctrl+V fires paste here */}
      <div
        ref={pasteIntRef}
        contentEditable
        suppressContentEditableWarning
        data-paste-interceptor="true"
        onPaste={onPasteIntercept}
        onInput={(e) => { e.currentTarget.innerHTML = ''; }}
        onKeyDown={(e) => {
          // Don't let typing accumulate; let canvas-level shortcuts (Backspace, Ctrl+D, etc.) still work
          if ((e.ctrlKey || e.metaKey) && (e.key === 'v' || e.key === 'V')) return;
          if (e.key.length === 1) e.preventDefault();
        }}
        style={{ position:'fixed', opacity:0, width:1, height:1, overflow:'hidden', top:0, left:0, zIndex:-1, outline:'none' }}
      />
      <window.Topbar
        lang={lang} setLang={setLang}
        theme={theme} setTheme={setTheme}
        crumbs={crumbs} onCrumb={onCrumb} onCrumbRename={onCrumbRename}
        onSettingsClick={onSettingsClick}
        onManualSync={onManualSync}
        isSyncingDrive={isSyncingDrive}
        needsDriveAuth={needsDriveAuth}
        activeTool={activeTool} setActiveTool={setActiveTool}
        onHome={onHome}
        onUndo={undo} onRedo={redo}
        canUndo={historyIdx > 0} canRedo={historyIdx < history.length - 1}
        onToolDragStart={onToolDragStart}
        updateAvailable={updateAvailable}
        onUpdateClick={onUpdateClick}
        volume={volume}
        onChangeVolume={onChangeVolume}
        {...(() => {
          // El estado de compartir sube a la barra de arriba. Tres estados y no
          // dos: publicado y "puedo hablar con Drive ahora mismo" son cosas
          // distintas, y confundirlas dejaba proyectos publicados marcados como
          // offline para siempre cuando caducaba el permiso.
          if (!currentProject) return {};
          const publicado = !!currentProject.isPublic;
          const caido = publicado && driveReachable === false;
          // Estar de invitado en la sala de otro manda sobre todo lo demás.
          //
          // Antes ese caso caía en "offline" y el botón salía gris, que es lo
          // que menos se parece a lo que está pasando: no estás desconectado,
          // estás dentro del lienzo de otra persona y lo que escribes lo está
          // viendo ella. Ámbar, como los proyectos prestados del menú; el
          // verde se queda para quien es el dueño de lo que hay en pantalla.
          const invitado = enSala === 'invitado';
          return {
            estadoCompartir: invitado ? 'invitado' : !publicado ? 'offline' : caido ? 'caido' : 'online',
            estadoTitulo: invitado
              ? window.t('Estás en la sesión de otra persona. Haz clic para ver quién hay o salir.', 'You are in someone else\'s session. Click to see who is there or leave.')
              : !publicado
              ? window.t('Solo en este equipo. Haz clic para compartirlo o trabajar en vivo con alguien.', 'Only on this device. Click to share it or work live with someone.')
              : caido
                ? window.t('Publicado en tu Google Drive, pero ahora no hay conexión: los cambios se guardan aquí y se subirán al reconectar.', 'Published to your Google Drive, but there is no connection right now: changes are saved here and will upload once reconnected.')
                : window.t('Publicado en tu Google Drive. Haz clic para compartir o empezar una sesión en vivo.', 'Published to your Google Drive. Click to share or start a live session.'),
            onEstadoCompartir: () => { onSharingClick && onSharingClick(projectId); },
          };
        })()}
        userProfile={userProfile}
        esPatrocinador={esPatrocinador}
        onAbrirCorona={onAbrirCorona}
        onUserClick={onUserClick}
      />

      {toolGhost && <ToolGhost {...toolGhost} lang={lang}/>}

      {/* Contextual sidebar — hidden while actively editing THIS node's text (format sidebar shows instead) */}
      {/* El nodo de codigo es la excepcion: mientras se escribe SI se le
          deja su barra, porque ahi vive el boton de comentar. La de formato
          de texto (negrita, cursiva, colores) no pinta nada en un bloque de
          codigo, asi que no la sustituye ninguna. */}
      {selectedItem && !captionFocusId && (editing !== selectedItem.id || selectedItem.type === 'code') && !selectedItem._editingTitle && (() => {
        const isColChild = editingChild && selectedItem.id === editingChild.childId;
        return (
          <window.ContextSidebar
            item={selectedItem}
            lang={lang}
            isColChild={isColChild}
            callbacks={callbacks}
            backlinks={backlinksForSelected}
            onGoToBacklink={(b) => onGoToNode && onGoToNode(b)}
            onStartEdit={() => setEditing(selectedItem.id)}
            editando={editing === selectedItem.id}
            onUpdate={(patch) => {
              if (isColChild) {
                callbacks.updateColChild(editingChild.colId, editingChild.childId, patch);
              } else {
                updateItem(selectedItem.id, patch);
              }
            }}
            onDelete={() => {
              if (isColChild) {
                setCanvases(prev => {
                  const c = prev[currentId];
                  return { ...prev, [currentId]: {
                    ...c,
                    items: withResizedColumn(
                      c.items.map(it => {
                        if (it.id !== editingChild.colId) return it;
                        return { ...it, children: (it.children || []).filter(ch => ch.id !== editingChild.childId) };
                      }),
                      editingChild.colId
                    )
                  }};
                });
                setEditingChildState(null);
              } else {
                deleteItem(selectedItem.id);
              }
            }}
            onDuplicate={() => {
              if (isColChild) {
                setCanvases(prev => {
                  const c = prev[currentId];
                  const col = c.items.find(it => it.id === editingChild.colId);
                  const child = col?.children?.find(ch => ch.id === editingChild.childId);
                  if (!child) return prev;
                  const copy = JSON.parse(JSON.stringify(child));
                  copy.id = `it-${Date.now()}-${Math.floor(Math.random()*9999)}`;
                  if (copy.type === 'board' && copy.canvasId) {
                    const origCid = copy.canvasId;
                    const newCid = `b-${Date.now()}-${Math.floor(Math.random()*9999)}`;
                    copy.canvasId = newCid;
                    const next = { ...prev, [currentId]: {
                      ...c,
                      items: withResizedColumn(
                        c.items.map(it => {
                          if (it.id !== editingChild.colId) return it;
                          return { ...it, children: [...(it.children || []), copy] };
                        }),
                        editingChild.colId
                      )
                    }};
                    duplicateCanvasState(next, origCid, newCid);
                    return next;
                  } else {
                    return { ...prev, [currentId]: {
                      ...c,
                      items: withResizedColumn(
                        c.items.map(it => {
                          if (it.id !== editingChild.colId) return it;
                          return { ...it, children: [...(it.children || []), copy] };
                        }),
                        editingChild.colId
                      )
                    }};
                  }
                });
              } else {
                duplicateItem(selectedItem.id);
              }
            }}
            onOpen={selectedItem.type === 'board' ? () => {
              if (isColChild) {
                openBoard(selectedItem.canvasId, selectedItem.id);
              } else {
                openBoard(selectedItem.canvasId, selectedItem.id);
              }
            } : null}
            onClose={() => {
              if (isColChild) {
                setEditingChildState(null);
              } else {
                setSelected(null);
              }
            }}
          />
        );
      })()}

      {/* Connector sidebar (same style as item ctx) */}
      {selectedConn && (() => {
        const conn = (current.connectors || []).find(c => c.id === selectedConn);
        if (!conn) return null;
        const style = conn.style || 'solid';
        const shape = conn.shape || 'curve';
        const conCandado = flechaBloqueada(conn);
        const porNodos = flechaPorNodos(conn);

        return (
          <div className="ctx-side" onMouseDown={(e)=>e.stopPropagation()}>
            <button className="ctx-close" onClick={()=>setSelectedConn(null)} title={window.t('Cerrar', 'Close')}>
              <span className="material-symbols-rounded">arrow_back</span>
            </button>

            {/* El candado de la flecha: quieta, sin doblarse ni borrarse. Si
                lo tiene uno de sus nodos, la flecha va con él. */}
            <button
              className={`ctx-btn ${conCandado ? 'active' : ''}`}
              onClick={()=>alternaBloqueoFlecha(conn.id)}
              title={porNodos && !conn.bloqueado
                ? window.t('Bloqueada porque uno de sus nodos tiene el candado', 'Locked because one of its nodes is locked')
                : conCandado
                  ? window.t('Quitar el candado (Ctrl+Shift+L)', 'Unlock (Ctrl+Shift+L)')
                  : window.t('Bloquear: no se mueve, no se dobla ni se borra (Ctrl+Shift+L)', 'Lock: it cannot be moved, bent or deleted (Ctrl+Shift+L)')}
            >
              <span className="material-symbols-rounded">{conCandado ? 'lock' : 'lock_open'}</span>
              <span>{conCandado ? window.t('Bloqueada', 'Locked') : window.t('Bloquear', 'Lock')}</span>
            </button>

            <div className="ctx-sep-h"/>

            <button className={`ctx-btn ${shape==='curve' ? 'active' : ''}`} disabled={conCandado} onClick={()=>updateConnector(conn.id, { shape: 'curve' })}>
              <span className="material-symbols-rounded">show_chart</span>
              <span>{window.t('Curva', 'Curve')}</span>
            </button>
            <button className={`ctx-btn ${shape==='orthogonal' ? 'active' : ''}`} disabled={conCandado} onClick={()=>updateConnector(conn.id, { ...(window.FlechaRecta ? window.FlechaRecta.patchAutomatica(conn) : { ortho: undefined, orthoManual: undefined }), shape: 'orthogonal', bend: { x: 0, y: 0 } })}>
              <span className="material-symbols-rounded">stairs</span>
              <span>{window.t('Recta', 'Right-angle')}</span>
            </button>
            {/* Con el recorrido hecho a mano, volver al automático: el que se
                rehace solo al mover los nodos. */}
            {shape === 'orthogonal' && !conCandado && window.FlechaRecta && window.FlechaRecta.esAMano(conn) && (
              <button
                className="ctx-btn"
                onClick={()=>{ updateConnector(conn.id, window.FlechaRecta.patchAutomatica(conn)); window.playAudioTone && window.playAudioTone('click'); }}
                title={window.t('Volver al recorrido automático, el que se recoloca solo al mover los nodos', 'Back to the automatic route, which rearranges itself when nodes move')}
              >
                <span className="material-symbols-rounded">auto_fix_high</span>
                <span>{window.t('Automática', 'Automatic')}</span>
              </button>
            )}

            <div className="ctx-sep-h"/>

            <button className={`ctx-btn ${style==='solid' ? 'active' : ''}`} onClick={()=>updateConnector(conn.id, { style: 'solid' })}>
              <svg width="22" height="6"><line x1="0" y1="3" x2="22" y2="3" stroke="currentColor" strokeWidth="2.5"/></svg>
              <span>{window.t('Sólida', 'Solid')}</span>
            </button>
            <button className={`ctx-btn ${style==='dashed' ? 'active' : ''}`} onClick={()=>updateConnector(conn.id, { style: 'dashed' })}>
              <svg width="22" height="6"><line x1="0" y1="3" x2="22" y2="3" stroke="currentColor" strokeWidth="2.5" strokeDasharray="5 3"/></svg>
              <span>{window.t('Discontinua', 'Dashed')}</span>
            </button>
            <button className={`ctx-btn ${style==='dotted' ? 'active' : ''}`} onClick={()=>updateConnector(conn.id, { style: 'dotted' })}>
              <svg width="22" height="6"><line x1="0" y1="3" x2="22" y2="3" stroke="currentColor" strokeWidth="2.5" strokeDasharray="1 3" strokeLinecap="round"/></svg>
              <span>{window.t('Punteada', 'Dotted')}</span>
            </button>

            <div className="ctx-sep-h"/>

            <div style={{ padding: '4px 6px' }}>
              <window.SelectorColor
                valor={conn.color || '#1A1A1A'}
                onCambio={(c) => updateConnector(conn.id, { color: c, isColorExplicit: true })}
                colores={window.COLORES_ODINOTE_FLECHA}
                tam={20}
              />
            </div>

            <div className="ctx-sep-h"/>

            <button
              className={`ctx-btn ${conn.bidirectional ? 'active' : ''}`}
              onClick={()=>updateConnector(conn.id, { bidirectional: !conn.bidirectional })}
              title={window.t('Flecha bidireccional', 'Bidirectional arrow')}
            >
              <span className="material-symbols-rounded">sync_alt</span>
              <span>{window.t('Doble', 'Two-way')}</span>
            </button>

            <button
              className={`ctx-btn ${connLabelOpen ? 'active' : ''}`}
              onClick={()=>setConnLabelOpen(o => !o)}
              title={window.t('Etiqueta', 'Label')}
            >
              <span className="material-symbols-rounded">label</span>
              <span>{window.t('Etiqueta', 'Label')}</span>
            </button>

            {connLabelOpen && (
              <div style={{padding:'4px 6px'}}>
                <input
                  className="conn-label-input"
                  autoFocus
                  value={conn.label || ''}
                  placeholder={window.t('Etiqueta…', 'Label…')}
                  onChange={(e)=>updateConnector(conn.id, { label: e.target.value })}
                  onClick={(e)=>e.stopPropagation()}
                  onMouseDown={(e)=>e.stopPropagation()}
                  style={{ width:'100%', font:'inherit', fontSize:12, padding:'5px 7px', border:'1.5px solid var(--line-soft)', borderRadius:2 }}
                />
              </div>
            )}

            <div className="ctx-sep-h"/>

            <button className="ctx-btn danger" onClick={()=>{ if (conCandado) { avisaFlechaBloqueada(); return; } deleteConnector(conn.id); }}>
              <span className="material-symbols-rounded">delete</span>
              <span>{window.t('Eliminar', 'Delete')}</span>
            </button>
          </div>
        );
      })()}

      {/* Text format sidebar (when editing a text-based item) */}
      {((editing && editing === selected)
        || (selectedItem && (selectedItem.type === 'map' || selectedItem.type === 'code' || selectedItem.type === 'carpeta' || selectedItem.type === 'timer') && selectedItem._editingTitle)
       ) && !captionFocusId && (() => {
        const it = selectedItem;
        if (!it) return null;
        const isEditingMapTitle = it.type === 'map' && it._editingTitle;
        // El titulo de un bloque de codigo se edita como los demas titulos:
        // con esta misma barra (color, negrita, cursiva...), no con un menu
        // aparte inventado solo para el.
        const isEditingCodeTitle = it.type === 'code' && it._editingTitle;
        // Y el del reloj igual: es un título como el del bloque de código,
        // guardado en su propio campo y no dentro del texto del nodo.
        const isEditingTimerTitle = it.type === 'timer' && it._editingTitle;
        // Y el nombre de la carpeta: su campo de texto propio, con su color.
        const isEditingCarpetaTitle = it.type === 'carpeta' && it._editingTitle;
        // 'board' entró aquí porque, al empezar a escribir su título, el menú
        // contextual normal se ocultaba (se oculta siempre que editing===selected)
        // y esta barra de texto tampoco lo cubría: el panel de la izquierda
        // desaparecía entero mientras se cambiaba el nombre del tablero.
        const isEditingTextNode = editing && editing === selected && ['note','comment','bigtitle','frame','todo','board','shape','separator'].includes(it.type);
        if (!isEditingTextNode && !isEditingMapTitle && !isEditingCodeTitle && !isEditingTimerTitle && !isEditingCarpetaTitle) return null;
        return (
          <window.TextFormatSidebar
            item={it}
            lang={lang}
            onUpdate={(patch)=>updateItem(it.id, patch)}
            onClose={()=>{
              if (isEditingMapTitle || isEditingCodeTitle || isEditingTimerTitle || isEditingCarpetaTitle) {
                updateItem(it.id, { _editingTitle: false });
              } else {
                setEditing(null);
              }
            }}
          />
        );
      })()}

      {/* Caption ("leyenda") format sidebar — reduced option set, shown while a caption is focused */}
      {captionFocusId && (() => {
        const it = current.items.find(i => i.id === captionFocusId);
        if (!it) return null;
        return (
          <window.TextFormatSidebar
            variant="caption"
            item={it}
            lang={lang}
            onUpdate={(patch)=>updateItem(it.id, patch)}
            onClose={()=>{ setCaptionFocusId(null); }}
          />
        );
      })()}

      {/* Estás mirando, no editando.
          Sin un cartel, un lienzo donde lo que tocas vuelve a su sitio parece
          un programa roto. Va arriba y en el centro, donde se mira al no
          entender qué está pasando, y no tapa ninguna herramienta. */}
      {(miRol === 'lector' || corte === 'reconectando') && (
        <div style={{
          position: 'fixed', top: 'calc(8px + env(safe-area-inset-top, 0px))', left: '50%',
          transform: 'translateX(-50%)', zIndex: 9000,
          display: 'flex', alignItems: 'center', gap: '6px',
          padding: '5px 12px', borderRadius: '999px',
          background: corte === 'reconectando' ? 'var(--yellow, #F7DA84)' : 'var(--wine, #E6544F)',
          color: corte === 'reconectando' ? '#1A1A1A' : 'white',
          fontSize: '11.5px', fontWeight: 700, boxShadow: 'var(--pop-sm)',
          pointerEvents: 'none', whiteSpace: 'nowrap',
        }}>
          <span className="material-symbols-rounded" style={{
            fontSize: 15,
            animation: corte === 'reconectando' ? 'spin 1.5s linear infinite' : undefined,
          }}>
            {corte === 'reconectando' ? 'progress_activity' : 'visibility'}
          </span>
          <span>
            {corte === 'reconectando'
              // Lo importante no es que se haya cortado, es que lo que escribas
              // ahora mismo no lo está viendo nadie.
              ? window.t('Reconectando… lo que hagas ahora no se está enviando', 'Reconnecting… what you do now is not being sent')
              : window.t('Solo lectura', 'Read only')}
          </span>
        </div>
      )}

      <div
        ref={surfaceRef}
        className={wrapClass.join(' ')}
        style={{
          backgroundSize: `${22 * scale}px ${22 * scale}px`,
          backgroundPosition: `${pan.x}px ${pan.y}px`
        }}
        onMouseDown={onSurfaceMouseDown}
        onMouseMove={(e) => { if (sesionRef.current) mandaCursor(e.clientX, e.clientY); }}
        onDoubleClick={onSurfaceDoubleClick}
        onContextMenu={onSurfaceContextMenu}
      >
        <div
          className="canvas-surface"
          style={{ 
            left: 0, 
            top: 0, 
            transform: `translate(${pan.x}px, ${pan.y}px) scale(${scale})`, 
            transformOrigin: '0 0', 
            width: bounds.w,
            height: bounds.h,
          }}
        >
          <div className="canvas-content" style={{width: bounds.w, height: bounds.h}}>
            <svg className="connectors" width={bounds.w} height={bounds.h} viewBox={`0 0 ${bounds.w} ${bounds.h}`} style={{ overflow:'visible' }}>
              <g>
                {(current.connectors || []).map(conn => (
                  <window.Connector
                    key={conn.id}
                    layer="lines"
                    conn={conn}
                    items={current.items}
                    selected={selectedConn === conn.id}
                    selectedIds={selectedIds}
                    onSelect={(id)=>{ setSelectedConn(id); setSelected(null); }}
                    onUpdate={updateConnector}
                    onDragNodes={dragItemsSilent}
                    onDragNodesEnd={commitItemsDrag}
                    panZoom={{ scale }}
                    screenToCanvas={screenToCanvas}
                    theme={theme}
                  />
                ))}
                {/* preview of pending connector */}
                {pendingConn && (
                  <g>
                    <line
                      x1={pendingConn.fromX} y1={pendingConn.fromY}
                      x2={pendingConn.mx} y2={pendingConn.my}
                      stroke="var(--olive)" strokeWidth="2" strokeDasharray="6 4" strokeLinecap="round"
                    />
                    <circle cx={pendingConn.mx} cy={pendingConn.my} r="5" fill="var(--olive)" stroke="white" strokeWidth="2"/>
                  </g>
                )}
              </g>
            </svg>

            {(() => { varsTiradoresRef.current = { '--handle-scale': Math.min(3, 1 / scale), '--handle-libre': 1 / scale }; return null; })()}
            {/* Se pintan en el orden de la lista (el último, encima). Antes se
                reordenaban por el último nodo tocado y elegir uno lo subía al
                frente; ahora la capa solo cambia con el clic derecho. */}
            {current.items.map(item => {
              const matches = matchesSearch(item);
              const isEditing = editing === item.id;
              const isDropTarget = dropTargetCol === item.id || dropTargetTodo === item.id;
              const def = defaultDims(item.type);
              const rawScale = Math.min((item.w || def.w) / def.w, (item.h || def.h) / def.h);
              const nodeScale = item.type === 'column'
                ? 1
                : 1 + (Math.min(2.25, Math.max(1, rawScale)) - 1) * 0.5;
              // Tamaño de texto elegido por el usuario con los botones A+ / A−
              const textScale = item.textScale || 1;
              return (
                <div
                  key={item.id}
                  data-item-id={item.id}
                  className={`item ${item.bloqueado ? 'bloqueado' : ''} ${item.type === 'frame' ? 'item-frame' : ''} ${(selected === item.id || selectedIds.includes(item.id)) ? 'selected' : ''} ${item._dragging ? 'dragging' : ''} ${isEditing ? 'editing' : ''} ${item._new ? 'new-item' : ''} ${isDropTarget ? 'drop-target' : ''} ${linkTargetId === item.id ? 'link-target' : ''} ${jumpHighlight === item.id ? 'jump-target' : ''}`}
                  style={{
                    left: item.x, top: item.y,
                    width: item.w !== undefined ? item.w : def.w,
                    height: item.h !== undefined ? item.h : def.h,
                    // El giro se pinta sobre el centro del nodo, que es lo que
                    // uno espera al arrastrar el tirador: el nodo pivota, no
                    // se va de paseo. x/y siguen siendo la esquina del nodo
                    // SIN girar, así que mover, guardar y sincronizar no se
                    // enteran de que esto existe.
                    transform: item.rot ? `rotate(${item.rot}deg)` : undefined,
                    '--node-scale': nodeScale,
                    '--text-scale': textScale,
                    ...(TIPOS_CON_TIRADORES.has(item.type) ? varsTiradoresRef.current : null),
                    // Elegir un nodo ya no lo trae al frente: cada uno se
                    // queda en su capa (el orden de la lista; el último,
                    // encima) y se cambia con el clic derecho. Los marcos,
                    // siempre debajo de los nodos que agrupan.
                    zIndex: item.type === 'frame' ? 1 : 2,
                    // El nodo que se está dibujando lo pinta la capa de dibujo:
                    // dejarlo visible aquí lo mostraría dos veces, uno bajo el velo.
                    opacity: drawingId === item.id ? 0 : (matches ? 1 : 0.18),
                    transition: item._dragging ? 'none' : 'opacity 200ms ease',
                    // Un dibujo sin elegir solo responde en sus trazos (ver DrawItem): lo
                    // vacío de su caja deja pasar el clic a lo que haya debajo. Antes
                    // un dibujo fino encima de unos nodos los tapaba enteros y había
                    // que apartarlo para llegar a ellos. Elegido, se agarra por toda la caja.
                    pointerEvents: (item.type === 'draw' && (item.strokes || []).length && selected !== item.id && !selectedIds.includes(item.id)) ? 'none' : 'auto',
                  }}
                  onMouseDown={(e)=>{
                    if (croppingId === item.id) { e.stopPropagation(); return; }
                    if (activeTool === 'line') { startLineDrag(e, item.id); return; }
                    startDragItem(e, item.id);
                  }}
                  onDoubleClick={(e)=>{
                    if (item.type === 'doc') { e.stopPropagation(); setDocOpen({ id: item.id }); return; }
                    if (item.type === 'draw') { e.stopPropagation(); enterDrawMode(item.id); return; }
                    if (item.type === 'board') return;
                    if (['note','comment','todo','column','link','board','bigtitle','frame','shape','code','separator'].includes(item.type)) {
                      e.stopPropagation();
                      // Los dos modos del bloque de código son excluyentes: al
                      // entrar a escribir código se cierra el del título. Si no,
                      // quedaban los dos puestos y entrabas al código con la
                      // barra del título — o sin barra ninguna.
                      if (item.type === 'code' && item._editingTitle) updateItemSilent(item.id, { _editingTitle: false });
                      setSelected(item.id);
                      setSelectedIds([]);
                      setSelectedConn(null);
                      // Con el dedo NO se entra a editar tocando dos veces. Dos
                      // toques seguidos son lo que uno hace sin querer al
                      // seleccionar y arrastrar, o al pulsar Eliminar justo
                      // después de seleccionar, y el editor se abría solo (con
                      // teclado incluido). En táctil se edita con el botón
                      // "Editar" de la barra del nodo. Se mira el último gesto
                      // y no la capacidad del aparato, para que en un portátil
                      // táctil el doble clic con ratón siga entrando a editar.
                      if (!window.odiLastInputWasTouch) setEditing(item.id);
                    }
                  }}
                  onContextMenu={(e)=>{
                    if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA' || e.target.closest('[contenteditable="true"]')) {
                      return;
                    }
                    if (editing && editing !== item.id) {
                      setEditing(null);
                      setEditingChildState(null);
                    }
                    e.preventDefault(); e.stopPropagation();
                    setSelected(item.id);
                    const rect = surfaceRef.current.getBoundingClientRect();
                    setContextMenu({ x: e.clientX - rect.left, y: e.clientY - rect.top, itemId: item.id });
                  }}
                >
                  <window.NodeErrorBoundary key={`eb-${item.id}`}>
                    <window.ItemRenderer item={item} lang={lang} editing={isEditing} callbacks={callbacks} tema={theme}
                      lienzos={item.type === 'progreso' ? canvases : (item.type === 'board' ? canvases[item.canvasId] : undefined)}/>
                  </window.NodeErrorBoundary>
                  {item.reactions && Object.keys(item.reactions).length > 0 && (
                    <div className="item-reactions" style={{position:'absolute', left: 6, bottom: -10, zIndex: 6}}>
                      {Object.entries(item.reactions).map(([emoji, count]) => (
                        <div key={emoji} className="item-reaction-pill">
                          <span>{emoji}</span>
                          <span>{count}</span>
                        </div>
                      ))}
                    </div>
                  )}
                  {/* Comment badge */}
                  {item.comments && item.comments.length > 0 && (
                    <div
                      className="item-comment-badge"
                      onClick={(e)=>{ e.stopPropagation(); setSelected(item.id); }}
                      title={`${item.comments.length} ${window.t('comentarios', 'comments')}`}
                    >
                      {item.comments.length}
                    </div>
                  )}
                  {/* Puntos de conexión estilo Miro en los cuatro lados del nodo.
                      Aparecen al pasar el cursor; arrastra cualquiera hasta otro nodo. */}
                  {!isEditing && !activeTool && item.type !== 'line' && (
                    <div className="anchors" style={varsTiradoresRef.current}>
                      {['top', 'right', 'bottom', 'left'].map(pos => (
                        <div
                          key={pos}
                          className={`connect-handle ${pos === 'right' ? '' : `pos-${pos}`}`}
                          title={window.t('Arrastra para conectar', 'Drag to connect')}
                          onMouseDown={(e)=>startAnchorDrag(e, item.id, 'center')}
                        >
                          <span className="material-symbols-rounded">trip_origin</span>
                        </div>
                      ))}
                    </div>
                  )}
                  {/* Con el dedo: arrastrar desde CUALQUIER punto del nodo.
                      Esta lámina existía solo para calendarios y tablas, y el
                      problema era de todos los nodos. Midiéndolo sobre un nodo
                      normal, el 45% de su superficie NO lo arrastraba: los
                      tiradores de tamaño se llevaban el 38% de los toques y los
                      de conexión otro 7%. En táctil esos tiradores crecen para
                      poder acertarles con el dedo, y crecen hacia DENTRO: sobre
                      un nodo de 120x32 en pantalla —lo normal al 40% de zoom—
                      se comen casi todo el interior. De ahí lo de "no importa
                      lo que haga, no puedo arrastrarlos": a veces caías en el
                      trozo bueno y a veces no, sin nada que lo explicara.

                      La regla ahora es una y se entiende sola: dentro del nodo
                      se mueve, y para cambiar el tamaño o tirar de un conector
                      se usa la mitad de fuera del tirador, que sobresale del
                      borde. La lámina va por encima de ellos (ver el z-index en
                      styles.css) justo para eso.

                      Un toque limpio sin arrastrar entra a editar, pero solo en
                      los nodos donde eso significaba algo desde siempre; en los
                      demás no hace nada, que es lo correcto sobre un nodo que
                      ya está seleccionado. El doble toque no se pierde: la
                      lámina no lo escucha, así que sube al nodo como siempre y
                      un tablero se sigue abriendo con dos toques. */}
                  {window.odiIsTouch && window.odiIsTouch() &&
                   selected === item.id && !isEditing && croppingId !== item.id && (
                    <div
                      className="item-drag-shield"
                      onMouseDown={(e) => {
                        const x0 = e.clientX, y0 = e.clientY;
                        let movido = false;
                        const alMover = (ev) => {
                          if (Math.hypot(ev.clientX - x0, ev.clientY - y0) > 6) movido = true;
                        };
                        const alSoltar = () => {
                          window.removeEventListener('mousemove', alMover);
                          window.removeEventListener('mouseup', alSoltar);
                          if (movido) return;
                          if (['calendar', 'table', 'todo', 'column', 'map', 'note', 'document', 'title', 'comment'].includes(item.type)) {
                            setEditing(item.id);
                          }
                        };
                        window.addEventListener('mousemove', alMover);
                        window.addEventListener('mouseup', alSoltar);
                        startDragItem(e, item.id);
                      }}
                    />
                  )}

                  {/* El candado: se ve al elegirlo, y es lo que dice por qué no
                      se deja mover. */}
                  {item.bloqueado && (selected === item.id || selectedIds.includes(item.id)) && (
                    <div className="nodo-candado" style={varsTiradoresRef.current} title={window.t('Bloqueado', 'Locked')}>
                      <span className="material-symbols-rounded">lock</span>
                    </div>
                  )}
                  {/* Resize handles */}
                  {(selected === item.id || (selectedIds.includes(item.id) && selectedIds.length > 1)) && !isEditing && croppingId !== item.id && !item.bloqueado && (
                    <div className="handles" style={varsTiradoresRef.current}>
                      <div className="handle tl" onMouseDown={(e)=>startResize(e, item.id, 'tl')}/>
                      <div className="handle tr" onMouseDown={(e)=>startResize(e, item.id, 'tr')}/>
                      <div className="handle bl" onMouseDown={(e)=>startResize(e, item.id, 'bl')}/>
                      <div className="handle br" onMouseDown={(e)=>startResize(e, item.id, 'br')}/>
                      {/* Girar. Va fuera de la caja y solo con un nodo
                          seleccionado: en grupo, cada nodo giraría sobre su
                          propio centro y el conjunto se desarmaría. */}
                      {selected === item.id && selectedIds.length <= 1 && (
                        <div
                          className="handle rot"
                          title={window.t('Girar · se pega cada 90°, con Shift gira fino', 'Rotate · snaps every 90°, hold Shift for fine')}
                          onMouseDown={(e)=>startRotate(e, item.id)}
                          onDoubleClick={(e)=>{ e.stopPropagation(); updateItem(item.id, { rot: 0 }); }}
                        >
                          <span className="material-symbols-rounded">rotate_right</span>
                        </div>
                      )}
                    </div>
                  )}
                </div>
              );
            })}

            {/* El borde de puntos de cada grupo elegido: dice que esos nodos van
                juntos y hasta dónde llega el grupo. */}
            {(() => {
              const ids = selectedIds.length ? selectedIds : (selected ? [selected] : []);
              const grupos = [...new Set(current.items.filter(it => ids.includes(it.id) && it.grupo).map(it => it.grupo))];
              return grupos.map(g => {
                const m = current.items.filter(it => it.grupo === g);
                if (m.length < 2) return null;
                const tam = (it) => ({ w: it.w !== undefined ? it.w : defaultDims(it.type).w, h: it.h !== undefined ? it.h : defaultDims(it.type).h });
                const x0 = Math.min(...m.map(it => it.x)), y0 = Math.min(...m.map(it => it.y));
                const x1 = Math.max(...m.map(it => it.x + tam(it).w)), y1 = Math.max(...m.map(it => it.y + tam(it).h));
                return <div key={'grupo-' + g} className="grupo-marco" style={{ left: x0 - 10, top: y0 - 10, width: x1 - x0 + 20, height: y1 - y0 + 20 }}/>;
              });
            })()}

            {/* Connector handles layer — drawn ABOVE the nodes so the center
                anchors stay grabbable even when they sit inside a node. */}
            <svg className="connectors connectors-top" width={bounds.w} height={bounds.h} viewBox={`0 0 ${bounds.w} ${bounds.h}`} style={{ overflow:'visible' }}>
              <g>
                {(current.connectors || []).map(conn => (
                  <window.Connector
                    key={`h-${conn.id}`}
                    layer="handles"
                    conn={conn}
                    items={current.items}
                    selected={selectedConn === conn.id}
                    selectedIds={selectedIds}
                    onSelect={(id)=>{ setSelectedConn(id); setSelected(null); }}
                    onUpdate={updateConnector}
                    onDragNodes={dragItemsSilent}
                    onDragNodesEnd={commitItemsDrag}
                    panZoom={{ scale }}
                    screenToCanvas={screenToCanvas}
                    theme={theme}
                  />
                ))}
              </g>
            </svg>

            {/* Drag-create preview */}
            {dragCreate && (dragCreate.w > 6 || dragCreate.h > 6) && (
              <div className="drag-preview" style={{ left: dragCreate.x, top: dragCreate.y, width: dragCreate.w, height: dragCreate.h }}/>
            )}

            {/* Marquee selection rectangle */}
            {marquee && (marquee.w > 2 || marquee.h > 2) && (
              <div className="marquee-rect" style={{ left: marquee.x, top: marquee.y, width: marquee.w, height: marquee.h }}/>
            )}

            {/* Smart alignment guides (Canva-style) — inside the transform so they track the nodes */}
            {guides && (guides.x?.length || guides.y?.length || guides.spacing?.length) ? (
              <svg className="alignment-guides" width={bounds.w} height={bounds.h} viewBox={`0 0 ${bounds.w} ${bounds.h}`} style={{ overflow:'visible', position: 'absolute', left: 0, top: 0, pointerEvents: 'none', zIndex: 9000 }}>
                {guides.x && guides.x.map((g, idx) => (
                  <line key={`gx-${idx}`} x1={g.x} y1={g.y1} x2={g.x} y2={g.y2} stroke="#18a0fb" strokeWidth={1.5 / scale} strokeDasharray={`${4/scale} ${3/scale}`}/>
                ))}
                {guides.y && guides.y.map((g, idx) => (
                  <line key={`gy-${idx}`} x1={g.x1} y1={g.y} x2={g.x2} y2={g.y} stroke="#18a0fb" strokeWidth={1.5 / scale} strokeDasharray={`${4/scale} ${3/scale}`}/>
                ))}
                {/* Equal-spacing brackets (pink bars showing equal gaps between nodes) */}
                {guides.spacing && guides.spacing.map((s, idx) => {
                  const cap = 5 / scale, sw = 2 / scale, col = 'var(--pink, #E58AB8)';
                  if (s.horizontal) {
                    return (
                      <g key={`sp-${idx}`} stroke={col} strokeWidth={sw}>
                        <line x1={s.x} y1={s.y} x2={s.x + s.w} y2={s.y}/>
                        <line x1={s.x} y1={s.y - cap} x2={s.x} y2={s.y + cap}/>
                        <line x1={s.x + s.w} y1={s.y - cap} x2={s.x + s.w} y2={s.y + cap}/>
                      </g>
                    );
                  }
                  return (
                    <g key={`sp-${idx}`} stroke={col} strokeWidth={sw}>
                      <line x1={s.x} y1={s.y} x2={s.x} y2={s.y + s.h}/>
                      <line x1={s.x - cap} y1={s.y} x2={s.x + cap} y2={s.y}/>
                      <line x1={s.x - cap} y1={s.y + s.h} x2={s.x + cap} y2={s.y + s.h}/>
                    </g>
                  );
                })}
              </svg>
            ) : null}

            {/* Cursores de las otras personas. Van dentro de la capa que se
                mueve con el lienzo, así que si alguien señala un nodo, su
                flecha señala ESE nodo aunque cada uno tenga el lienzo en otro
                sitio o con otro zoom. Solo se ven los que están mirando este
                mismo tablero. */}
            {Object.keys(cursoresAjenos).map(uid => {
              const c = cursoresAjenos[uid];
              if (!c || c.lienzo !== currentId) return null;
              const color = colorDe(uid);
              return (
                <div
                  key={uid}
                  className="odi-cursor"
                  // El puntero se dibuja siempre del mismo tamaño: se deshace
                  // el zoom del lienzo para que no acabe siendo una flecha
                  // gigante al alejarse ni un punto al acercarse.
                  style={{ left: c.x, top: c.y, transform: `scale(${1 / scale})` }}
                >
                  <svg width="20" height="22" viewBox="0 0 20 22" style={{ display: 'block' }}>
                    <path d="M2 1 L2 17 L6.5 13 L9.5 20 L12.5 18.7 L9.6 12 L15.5 12 Z"
                          fill={color} stroke="#fff" strokeWidth="1.4" strokeLinejoin="round"/>
                  </svg>
                  <span className="odi-cursor-nombre" style={{ background: color }}>{nombreDe(uid)}</span>
                </div>
              );
            })}

            {/* Connector toolbar moved to left sidebar (rendered above) */}
          </div>
        </div>

        {/* Capa de dibujo a mano: va fuera de .canvas-surface para poder velar
            todo el lienzo, y repite su misma transformación para que los
            trazos queden clavados sobre los nodos. */}
        {drawingId && drawSession && (
          <window.DrawOverlay
            strokes={drawSession.strokes}
            tool={drawTool}
            color={drawColor}
            width={drawWidth}
            pressureMode={drawPressureMode}
            estabilizador={drawEstabilizador}
            scale={scale}
            pan={pan}
            bounds={bounds}
            theme={theme}
            selectedStrokeId={selectedStrokeId}
            onCommitStroke={commitStroke}
            onEraseStroke={eraseStroke}
            onEraseArea={eraseArea}
            onMoveStroke={moveStroke}
            onSelectStroke={setSelectedStrokeId}
          />
        )}

        {/* Mini-search */}
        <div
          className={'mini-search ' + (buscadorAbierto ? 'abierta' : 'plegada')}
          ref={buscadorRef}
          onMouseDown={(e)=>e.stopPropagation()}
        >
          {/* La lupa es un botón de verdad, no un adorno: en el móvil es lo
              único que se ve cuando está plegada, y es lo que la abre y la
              cierra. En el escritorio no pliega nada, así que da igual. */}
          <button
            className="mini-search-lupa"
            onClick={() => { setBuscadorAbierto(v => !v); if (buscadorAbierto) setSearch(''); }}
            title={window.t('Buscar en este lienzo', 'Search this canvas')}
          >
            <span className="material-symbols-rounded" style={{color:'var(--ink-3)', fontSize: 17}}>search</span>
          </button>
          <input ref={buscadorInputRef} placeholder={window.TRANSLATIONS[lang].search_canvas} value={search} onChange={(e)=>setSearch(e.target.value)}/>
          {/* Puerta al buscador global. En móvil no hay Ctrl+K, así que este
              botón es el único acceso; en escritorio recuerda el atajo. */}
          <button
            className="mini-search-global"
            onClick={() => onSearchClick && onSearchClick()}
            title={window.t('Buscar en todos los proyectos (Ctrl+K)', 'Search every project (Ctrl+K)')}
          >
            <span className="material-symbols-rounded" style={{ fontSize: 16 }}>travel_explore</span>
            <span className="mini-search-kbd">{window.odiTecla ? window.odiTecla('Ctrl') : 'Ctrl'} K</span>
          </button>
          {search && (
            <button onClick={()=>setSearch('')}>
              <span className="material-symbols-rounded" style={{fontSize: 15, color:'var(--ink-3)'}}>close</span>
            </button>
          )}
        </div>

        {/* Con varios elegidos no sale la barra lateral (es de un nodo): esta
            barrita encima de la selección agrupa y bloquea. */}
        {selectedIds.length > 1 && !drawingId && (() => {
          const sel = current.items.filter(it => selectedIds.includes(it.id));
          if (sel.length < 2) return null;
          const tam = (it) => ({ w: it.w !== undefined ? it.w : defaultDims(it.type).w, h: it.h !== undefined ? it.h : defaultDims(it.type).h });
          const x0 = Math.min(...sel.map(it => it.x)), y0 = Math.min(...sel.map(it => it.y));
          const x1 = Math.max(...sel.map(it => it.x + tam(it).w));
          const grupos = new Set(sel.map(it => it.grupo).filter(Boolean));
          const unGrupo = grupos.size === 1 && sel.every(it => it.grupo);
          const todosBloqueados = sel.every(it => it.bloqueado);
          const cx = ((x0 + x1) / 2) * scale + pan.x;
          const arriba = y0 * scale + pan.y - 56;
          return (
            <div className="seleccion-barra" style={{ left: cx, top: Math.max(60, arriba) }} onMouseDown={(e) => e.stopPropagation()}>
              <span className="seleccion-cuenta">{window.t(`${sel.length} nodos`, `${sel.length} nodes`)}</span>
              <button onClick={unGrupo ? desagrupaSeleccion : agrupaSeleccion}
                title={unGrupo ? window.t('Desagrupar (Ctrl+Shift+G)', 'Ungroup (Ctrl+Shift+G)') : window.t('Agrupar: se mueven, se copian y se borran juntos (Ctrl+G)', 'Group: they move, copy and delete together (Ctrl+G)')}>
                <span className="material-symbols-rounded">{unGrupo ? 'link_off' : 'link'}</span>
                <span>{unGrupo ? window.t('Desagrupar', 'Ungroup') : window.t('Agrupar', 'Group')}</span>
              </button>
              <button onClick={() => setAlineaAbierto(a => !a)} className={alineaAbierto ? 'activo' : ''}
                title={window.t('Alinear, repartir o igualar el tamaño', 'Align, distribute or match the size')}>
                <span className="material-symbols-rounded">align_horizontal_left</span>
                <span>{window.t('Alinear', 'Align')}</span>
              </button>
              <button onClick={alternaBloqueo} className={todosBloqueados ? 'activo' : ''}
                title={todosBloqueados ? window.t('Quitar el candado (Ctrl+Shift+L)', 'Unlock (Ctrl+Shift+L)') : window.t('Bloquear: no se mueven, no se estiran ni se borran (Ctrl+Shift+L)', 'Lock: they cannot be moved, resized or deleted (Ctrl+Shift+L)')}>
                <span className="material-symbols-rounded">{todosBloqueados ? 'lock' : 'lock_open'}</span>
                <span>{todosBloqueados ? window.t('Bloqueados', 'Locked') : window.t('Bloquear', 'Lock')}</span>
              </button>
              {alineaAbierto && (
                <div className="alinea-panel">
                  {[
                    [window.t('Alinear', 'Align'), [
                      ['izquierda', 'align_horizontal_left', window.t('A la izquierda', 'Left')],
                      ['centroH', 'align_horizontal_center', window.t('Al centro (en horizontal)', 'Centre (horizontal)')],
                      ['derecha', 'align_horizontal_right', window.t('A la derecha', 'Right')],
                      ['arriba', 'align_vertical_top', window.t('Arriba', 'Top')],
                      ['centroV', 'align_vertical_center', window.t('Al medio (en vertical)', 'Middle (vertical)')],
                      ['abajo', 'align_vertical_bottom', window.t('Abajo', 'Bottom')],
                    ]],
                    [window.t('Repartir', 'Distribute'), [
                      ['repartirH', 'horizontal_distribute', window.t('A la misma distancia en horizontal (3 o más)', 'Same spacing horizontally (3 or more)')],
                      ['repartirV', 'vertical_distribute', window.t('A la misma distancia en vertical (3 o más)', 'Same spacing vertically (3 or more)')],
                    ]],
                    [window.t('Igualar', 'Match'), [
                      ['mismoAncho', 'width', window.t('El mismo ancho (el del más ancho)', 'Same width (the widest)')],
                      ['mismoAlto', 'height', window.t('El mismo alto (el del más alto)', 'Same height (the tallest)')],
                      ['mismoTamano', 'aspect_ratio', window.t('El mismo tamaño (el del más grande)', 'Same size (the largest)')],
                    ]],
                  ].map(([titulo, modos], k) => (
                    <div className="alinea-panel__grupo" key={k} role="group" aria-label={titulo}>
                      {modos.map(([modo, icono, texto]) => (
                        <button key={modo} className="alinea-panel__boton" title={texto} aria-label={texto}
                          disabled={(modo === 'repartirH' || modo === 'repartirV') && sel.length < 3}
                          onClick={() => alineaElegidos(modo)}>
                          <span className="material-symbols-rounded">{icono}</span>
                        </button>
                      ))}
                    </div>
                  ))}
                </div>
              )}
            </div>
          );
        })()}

        {/* La chincheta (siempre encima y sin bordes), en la línea del
            buscador y a la derecha del todo: arriba a la izquierda, junto al
            avatar, no gustó. Solo en el escritorio. */}
        {window.electronAPI && window.electronAPI.ventana && window.BotonFijar && (
          <div className="fijar-lienzo" onMouseDown={(e) => e.stopPropagation()}>
            <window.BotonFijar/>
          </div>
        )}

        {/* Place hint
            Con el dedo decía "clic simple" y ofrecía una tecla `esc` — en un
            teléfono no hay ni una cosa ni la otra, así que el cartel explicaba
            el gesto en un idioma que ese aparato no habla y encima dejaba a la
            vista una salida que no se podía usar. Mismo cartel, dicho como
            corresponde, y la tecla se convierte en un botón de verdad. */}
        {activeTool && (() => {
          const dedo = !!(window.odiIsTouch && window.odiIsTouch());
          return (
            <div className="place-hint">
              <span className="material-symbols-rounded" style={{fontSize:15}}>
                {activeTool === 'line' ? 'arrow_outward' : 'ads_click'}
              </span>
              {activeTool === 'line'
                ? (window.t('Arrastra de un nodo a otro', 'Drag from one node to another'))
                : dedo
                  ? (window.t('Arrastra para darle tamaño · un toque para el normal', 'Drag to size · one tap for the normal size'))
                  : (window.t('Arrastra para dibujar · clic simple para tamaño por defecto', 'Drag to size · single click for default'))
              }
              {dedo ? (
                <button
                  className="esc como-boton"
                  onClick={() => { setActiveTool(null); window.playAudioTone && window.playAudioTone('click'); }}
                >
                  {window.t('Cancelar', 'Cancel')}
                </button>
              ) : (
                <span className="esc">esc</span>
              )}
            </div>
          );
        })()}

        {/* Status pills */}
        <div className="status-bar">
          {/* La pastilla de Online/Offline se mudó a la barra de arriba,
              junto al volumen: no era un dato de fondo como "Guardado" o el
              número de nodos, sino el botón que abre compartir y las
              sesiones en vivo. Abajo del todo nadie lo miraba. */}
          <div className="status-pill"><div className="dot-live"/> {window.t('Guardado', 'Saved')}</div>
          {/* Vista de conexiones, junto al contador de nodos: es información
              sobre el lienzo, así que vive con el resto de la información. */}
          <button
            className="odi-graph-btn"
            onClick={() => { onGraphClick && onGraphClick(); window.playAudioTone && window.playAudioTone('click'); }}
            title={window.t('Ver cómo se conectan los nodos', 'See how the nodes connect')}
          >
            <span className="material-symbols-rounded">hub</span>
            <span>{window.t('Conexiones', 'Connections')}</span>
          </button>
          <div className="status-pill">
            <span className="material-symbols-rounded" style={{fontSize:14}}>category</span>
            {current.items.length} {(() => {
              // «1 nodos» no: uno en singular. Y el ruso tiene una forma para 2–4.
              const T = window.TRANSLATIONS[lang] || window.TRANSLATIONS.en, n = current.items.length;
              if (n === 1) return T.items_count_one || T.items_count;
              if (lang === 'ru' && n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14)) return 'узла';
              return T.items_count;
            })()}
          </div>
          {stack.length > 1 && (
            <div className="status-pill accent">
              <span className="material-symbols-rounded" style={{fontSize:14}}>layers</span>
              {window.t(`Nivel ${stack.length}`, `Level ${stack.length}`)}
            </div>
          )}
        </div>

        {/* Las plantillas: abajo a la derecha, donde las pidió el usuario, a la
            izquierda de la cámara (o en su sitio, en la web, que no la tiene). */}
        {window.OdiPlantillas && !drawingId && (
          <div className="plantillas-esquina" style={{ right: (window.electronAPI && window.electronAPI.capturarLienzo) ? 250 : 205 }}
            onMouseDown={(e) => e.stopPropagation()}>
            {plantillasAbiertas && (
              <div className="plantillas-panel">
                <div className="plantillas-tit">{window.t('Empezar con una plantilla', 'Start from a template')}</div>
                {window.OdiPlantillas.PLANTILLAS.map(pl => (
                  <button key={pl.id} className="plantilla" onClick={() => { ponPlantilla(pl.id); setPlantillasAbiertas(false); }}>
                    <span className="material-symbols-rounded plantilla-icono">{pl.icono}</span>
                    <span className="plantilla-textos">
                      <b>{window.t(pl.nombre.es, pl.nombre.en)}</b>
                      <small>{window.t(pl.descripcion.es, pl.descripcion.en)}</small>
                    </span>
                  </button>
                ))}
              </div>
            )}
            <button
              className={`icon-btn lift plantillas-boton ${plantillasAbiertas ? 'abierto' : ''}`}
              onClick={() => { setPlantillasAbiertas(v => !v); window.playAudioTone && window.playAudioTone('click'); }}
              title={window.t('Plantillas de tablero', 'Board templates')}
            >
              <span className="material-symbols-rounded">dashboard_customize</span>
              <span>{window.t('Plantillas', 'Templates')}</span>
            </button>
          </div>
        )}

        {/* Zoom controls */}
        <div className="zoom-ctrls">
          <button title={window.t('Alejar', 'Zoom out')} onClick={()=>setScale(s => Math.max(0.2, s - 0.1))}>
            <span className="material-symbols-rounded" style={{fontSize:15}}>remove</span>
          </button>
          <button
            className="zoom-level"
            title={(window.odiIsMobile && window.odiIsMobile()) ? window.t('Ver todo el lienzo', 'Show the whole canvas') : window.t('Volver al 100 %', 'Back to 100 %')}
            onClick={()=>{
              // En el teléfono, encuadrar todo lo que hay (como «ajustar al
              // contenido» de Freeform): alejarse a pellizcos para buscar un
              // nodo perdido es lo más pesado de un lienzo grande en el móvil.
              // En el escritorio, volver al 100 % como siempre.
              if (!(window.odiIsMobile && window.odiIsMobile())) { setScale(1); setPan({x:40, y:20}); return; }
              const its = (current && current.items) || [];
              const r = surfaceRef.current ? surfaceRef.current.getBoundingClientRect() : { width: window.innerWidth, height: window.innerHeight };
              if (!its.length) { setScale(1); setPan({x:40, y:20}); return; }
              const x0 = Math.min(...its.map(i => i.x)), y0 = Math.min(...its.map(i => i.y));
              const x1 = Math.max(...its.map(i => i.x + (i.w || 0))), y1 = Math.max(...its.map(i => i.y + (i.h || 0)));
              // Lo de abajo (la barra y la fila del zoom) tapa unos 150 px.
              const libreAlto = Math.max(120, r.height - 150);
              const s = Math.max(0.2, Math.min(1.25, Math.min((r.width - 48) / Math.max(1, x1 - x0), (libreAlto - 48) / Math.max(1, y1 - y0))));
              setScale(s);
              setPan({ x: Math.round(r.width / 2 - ((x0 + x1) / 2) * s), y: Math.round(libreAlto / 2 - ((y0 + y1) / 2) * s) });
            }}
          >
            {Math.round(scale * 100)}%
          </button>
          <button title={window.t('Acercar', 'Zoom in')} onClick={()=>setScale(s => Math.min(2.5, s + 0.1))}>
            <span className="material-symbols-rounded" style={{fontSize:15}}>add</span>
          </button>
        </div>

        {/* La foto del lienzo. Suelta y no dentro del grupo del zoom: alli
            quedaba amontonada entre el menos y el mas, y una accion que guarda
            un archivo no es del mismo tipo que ajustar el aumento. */}
        {window.electronAPI && window.electronAPI.capturarLienzo && (
          <button
            className="icon-btn lift canvas-shot"
            title={window.t('Guardar una foto de este lienzo', 'Save a picture of this canvas')}
            onClick={capturaLienzo}
          >
            <span className="material-symbols-rounded" style={{fontSize:17}}>photo_camera</span>
          </button>
        )}

        {/* El fogonazo. Se enciende justo antes de disparar y se apaga antes de
            que la camara mire, para que no salga en la foto. */}
        {fogonazo && <div className="canvas-fogonazo" />}

        {/* Background Color Selector */}
        <div className="canvas-bg-selector" style={{ position: 'absolute', bottom: '16px', right: '160px', zIndex: 40 }}>
          <button 
            className="icon-btn lift" 
            style={{ 
              width: '32px', 
              height: '32px', 
              borderRadius: '2px', 
              display: 'flex', 
              alignItems: 'center', 
              justifyContent: 'center',
              cursor: 'pointer'
            }}
            onClick={() => setShowBgSelector(!showBgSelector)}
            title={window.t('Color de fondo del lienzo', 'Canvas background color')}
          >
            <span className="material-symbols-rounded" style={{ fontSize: '18px' }}>texture</span>
          </button>
          {showBgSelector && (
            <div 
              className="ctx-popout" 
              style={{ 
                position: 'absolute', 
                bottom: '40px', 
                right: '0', 
                top: 'auto',
                left: 'auto',
                width: 'auto',
                padding: '10px', 
                display: 'flex', 
                flexDirection: 'column', 
                gap: '8px', 
                minWidth: '130px',
                borderRadius: '4px',
                boxShadow: 'var(--pop-md)'
              }}
              onMouseDown={(e)=>e.stopPropagation()}
            >
              <div style={{ fontSize: '11px', fontWeight: '700', color: 'var(--text-soft, #595459)', marginBottom: '2px' }}>
                {window.t('Fondo del Lienzo', 'Canvas Background')}
              </div>
              <div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '6px' }}>
                {['default', 'gray', 'sand', 'mint', 'sky', 'pink'].map(bgOpt => {
                  const colorsMap = {
                    default: { light: '#FAF8F6', dark: '#2A282A', label: window.t('Por defecto', 'Default') },
                    gray: { light: '#ECEAE6', dark: '#1E1C1E', label: window.t('Gris', 'Gray') },
                    sand: { light: '#F4EFE6', dark: '#38322B', label: window.t('Arena', 'Sand') },
                    mint: { light: '#EAF2EB', dark: '#25332A', label: window.t('Menta', 'Mint') },
                    sky: { light: '#E6F0FA', dark: '#232F3D', label: window.t('Celeste', 'Sky') },
                    pink: { light: '#FAEBEF', dark: '#3A232F', label: window.t('Rosa', 'Pink') }
                  };
                  const colorHex = theme === 'dark' ? colorsMap[bgOpt].dark : colorsMap[bgOpt].light;
                  const active = (current.bgColor || 'default') === bgOpt;
                  return (
                    <button
                      key={bgOpt}
                      style={{
                        width: '28px',
                        height: '28px',
                        borderRadius: '50%',
                        background: colorHex,
                        border: active ? '2.5px solid var(--wine, #7B2D26)' : '1px solid var(--line-soft, #E5E1DD)',
                        cursor: 'pointer',
                        boxShadow: active ? '0 0 4px rgba(0,0,0,0.2)' : 'none',
                        transition: 'transform 100ms'
                      }}
                      onClick={() => {
                        setCanvases(prev => ({
                          ...prev,
                          [currentId]: {
                            ...prev[currentId],
                            bgColor: bgOpt
                          }
                        }));
                      }}
                      title={colorsMap[bgOpt].label}
                    />
                  );
                })}
              </div>
            </div>
          )}
        </div>

        {/* Context menu */}
        {/* Right-click on empty canvas → quick-create menu */}
        {contextMenu && contextMenu.canvas && (
          <div ref={ctxMenuRef} className="context-menu context-menu-create" style={{ left: contextMenu.x, top: contextMenu.y }} onMouseDown={(e)=>e.stopPropagation()}>
            <button disabled={historyIdx <= 0} onClick={()=>{ undo(); setContextMenu(null); }}>
              <span className="material-symbols-rounded">undo</span>
              {window.t('Deshacer', 'Undo')} <span style={{marginLeft:'auto', fontFamily:'var(--font-mono)', fontSize:10, color:'var(--ink-3)'}}>⌘Z</span>
            </button>
            <button onClick={()=>{ selectAllItems(); setContextMenu(null); }}>
              <span className="material-symbols-rounded">select_all</span>
              {window.t('Seleccionar todo', 'Select all')} <span style={{marginLeft:'auto', fontFamily:'var(--font-mono)', fontSize:10, color:'var(--ink-3)'}}>⌘A</span>
            </button>
            <button onClick={()=>{
              setCanvases(prev => ({ ...prev, [currentId]: { ...prev[currentId], sinCuadricula: !prev[currentId].sinCuadricula } }));
              setContextMenu(null);
            }}>
              <span className="material-symbols-rounded">{current.sinCuadricula ? 'grid_on' : 'grid_off'}</span>
              {current.sinCuadricula ? window.t('Mostrar la cuadrícula', 'Show the grid') : window.t('Ocultar la cuadrícula', 'Hide the grid')}
            </button>
            <div className="ctx-sep"/>
            {(window.TOOLS || []).map(tool => (
              <button key={tool.id} onClick={()=>{ createNodeAt(tool.id, contextMenu.cx, contextMenu.cy); setContextMenu(null); }}>
                <span className="material-symbols-rounded">{tool.icon}</span>
                {window.TRANSLATIONS[lang][tool.label] || tool.id}
              </button>
            ))}
            <div className="ctx-sep"/>
            <div style={{ padding: '6px 12px', fontSize: '11px', fontWeight: '700', color: 'var(--text-soft, #595459)' }}>
              {window.t('Cambiar fondo del lienzo', 'Change canvas background')}
            </div>
            <div style={{ display: 'flex', gap: '6px', padding: '4px 12px 10px 12px', justifyContent: 'space-between', width: '100%', boxSizing: 'border-box' }} onMouseDown={(e)=>e.stopPropagation()}>
              {['default', 'gray', 'sand', 'mint', 'sky', 'pink'].map(bgOpt => {
                const colorsMap = {
                  default: { light: '#FAF8F6', dark: '#2A282A', label: window.t('Por defecto', 'Default') },
                  gray: { light: '#ECEAE6', dark: '#1E1C1E', label: window.t('Gris', 'Gray') },
                  sand: { light: '#F4EFE6', dark: '#38322B', label: window.t('Arena', 'Sand') },
                  mint: { light: '#EAF2EB', dark: '#25332A', label: window.t('Menta', 'Mint') },
                  sky: { light: '#E6F0FA', dark: '#232F3D', label: window.t('Celeste', 'Sky') },
                  pink: { light: '#FAEBEF', dark: '#3A232F', label: window.t('Rosa', 'Pink') }
                };
                const colorHex = theme === 'dark' ? colorsMap[bgOpt].dark : colorsMap[bgOpt].light;
                const active = (current.bgColor || 'default') === bgOpt;
                return (
                  <button
                    key={bgOpt}
                    style={{
                      display: 'block',
                      width: '20px',
                      height: '20px',
                      minWidth: '20px',
                      padding: 0,
                      borderRadius: '50%',
                      background: colorHex,
                      border: active ? '2.5px solid var(--wine, #7B2D26)' : '1px solid var(--line-soft, #E5E1DD)',
                      cursor: 'pointer',
                      boxShadow: active ? '0 0 4px rgba(0,0,0,0.2)' : 'none',
                      transition: 'transform 100ms',
                      margin: 0
                    }}
                    onClick={() => {
                      setCanvases(prev => ({
                        ...prev,
                        [currentId]: {
                          ...prev[currentId],
                          bgColor: bgOpt
                        }
                      }));
                      setContextMenu(null);
                    }}
                    title={colorsMap[bgOpt].label}
                  />
                );
              })}
            </div>
          </div>
        )}

        {contextMenu && !contextMenu.canvas && (() => {
          const it = current.items.find(i => i.id === contextMenu.itemId);
          if (!it) return null;
          return (
            <div ref={ctxMenuRef} className="context-menu" style={{ left: contextMenu.x, top: contextMenu.y }} onMouseDown={(e)=>e.stopPropagation()}>
              {['note','todo','link','column','comment','board'].includes(it.type) && (
                <button onClick={()=>{ setEditing(it.id); setContextMenu(null); }}>
                  <span className="material-symbols-rounded">edit</span>
                  {window.t('Editar', 'Edit')}
                </button>
              )}
              {it.type === 'image' && (
                <button onClick={()=>{ setCroppingId(it.id); setContextMenu(null); }}>
                  <span className="material-symbols-rounded">crop</span>
                  {window.t('Recortar imagen', 'Crop Image')}
                </button>
              )}
              {it.type === 'doc' && (
                <button onClick={()=>{ setDocOpen({ id: it.id }); setContextMenu(null); }}>
                  <span className="material-symbols-rounded">edit_note</span>
                  {window.t('Abrir documento', 'Open document')}
                </button>
              )}
              {it.type === 'board' && (
                <button onClick={()=>{ openBoard(it.canvasId, it.id); setContextMenu(null); }}>
                  <span className="material-symbols-rounded">open_in_full</span>
                  {window.t('Abrir tablero', 'Open board')}
                </button>
              )}
              {it.type !== 'frame' && (
                <>
                  <div className="ctx-sep"/>
                  {[
                    ['frente', 'flip_to_front', window.t('Traer al frente', 'Bring to front')],
                    ['delante', 'arrow_upward', window.t('Traer adelante', 'Bring forward')],
                    ['detras', 'arrow_downward', window.t('Enviar atrás', 'Send backward')],
                    ['fondo', 'flip_to_back', window.t('Enviar al fondo', 'Send to back')],
                  ].map(([hacia, icono, texto]) => (
                    <button key={hacia} onClick={()=>{ cambiaCapa(it.id, hacia); setContextMenu(null); }}>
                      <span className="material-symbols-rounded">{icono}</span>
                      {texto}
                    </button>
                  ))}
                  <div className="ctx-sep"/>
                </>
              )}
              <button onClick={()=>{ duplicateItem(it.id); setContextMenu(null); }}>
                <span className="material-symbols-rounded">content_copy</span>
                {window.t('Duplicar', 'Duplicate')} <span style={{marginLeft:'auto', fontFamily:'var(--font-mono)', fontSize:10, color:'var(--ink-3)'}}>⌘D</span>
              </button>
              <div className="ctx-sep"/>
              <button className="danger" onClick={()=>{ deleteItem(it.id); }}>
                <span className="material-symbols-rounded">delete</span>
                {window.t('Eliminar', 'Delete')} <span style={{marginLeft:'auto', fontFamily:'var(--font-mono)', fontSize:10, color:'var(--ink-3)'}}>⌫</span>
              </button>
            </div>
          );
        })()}
      </div>

      {/* Doc fullscreen modal */}
      {docItem && (
        <window.DocModal
          docItem={docItem}
          lang={lang}
          onClose={()=>setDocOpen(null)}
          onUpdate={docUpdater}
        />
      )}

      {/* File viewer modal (read-only, paginated) */}
      {fileOpen && (() => {
        const fileItem = current.items.find(i => i.id === fileOpen.id);
        if (!fileItem) return null;
        return <window.FileViewerModal fileItem={fileItem} lang={lang} onClose={()=>setFileOpen(null)}/>;
      })()}

      {/* Alignment Guides */}
      {/* Dragged Task Ghost */}
      {draggedTask && (
        <div
          className="todo-drag-ghost"
          style={{
            position: 'fixed',
            left: draggedTask.x + 12,
            top: draggedTask.y + 12,
            pointerEvents: 'none',
            zIndex: 99999,
            background: 'var(--paper)',
            border: '1.5px solid var(--wine)',
            borderRadius: '3px',
            padding: '8px 12px',
            boxShadow: 'var(--pop-deep)',
            fontSize: '12px',
            fontWeight: '600',
            color: 'var(--ink)',
            display: 'flex',
            alignItems: 'center',
            gap: '8px',
            maxWidth: '240px',
            wordBreak: 'break-all',
            opacity: 0.95
          }}
        >
          <span className="material-symbols-rounded" style={{ fontSize: '15px', color: 'var(--wine-texto)' }}>checklist</span>
          <span>{draggedTask.text || '...'}</span>
        </div>
      )}
    </div>
  );
}

window.Canvas = Canvas;


